From 43d2aafbbee05fea8c0fc61fb1362ba1e6c5654e Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 02:07:03 -0500 Subject: [PATCH] MAESTRO: Create typed API client with in-memory JWT auth, fetch wrappers, and WebSocket helper --- Auto Run Docs/01-scaffold.md | 3 +- frontend/src/api/.gitkeep | 0 frontend/src/api/client.test.ts | 552 ++++++++++++++++++++++++++++++++ frontend/src/api/client.ts | 545 +++++++++++++++++++++++++++++++ 4 files changed, 1099 insertions(+), 1 deletion(-) delete mode 100644 frontend/src/api/.gitkeep create mode 100644 frontend/src/api/client.test.ts create mode 100644 frontend/src/api/client.ts diff --git a/Auto Run Docs/01-scaffold.md b/Auto Run Docs/01-scaffold.md index 0f6488b..dabb6fd 100644 --- a/Auto Run Docs/01-scaffold.md +++ b/Auto Run Docs/01-scaffold.md @@ -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. diff --git a/frontend/src/api/.gitkeep b/frontend/src/api/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts new file mode 100644 index 0000000..6ba5fa8 --- /dev/null +++ b/frontend/src/api/client.test.ts @@ -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)["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)["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)["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)["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(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..d5b15a5 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 | null; + pipeline_stages?: Record | null; + scoring_config?: Record | null; + parameter_space?: Record | null; +} + +export interface ExperimentUpdate { + name?: string | null; + description?: string | null; + sample_data?: Record | null; + pipeline_stages?: Record | null; + scoring_config?: Record | null; + parameter_space?: Record | null; + status?: string | null; +} + +export interface ExperimentResponse { + id: string; + project_id: string; + name: string; + description: string | null; + sample_data: Record | null; + pipeline_stages: Record | null; + scoring_config: Record | null; + parameter_space: Record | 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; + 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 | 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 | null; + created_at: string; +} + +export interface RunDetailResponse extends RunResponse { + stage_results: StageResultResponse[]; + scores: ScoreResponse[]; +} + +export interface ScoreInput { + scorer_name: string; + value: number; + metadata?: Record | 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 | null; + is_active?: boolean; +} + +export interface WebhookUpdate { + event_type?: string | null; + url?: string | null; + headers?: Record | null; + is_active?: boolean | null; +} + +export interface WebhookResponse { + id: string; + event_type: string; + url: string; + headers: Record | 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; + status: string; + duration_ms: number | null; + tokens_in: number | null; + tokens_out: number | null; + cost_estimate: number | null; + scores: Record; +} + +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( + path: string, + options: RequestInit = {}, +): Promise { + const headers: Record = { + ...(options.headers as Record | 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; +} + +function get(path: string): Promise { + return request(path, { method: "GET" }); +} + +function post(path: string, body?: unknown): Promise { + return request(path, { + method: "POST", + body: body != null ? JSON.stringify(body) : undefined, + }); +} + +function put(path: string, body?: unknown): Promise { + return request(path, { + method: "PUT", + body: body != null ? JSON.stringify(body) : undefined, + }); +} + +function del(path: string): Promise { + return request(path, { method: "DELETE" }); +} + +// --------------------------------------------------------------------------- +// Health +// --------------------------------------------------------------------------- + +export const health = { + check: () => get("/health"), +}; + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +export const auth = { + setup: (data: SetupRequest) => + post("/api/auth/setup", data), + + login: async (data: LoginRequest): Promise => { + const resp = await post("/api/auth/login", data); + setToken(resp.access_token); + return resp; + }, + + me: () => get("/api/auth/me"), + + logout: () => { + clearToken(); + }, +}; + +// --------------------------------------------------------------------------- +// Projects +// --------------------------------------------------------------------------- + +export const projects = { + list: () => get("/api/projects/"), + + create: (data: ProjectCreate) => + post("/api/projects/", data), + + get: (id: string) => get(`/api/projects/${id}`), + + update: (id: string, data: ProjectUpdate) => + put(`/api/projects/${id}`, data), + + delete: (id: string) => del(`/api/projects/${id}`), +}; + +// --------------------------------------------------------------------------- +// Experiments +// --------------------------------------------------------------------------- + +export const experiments = { + list: () => get("/api/experiments/"), + + create: (data: ExperimentCreate) => + post("/api/experiments/", data), + + get: (id: string) => get(`/api/experiments/${id}`), + + update: (id: string, data: ExperimentUpdate) => + put(`/api/experiments/${id}`, data), + + delete: (id: string) => del(`/api/experiments/${id}`), + + startSweep: (id: string) => + post(`/api/experiments/${id}/sweep`), + + pause: (id: string) => + post(`/api/experiments/${id}/pause`), + + resume: (id: string) => + post(`/api/experiments/${id}/resume`), + + stop: (id: string) => + post(`/api/experiments/${id}/stop`), +}; + +// --------------------------------------------------------------------------- +// Runs +// --------------------------------------------------------------------------- + +export const runs = { + list: (experimentId: string) => + get(`/api/runs/experiments/${experimentId}/runs`), + + get: (runId: string) => + get(`/api/runs/${runId}`), + + create: (data: Record) => + post("/api/runs/", data), + + score: (runId: string, data: ScoreInput) => + post(`/api/runs/${runId}/score`, data), + + leaderboard: (experimentId: string) => + get( + `/api/runs/experiments/${experimentId}/leaderboard`, + ), +}; + +// --------------------------------------------------------------------------- +// Endpoints (LLM targets) +// --------------------------------------------------------------------------- + +export const endpoints = { + list: () => get("/api/endpoints/"), + + create: (data: EndpointCreate) => + post("/api/endpoints/", data), + + update: (id: string, data: EndpointUpdate) => + put(`/api/endpoints/${id}`, data), + + delete: (id: string) => del(`/api/endpoints/${id}`), + + test: (id: string) => + post>(`/api/endpoints/${id}/test`), +}; + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +export const exportApi = { + best: (experimentId: string) => + get>( + `/api/export/experiments/${experimentId}/best`, + ), + + env: (experimentId: string) => + get(`/api/export/experiments/${experimentId}/env`), + + yaml: (experimentId: string) => + get(`/api/export/experiments/${experimentId}/yaml`), + + report: (experimentId: string) => + get(`/api/export/experiments/${experimentId}/report`), +}; + +// --------------------------------------------------------------------------- +// Webhooks +// --------------------------------------------------------------------------- + +export const webhooks = { + list: () => get("/api/webhooks/"), + + create: (data: WebhookCreate) => + post("/api/webhooks/", data), + + delete: (id: string) => del(`/api/webhooks/${id}`), +}; + +// --------------------------------------------------------------------------- +// Admin +// --------------------------------------------------------------------------- + +export const admin = { + getSettings: () => + get>("/api/admin/settings"), + + updateSettings: (data: Record) => + put>("/api/admin/settings", data), + + getStats: () => get>("/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(); + }, + }; +}