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>
This commit is contained in:
ryan-crabbe-berri 2026-07-21 14:26:02 -07:00 committed by GitHub
parent e9ac84dc8b
commit b47fe730a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 65 additions and 2 deletions

View file

@ -19,6 +19,21 @@ const capturingFetch = (response: Response) => {
return { fetch, requests };
};
const spyOnRequestConstruction = () => {
const NativeRequest = globalThis.Request;
const inits: Array<RequestInit | Request | undefined> = [];
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" } }));

View file

@ -9,10 +9,28 @@ const rebaseUrl = (requestUrl: string, base: string): string => {
return `${base.replace(/\/+$/, "")}${pathname}${search}`;
};
const rebaseRequest = async (request: Request, url: string): Promise<Request> => {
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}`);