From b47fe730a4adf5b488d9e77fced642fd399772e6 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 14:26:02 -0700 Subject: [PATCH] fix(ui): stop cloning body-carrying requests into stream uploads in fetchClient middleware (#34122) * 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 * Update ui/litellm-dashboard/src/lib/http/api.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/lib/http/api.test.ts | 45 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/api.ts | 22 ++++++++- 2 files changed, 65 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 fba2b7f77dc..7bbbf38da09 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 aa6c2d6c0fd..905fa045c26 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -9,10 +9,28 @@ 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, + referrerPolicy: request.referrerPolicy, + 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}`);