279 lines
9.0 KiB
JavaScript
279 lines
9.0 KiB
JavaScript
import { readFileSync } from "node:fs";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { apiFetch } from "../api.mjs";
|
|
import { emit } from "../output.mjs";
|
|
import { t } from "../i18n.mjs";
|
|
|
|
function truncate(v, len = 30) {
|
|
if (v == null) return "-";
|
|
const s = String(v);
|
|
return s.length > len ? s.slice(0, len - 1) + "…" : s;
|
|
}
|
|
|
|
function fmtTs(v) {
|
|
if (!v) return "-";
|
|
try {
|
|
return new Date(v).toLocaleString();
|
|
} catch {
|
|
return String(v);
|
|
}
|
|
}
|
|
|
|
const suiteSchema = [
|
|
{ key: "id", header: "Suite ID", width: 22 },
|
|
{ key: "name", header: "Name", width: 30 },
|
|
{ key: "samples", header: "Samples" },
|
|
{ key: "rubric", header: "Rubric", width: 16 },
|
|
{ key: "updatedAt", header: "Updated", formatter: fmtTs },
|
|
];
|
|
|
|
const runSchema = [
|
|
{ key: "id", header: "Run ID", width: 22 },
|
|
{ key: "suiteId", header: "Suite", width: 18 },
|
|
{ key: "status", header: "Status", width: 12 },
|
|
{ key: "model", header: "Model", width: 25 },
|
|
{ key: "score", header: "Score", formatter: (v) => (v != null ? v.toFixed(3) : "-") },
|
|
{
|
|
key: "duration",
|
|
header: "Duration",
|
|
formatter: (v) => (v != null ? `${(v / 1000).toFixed(1)}s` : "-"),
|
|
},
|
|
{ key: "startedAt", header: "Started", formatter: fmtTs },
|
|
];
|
|
|
|
const sampleSchema = [
|
|
{ key: "id", header: "Sample", width: 14 },
|
|
{ key: "score", header: "Score", formatter: (v) => (v != null ? v.toFixed(2) : "-") },
|
|
{ key: "passed", header: "✓", formatter: (v) => (v ? "✓" : "✗") },
|
|
{ key: "input", header: "Input", width: 30, formatter: truncate },
|
|
{ key: "output", header: "Output", width: 30, formatter: truncate },
|
|
];
|
|
|
|
async function confirm(q) {
|
|
return new Promise((resolve) => {
|
|
process.stdout.write(`${q} (yes/no) `);
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.once("data", (c) => resolve(c.toString().trim().toLowerCase().startsWith("y")));
|
|
});
|
|
}
|
|
|
|
async function watchRun(runId, globalOpts) {
|
|
let lastStatus = "";
|
|
while (true) {
|
|
await sleep(3000);
|
|
const res = await apiFetch(`/api/evals/${runId}`);
|
|
if (!res.ok) continue;
|
|
const r = await res.json();
|
|
if (r.status !== lastStatus) {
|
|
const done = r.progress?.completed ?? 0;
|
|
const total = r.progress?.total ?? "?";
|
|
process.stderr.write(`[${new Date().toISOString()}] ${r.status} — ${done}/${total}\n`);
|
|
lastStatus = r.status;
|
|
}
|
|
if (["completed", "failed", "cancelled"].includes(r.status)) {
|
|
emit(r, globalOpts, runSchema);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderScorecard(data) {
|
|
const score = data.score ?? data.overallScore ?? null;
|
|
const passed = data.passed ?? data.summary?.passed ?? null;
|
|
const total = data.total ?? data.summary?.total ?? null;
|
|
process.stdout.write("\n=== Scorecard ===\n");
|
|
if (score != null) process.stdout.write(`Overall score: ${(score * 100).toFixed(1)}%\n`);
|
|
if (passed != null && total != null) {
|
|
process.stdout.write(`Passed: ${passed}/${total}\n`);
|
|
const bar = "█".repeat(Math.round((passed / total) * 20)).padEnd(20, "░");
|
|
process.stdout.write(`[${bar}] ${((passed / total) * 100).toFixed(0)}%\n`);
|
|
}
|
|
const metrics = data.metrics ?? data.breakdown ?? {};
|
|
for (const [k, v] of Object.entries(metrics)) {
|
|
process.stdout.write(` ${k}: ${typeof v === "number" ? v.toFixed(3) : v}\n`);
|
|
}
|
|
process.stdout.write("\n");
|
|
}
|
|
|
|
export async function runEvalSuitesList(opts, cmd) {
|
|
const res = await apiFetch("/api/evals/suites");
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
const data = await res.json();
|
|
emit(data.items ?? data, cmd.optsWithGlobals(), suiteSchema);
|
|
}
|
|
|
|
export async function runEvalSuitesGet(id, opts, cmd) {
|
|
const res = await apiFetch(`/api/evals/suites/${id}`);
|
|
if (!res.ok) {
|
|
process.stderr.write(`Not found: ${id}\n`);
|
|
process.exit(1);
|
|
}
|
|
emit(await res.json(), cmd.optsWithGlobals());
|
|
}
|
|
|
|
export async function runEvalSuitesCreate(opts, cmd) {
|
|
if (!opts.file) {
|
|
process.stderr.write("--file required\n");
|
|
process.exit(2);
|
|
}
|
|
const body = JSON.parse(readFileSync(opts.file, "utf8"));
|
|
const res = await apiFetch("/api/evals/suites", { method: "POST", body });
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
emit(await res.json(), cmd.optsWithGlobals());
|
|
}
|
|
|
|
export async function runEvalRun(suiteId, opts, cmd) {
|
|
const globalOpts = cmd.optsWithGlobals();
|
|
const body = {
|
|
suiteId,
|
|
model: opts.model ?? "auto",
|
|
...(opts.combo ? { combo: opts.combo } : {}),
|
|
concurrency: opts.concurrency ?? 4,
|
|
...(opts.tag ? { tag: opts.tag } : {}),
|
|
};
|
|
const res = await apiFetch("/api/evals", { method: "POST", body });
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
const run = await res.json();
|
|
emit(run, globalOpts, runSchema);
|
|
if (opts.watch) {
|
|
if (process.stdout.isTTY) {
|
|
const { startEvalWatchTui } = await import("../tui/EvalWatch.jsx");
|
|
await startEvalWatchTui({
|
|
runId: run.id,
|
|
suiteId: opts.suite,
|
|
baseUrl: globalOpts.baseUrl ?? "http://localhost:20128",
|
|
apiKey: globalOpts.apiKey ?? process.env.OMNIROUTE_API_KEY,
|
|
});
|
|
} else {
|
|
process.stderr.write("\nWatching run... (Ctrl+C to detach)\n");
|
|
await watchRun(run.id, globalOpts);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function runEvalList(opts, cmd) {
|
|
const params = new URLSearchParams({ limit: String(opts.limit ?? 50) });
|
|
if (opts.suite) params.set("suiteId", opts.suite);
|
|
if (opts.status) params.set("status", opts.status);
|
|
if (opts.since) params.set("since", opts.since);
|
|
const res = await apiFetch(`/api/evals?${params}`);
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
const data = await res.json();
|
|
emit(data.items ?? data, cmd.optsWithGlobals(), runSchema);
|
|
}
|
|
|
|
export async function runEvalGet(id, opts, cmd) {
|
|
const res = await apiFetch(`/api/evals/${id}`);
|
|
if (!res.ok) {
|
|
process.stderr.write(`Not found: ${id}\n`);
|
|
process.exit(1);
|
|
}
|
|
emit(await res.json(), cmd.optsWithGlobals());
|
|
}
|
|
|
|
export async function runEvalResults(id, opts, cmd) {
|
|
const params = new URLSearchParams();
|
|
if (opts.failed) params.set("filter", "failed");
|
|
const res = await apiFetch(`/api/evals/${id}?${params}`);
|
|
if (!res.ok) {
|
|
process.stderr.write(`Not found: ${id}\n`);
|
|
process.exit(1);
|
|
}
|
|
const data = await res.json();
|
|
emit(data.samples ?? data.results ?? [], cmd.optsWithGlobals(), sampleSchema);
|
|
}
|
|
|
|
export async function runEvalCancel(id, opts, cmd) {
|
|
if (!opts.yes) {
|
|
const ok = await confirm(`Cancel run ${id}?`);
|
|
if (!ok) return;
|
|
}
|
|
const res = await apiFetch(`/api/evals/${id}`, { method: "POST", body: { op: "cancel" } });
|
|
if (!res.ok) {
|
|
process.stderr.write(`Error: ${res.status}\n`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write("Cancelled\n");
|
|
}
|
|
|
|
export async function runEvalScorecard(id, opts, cmd) {
|
|
const res = await apiFetch(`/api/evals/${id}?scorecard=true`);
|
|
if (!res.ok) {
|
|
process.stderr.write(`Not found: ${id}\n`);
|
|
process.exit(1);
|
|
}
|
|
const data = await res.json();
|
|
const globalOpts = cmd.optsWithGlobals();
|
|
if (globalOpts.output === "json") {
|
|
emit(data, globalOpts);
|
|
} else {
|
|
renderScorecard(data);
|
|
}
|
|
}
|
|
|
|
export function registerEval(program) {
|
|
const evalCmd = program.command("eval").description(t("eval.description"));
|
|
|
|
const suites = evalCmd.command("suites").description(t("eval.suites.description"));
|
|
suites.command("list").description(t("eval.suites.list.description")).action(runEvalSuitesList);
|
|
suites
|
|
.command("get <suiteId>")
|
|
.description(t("eval.suites.get.description"))
|
|
.action(runEvalSuitesGet);
|
|
suites
|
|
.command("create")
|
|
.description(t("eval.suites.create.description"))
|
|
.option("--file <path>", t("eval.suites.create.file"))
|
|
.action(runEvalSuitesCreate);
|
|
|
|
evalCmd
|
|
.command("run <suiteId>")
|
|
.description(t("eval.run.description"))
|
|
.option("-m, --model <id>", t("eval.run.model"), "auto")
|
|
.option("--combo <name>", t("eval.run.combo"))
|
|
.option("--concurrency <n>", t("eval.run.concurrency"), parseInt, 4)
|
|
.option("--tag <tag>", t("eval.run.tag"))
|
|
.option("--watch", t("eval.run.watch"))
|
|
.action(runEvalRun);
|
|
|
|
evalCmd
|
|
.command("list")
|
|
.description(t("eval.list.description"))
|
|
.option("--suite <id>", t("eval.list.suite"))
|
|
.option("--status <s>", t("eval.list.status"))
|
|
.option("--since <ts>", t("eval.list.since"))
|
|
.option("--limit <n>", t("eval.list.limit"), parseInt, 50)
|
|
.action(runEvalList);
|
|
|
|
evalCmd.command("get <runId>").description(t("eval.get.description")).action(runEvalGet);
|
|
|
|
evalCmd
|
|
.command("results <runId>")
|
|
.description(t("eval.results.description"))
|
|
.option("--failed", t("eval.results.failed"))
|
|
.action(runEvalResults);
|
|
|
|
evalCmd
|
|
.command("cancel <runId>")
|
|
.description(t("eval.cancel.description"))
|
|
.option("--yes", t("eval.cancel.yes"))
|
|
.action(runEvalCancel);
|
|
|
|
evalCmd
|
|
.command("scorecard <runId>")
|
|
.description(t("eval.scorecard.description"))
|
|
.action(runEvalScorecard);
|
|
}
|