MAESTRO: Create typed API client with in-memory JWT auth, fetch wrappers, and WebSocket helper
This commit is contained in:
parent
4cd0b8a1c8
commit
43d2aafbbe
4 changed files with 1099 additions and 1 deletions
|
|
@ -41,6 +41,7 @@ Set up the PromptLooper repository, Docker infrastructure, and basic project ske
|
|||
- [x] Initialize the frontend: run npm create vite@latest with React + TypeScript template. Install Tailwind CSS and configure it. Install react-router-dom for routing. Create the basic App.tsx with routes for Setup, Login, Dashboard, Projects, Experiment, Live, Compare, and Admin pages (all as placeholder components). Verify it builds cleanly.
|
||||
> Frontend was already scaffolded with Vite + React + TypeScript + Tailwind + react-router-dom from the Dockerfile task. Added 8 placeholder page components (SetupPage, LoginPage, DashboardPage, ProjectsPage, ExperimentPage, LivePage, ComparePage, AdminPage) in frontend/src/pages/. Updated App.tsx with react-router-dom Routes and main.tsx with BrowserRouter. Unknown routes redirect to dashboard. Installed vitest + @testing-library/react for testing. 9 routing tests in App.test.tsx all passing. Build completes cleanly. All 132 backend tests still pass.
|
||||
|
||||
- [ ] Create frontend/src/api/client.ts with a typed API client using fetch. Include JWT token management (stored in memory, not localStorage), request/response interceptors for auth headers, and typed wrapper functions for each API endpoint group. Include WebSocket connection helper.
|
||||
- [x] Create frontend/src/api/client.ts with a typed API client using fetch. Include JWT token management (stored in memory, not localStorage), request/response interceptors for auth headers, and typed wrapper functions for each API endpoint group. Include WebSocket connection helper.
|
||||
> Created frontend/src/api/client.ts with: TypeScript interfaces mirroring all backend Pydantic schemas, in-memory JWT token management (setToken/getToken/clearToken — never localStorage), automatic Authorization header injection on all requests, Content-Type header for POST/PUT bodies, ApiError class for non-ok responses, typed wrapper functions for all 8 endpoint groups (auth, projects, experiments, runs, endpoints, export, webhooks, admin) plus health check, and connectWebSocket() helper that derives ws/wss from current protocol and handles JSON message parsing. 39 tests in src/api/client.test.ts covering token management, header injection, all endpoint groups, error handling, and WebSocket lifecycle. All 48 frontend tests pass. All 132 backend tests still pass.
|
||||
|
||||
- [ ] Verify the full stack runs: docker compose up should start all services. The API should respond to /health. The frontend should load and show the setup screen (since no admin exists). The database migration should have run. Document any manual steps needed in the README.
|
||||
|
|
|
|||
552
frontend/src/api/client.test.ts
Normal file
552
frontend/src/api/client.test.ts
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
setToken,
|
||||
getToken,
|
||||
clearToken,
|
||||
ApiError,
|
||||
auth,
|
||||
projects,
|
||||
experiments,
|
||||
runs,
|
||||
endpoints,
|
||||
exportApi,
|
||||
webhooks,
|
||||
admin,
|
||||
health,
|
||||
connectWebSocket,
|
||||
} from "./client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
clearToken();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "Error",
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
headers: new Headers(),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function noContentResponse(): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204,
|
||||
statusText: "No Content",
|
||||
json: () => Promise.reject(new Error("no body")),
|
||||
text: () => Promise.resolve(""),
|
||||
headers: new Headers(),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("token management", () => {
|
||||
it("starts with null token", () => {
|
||||
expect(getToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("sets and gets token", () => {
|
||||
setToken("abc123");
|
||||
expect(getToken()).toBe("abc123");
|
||||
});
|
||||
|
||||
it("clears token", () => {
|
||||
setToken("abc123");
|
||||
clearToken();
|
||||
expect(getToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth header injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("auth header injection", () => {
|
||||
it("sends Authorization header when token is set", async () => {
|
||||
setToken("my-jwt");
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
|
||||
|
||||
await health.check();
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>)["Authorization"]).toBe(
|
||||
"Bearer my-jwt",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits Authorization header when no token", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ status: "ok" }));
|
||||
|
||||
await health.check();
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(
|
||||
(init.headers as Record<string, string>)["Authorization"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ApiError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ApiError", () => {
|
||||
it("throws ApiError on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ detail: "not found" }, 404),
|
||||
);
|
||||
|
||||
await expect(projects.get("some-id")).rejects.toThrow(ApiError);
|
||||
|
||||
try {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ detail: "bad" }, 400),
|
||||
);
|
||||
await projects.get("some-id");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ApiError);
|
||||
expect((e as ApiError).status).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content-Type header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("content-type", () => {
|
||||
it("sets Content-Type for POST with body", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ access_token: "tok", token_type: "bearer" }),
|
||||
);
|
||||
|
||||
await auth.setup({ username: "admin", password: "password123" });
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>)["Content-Type"]).toBe(
|
||||
"application/json",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits Content-Type for GET requests", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||
|
||||
await projects.list();
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(
|
||||
(init.headers as Record<string, string>)["Content-Type"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("health", () => {
|
||||
it("calls /health", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ status: "ok", database: true, redis: true }),
|
||||
);
|
||||
|
||||
const result = await health.check();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/health", expect.anything());
|
||||
expect(result.status).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("auth", () => {
|
||||
it("setup POSTs to /api/auth/setup", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ access_token: "tok", token_type: "bearer" }),
|
||||
);
|
||||
|
||||
const result = await auth.setup({
|
||||
username: "admin",
|
||||
password: "password123",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/auth/setup",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.access_token).toBe("tok");
|
||||
});
|
||||
|
||||
it("login sets token automatically", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ access_token: "jwt-123", token_type: "bearer" }),
|
||||
);
|
||||
|
||||
await auth.login({ username: "admin", password: "pass" });
|
||||
|
||||
expect(getToken()).toBe("jwt-123");
|
||||
});
|
||||
|
||||
it("me GETs /api/auth/me", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "u1",
|
||||
username: "admin",
|
||||
is_admin: true,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await auth.me();
|
||||
expect(user.username).toBe("admin");
|
||||
});
|
||||
|
||||
it("logout clears token", () => {
|
||||
setToken("tok");
|
||||
auth.logout();
|
||||
expect(getToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("projects", () => {
|
||||
it("list GETs /api/projects/", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||
await projects.list();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/projects/",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("create POSTs to /api/projects/", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ id: "p1", name: "Test" }),
|
||||
);
|
||||
await projects.create({ name: "Test" });
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ name: "Test" });
|
||||
});
|
||||
|
||||
it("get fetches by id", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "p1" }));
|
||||
await projects.get("p1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/projects/p1",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("update PUTs by id", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "p1" }));
|
||||
await projects.update("p1", { name: "New" });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("/api/projects/p1");
|
||||
expect(init.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("delete DELETEs by id", async () => {
|
||||
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||
await projects.delete("p1");
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("/api/projects/p1");
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experiments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("experiments", () => {
|
||||
it("list GETs /api/experiments/", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||
await experiments.list();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/experiments/",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("startSweep POSTs to sweep endpoint", async () => {
|
||||
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||
await experiments.startSweep("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/experiments/e1/sweep",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("pause POSTs to pause endpoint", async () => {
|
||||
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||
await experiments.pause("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/experiments/e1/pause",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("resume POSTs to resume endpoint", async () => {
|
||||
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||
await experiments.resume("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/experiments/e1/resume",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("stop POSTs to stop endpoint", async () => {
|
||||
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||
await experiments.stop("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/experiments/e1/stop",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("runs", () => {
|
||||
it("list GETs runs for experiment", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||
await runs.list("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/runs/experiments/e1/runs",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("get fetches run detail", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ id: "r1", stage_results: [], scores: [] }),
|
||||
);
|
||||
await runs.get("r1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/runs/r1",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("score POSTs to run score endpoint", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "s1" }));
|
||||
await runs.score("r1", { scorer_name: "human", value: 0.9 });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("/api/runs/r1/score");
|
||||
expect(init.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("leaderboard GETs leaderboard", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||
await runs.leaderboard("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/runs/experiments/e1/leaderboard",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("endpoints", () => {
|
||||
it("list GETs /api/endpoints/", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||
await endpoints.list();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/endpoints/",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("test POSTs to test endpoint", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ models: [] }));
|
||||
await endpoints.test("ep1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/endpoints/ep1/test",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("exportApi", () => {
|
||||
it("best GETs best config", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
||||
await exportApi.best("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/export/experiments/e1/best",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("env GETs env export", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse("KEY=val"));
|
||||
await exportApi.env("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/export/experiments/e1/env",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("report GETs report", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse("# Report"));
|
||||
await exportApi.report("e1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/export/experiments/e1/report",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("webhooks", () => {
|
||||
it("list GETs /api/webhooks/", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], total: 0 }));
|
||||
await webhooks.list();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/webhooks/",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("create POSTs webhook", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "w1" }));
|
||||
await webhooks.create({ event_type: "run.complete", url: "http://x" });
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("delete DELETEs webhook", async () => {
|
||||
mockFetch.mockResolvedValueOnce(noContentResponse());
|
||||
await webhooks.delete("w1");
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("/api/webhooks/w1");
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("admin", () => {
|
||||
it("getSettings GETs /api/admin/settings", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
||||
await admin.getSettings();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/admin/settings",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("updateSettings PUTs /api/admin/settings", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
||||
await admin.updateSettings({ guest_access: true });
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("getStats GETs /api/admin/stats", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({}));
|
||||
await admin.getStats();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/admin/stats",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("connectWebSocket", () => {
|
||||
it("creates WebSocket with correct URL and handles messages", () => {
|
||||
const sendSpy = vi.fn();
|
||||
const closeSpy = vi.fn();
|
||||
let capturedInstance: {
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
readyState: number;
|
||||
};
|
||||
|
||||
// Use a class constructor so `new WebSocket(...)` works
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
readyState = 1;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
send = sendSpy;
|
||||
close = closeSpy;
|
||||
constructor(public url: string) {
|
||||
capturedInstance = this;
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "http:", host: "localhost:5173" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const conn = connectWebSocket(onMessage, onClose);
|
||||
|
||||
expect(capturedInstance!.url).toBe("ws://localhost:5173/ws");
|
||||
|
||||
// Simulate incoming message
|
||||
capturedInstance!.onmessage!({ data: JSON.stringify({ type: "update" }) });
|
||||
expect(onMessage).toHaveBeenCalledWith({ type: "update" });
|
||||
|
||||
// Send message
|
||||
conn.send({ type: "ping" });
|
||||
expect(sendSpy).toHaveBeenCalledWith('{"type":"ping"}');
|
||||
|
||||
// Simulate close
|
||||
capturedInstance!.onclose!();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
// Close from client
|
||||
conn.close();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
545
frontend/src/api/client.ts
Normal file
545
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
/**
|
||||
* PromptLooper typed API client.
|
||||
*
|
||||
* - JWT token stored in memory (never localStorage) for security.
|
||||
* - Automatic Authorization header injection.
|
||||
* - Typed wrapper functions for every API endpoint group.
|
||||
* - WebSocket connection helper for real-time updates.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirrors backend Pydantic schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProjectCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectListResponse {
|
||||
items: ProjectResponse[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ExperimentCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
sample_data?: Record<string, unknown> | null;
|
||||
pipeline_stages?: Record<string, unknown> | null;
|
||||
scoring_config?: Record<string, unknown> | null;
|
||||
parameter_space?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ExperimentUpdate {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
sample_data?: Record<string, unknown> | null;
|
||||
pipeline_stages?: Record<string, unknown> | null;
|
||||
scoring_config?: Record<string, unknown> | null;
|
||||
parameter_space?: Record<string, unknown> | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export interface ExperimentResponse {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sample_data: Record<string, unknown> | null;
|
||||
pipeline_stages: Record<string, unknown> | null;
|
||||
scoring_config: Record<string, unknown> | null;
|
||||
parameter_space: Record<string, unknown> | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ExperimentListResponse {
|
||||
items: ExperimentResponse[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RunResponse {
|
||||
id: string;
|
||||
experiment_id: string;
|
||||
config_hash: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
duration_ms: number | null;
|
||||
tokens_in: number | null;
|
||||
tokens_out: number | null;
|
||||
cost_estimate: number | null;
|
||||
}
|
||||
|
||||
export interface RunListResponse {
|
||||
items: RunResponse[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface StageResultResponse {
|
||||
id: string;
|
||||
run_id: string;
|
||||
stage_index: number;
|
||||
prompt_sent: string;
|
||||
response_raw: string;
|
||||
model_used: string;
|
||||
parameters: Record<string, unknown> | null;
|
||||
tokens_in: number | null;
|
||||
tokens_out: number | null;
|
||||
latency_ms: number | null;
|
||||
}
|
||||
|
||||
export interface ScoreResponse {
|
||||
id: string;
|
||||
run_id: string;
|
||||
scorer_name: string;
|
||||
value: number;
|
||||
scorer_metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RunDetailResponse extends RunResponse {
|
||||
stage_results: StageResultResponse[];
|
||||
scores: ScoreResponse[];
|
||||
}
|
||||
|
||||
export interface ScoreInput {
|
||||
scorer_name: string;
|
||||
value: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EndpointCreate {
|
||||
name: string;
|
||||
url: string;
|
||||
api_key?: string | null;
|
||||
default_model?: string | null;
|
||||
}
|
||||
|
||||
export interface EndpointUpdate {
|
||||
name?: string | null;
|
||||
url?: string | null;
|
||||
api_key?: string | null;
|
||||
default_model?: string | null;
|
||||
}
|
||||
|
||||
export interface EndpointResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
default_model: string | null;
|
||||
}
|
||||
|
||||
export interface EndpointListResponse {
|
||||
items: EndpointResponse[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface WebhookCreate {
|
||||
event_type: string;
|
||||
url: string;
|
||||
headers?: Record<string, string> | null;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookUpdate {
|
||||
event_type?: string | null;
|
||||
url?: string | null;
|
||||
headers?: Record<string, string> | null;
|
||||
is_active?: boolean | null;
|
||||
}
|
||||
|
||||
export interface WebhookResponse {
|
||||
id: string;
|
||||
event_type: string;
|
||||
url: string;
|
||||
headers: Record<string, string> | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookListResponse {
|
||||
items: WebhookResponse[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SetupRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
database: boolean;
|
||||
redis: boolean;
|
||||
}
|
||||
|
||||
export interface ExportRunRow {
|
||||
run_id: string;
|
||||
experiment_id: string;
|
||||
config_hash: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
duration_ms: number | null;
|
||||
tokens_in: number | null;
|
||||
tokens_out: number | null;
|
||||
cost_estimate: number | null;
|
||||
scores: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ExportResponse {
|
||||
experiment_id: string;
|
||||
experiment_name: string;
|
||||
rows: ExportRunRow[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API Error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
public body: unknown,
|
||||
) {
|
||||
super(`API ${status}: ${statusText}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token management (in-memory only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _accessToken: string | null = null;
|
||||
|
||||
export function setToken(token: string | null): void {
|
||||
_accessToken = token;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return _accessToken;
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
_accessToken = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base fetch wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BASE_URL = ""; // Uses Vite proxy in dev; same origin in prod
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
// Inject auth header
|
||||
if (_accessToken) {
|
||||
headers["Authorization"] = `Bearer ${_accessToken}`;
|
||||
}
|
||||
|
||||
// Default content-type for requests with bodies
|
||||
if (options.body && !headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = await response.text();
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function get<T>(path: string): Promise<T> {
|
||||
return request<T>(path, { method: "GET" });
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: "POST",
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function put<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: "PUT",
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function del<T>(path: string): Promise<T> {
|
||||
return request<T>(path, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const health = {
|
||||
check: () => get<HealthResponse>("/health"),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const auth = {
|
||||
setup: (data: SetupRequest) =>
|
||||
post<TokenResponse>("/api/auth/setup", data),
|
||||
|
||||
login: async (data: LoginRequest): Promise<TokenResponse> => {
|
||||
const resp = await post<TokenResponse>("/api/auth/login", data);
|
||||
setToken(resp.access_token);
|
||||
return resp;
|
||||
},
|
||||
|
||||
me: () => get<UserResponse>("/api/auth/me"),
|
||||
|
||||
logout: () => {
|
||||
clearToken();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const projects = {
|
||||
list: () => get<ProjectListResponse>("/api/projects/"),
|
||||
|
||||
create: (data: ProjectCreate) =>
|
||||
post<ProjectResponse>("/api/projects/", data),
|
||||
|
||||
get: (id: string) => get<ProjectResponse>(`/api/projects/${id}`),
|
||||
|
||||
update: (id: string, data: ProjectUpdate) =>
|
||||
put<ProjectResponse>(`/api/projects/${id}`, data),
|
||||
|
||||
delete: (id: string) => del<void>(`/api/projects/${id}`),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experiments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const experiments = {
|
||||
list: () => get<ExperimentListResponse>("/api/experiments/"),
|
||||
|
||||
create: (data: ExperimentCreate) =>
|
||||
post<ExperimentResponse>("/api/experiments/", data),
|
||||
|
||||
get: (id: string) => get<ExperimentResponse>(`/api/experiments/${id}`),
|
||||
|
||||
update: (id: string, data: ExperimentUpdate) =>
|
||||
put<ExperimentResponse>(`/api/experiments/${id}`, data),
|
||||
|
||||
delete: (id: string) => del<void>(`/api/experiments/${id}`),
|
||||
|
||||
startSweep: (id: string) =>
|
||||
post<void>(`/api/experiments/${id}/sweep`),
|
||||
|
||||
pause: (id: string) =>
|
||||
post<void>(`/api/experiments/${id}/pause`),
|
||||
|
||||
resume: (id: string) =>
|
||||
post<void>(`/api/experiments/${id}/resume`),
|
||||
|
||||
stop: (id: string) =>
|
||||
post<void>(`/api/experiments/${id}/stop`),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const runs = {
|
||||
list: (experimentId: string) =>
|
||||
get<RunListResponse>(`/api/runs/experiments/${experimentId}/runs`),
|
||||
|
||||
get: (runId: string) =>
|
||||
get<RunDetailResponse>(`/api/runs/${runId}`),
|
||||
|
||||
create: (data: Record<string, unknown>) =>
|
||||
post<RunResponse>("/api/runs/", data),
|
||||
|
||||
score: (runId: string, data: ScoreInput) =>
|
||||
post<ScoreResponse>(`/api/runs/${runId}/score`, data),
|
||||
|
||||
leaderboard: (experimentId: string) =>
|
||||
get<RunListResponse>(
|
||||
`/api/runs/experiments/${experimentId}/leaderboard`,
|
||||
),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoints (LLM targets)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const endpoints = {
|
||||
list: () => get<EndpointListResponse>("/api/endpoints/"),
|
||||
|
||||
create: (data: EndpointCreate) =>
|
||||
post<EndpointResponse>("/api/endpoints/", data),
|
||||
|
||||
update: (id: string, data: EndpointUpdate) =>
|
||||
put<EndpointResponse>(`/api/endpoints/${id}`, data),
|
||||
|
||||
delete: (id: string) => del<void>(`/api/endpoints/${id}`),
|
||||
|
||||
test: (id: string) =>
|
||||
post<Record<string, unknown>>(`/api/endpoints/${id}/test`),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const exportApi = {
|
||||
best: (experimentId: string) =>
|
||||
get<Record<string, unknown>>(
|
||||
`/api/export/experiments/${experimentId}/best`,
|
||||
),
|
||||
|
||||
env: (experimentId: string) =>
|
||||
get<string>(`/api/export/experiments/${experimentId}/env`),
|
||||
|
||||
yaml: (experimentId: string) =>
|
||||
get<string>(`/api/export/experiments/${experimentId}/yaml`),
|
||||
|
||||
report: (experimentId: string) =>
|
||||
get<string>(`/api/export/experiments/${experimentId}/report`),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const webhooks = {
|
||||
list: () => get<WebhookListResponse>("/api/webhooks/"),
|
||||
|
||||
create: (data: WebhookCreate) =>
|
||||
post<WebhookResponse>("/api/webhooks/", data),
|
||||
|
||||
delete: (id: string) => del<void>(`/api/webhooks/${id}`),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const admin = {
|
||||
getSettings: () =>
|
||||
get<Record<string, unknown>>("/api/admin/settings"),
|
||||
|
||||
updateSettings: (data: Record<string, unknown>) =>
|
||||
put<Record<string, unknown>>("/api/admin/settings", data),
|
||||
|
||||
getStats: () => get<Record<string, unknown>>("/api/admin/stats"),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WsMessageHandler = (data: unknown) => void;
|
||||
|
||||
export interface WsConnection {
|
||||
send: (data: unknown) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the real-time WebSocket endpoint.
|
||||
*
|
||||
* @param onMessage Called for each incoming message.
|
||||
* @param onClose Optional callback when connection closes.
|
||||
* @returns Object with `send()` and `close()` methods.
|
||||
*/
|
||||
export function connectWebSocket(
|
||||
onMessage: WsMessageHandler,
|
||||
onClose?: () => void,
|
||||
): WsConnection {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: unknown = JSON.parse(event.data as string);
|
||||
onMessage(data);
|
||||
} catch {
|
||||
onMessage(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return {
|
||||
send: (data: unknown) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
ws.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue