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() {} + }; +}