Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions ui/litellm-dashboard/src/lib/http/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => "");
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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" } }));

Expand Down
22 changes: 20 additions & 2 deletions ui/litellm-dashboard/src/lib/http/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
ryan-crabbe-berri marked this conversation as resolved.
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}`);
Expand Down
Loading