From 979e869f9caa5c9a416eb1c8f0effc4421ec77e2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 11:26:21 -0700 Subject: [PATCH 1/2] fix(ui): stop cloning body-carrying requests into stream uploads in fetchClient middleware The openapi-fetch middleware rebuilt every outgoing request with new Request(url, request), which converts a string JSON body into a ReadableStream with duplex=half. Chromium only allows streaming uploads over HTTP/2 or HTTP/3, so against any HTTP/1.1 hop (uvicorn serves HTTP/1.1 only) the fetch dies at the network layer with net::ERR_ALPN_NEGOTIATION_FAILED, surfaced as "Failed to fetch". GET callers were unaffected (null body); the first body-carrying caller arrived with the MCP BYOK credential modal, breaking that flow on plain http deployments in the v1.94.0 RCs. The middleware now mutates headers on the original request when no runtime base is registered, and when rebasing onto a runtime base it rebuilds the request with the body materialized as bytes via arrayBuffer(), which fetch sends with Content-Length instead of a streaming upload --- ui/litellm-dashboard/src/lib/http/api.test.ts | 45 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/api.ts | 21 ++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/api.test.ts b/ui/litellm-dashboard/src/lib/http/api.test.ts index fba2b7f77dc4..7bbbf38da095 100644 --- a/ui/litellm-dashboard/src/lib/http/api.test.ts +++ b/ui/litellm-dashboard/src/lib/http/api.test.ts @@ -19,6 +19,21 @@ const capturingFetch = (response: Response) => { return { fetch, requests }; }; +const spyOnRequestConstruction = () => { + const NativeRequest = globalThis.Request; + const inits: Array = []; + class SpyingRequest extends NativeRequest { + constructor(input: RequestInfo | URL, init?: RequestInit) { + inits.push(init); + super(input, init); + } + } + vi.stubGlobal("Request", SpyingRequest); + const streamBodiedInits = () => + inits.filter((init) => (init instanceof NativeRequest ? init.body !== null : init?.body instanceof ReadableStream)); + return { streamBodiedInits }; +}; + describe("typed api client middleware", () => { beforeEach(() => { registerBaseUrlGetter(() => ""); @@ -29,6 +44,7 @@ describe("typed api client middleware", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("injects the bearer token under the registered auth header name", async () => { @@ -62,6 +78,35 @@ describe("typed api client middleware", () => { expect(url.searchParams.get("model_group")).toBe("gpt-4o"); }); + it("sends a POST body as bytes, never as a ReadableStream (Chromium rejects stream uploads over HTTP/1.1)", async () => { + registerAuthTokenGetter(() => "sk-test"); + const { streamBodiedInits } = spyOnRequestConstruction(); + const { fetch, requests } = capturingFetch(jsonResponse(200, { key: "sk-new" })); + + await fetchClient.POST("/key/generate", { fetch, body: { key_alias: "my-key" } }); + + expect(streamBodiedInits()).toEqual([]); + expect(requests[0].headers.get("Authorization")).toBe("Bearer sk-test"); + expect(await requests[0].text()).toBe(JSON.stringify({ key_alias: "my-key" })); + }); + + it("keeps the POST body as bytes when rebasing onto a runtime base url", async () => { + registerBaseUrlGetter(() => "https://proxy.example.com"); + registerAuthTokenGetter(() => "sk-test"); + const { streamBodiedInits } = spyOnRequestConstruction(); + const { fetch, requests } = capturingFetch(jsonResponse(200, { key: "sk-new" })); + + await fetchClient.POST("/key/generate", { fetch, body: { key_alias: "my-key" } }); + + expect(streamBodiedInits()).toEqual([]); + const sent = requests[0]; + expect(new URL(sent.url).origin).toBe("https://proxy.example.com"); + expect(sent.method).toBe("POST"); + expect(sent.headers.get("Authorization")).toBe("Bearer sk-test"); + expect(sent.headers.get("Content-Type")).toBe("application/json"); + expect(await sent.text()).toBe(JSON.stringify({ key_alias: "my-key" })); + }); + it("maps a non-2xx response to an ApiError carrying status and the derived message", async () => { const { fetch } = capturingFetch(jsonResponse(403, { error: { message: "no access" } })); diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts index aa6c2d6c0fdc..fa8c2c64d7ab 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -9,10 +9,27 @@ const rebaseUrl = (requestUrl: string, base: string): string => { return `${base.replace(/\/+$/, "")}${pathname}${search}`; }; +const rebaseRequest = async (request: Request, url: string): Promise => { + const init: RequestInit = { + method: request.method, + headers: request.headers, + body: request.body ? await request.arrayBuffer() : undefined, + mode: request.mode, + credentials: request.credentials, + cache: request.cache, + redirect: request.redirect, + referrer: request.referrer, + integrity: request.integrity, + keepalive: request.keepalive, + signal: request.signal, + }; + return new Request(url, init); +}; + const middleware: Middleware = { - onRequest({ request }) { + async onRequest({ request }) { const base = getRequestBaseUrl(); - const next = new Request(base ? rebaseUrl(request.url, base) : request.url, request); + const next = base ? await rebaseRequest(request, rebaseUrl(request.url, base)) : request; const token = getAuthToken(); if (token) { next.headers.set(getAuthHeaderName(), `Bearer ${token}`); From 113ed0d74120f3b83d1c768ea1b4e988806a4823 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 11:31:48 -0700 Subject: [PATCH 2/2] Update ui/litellm-dashboard/src/lib/http/api.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/lib/http/api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts index fa8c2c64d7ab..905fa045c260 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -19,6 +19,7 @@ const rebaseRequest = async (request: Request, url: string): Promise => cache: request.cache, redirect: request.redirect, referrer: request.referrer, + referrerPolicy: request.referrerPolicy, integrity: request.integrity, keepalive: request.keepalive, signal: request.signal,