fix(ui): keep gateway auth and content-type headers ahead of playground custom headers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-15 22:24:13 +00:00
parent c05af7a12f
commit 754e87fa67
6 changed files with 66 additions and 16 deletions

View file

@ -3,7 +3,7 @@
import { v4 as uuidv4 } from "uuid";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import type { CustomHeaders } from "@/components/llm_calls/request_headers";
import { type CustomHeaders, withRequiredHeaders } from "@/components/llm_calls/request_headers";
import { A2ATaskMetadata } from "@/components/chat_ui/types";
interface A2AMessagePart {
@ -148,11 +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",
...customHeaders,
},
}),
body: JSON.stringify(jsonRpcRequest),
signal,
});

View file

@ -96,4 +96,22 @@ describe("embeddings_api", () => {
},
});
});
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",
});
});
});

View file

@ -1,6 +1,10 @@
import { toast } from "@/lib/toast";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import {
buildPlaygroundHeaders,
type CustomHeaders,
withRequiredHeaders,
} from "@/components/llm_calls/request_headers";
export async function makeOpenAIEmbeddingsRequest(
input: string,
@ -22,7 +26,10 @@ export async function makeOpenAIEmbeddingsRequest(
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const headers = buildPlaygroundHeaders(tags, customHeaders);
const headers = withRequiredHeaders(buildPlaygroundHeaders(tags, customHeaders), {
"Content-Type": "application/json",
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
});
try {
const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl;
@ -30,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,

View file

@ -1,6 +1,10 @@
import { toast } from "@/lib/toast";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import {
buildPlaygroundHeaders,
type CustomHeaders,
withRequiredHeaders,
} from "@/components/llm_calls/request_headers";
export async function makeInteractionsRequest(
input: string,
@ -26,11 +30,10 @@ export async function makeInteractionsRequest(
const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl;
const requestUrl = `${normalizedBaseUrl}/v1beta/interactions`;
const headers: Record<string, string> = {
const headers: Record<string, string> = withRequiredHeaders(buildPlaygroundHeaders(tags, customHeaders), {
"Content-Type": "application/json",
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
...buildPlaygroundHeaders(tags, customHeaders),
};
});
const body: Record<string, unknown> = {
model: selectedModel,

View file

@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildPlaygroundHeaders, customHeadersFromPairs, parseStoredHeaderPairs } from "./request_headers";
import {
buildPlaygroundHeaders,
customHeadersFromPairs,
parseStoredHeaderPairs,
withRequiredHeaders,
} from "./request_headers";
describe("customHeadersFromPairs", () => {
it("trims header names and drops rows without a name", () => {
@ -42,3 +47,14 @@ describe("buildPlaygroundHeaders", () => {
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" });
});
});

View file

@ -25,3 +25,14 @@ export const buildPlaygroundHeaders = (
...(tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : {}),
...customHeaders,
});
export const withRequiredHeaders = (
headers: Readonly<Record<string, string>>,
required: Readonly<Record<string, string>>,
): Record<string, string> => {
const reserved = new Set(Object.keys(required).map((name) => name.toLowerCase()));
return {
...Object.fromEntries(Object.entries(headers).filter(([name]) => !reserved.has(name.toLowerCase()))),
...required,
};
};