mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
refactor(ui): inject the fetch client's base url instead of reading it at import
api.ts read globalThis.location when the module loaded, which froze the base URL at import and pinned its test file to jsdom. The creation-time baseUrl and the middleware's runtime rebase were also two mechanisms doing overlapping work, and the rebase hand-copied eleven RequestInit fields on every call. Pass openapi-fetch's Request option instead, so the constructor applies whatever getRequestBaseUrl() returns at the moment the request is built. registerBaseUrlGetter is now the single source of the base URL, rebaseUrl and rebaseRequest are deleted, and the request is constructed once, so the init openapi-fetch assembled reaches the platform Request untouched. The abort signal is no longer copied by hand. This preserves behaviour rather than approximating it: getProxyBaseUrl() falls back to location.origin, so the runtime base was never empty in a browser and the old middleware already rebased every request, discarding the creation-time value each time. setupTests.ts gates its DOM-only tail behind a window check; setup files run for every environment, so that tail previously stopped any node-environment test file from loading. api.test.ts now runs under @vitest-environment node with its assertions intact and no location stub, plus regressions for per-call base resolution and abort forwarding. api.sameOrigin.test.ts covers the browser fallback to the page origin, which needs a DOM environment.
This commit is contained in:
parent
cbeaf86c8d
commit
a6b9cedd03
4 changed files with 171 additions and 104 deletions
46
ui/litellm-dashboard/src/lib/http/api.sameOrigin.test.ts
Normal file
46
ui/litellm-dashboard/src/lib/http/api.sameOrigin.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchClient } from "./api";
|
||||
import { registerAuthTokenGetter, registerBaseUrlGetter, registerErrorHandler } from "./runtime";
|
||||
|
||||
const jsonResponse = (status: number, body: unknown): Response =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
const capturingFetch = (response: Response) => {
|
||||
const requests: Request[] = [];
|
||||
const fetch = vi.fn(async (request: Request) => {
|
||||
requests.push(request);
|
||||
return response;
|
||||
});
|
||||
return { fetch, requests };
|
||||
};
|
||||
|
||||
describe("typed api client on a same-origin deployment", () => {
|
||||
beforeEach(() => {
|
||||
registerAuthTokenGetter(() => null);
|
||||
registerErrorHandler(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sends requests to the page origin when no base url is registered", async () => {
|
||||
registerBaseUrlGetter(() => "");
|
||||
const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] }));
|
||||
|
||||
await fetchClient.GET("/model_group/info", { fetch });
|
||||
|
||||
expect(requests[0].url).toBe(`${window.location.origin}/model_group/info`);
|
||||
});
|
||||
|
||||
it("prefers a registered cross-origin base over the page origin", async () => {
|
||||
registerBaseUrlGetter(() => "https://proxy.example.com");
|
||||
const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] }));
|
||||
|
||||
await fetchClient.GET("/model_group/info", { fetch });
|
||||
|
||||
expect(requests[0].url).toBe("https://proxy.example.com/model_group/info");
|
||||
expect(new URL(requests[0].url).origin).not.toBe(window.location.origin);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchClient } from "./api";
|
||||
import {
|
||||
|
|
@ -36,7 +37,7 @@ const spyOnRequestConstruction = () => {
|
|||
|
||||
describe("typed api client middleware", () => {
|
||||
beforeEach(() => {
|
||||
registerBaseUrlGetter(() => "");
|
||||
registerBaseUrlGetter(() => "http://localhost:4000");
|
||||
registerAuthHeaderNameGetter(() => "Authorization");
|
||||
registerErrorHandler(() => {});
|
||||
registerAuthTokenGetter(() => null);
|
||||
|
|
@ -66,7 +67,7 @@ describe("typed api client middleware", () => {
|
|||
expect(requests[0].headers.get("Authorization")).toBeNull();
|
||||
});
|
||||
|
||||
it("rebases the request onto the registered base url, preserving path and query", async () => {
|
||||
it("builds the request url from the registered base url, preserving path and query", async () => {
|
||||
registerBaseUrlGetter(() => "https://proxy.example.com/");
|
||||
const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] }));
|
||||
|
||||
|
|
@ -90,7 +91,7 @@ describe("typed api client middleware", () => {
|
|||
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 () => {
|
||||
it("keeps the POST body as bytes when a different runtime base url is registered", async () => {
|
||||
registerBaseUrlGetter(() => "https://proxy.example.com");
|
||||
registerAuthTokenGetter(() => "sk-test");
|
||||
const { streamBodiedInits } = spyOnRequestConstruction();
|
||||
|
|
@ -107,6 +108,43 @@ describe("typed api client middleware", () => {
|
|||
expect(await sent.text()).toBe(JSON.stringify({ key_alias: "my-key" }));
|
||||
});
|
||||
|
||||
it("reads the base url on every call, so a base registered after import still takes effect", async () => {
|
||||
const requests: Request[] = [];
|
||||
const fetch = vi.fn(async (request: Request) => {
|
||||
requests.push(request);
|
||||
return jsonResponse(200, { data: [] });
|
||||
});
|
||||
|
||||
registerBaseUrlGetter(() => "https://first.example.com");
|
||||
await fetchClient.GET("/model_group/info", { fetch });
|
||||
registerBaseUrlGetter(() => "https://second.example.com");
|
||||
await fetchClient.GET("/model_group/info", { fetch });
|
||||
|
||||
expect(requests.map((request) => new URL(request.url).origin)).toEqual([
|
||||
"https://first.example.com",
|
||||
"https://second.example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards the caller's abort signal so an in-flight request can be cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
const seen: Request[] = [];
|
||||
const fetch = vi.fn(
|
||||
(request: Request) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
seen.push(request);
|
||||
request.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = fetchClient.GET("/model_group/info", { fetch, signal: controller.signal });
|
||||
await vi.waitFor(() => expect(seen).toHaveLength(1));
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(seen[0].signal.aborted).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
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" } }));
|
||||
|
||||
|
|
|
|||
|
|
@ -4,38 +4,18 @@ import type { paths } from "./schema";
|
|||
import { ApiError, deriveErrorMessage } from "./client";
|
||||
import { getAuthHeaderName, getAuthToken, getRequestBaseUrl, reportError } from "./runtime";
|
||||
|
||||
const rebaseUrl = (requestUrl: string, base: string): string => {
|
||||
const { pathname, search } = new URL(requestUrl);
|
||||
return `${base.replace(/\/+$/, "")}${pathname}${search}`;
|
||||
};
|
||||
const resolveRequestBase = (): string => (getRequestBaseUrl() || globalThis.location?.origin || "").replace(/\/+$/, "");
|
||||
|
||||
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 BaseAwareRequest = function (url: string, init?: RequestInit): Request {
|
||||
return new globalThis.Request(`${resolveRequestBase()}${url}`, init);
|
||||
} as unknown as typeof Request;
|
||||
|
||||
const middleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
const base = getRequestBaseUrl();
|
||||
const next = base ? await rebaseRequest(request, rebaseUrl(request.url, base)) : request;
|
||||
onRequest({ request }) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
next.headers.set(getAuthHeaderName(), `Bearer ${token}`);
|
||||
request.headers.set(getAuthHeaderName(), `Bearer ${token}`);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
async onResponse({ response }) {
|
||||
if (response.ok) return response;
|
||||
|
|
@ -58,12 +38,13 @@ const middleware: Middleware = {
|
|||
* (`fetchClient.GET("/path", { params })`) and for imperative calls; path
|
||||
* params, query params, and request bodies are inferred from schema.d.ts.
|
||||
*
|
||||
* The creation-time base is the current origin so request URLs are absolute; the
|
||||
* middleware rebases each call onto the runtime base when one is registered (a
|
||||
* split-origin proxy or worker URL), injects the auth header, and maps non-2xx
|
||||
* responses to ApiError so query functions can just read `.data`.
|
||||
* The base URL is injected, not fixed at import: every request is built against
|
||||
* whatever registerBaseUrlGetter supplies at call time (a split-origin proxy or
|
||||
* worker URL), falling back to the current origin. The middleware injects the
|
||||
* auth header and maps non-2xx responses to ApiError so query functions can just
|
||||
* read `.data`.
|
||||
*/
|
||||
export const fetchClient = createFetchClient<paths>({ baseUrl: globalThis.location?.origin ?? "" });
|
||||
export const fetchClient = createFetchClient<paths>({ Request: BaseAwareRequest });
|
||||
fetchClient.use(middleware);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -183,78 +183,80 @@ vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(function (this: Da
|
|||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
});
|
||||
|
||||
// Fixed matchMedia not found error in tests: https://github.com/vitest-dev/vitest/issues/821
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
// Fixed matchMedia not found error in tests: https://github.com/vitest-dev/vitest/issues/821
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Silence jsdom "getComputedStyle with pseudo-elements" not implemented warnings
|
||||
// by ignoring the second argument and delegating to the native implementation.
|
||||
const realGetComputedStyle = window.getComputedStyle.bind(window);
|
||||
window.getComputedStyle = ((elt: Element) => realGetComputedStyle(elt)) as any;
|
||||
// Silence jsdom "getComputedStyle with pseudo-elements" not implemented warnings
|
||||
// by ignoring the second argument and delegating to the native implementation.
|
||||
const realGetComputedStyle = window.getComputedStyle.bind(window);
|
||||
window.getComputedStyle = ((elt: Element) => realGetComputedStyle(elt)) as any;
|
||||
|
||||
// Avoid "navigation to another Document" warnings when clicking <a> with blob: URLs
|
||||
// used by download flows in tests.
|
||||
Object.defineProperty(HTMLAnchorElement.prototype, "click", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
// Avoid "navigation to another Document" warnings when clicking <a> with blob: URLs
|
||||
// used by download flows in tests.
|
||||
Object.defineProperty(HTMLAnchorElement.prototype, "click", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
if (!document.getAnimations) {
|
||||
document.getAnimations = () => [];
|
||||
}
|
||||
|
||||
// Stub URL.revokeObjectURL so vi.spyOn can intercept it in tests
|
||||
if (!URL.revokeObjectURL) {
|
||||
URL.revokeObjectURL = () => {};
|
||||
}
|
||||
|
||||
// Mock ResizeObserver for components that use it (recharts, Tremor UI components).
|
||||
// JSDOM has no layout, so for observers inside a shadcn ChartContainer ([data-slot="chart"])
|
||||
// the mock immediately reports a fixed 800x400 box; recharts renders nothing until it
|
||||
// observes a size. Scoped to chart subtrees only: firing for every observer re-enters
|
||||
// React mid-effect for tremor/headlessui consumers whose tests assume the old no-op
|
||||
// (chart text would duplicate getByText targets, popover clicks go stale). Widen or
|
||||
// drop the scoping once tremor is gone.
|
||||
const MOCK_RESIZE_BOX = { inlineSize: 800, blockSize: 400 };
|
||||
const MOCK_RESIZE_RECT: DOMRectReadOnly = {
|
||||
width: 800,
|
||||
height: 400,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
private readonly callback: ResizeObserverCallback;
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
if (!document.getAnimations) {
|
||||
document.getAnimations = () => [];
|
||||
}
|
||||
observe(target: Element) {
|
||||
if (!target.closest('[data-slot="chart"]')) return;
|
||||
const entry: ResizeObserverEntry = {
|
||||
target,
|
||||
contentRect: MOCK_RESIZE_RECT,
|
||||
borderBoxSize: [MOCK_RESIZE_BOX],
|
||||
contentBoxSize: [MOCK_RESIZE_BOX],
|
||||
devicePixelContentBoxSize: [MOCK_RESIZE_BOX],
|
||||
};
|
||||
this.callback([entry], this);
|
||||
|
||||
// Stub URL.revokeObjectURL so vi.spyOn can intercept it in tests
|
||||
if (!URL.revokeObjectURL) {
|
||||
URL.revokeObjectURL = () => {};
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
// Mock ResizeObserver for components that use it (recharts, Tremor UI components).
|
||||
// JSDOM has no layout, so for observers inside a shadcn ChartContainer ([data-slot="chart"])
|
||||
// the mock immediately reports a fixed 800x400 box; recharts renders nothing until it
|
||||
// observes a size. Scoped to chart subtrees only: firing for every observer re-enters
|
||||
// React mid-effect for tremor/headlessui consumers whose tests assume the old no-op
|
||||
// (chart text would duplicate getByText targets, popover clicks go stale). Widen or
|
||||
// drop the scoping once tremor is gone.
|
||||
const MOCK_RESIZE_BOX = { inlineSize: 800, blockSize: 400 };
|
||||
const MOCK_RESIZE_RECT: DOMRectReadOnly = {
|
||||
width: 800,
|
||||
height: 400,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
private readonly callback: ResizeObserverCallback;
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe(target: Element) {
|
||||
if (!target.closest('[data-slot="chart"]')) return;
|
||||
const entry: ResizeObserverEntry = {
|
||||
target,
|
||||
contentRect: MOCK_RESIZE_RECT,
|
||||
borderBoxSize: [MOCK_RESIZE_BOX],
|
||||
contentBoxSize: [MOCK_RESIZE_BOX],
|
||||
devicePixelContentBoxSize: [MOCK_RESIZE_BOX],
|
||||
};
|
||||
this.callback([entry], this);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue