From 80e5f22e26c7e0a23f86e91d7b66e29a82dbf636 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 6 Jun 2026 21:45:34 -0700 Subject: [PATCH] feat(ui): add the typed openapi-fetch client (fetchClient) as the dashboard fetch foundation Introduces fetchClient (openapi-fetch) bound to schema.d.ts, used inside ordinary TanStack Query hooks so path/query/body types come from the proxy's OpenAPI spec. A small runtime registry feeds the client the base URL and auth header name (registered by networking) and the session token (published by AuthContext), so call sites carry no token plumbing; auth-header injection and ApiError mapping live in openapi-fetch middleware reusing deriveErrorMessage/ApiError from client.ts, and non-2xx maps to a thrown ApiError so query functions just read .data. The base URL default resolves from NEXT_PUBLIC_BASE_URL so a request still targets the right origin if it fires before networking registers its getter. AuthContext clears accessToken alongside the token on logout so no query fires unauthenticated after the session ends. Foundation only; callers migrate one at a time, each fully typed, in follow-up changes. --- ui/litellm-dashboard/package-lock.json | 16 ++++ ui/litellm-dashboard/package.json | 1 + .../src/components/networking.tsx | 4 + .../src/contexts/AuthContext.tsx | 4 + ui/litellm-dashboard/src/lib/http/api.test.ts | 78 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/api.ts | 47 +++++++++++ .../src/lib/http/runtime.test.ts | 22 ++++++ ui/litellm-dashboard/src/lib/http/runtime.ts | 38 +++++++++ 8 files changed, 210 insertions(+) create mode 100644 ui/litellm-dashboard/src/lib/http/api.test.ts create mode 100644 ui/litellm-dashboard/src/lib/http/api.ts create mode 100644 ui/litellm-dashboard/src/lib/http/runtime.test.ts create mode 100644 ui/litellm-dashboard/src/lib/http/runtime.ts diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 7000efd63b4..a20f4fcb2c1 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -25,6 +25,7 @@ "moment": "2.30.1", "next": "16.2.6", "openai": "4.104.0", + "openapi-fetch": "^0.17.0", "papaparse": "5.5.3", "react": "18.3.1", "react-copy-to-clipboard": "5.1.1", @@ -10039,6 +10040,15 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, "node_modules/openapi-typescript": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", @@ -10060,6 +10070,12 @@ "typescript": "^5.x" } }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/openapi-typescript/node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index ca753e59dc5..529d1233ac2 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -38,6 +38,7 @@ "moment": "2.30.1", "next": "16.2.6", "openai": "4.104.0", + "openapi-fetch": "^0.17.0", "papaparse": "5.5.3", "react": "18.3.1", "react-copy-to-clipboard": "5.1.1", diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d303734dffd..feba5e211a7 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -43,6 +43,7 @@ import NotificationsManager from "./molecules/notifications_manager"; import type { MCPUserEnvVarsStatus } from "./mcp_tools/types"; import { createApiClient, deriveErrorMessage } from "@/lib/http/client"; import { resolveApiBase } from "@/lib/http/resolveApiBase"; +import { registerAuthHeaderNameGetter, registerBaseUrlGetter } from "@/lib/http/runtime"; export { deriveErrorMessage }; export { ApiError } from "@/lib/http/client"; @@ -377,6 +378,9 @@ const apiClient = createApiClient({ onError: handleError, }); +registerBaseUrlGetter(getProxyBaseUrl); +registerAuthHeaderNameGetter(getGlobalLitellmHeaderName); + export const makeModelGroupPublic = async (accessToken: string, modelGroups: string[]) => { const url = proxyBaseUrl ? `${proxyBaseUrl}/model_group/make_public` : `/model_group/make_public`; const response = await fetch(url, { diff --git a/ui/litellm-dashboard/src/contexts/AuthContext.tsx b/ui/litellm-dashboard/src/contexts/AuthContext.tsx index 3693d858952..defdb15b639 100644 --- a/ui/litellm-dashboard/src/contexts/AuthContext.tsx +++ b/ui/litellm-dashboard/src/contexts/AuthContext.tsx @@ -6,6 +6,7 @@ import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { isJwtExpired } from "@/utils/jwtUtils"; import { formatUserRole } from "@/utils/roles"; import { getUiConfig, setGlobalLitellmHeaderName } from "@/components/networking"; +import { setAuthToken } from "@/lib/http/runtime"; function deleteCookie(name: string, path = "/") { document.cookie = `${name}=; Max-Age=0; Path=${path}`; @@ -83,6 +84,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Decode JWT and populate derived auth state whenever the token changes. useEffect(() => { if (!token) { + setAuthToken(null); + setAccessToken(null); return; } @@ -104,6 +107,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (!decoded) return; setAccessToken(decoded.key); + setAuthToken(decoded.key); setDisabledPersonalKeyCreation(decoded.disabled_non_admin_personal_key_creation); if (decoded.user_role) { diff --git a/ui/litellm-dashboard/src/lib/http/api.test.ts b/ui/litellm-dashboard/src/lib/http/api.test.ts new file mode 100644 index 00000000000..798645d38b8 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/api.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchClient } from "./api"; +import { registerAuthHeaderNameGetter, registerBaseUrlGetter, setAuthToken } 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 middleware", () => { + beforeEach(() => { + registerBaseUrlGetter(() => ""); + registerAuthHeaderNameGetter(() => "Authorization"); + setAuthToken(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("injects the bearer token under the registered auth header name", async () => { + setAuthToken("sk-test"); + registerAuthHeaderNameGetter(() => "x-litellm-key"); + const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] })); + + await fetchClient.GET("/model_group/info", { fetch }); + + expect(requests[0].headers.get("x-litellm-key")).toBe("Bearer sk-test"); + expect(requests[0].headers.get("Authorization")).toBeNull(); + }); + + it("omits the auth header when no token is set", async () => { + const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] })); + + await fetchClient.GET("/model_group/info", { fetch }); + + expect(requests[0].headers.get("Authorization")).toBeNull(); + }); + + it("rebases the request onto the registered base url, preserving path and query", async () => { + registerBaseUrlGetter(() => "https://proxy.example.com/"); + const { fetch, requests } = capturingFetch(jsonResponse(200, { data: [] })); + + await fetchClient.GET("/model_group/info", { fetch, params: { query: { model_group: "gpt-4o" } } }); + + const url = new URL(requests[0].url); + expect(url.origin).toBe("https://proxy.example.com"); + expect(url.pathname).toBe("/model_group/info"); + expect(url.searchParams.get("model_group")).toBe("gpt-4o"); + }); + + 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" } })); + + await expect(fetchClient.GET("/model_group/info", { fetch })).rejects.toMatchObject({ + name: "ApiError", + status: 403, + message: "no access", + }); + }); + + it("returns the parsed body on a successful response", async () => { + const body = { data: [{ model_group: "gpt-4o" }] }; + const { fetch } = capturingFetch(jsonResponse(200, body)); + + const { data, error } = await fetchClient.GET("/model_group/info", { fetch }); + + expect(error).toBeUndefined(); + expect(data).toEqual(body); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts new file mode 100644 index 00000000000..9d9eaf883e6 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -0,0 +1,47 @@ +import createFetchClient, { type Middleware } from "openapi-fetch"; +import type { paths } from "./schema"; +import { ApiError, deriveErrorMessage } from "./client"; +import { getAuthHeaderName, getAuthToken, getRequestBaseUrl } from "./runtime"; + +const rebaseUrl = (requestUrl: string, base: string): string => { + const { pathname, search } = new URL(requestUrl); + return `${base.replace(/\/+$/, "")}${pathname}${search}`; +}; + +const middleware: Middleware = { + onRequest({ request }) { + const base = getRequestBaseUrl(); + const next = new Request(base ? rebaseUrl(request.url, base) : request.url, request); + const token = getAuthToken(); + if (token) { + next.headers.set(getAuthHeaderName(), `Bearer ${token}`); + } + return next; + }, + async onResponse({ response }) { + if (response.ok) return response; + const raw = await response.clone().text(); + let body: unknown = raw; + let message: string; + try { + body = JSON.parse(raw); + message = deriveErrorMessage(body); + } catch { + message = raw || `HTTP ${response.status}`; + } + throw new ApiError(message, response.status, body); + }, +}; + +/** + * The typed, schema-bound HTTP client. Use it inside TanStack Query hooks + * (`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`. + */ +export const fetchClient = createFetchClient({ baseUrl: globalThis.location?.origin ?? "" }); +fetchClient.use(middleware); diff --git a/ui/litellm-dashboard/src/lib/http/runtime.test.ts b/ui/litellm-dashboard/src/lib/http/runtime.test.ts new file mode 100644 index 00000000000..a475cc2bb10 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/runtime.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getAuthHeaderName, getRequestBaseUrl } from "./runtime"; + +describe("runtime request config defaults", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("resolves the default base URL from NEXT_PUBLIC_BASE_URL before a getter is registered", () => { + vi.stubEnv("NEXT_PUBLIC_BASE_URL", "https://proxy.example.com/"); + expect(getRequestBaseUrl()).toBe("https://proxy.example.com"); + }); + + it("defaults the base URL to same-origin when NEXT_PUBLIC_BASE_URL is unset", () => { + vi.stubEnv("NEXT_PUBLIC_BASE_URL", ""); + expect(getRequestBaseUrl()).toBe(""); + }); + + it("defaults the auth header name to Authorization", () => { + expect(getAuthHeaderName()).toBe("Authorization"); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/http/runtime.ts b/ui/litellm-dashboard/src/lib/http/runtime.ts new file mode 100644 index 00000000000..6397d440efa --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/runtime.ts @@ -0,0 +1,38 @@ +import { resolveApiBase } from "./resolveApiBase"; + +/** + * Runtime request config the typed client reads on every call. The values are + * mutable at runtime (base URL can switch to a worker origin; the auth header + * name and token come from the logged-in session), and they are owned outside + * this module: networking.tsx registers the base URL / header-name getters and + * AuthContext publishes the token. Keeping the seam here (not importing from the + * component tree) lets api.ts stay in lib/http without a layering inversion. + * + * The base URL default resolves from NEXT_PUBLIC_BASE_URL so a request still + * hits the right origin if it fires before networking registers its fuller + * getter (which additionally folds in the server root path from the live UI + * config). The auth header name has no build-time source, so it defaults to + * "Authorization" until the session's JWT supplies a custom one. + */ + +type Getter = () => T; + +let baseUrlGetter: Getter = () => resolveApiBase({ explicitBase: process.env.NEXT_PUBLIC_BASE_URL }); +let authHeaderNameGetter: Getter = () => "Authorization"; +let authToken: string | null = null; + +export const registerBaseUrlGetter = (getter: Getter): void => { + baseUrlGetter = getter; +}; + +export const registerAuthHeaderNameGetter = (getter: Getter): void => { + authHeaderNameGetter = getter; +}; + +export const setAuthToken = (token: string | null): void => { + authToken = token; +}; + +export const getRequestBaseUrl = (): string => baseUrlGetter(); +export const getAuthHeaderName = (): string => authHeaderNameGetter(); +export const getAuthToken = (): string | null => authToken;