promptlooper/frontend/src/api/client.ts

545 lines
14 KiB
TypeScript

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