MAESTRO: Create typed API client with in-memory JWT auth, fetch wrappers, and WebSocket helper

This commit is contained in:
John Lightner 2026-04-07 02:07:03 -05:00
parent 4cd0b8a1c8
commit 43d2aafbbe
4 changed files with 1099 additions and 1 deletions

View file

@ -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.

View 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
View 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();
},
};
}