diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/a2a_send_message.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/a2a_send_message.tsx
index 0b97d9931c4..db14290f42e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/a2a_send_message.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/a2a_send_message.tsx
@@ -3,6 +3,7 @@
import { v4 as uuidv4 } from "uuid";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
+import { type CustomHeaders, withRequiredHeaders } from "@/components/llm_calls/request_headers";
import { A2ATaskMetadata } from "@/components/chat_ui/types";
interface A2AMessagePart {
@@ -116,6 +117,7 @@ export const makeA2ASendMessageRequest = async (
onA2AMetadata?: (metadata: A2ATaskMetadata) => void,
customBaseUrl?: string,
guardrails?: string[],
+ customHeaders?: CustomHeaders,
): Promise => {
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const url = proxyBaseUrl ? `${proxyBaseUrl}/a2a/${agentId}/message/send` : `/a2a/${agentId}/message/send`;
@@ -146,10 +148,10 @@ export const makeA2ASendMessageRequest = async (
try {
const response = await fetch(url, {
method: "POST",
- headers: {
+ headers: withRequiredHeaders(customHeaders ?? {}, {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
- },
+ }),
body: JSON.stringify(jsonRpcRequest),
signal,
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.test.tsx
index 9f030d8b2d4..bed6d9f4fd9 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.test.tsx
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
+import Anthropic from "@anthropic-ai/sdk";
import { makeAnthropicMessagesRequest } from "./anthropic_messages";
import type { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
@@ -122,4 +123,26 @@ describe("anthropic_messages non-streaming", () => {
expect(mockMessagesCreate).not.toHaveBeenCalled();
expect(mockMessagesStream.mock.calls[0][0]).toMatchObject({ stream: true });
});
+
+ it("sends custom headers alongside the tags header on the Anthropic client", async () => {
+ mockMessagesCreate.mockResolvedValue({ content: [{ type: "text", text: "OK" }], usage: {} });
+
+ await makeAnthropicMessagesRequest(
+ [{ role: "user", content: "Hello" }],
+ vi.fn(),
+ "claude-haiku-4-5",
+ "test-token",
+ ["team-a"],
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ ...NON_STREAMING_ARGS,
+ { "anthropic-beta": "context-1m-2025-08-07" },
+ );
+
+ expect(vi.mocked(Anthropic).mock.calls[0][0]).toMatchObject({
+ defaultHeaders: { "x-litellm-tags": "team-a", "anthropic-beta": "context-1m-2025-08-07" },
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx
index 9dd2f675c44..4143d2641e8 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx
@@ -2,6 +2,7 @@ import Anthropic from "@anthropic-ai/sdk";
import { MessageType } from "@/components/chat_ui/types";
import { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
import { buildMcpToolBlocks } from "@/components/llm_calls/mcp_tool_blocks";
+import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
import { getProxyBaseUrl } from "@/components/networking";
import { toast } from "@/lib/toast";
@@ -34,6 +35,7 @@ export async function makeAnthropicMessagesRequest(
mcpServerToolRestrictions?: Record,
mcpToolsets?: MCPToolset[],
streamingEnabled: boolean = true,
+ customHeaders?: CustomHeaders,
) {
if (!accessToken) {
throw new Error("Virtual Key is required");
@@ -46,11 +48,7 @@ export async function makeAnthropicMessagesRequest(
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
- // Prepare headers with tags and trace ID
- const headers: Record = {};
- if (tags && tags.length > 0) {
- headers["x-litellm-tags"] = tags.join(",");
- }
+ const headers = buildPlaygroundHeaders(tags, customHeaders);
const client = new Anthropic({
apiKey: accessToken,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_speech.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_speech.tsx
index d9361bd0777..367bd9aa6a6 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_speech.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_speech.tsx
@@ -1,5 +1,6 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
+import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { toast } from "@/lib/toast";
import type { OpenAIVoice } from "../components/chat_ui/chatConstants";
@@ -14,6 +15,7 @@ export async function makeOpenAIAudioSpeechRequest(
responseFormat?: string,
speed?: number,
customBaseUrl?: string,
+ customHeaders?: CustomHeaders,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
@@ -25,7 +27,7 @@ export async function makeOpenAIAudioSpeechRequest(
apiKey: accessToken,
baseURL: proxyBaseUrl,
dangerouslyAllowBrowser: true,
- defaultHeaders: tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : undefined,
+ defaultHeaders: buildPlaygroundHeaders(tags, customHeaders),
});
try {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx
index 50d5fb19990..12b2d945f98 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx
@@ -1,5 +1,6 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
+import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { toast } from "@/lib/toast";
export async function makeOpenAIAudioTranscriptionRequest(
@@ -14,6 +15,7 @@ export async function makeOpenAIAudioTranscriptionRequest(
responseFormat?: string,
temperature?: number,
customBaseUrl?: string,
+ customHeaders?: CustomHeaders,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
@@ -26,7 +28,7 @@ export async function makeOpenAIAudioTranscriptionRequest(
apiKey: accessToken,
baseURL: proxyBaseUrl,
dangerouslyAllowBrowser: true,
- defaultHeaders: tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : undefined,
+ defaultHeaders: buildPlaygroundHeaders(tags, customHeaders),
});
try {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.test.tsx
index 19a79f3f9be..ef5c9f5025d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.test.tsx
@@ -76,4 +76,42 @@ describe("embeddings_api", () => {
input: "Sample text",
});
});
+
+ it("sends custom headers on the fetch request, letting them override the tags header", async () => {
+ await makeOpenAIEmbeddingsRequest(
+ "Sample text",
+ mockUpdateEmbeddingsUI,
+ "text-embedding-3-small",
+ "abcdef",
+ ["team-a"],
+ undefined,
+ { "x-litellm-tags": "team-b", "x-request-source": "playground" },
+ );
+
+ expect(mockFetch.mock.calls[0][1]).toMatchObject({
+ headers: {
+ Authorization: "Bearer abcdef",
+ "x-litellm-tags": "team-b",
+ "x-request-source": "playground",
+ },
+ });
+ });
+
+ it("does not let custom headers replace the gateway auth or content-type headers", async () => {
+ await makeOpenAIEmbeddingsRequest(
+ "Sample text",
+ mockUpdateEmbeddingsUI,
+ "text-embedding-3-small",
+ "abcdef",
+ undefined,
+ undefined,
+ { authorization: "Bearer stolen", "Content-Type": "text/plain", "x-request-source": "playground" },
+ );
+
+ expect(mockFetch.mock.calls[0][1].headers).toEqual({
+ Authorization: "Bearer abcdef",
+ "Content-Type": "application/json",
+ "x-request-source": "playground",
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx
index b43955b711d..ef8a650f1d2 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx
@@ -1,5 +1,10 @@
import { toast } from "@/lib/toast";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
+import {
+ buildPlaygroundHeaders,
+ type CustomHeaders,
+ withRequiredHeaders,
+} from "@/components/llm_calls/request_headers";
export async function makeOpenAIEmbeddingsRequest(
input: string,
@@ -8,6 +13,7 @@ export async function makeOpenAIEmbeddingsRequest(
accessToken: string,
tags?: string[],
customBaseUrl?: string,
+ customHeaders?: CustomHeaders,
) {
if (!accessToken) {
throw new Error("Virtual Key is required");
@@ -20,11 +26,10 @@ export async function makeOpenAIEmbeddingsRequest(
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
- // Prepare headers with tags and trace ID
- const headers: Record = {};
- if (tags && tags.length > 0) {
- headers["x-litellm-tags"] = tags.join(",");
- }
+ const headers = withRequiredHeaders(buildPlaygroundHeaders(tags, customHeaders), {
+ "Content-Type": "application/json",
+ [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
+ });
try {
const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl;
@@ -32,11 +37,7 @@ export async function makeOpenAIEmbeddingsRequest(
const response = await fetch(requestUrl, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
- ...headers,
- },
+ headers,
body: JSON.stringify({
model: selectedModel,
input,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_edits.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_edits.tsx
index f66be10897d..b956cd1d7de 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_edits.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_edits.tsx
@@ -1,5 +1,6 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
+import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { toast } from "@/lib/toast";
export async function makeOpenAIImageEditsRequest(
@@ -11,6 +12,7 @@ export async function makeOpenAIImageEditsRequest(
tags?: string[],
signal?: AbortSignal,
customBaseUrl?: string,
+ customHeaders?: CustomHeaders,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
@@ -23,7 +25,7 @@ export async function makeOpenAIImageEditsRequest(
apiKey: accessToken,
baseURL: proxyBaseUrl,
dangerouslyAllowBrowser: true,
- defaultHeaders: tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : undefined,
+ defaultHeaders: buildPlaygroundHeaders(tags, customHeaders),
});
try {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_generation.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_generation.tsx
index 7d3fb098257..1bbda4234b0 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_generation.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_generation.tsx
@@ -1,5 +1,6 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
+import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { toast } from "@/lib/toast";
export async function makeOpenAIImageGenerationRequest(
@@ -10,6 +11,7 @@ export async function makeOpenAIImageGenerationRequest(
tags?: string[],
signal?: AbortSignal,
customBaseUrl?: string,
+ customHeaders?: CustomHeaders,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
@@ -21,7 +23,7 @@ export async function makeOpenAIImageGenerationRequest(
apiKey: accessToken,
baseURL: proxyBaseUrl,
dangerouslyAllowBrowser: true,
- defaultHeaders: tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : undefined,
+ defaultHeaders: buildPlaygroundHeaders(tags, customHeaders),
});
try {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.tsx
index f1f925ca4e3..ab1bb7c1707 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.tsx
@@ -1,5 +1,10 @@
import { toast } from "@/lib/toast";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
+import {
+ buildPlaygroundHeaders,
+ type CustomHeaders,
+ withRequiredHeaders,
+} from "@/components/llm_calls/request_headers";
export async function makeInteractionsRequest(
input: string,
@@ -10,6 +15,7 @@ export async function makeInteractionsRequest(
signal?: AbortSignal,
customBaseUrl?: string,
previousInteractionId?: string,
+ customHeaders?: CustomHeaders,
): Promise {
if (!accessToken) {
throw new Error("Virtual Key is required");
@@ -24,13 +30,10 @@ export async function makeInteractionsRequest(
const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl;
const requestUrl = `${normalizedBaseUrl}/v1beta/interactions`;
- const headers: Record = {
+ const headers: Record = withRequiredHeaders(buildPlaygroundHeaders(tags, customHeaders), {
"Content-Type": "application/json",
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
- };
- if (tags && tags.length > 0) {
- headers["x-litellm-tags"] = tags.join(",");
- }
+ });
const body: Record = {
model: selectedModel,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
index 9cd1444b0b9..b4df567e250 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
@@ -45,6 +45,15 @@ const SETTINGS_FIXTURE = [
field_tab: "prompt_caching",
field_default_value: null,
},
+ {
+ field_name: "openai_system_messages_first",
+ field_type: "Boolean",
+ field_value: false,
+ field_description: "openai system first toggle",
+ stored_in_db: null,
+ field_tab: "prompt_caching",
+ field_default_value: false,
+ },
{
field_name: "max_ui_session_budget",
field_type: "Dollar",
@@ -101,6 +110,39 @@ describe("GeneralSettings General tab", () => {
});
});
+describe("GeneralSettings Prompt Caching tab", () => {
+ beforeEach(() => {
+ vi.mocked(getGeneralSettingsCall).mockResolvedValue([...SETTINGS_FIXTURE.map((s) => ({ ...s }))]);
+ vi.mocked(updateConfigFieldSetting).mockClear();
+ vi.mocked(deleteConfigFieldSetting).mockClear();
+ });
+
+ it("persists openai_system_messages_first when its switch is turned on", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(await screen.findByRole("tab", { name: "Prompt Caching" }));
+ const toggle = await screen.findByRole("switch", { name: "System messages first for OpenAI" });
+ expect(toggle).not.toBeChecked();
+
+ await user.click(toggle);
+
+ expect(toggle).toBeChecked();
+ expect(updateConfigFieldSetting).toHaveBeenCalledWith("token", "openai_system_messages_first", true);
+ expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
+ });
+
+ it("keeps the prompt caching rows off the General tab table", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(screen.getByText("General"));
+ await settingsRow("max_ui_session_budget");
+
+ expect(screen.queryByText("openai_system_messages_first")).not.toBeInTheDocument();
+ });
+});
+
// The five tabs here are proxy-wide settings. Auto-routers moved to Models + Endpoints.
describe("GeneralSettings tabs", () => {
beforeEach(() => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
index df9e328ec3b..9a718cbe9b8 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
@@ -18,6 +18,9 @@ import RoutingGroups from "@/components/routing_groups";
const PROMPT_CACHING_TAB = "prompt_caching";
const ENABLE_ANTHROPIC_PROMPT_CACHING = "enable_anthropic_prompt_caching";
const ANTHROPIC_PROMPT_CACHING_TTL = "anthropic_prompt_caching_ttl";
+const OPENAI_SYSTEM_MESSAGES_FIRST = "openai_system_messages_first";
+
+const isOn = (value: unknown) => value === true || value === "true";
interface GeneralSettingsPageProps {
accessToken: string | null;
@@ -117,14 +120,15 @@ export const PromptCachingPanel: React.FC<{
}> = ({ accessToken, settings, onChange }) => {
const enableSetting = settings.find((s) => s.field_name === ENABLE_ANTHROPIC_PROMPT_CACHING);
const ttlSetting = settings.find((s) => s.field_name === ANTHROPIC_PROMPT_CACHING_TTL);
+ const systemFirstSetting = settings.find((s) => s.field_name === OPENAI_SYSTEM_MESSAGES_FIRST);
- // The two rows come from the same registry the General tab reads; if they
+ // The rows come from the same registry the General tab reads; if they
// are not loaded yet there is nothing to render.
if (!enableSetting) {
return null;
}
- const enabled = enableSetting.field_value === true || enableSetting.field_value === "true";
+ const enabled = isOn(enableSetting.field_value);
// Apply immediately: a toggle and a dropdown are direct controls, so there is
// no separate Update button. Clearing the ttl resets it to the provider default.
@@ -175,6 +179,20 @@ export const PromptCachingPanel: React.FC<{
)}
+
+ {systemFirstSetting && (
+
+
+
System messages first for OpenAI
+
{systemFirstSetting.field_description}
+
+
persist(OPENAI_SYSTEM_MESSAGES_FIRST, checked)}
+ />
+
+ )}
);
diff --git a/ui/litellm-dashboard/src/components/chat_ui/CodeSnippets.test.tsx b/ui/litellm-dashboard/src/components/chat_ui/CodeSnippets.test.tsx
index 0113f7b2832..65f5ade7a5c 100644
--- a/ui/litellm-dashboard/src/components/chat_ui/CodeSnippets.test.tsx
+++ b/ui/litellm-dashboard/src/components/chat_ui/CodeSnippets.test.tsx
@@ -48,6 +48,28 @@ describe("CodeSnippets", () => {
expect(code).toContain("print(response.data[0].embedding)");
});
+ describe("custom headers", () => {
+ const customHeaders = { "anthropic-beta": "context-1m-2025-08-07", "x-request-source": "playground" };
+
+ it("passes configured headers as default_headers on the OpenAI client", () => {
+ const code = generateCodeSnippet({ ...baseParams, endpointType: EndpointType.CHAT, customHeaders });
+ expect(code).toContain('base_url="http://localhost:4000",\n\tdefault_headers={');
+ expect(code).toContain('"anthropic-beta": "context-1m-2025-08-07"');
+ expect(code).toContain('"x-request-source": "playground"');
+ });
+
+ it("passes configured headers as default_headers on the Azure client", () => {
+ const code = generateCodeSnippet({ ...baseParams, selectedSdk: "azure", customHeaders });
+ expect(code).toContain('api_version="2024-02-01",\n\tdefault_headers={');
+ expect(code).toContain('"anthropic-beta": "context-1m-2025-08-07"');
+ });
+
+ it("omits default_headers when no custom headers are configured", () => {
+ expect(generateCodeSnippet(baseParams)).not.toContain("default_headers");
+ expect(generateCodeSnippet({ ...baseParams, customHeaders: {} })).not.toContain("default_headers");
+ });
+ });
+
describe("base URL selection", () => {
it("should use LITELLM_UI_API_DOC_BASE_URL when provided", () => {
const customBaseUrl = "https://custom-doc.example.com";
diff --git a/ui/litellm-dashboard/src/components/chat_ui/CodeSnippets.tsx b/ui/litellm-dashboard/src/components/chat_ui/CodeSnippets.tsx
index f458e563b4d..9150ba0e632 100644
--- a/ui/litellm-dashboard/src/components/chat_ui/CodeSnippets.tsx
+++ b/ui/litellm-dashboard/src/components/chat_ui/CodeSnippets.tsx
@@ -1,6 +1,7 @@
import { MessageType } from "./types";
import { EndpointType } from "./mode_endpoint_mapping";
import { MCPServer } from "@/components/mcp_tools/types";
+import type { CustomHeaders } from "@/components/llm_calls/request_headers";
interface CodeGenMetadata {
tags?: string[];
@@ -30,6 +31,7 @@ interface GenerateCodeParams {
PROXY_BASE_URL?: string;
LITELLM_UI_API_DOC_BASE_URL?: string | null;
};
+ customHeaders?: CustomHeaders;
}
export const generateCodeSnippet = (params: GenerateCodeParams): string => {
@@ -48,6 +50,7 @@ export const generateCodeSnippet = (params: GenerateCodeParams): string => {
selectedModel,
selectedSdk,
proxySettings,
+ customHeaders,
} = params;
const effectiveApiKey = apiKeySource === "session" ? accessToken : apiKey;
@@ -76,6 +79,11 @@ export const generateCodeSnippet = (params: GenerateCodeParams): string => {
const modelNameForCode = selectedModel || "your-model-name";
+ const defaultHeadersCode =
+ customHeaders && Object.keys(customHeaders).length > 0
+ ? `,\n\tdefault_headers=${JSON.stringify(customHeaders, null, 2).replace(/\n/g, "\n\t")}`
+ : "";
+
const clientInitialization =
selectedSdk === "azure"
? `import openai
@@ -83,13 +91,13 @@ export const generateCodeSnippet = (params: GenerateCodeParams): string => {
client = openai.AzureOpenAI(
api_key="${effectiveApiKey || "YOUR_LITELLM_API_KEY"}",
azure_endpoint="${apiBase}",
- api_version="2024-02-01"
+ api_version="2024-02-01"${defaultHeadersCode}
)`
: `import openai
client = openai.OpenAI(
api_key="${effectiveApiKey || "YOUR_LITELLM_API_KEY"}",
- base_url="${apiBase}"
+ base_url="${apiBase}"${defaultHeadersCode}
)`;
let endpointSpecificCode;
diff --git a/ui/litellm-dashboard/src/components/llm_calls/chat_completion.test.tsx b/ui/litellm-dashboard/src/components/llm_calls/chat_completion.test.tsx
index ec477441586..f10502df77b 100644
--- a/ui/litellm-dashboard/src/components/llm_calls/chat_completion.test.tsx
+++ b/ui/litellm-dashboard/src/components/llm_calls/chat_completion.test.tsx
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import openai from "openai";
import { makeOpenAIChatCompletionRequest } from "./chat_completion";
import type { TokenUsage } from "../chat_ui/ResponseMetrics";
@@ -615,3 +616,47 @@ describe("chat_completion response cache", () => {
expect(onUsageData).toHaveBeenCalledWith(expect.not.objectContaining({ servedFromResponseCache: true }));
});
});
+
+describe("chat_completion custom headers", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("sends custom headers alongside the tags header on the OpenAI client", async () => {
+ mockCreate.mockReturnValueOnce(nonStreamingResponse({ choices: [{ message: { content: "Hi" } }] }));
+
+ await makeOpenAIChatCompletionRequest(
+ [{ role: "user", content: "Hello" }],
+ vi.fn(),
+ "gpt-4",
+ "test-token",
+ ["team-a"],
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ false,
+ { "anthropic-beta": "context-1m-2025-08-07", "x-litellm-tags": "overridden" },
+ );
+
+ expect(vi.mocked(openai.OpenAI).mock.calls[0][0]).toMatchObject({
+ defaultHeaders: { "anthropic-beta": "context-1m-2025-08-07", "x-litellm-tags": "overridden" },
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/llm_calls/chat_completion.tsx b/ui/litellm-dashboard/src/components/llm_calls/chat_completion.tsx
index ffa2877fbd9..fe5b6fb6e39 100644
--- a/ui/litellm-dashboard/src/components/llm_calls/chat_completion.tsx
+++ b/ui/litellm-dashboard/src/components/llm_calls/chat_completion.tsx
@@ -6,6 +6,7 @@ import { getProxyBaseUrl } from "@/components/networking";
import { MCPServer, MCPToolset, type MCPEvent } from "@/components/mcp_tools/types";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
import { parseUsageCost } from "./usage_cost";
+import { buildPlaygroundHeaders, type CustomHeaders } from "./request_headers";
const completionAsSingleChunk = (completion: ChatCompletion): ChatCompletionChunk =>
({
@@ -50,6 +51,7 @@ export async function makeOpenAIChatCompletionRequest(
mockTestFallbacks?: boolean,
mcpToolsets?: MCPToolset[],
streamingEnabled: boolean = true,
+ customHeaders?: CustomHeaders,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
@@ -57,11 +59,7 @@ export async function makeOpenAIChatCompletionRequest(
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
- // Prepare headers with tags and trace ID
- const headers: Record
= {};
- if (tags && tags.length > 0) {
- headers["x-litellm-tags"] = tags.join(",");
- }
+ const headers = buildPlaygroundHeaders(tags, customHeaders);
const client = new openai.OpenAI({
apiKey: accessToken,
diff --git a/ui/litellm-dashboard/src/components/llm_calls/request_headers.test.ts b/ui/litellm-dashboard/src/components/llm_calls/request_headers.test.ts
new file mode 100644
index 00000000000..cacedea3c87
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/llm_calls/request_headers.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildPlaygroundHeaders,
+ customHeadersFromPairs,
+ parseStoredHeaderPairs,
+ withRequiredHeaders,
+} from "./request_headers";
+
+describe("customHeadersFromPairs", () => {
+ it("trims header names and drops rows without a name", () => {
+ expect(
+ customHeadersFromPairs([
+ [" anthropic-beta ", "context-1m-2025-08-07"],
+ ["", "orphan value"],
+ [" ", "whitespace name"],
+ ["x-empty", ""],
+ ]),
+ ).toEqual({ "anthropic-beta": "context-1m-2025-08-07", "x-empty": "" });
+ });
+});
+
+describe("parseStoredHeaderPairs", () => {
+ it("round-trips pairs persisted as JSON", () => {
+ const pairs = [["anthropic-beta", "context-1m-2025-08-07"]] as const;
+ expect(parseStoredHeaderPairs(JSON.stringify(pairs))).toEqual(pairs);
+ });
+
+ it("returns no pairs for missing, malformed, or wrongly shaped storage", () => {
+ expect(parseStoredHeaderPairs(null)).toEqual([]);
+ expect(parseStoredHeaderPairs("not json")).toEqual([]);
+ expect(parseStoredHeaderPairs(JSON.stringify({ "anthropic-beta": "x" }))).toEqual([]);
+ expect(parseStoredHeaderPairs(JSON.stringify([["ok", "pair"], ["one"], [1, 2], "str"]))).toEqual([["ok", "pair"]]);
+ });
+});
+
+describe("buildPlaygroundHeaders", () => {
+ it("joins tags into x-litellm-tags and lets custom headers override it", () => {
+ expect(buildPlaygroundHeaders(["a", "b"], { "x-custom": "1" })).toEqual({
+ "x-litellm-tags": "a,b",
+ "x-custom": "1",
+ });
+ expect(buildPlaygroundHeaders(["a"], { "x-litellm-tags": "b" })).toEqual({ "x-litellm-tags": "b" });
+ });
+
+ it("omits x-litellm-tags when there are no tags", () => {
+ expect(buildPlaygroundHeaders([], { "x-custom": "1" })).toEqual({ "x-custom": "1" });
+ expect(buildPlaygroundHeaders(undefined, undefined)).toEqual({});
+ });
+});
+
+describe("withRequiredHeaders", () => {
+ it("keeps required headers regardless of custom header name casing", () => {
+ expect(
+ withRequiredHeaders(
+ { authorization: "Bearer stolen", "content-type": "text/plain", "x-custom": "1" },
+ { Authorization: "Bearer real", "Content-Type": "application/json" },
+ ),
+ ).toEqual({ Authorization: "Bearer real", "Content-Type": "application/json", "x-custom": "1" });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/llm_calls/request_headers.ts b/ui/litellm-dashboard/src/components/llm_calls/request_headers.ts
new file mode 100644
index 00000000000..8c0c0056fca
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/llm_calls/request_headers.ts
@@ -0,0 +1,38 @@
+import type { KeyValuePair } from "@/components/key_value_input";
+
+export type CustomHeaders = Readonly>;
+
+export const customHeadersFromPairs = (pairs: readonly KeyValuePair[]): CustomHeaders =>
+ Object.fromEntries(pairs.map(([name, value]) => [name.trim(), value]).filter(([name]) => name !== ""));
+
+const isHeaderPair = (entry: unknown): entry is KeyValuePair =>
+ Array.isArray(entry) && entry.length === 2 && entry.every((part) => typeof part === "string");
+
+export const parseStoredHeaderPairs = (raw: string | null): readonly KeyValuePair[] => {
+ if (!raw) return [];
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.filter(isHeaderPair) : [];
+ } catch {
+ return [];
+ }
+};
+
+export const buildPlaygroundHeaders = (
+ tags?: readonly string[],
+ customHeaders?: CustomHeaders,
+): Record => ({
+ ...(tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : {}),
+ ...customHeaders,
+});
+
+export const withRequiredHeaders = (
+ headers: Readonly>,
+ required: Readonly>,
+): Record => {
+ const reserved = new Set(Object.keys(required).map((name) => name.toLowerCase()));
+ return {
+ ...Object.fromEntries(Object.entries(headers).filter(([name]) => !reserved.has(name.toLowerCase()))),
+ ...required,
+ };
+};
diff --git a/ui/litellm-dashboard/src/components/llm_calls/responses_api.test.tsx b/ui/litellm-dashboard/src/components/llm_calls/responses_api.test.tsx
index 290c8b0b619..a2260d25ca6 100644
--- a/ui/litellm-dashboard/src/components/llm_calls/responses_api.test.tsx
+++ b/ui/litellm-dashboard/src/components/llm_calls/responses_api.test.tsx
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import openai from "openai";
import { makeOpenAIResponsesRequest } from "./responses_api";
import { MessageType } from "../chat_ui/types";
import type { TokenUsage } from "../chat_ui/ResponseMetrics";
@@ -611,3 +612,46 @@ describe("responses_api response cache", () => {
expect(onUsageData).toHaveBeenCalledWith(expect.not.objectContaining({ servedFromResponseCache: true }), "");
});
});
+
+describe("responses_api custom headers", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("sends custom headers alongside the tags header on the OpenAI client", async () => {
+ mockResponsesCreate.mockReturnValueOnce(nonStreamingResponse({ id: "resp_1", output: [] }));
+
+ await makeOpenAIResponsesRequest(
+ [{ role: "user", content: "Hello" }],
+ vi.fn(),
+ "gpt-4",
+ "test-token",
+ ["team-a"],
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ false,
+ undefined,
+ { "anthropic-beta": "context-1m-2025-08-07" },
+ );
+
+ expect(vi.mocked(openai.OpenAI).mock.calls[0][0]).toMatchObject({
+ defaultHeaders: { "x-litellm-tags": "team-a", "anthropic-beta": "context-1m-2025-08-07" },
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx b/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
index ce54c7c6b40..ec196a8d9a2 100644
--- a/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
+++ b/ui/litellm-dashboard/src/components/llm_calls/responses_api.tsx
@@ -5,6 +5,7 @@ import { getProxyBaseUrl } from "@/components/networking";
import { toast } from "@/lib/toast";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
import { parseUsageCost } from "./usage_cost";
+import { buildPlaygroundHeaders, type CustomHeaders } from "./request_headers";
import type { MCPEvent } from "@/components/mcp_tools/types";
import { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
import {
@@ -85,6 +86,7 @@ export async function makeOpenAIResponsesRequest(
mcpToolsets?: MCPToolset[],
streamingEnabled: boolean = true,
onTotalLatency?: (latency: number) => void,
+ customHeaders?: CustomHeaders,
) {
if (!accessToken) {
throw new Error("Virtual Key is required");
@@ -101,11 +103,7 @@ export async function makeOpenAIResponsesRequest(
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
- // Prepare headers with tags and trace ID
- const headers: Record = {};
- if (tags && tags.length > 0) {
- headers["x-litellm-tags"] = tags.join(",");
- }
+ const headers = buildPlaygroundHeaders(tags, customHeaders);
const client = new openai.OpenAI({
apiKey: accessToken,