Loom / app /src /api /panel.ts
deploy-space action
deploy 6158a00 (c)
b972454
Raw
History Blame Contribute Delete
5.37 kB
// Typed client for the loom panel gateway (the backend we built in critique-service/).
// Mirrors its real endpoints: /health, /api/roster, /api/panel, /api/run, /api/jobs/:id.
// Async-first: submit a job, then poll the snapshot for per-judge streaming progress
// (the gateway exposes content_chars / reasoning_chars / tail per running judge).
import { deriveToken } from "./token";
export interface Settings {
baseUrl: string; // "" when served by the gateway itself, "/backend" in dev (proxied), or "https://<space>.hf.space"
token: string; // static CRITIQUE_TOKEN, or a gen_token.py windowed token
rotationSecret: string; // CRITIQUE_ROTATION_SECRET; preferred - wire token derived per call
githubSessionId?: string; // user_id from GitHub OAuth — bearer for /api/auth/*, /api/workspaces/*
githubUsername?: string; // cached GitHub username for display
}
export interface JudgeProgress {
content_chars: number;
reasoning_chars: number;
tail: string;
updated_at: number;
}
export interface Judge {
model: string;
status?: "pending" | "running" | "done" | "error";
ok?: boolean;
routed_to?: string;
output?: string;
critique?: string;
error?: string;
elapsed_s?: number;
reasoning?: string;
progress?: JudgeProgress;
coverage?: { files_included: number; files_total: number; dropped: string[] };
cached?: boolean;
}
export interface PanelSnapshot {
job_id: string;
status: "running" | "complete";
kind: string;
role: string;
merge: string;
judges: Judge[];
merged: string;
meta: {
judges_ok: number;
judges_total: number;
judges_settled: number;
complete: boolean;
age_s: number;
};
pack?: { files: number; skipped: number; total_tokens: number };
}
export interface RosterModel {
logical: string;
frontier: boolean;
arena_elo: number | null;
privacy_safe_routable: boolean;
routable: boolean;
hosts: { slot: string; privacy_safe: boolean; cooling: boolean; blacklisted: boolean }[];
}
export interface Roster {
models: RosterModel[];
frontier_total: number;
frontier_privacy_safe_available: number;
frontier_ok: boolean;
}
export type Privacy = "strict" | "fallback" | "off";
export type Effort = "low" | "med" | "high" | "max";
export interface PanelRequest {
input?: string;
system?: string;
role?: string;
panel?: string[];
merge?: string;
effort?: Effort;
privacy?: Privacy;
reasoning?: boolean;
research?: boolean;
max_tokens?: number;
profile?: string;
files?: Record<string, string>;
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export class PanelClient {
constructor(private settings: Settings) {}
/** The bearer for this call: derived fresh from the rotation secret when set
* (so it auto-rotates), else the static token. */
private async bearer(windowsBack = 0): Promise<string> {
if (this.settings.rotationSecret) return deriveToken(this.settings.rotationSecret, windowsBack);
return this.settings.token;
}
private fetchWith(token: string, path: string, init?: RequestInit): Promise<Response> {
return fetch(this.settings.baseUrl + path, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
}
private async req<T>(path: string, init?: RequestInit): Promise<T> {
let r = await this.fetchWith(await this.bearer(), path, init);
if (r.status === 401 && this.settings.rotationSecret) {
// window-boundary / clock-skew insurance: the server still accepts the
// previous window's token, so retry once with it before surfacing a 401.
r = await this.fetchWith(await this.bearer(1), path, init);
}
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try {
const j = await r.json();
if (j?.error) msg = j.error;
} catch {
/* non-json error body */
}
throw new ApiError(r.status, msg);
}
return (await r.json()) as T;
}
health() {
return this.req<Record<string, unknown>>("/health");
}
roster() {
return this.req<Roster>("/api/roster");
}
/** Submit an async panel job; returns the initial (all-pending) snapshot with a job_id. */
submitPanel(body: PanelRequest) {
return this.req<PanelSnapshot>("/api/panel", {
method: "POST",
body: JSON.stringify({ ...body, async: true }),
});
}
job(jobId: string, trace = false) {
return this.req<PanelSnapshot>(`/api/jobs/${jobId}${trace ? "?trace=true" : ""}`);
}
/**
* Run a panel job to completion, calling onUpdate with each polled snapshot so the UI
* can stream per-judge progress. Resolves with the final snapshot.
*/
async runPanel(
body: PanelRequest,
onUpdate: (s: PanelSnapshot) => void,
opts: { intervalMs?: number; signal?: AbortSignal } = {},
): Promise<PanelSnapshot> {
const interval = opts.intervalMs ?? 1200;
let snap = await this.submitPanel(body);
onUpdate(snap);
while (!snap.meta.complete) {
if (opts.signal?.aborted) throw new DOMException("aborted", "AbortError");
await new Promise((res) => setTimeout(res, interval));
snap = await this.job(snap.job_id);
onUpdate(snap);
}
return snap;
}
}