mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
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:
parent
e9ac84dc8b
commit
b47fe730a4
2 changed files with 65 additions and 2 deletions
|
|
@ -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" } }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue