From a6b9cedd03a9bb738228bd141c021de0d6666c1b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 4 Aug 2026 12:07:45 -0700 Subject: [PATCH] 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. --- .../src/lib/http/api.sameOrigin.test.ts | 46 ++++++ ui/litellm-dashboard/src/lib/http/api.test.ts | 44 +++++- ui/litellm-dashboard/src/lib/http/api.ts | 43 ++---- ui/litellm-dashboard/tests/setupTests.ts | 142 +++++++++--------- 4 files changed, 171 insertions(+), 104 deletions(-) create mode 100644 ui/litellm-dashboard/src/lib/http/api.sameOrigin.test.ts diff --git a/ui/litellm-dashboard/src/lib/http/api.sameOrigin.test.ts b/ui/litellm-dashboard/src/lib/http/api.sameOrigin.test.ts new file mode 100644 index 00000000000..094b279bdd9 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/api.sameOrigin.test.ts @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/http/api.test.ts b/ui/litellm-dashboard/src/lib/http/api.test.ts index 7bbbf38da09..f7757e58e90 100644 --- a/ui/litellm-dashboard/src/lib/http/api.test.ts +++ b/ui/litellm-dashboard/src/lib/http/api.test.ts @@ -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((_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" } })); diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts index 905fa045c26..ef628169ea1 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -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 => { - 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({ baseUrl: globalThis.location?.origin ?? "" }); +export const fetchClient = createFetchClient({ Request: BaseAwareRequest }); fetchClient.use(middleware); /** diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index a865d5c24fe..1ff0bed9862 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -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 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 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() {} + }; +}