fix(ui): respect litellm_key_header_name in BYOK credential save and workflow runs fetches (#33103)

This commit is contained in:
ryan-crabbe-berri 2026-07-13 12:19:38 -07:00 • committed by GitHub
parent c75fccfd63
commit 7fce761cde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 324 additions and 180 deletions

View file

@ -515,6 +515,152 @@
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
"react-hooks/immutability": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
},
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
"no-nested-ternary": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/static-components": {
"count": 4
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 5
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -1860,45 +2006,11 @@
"count": 1
}
},
"src/components/mcp_tools/ByokCredentialModal.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
"react-hooks/immutability": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/mcp_tools/MCPToolArgumentsForm.tsx": {
"no-nested-ternary": {
"count": 5
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
},
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/components/mcp_tools/McpCrudPermissionPanel.tsx": {
"no-nested-ternary": {
"count": 3
@ -1907,123 +2019,6 @@
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
"no-nested-ternary": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/static-components": {
"count": 4
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 5
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/model_add/AddCredentialModal.tsx": {
"no-restricted-imports": {
"count": 1
@ -2545,4 +2540,4 @@
"count": 1
}
}
}
}

View file

@ -684,7 +684,6 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
refetch();
setByokModalServer(null);
}}
accessToken={accessToken || ""}
/>
)}

View file

@ -2186,7 +2186,6 @@ const ChatUI: React.FC<ChatUIProps> = ({
loadMCPServers();
setByokModalServer(null);
}}
accessToken={accessToken || ""}
/>
)}

View file

@ -4,7 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import WorkflowRuns from "./WorkflowRuns";
vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" }));
vi.mock("@/components/networking", () => ({
proxyBaseUrl: "",
getGlobalLitellmHeaderName: () => "x-litellm-api-key",
}));
interface FakeRun {
run_id: string;
@ -78,4 +81,18 @@ describe("WorkflowRuns (migrated onto shared DataTable)", () => {
expect(await screen.findByText("No workflow runs yet")).toBeInTheDocument();
});
it("sends the configured litellm key header on every fetch instead of hardcoding Authorization", async () => {
const user = userEvent.setup();
const fetchSpy = mockFetch(RUNS);
vi.stubGlobal("fetch", fetchSpy);
render(<WorkflowRuns accessToken="tok" />);
await user.click(await screen.findByText("First run"));
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(3));
for (const [url, init] of fetchSpy.mock.calls as [string, RequestInit][]) {
expect(init.headers, url).toEqual({ "x-litellm-api-key": "Bearer tok" });
}
});
});

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button, Collapse, Drawer, Empty, Spin, Tooltip, Typography } from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
import { proxyBaseUrl } from "@/components/networking";
import { getGlobalLitellmHeaderName, proxyBaseUrl } from "@/components/networking";
import {
DataTable,
DataTableFilterDrawer,
@ -507,7 +507,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
setLoadingRuns(true);
try {
const res = await fetch(`${proxyBaseUrl ?? ""}/v1/workflows/runs?limit=100`, {
headers: { Authorization: `Bearer ${accessToken}` },
headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
@ -531,10 +531,10 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
const base = proxyBaseUrl ?? "";
const [evRes, msgRes] = await Promise.all([
fetch(`${base}/v1/workflows/runs/${run.run_id}/events`, {
headers: { Authorization: `Bearer ${accessToken}` },
headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` },
}),
fetch(`${base}/v1/workflows/runs/${run.run_id}/messages`, {
headers: { Authorization: `Bearer ${accessToken}` },
headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` },
}),
]);
const evData = evRes.ok ? await evRes.json() : { events: [] };

View file

@ -0,0 +1,72 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerAuthHeaderNameGetter, registerAuthTokenGetter, registerBaseUrlGetter } from "@/lib/http/runtime";
import { ByokCredentialModal } from "./ByokCredentialModal";
import type { MCPServer } from "./types";
const fetchSpy = vi.hoisted(() => {
const spy = vi.fn<(request: Request) => Promise<Response>>();
vi.stubGlobal("fetch", spy);
return spy;
});
vi.mock("@/components/molecules/message_manager", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
const SERVER = { server_id: "srv-1", alias: "Linear", server_name: "Linear" } as MCPServer;
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
async function fillAndSubmit(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByText("Continue to Authentication"));
await user.type(screen.getByPlaceholderText("Enter your API key"), "linear-key");
await user.click(screen.getByRole("button", { name: /Connect & Authorize/ }));
}
beforeEach(() => {
fetchSpy.mockReset();
registerBaseUrlGetter(() => "");
registerAuthTokenGetter(() => "sk-session");
});
describe("ByokCredentialModal", () => {
it("saves the credential with the session's configured litellm key header, not a hardcoded Authorization", async () => {
registerAuthHeaderNameGetter(() => "x-litellm-api-key");
fetchSpy.mockResolvedValue(jsonResponse({ server_id: "srv-1", has_credential: true }));
const onSuccess = vi.fn();
const user = userEvent.setup();
render(<ByokCredentialModal server={SERVER} open onClose={() => {}} onSuccess={onSuccess} />);
await fillAndSubmit(user);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith("srv-1"));
const request = fetchSpy.mock.calls[0][0];
expect(request.method).toBe("POST");
expect(new URL(request.url).pathname).toBe("/v1/mcp/server/srv-1/user-credential");
expect(request.headers.get("x-litellm-api-key")).toBe("Bearer sk-session");
expect(request.headers.get("Authorization")).toBeNull();
expect(await request.json()).toEqual({ credential: "linear-key", save: true });
});
it("surfaces the backend's detail.error message when the save fails", async () => {
registerAuthHeaderNameGetter(() => "Authorization");
fetchSpy.mockResolvedValue(
jsonResponse({ detail: { error: "This MCP server does not support BYOK credentials" } }, 400),
);
const MessageManager = (await import("@/components/molecules/message_manager")).default;
const onSuccess = vi.fn();
const user = userEvent.setup();
render(<ByokCredentialModal server={SERVER} open onClose={() => {}} onSuccess={onSuccess} />);
await fillAndSubmit(user);
await waitFor(() =>
expect(MessageManager.error).toHaveBeenCalledWith("This MCP server does not support BYOK credentials"),
);
expect(onSuccess).not.toHaveBeenCalled();
});
});

View file

@ -3,6 +3,8 @@
import React, { useState } from "react";
import { Modal, Input, Switch } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { fetchClient } from "@/lib/http/api";
import { ApiError } from "@/lib/http/client";
import {
KeyOutlined,
LockOutlined,
@ -14,21 +16,22 @@ import {
} from "@ant-design/icons";
import { MCPServer } from "./types";
const byokSaveErrorMessage = (e: unknown): string => {
if (e instanceof ApiError) {
const detail = (e.body as { detail?: { error?: string } } | null)?.detail?.error;
if (detail) return detail;
}
return e instanceof Error && e.message ? e.message : "Failed to connect";
};
interface ByokCredentialModalProps {
server: MCPServer;
open: boolean;
onClose: () => void;
onSuccess: (serverId: string) => void;
accessToken: string;
}
export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({
server,
open,
onClose,
onSuccess,
accessToken,
}) => {
export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server, open, onClose, onSuccess }) => {
const [step, setStep] = useState<1 | 2>(1);
const [apiKey, setApiKey] = useState("");
const [saveKey, setSaveKey] = useState(true);
@ -52,23 +55,15 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({
}
setLoading(true);
try {
const response = await fetch(`/v1/mcp/server/${server.server_id}/user-credential`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ credential: apiKey.trim(), save: saveKey }),
await fetchClient.POST("/v1/mcp/server/{server_id}/user-credential", {
params: { path: { server_id: server.server_id } },
body: { credential: apiKey.trim(), save: saveKey },
});
if (!response.ok) {
const err = await response.json();
throw new Error(err?.detail?.error || "Failed to save credential");
}
MessageManager.success(`Connected to ${serverDisplayName}`);
onSuccess(server.server_id);
handleClose();
} catch (e: any) {
MessageManager.error(e.message || "Failed to connect");
} catch (e) {
MessageManager.error(byokSaveErrorMessage(e));
} finally {
setLoading(false);
}

View file

@ -530,3 +530,67 @@ describe("buildModelGroupTestRequest", () => {
expect(body).toEqual({ model: "text-embedding-3-small", input: "test from litellm" });
});
});
describe("testMCPToolsListRequest auth headers", () => {
const originalFetch = global.fetch;
const captureFetch = () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: vi.fn().mockResolvedValue({ tools: [] }),
} as any);
global.fetch = mockFetch as any;
return mockFetch;
};
const sentHeaders = (mockFetch: ReturnType<typeof vi.fn>): Record<string, string> =>
(mockFetch.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
afterEach(() => {
Networking.setGlobalLitellmHeaderName("Authorization");
global.fetch = originalFetch;
});
it("sends the litellm key under a custom litellm_key_header_name even when an upstream OAuth token uses Authorization", async () => {
Networking.setGlobalLitellmHeaderName("x-litellm-key");
const mockFetch = captureFetch();
await Networking.testMCPToolsListRequest("sk-key", {}, "upstream-oauth-token");
const headers = sentHeaders(mockFetch);
expect(headers["x-litellm-key"]).toBe("Bearer sk-key");
expect(headers["Authorization"]).toBe("Bearer upstream-oauth-token");
});
it("Bearer-prefixes x-litellm-api-key when it is the configured key header (raw values fail _get_bearer_token)", async () => {
Networking.setGlobalLitellmHeaderName("x-litellm-api-key");
const mockFetch = captureFetch();
await Networking.testMCPToolsListRequest("sk-key", {}, "upstream-oauth-token");
const headers = sentHeaders(mockFetch);
expect(headers["x-litellm-api-key"]).toBe("Bearer sk-key");
expect(headers["Authorization"]).toBe("Bearer upstream-oauth-token");
});
it("never clobbers the upstream OAuth token on default deployments", async () => {
const mockFetch = captureFetch();
await Networking.testMCPToolsListRequest("sk-key", {}, "upstream-oauth-token");
const headers = sentHeaders(mockFetch);
expect(headers["Authorization"]).toBe("Bearer upstream-oauth-token");
expect(headers["x-litellm-api-key"]).toBe("sk-key");
});
it("sends the litellm key as the bearer on default deployments without an OAuth token", async () => {
const mockFetch = captureFetch();
await Networking.testMCPToolsListRequest("sk-key", {});
const headers = sentHeaders(mockFetch);
expect(headers["Authorization"]).toBe("Bearer sk-key");
});
});

View file

@ -6650,6 +6650,9 @@ export const testMCPToolsListRequest = async (
};
if (accessToken) {
headers["x-litellm-api-key"] = accessToken;
if (globalLitellmHeaderName.toLowerCase() !== "authorization") {
headers[globalLitellmHeaderName] = `Bearer ${accessToken}`;
}
}
if (oauthAccessToken) {
headers["Authorization"] = `Bearer ${oauthAccessToken}`;