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.
This commit is contained in:
ryan-crabbe-berri 2026-06-06 21:45:34 -07:00
parent 118176f21a
commit 80e5f22e26
8 changed files with 210 additions and 0 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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, {

View file

@ -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) {

View file

@ -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);
});
});

View file

@ -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<paths>({ baseUrl: globalThis.location?.origin ?? "" });
fetchClient.use(middleware);

View file

@ -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");
});
});

View file

@ -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> = () => T;
let baseUrlGetter: Getter<string> = () => resolveApiBase({ explicitBase: process.env.NEXT_PUBLIC_BASE_URL });
let authHeaderNameGetter: Getter<string> = () => "Authorization";
let authToken: string | null = null;
export const registerBaseUrlGetter = (getter: Getter<string>): void => {
baseUrlGetter = getter;
};
export const registerAuthHeaderNameGetter = (getter: Getter<string>): 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;