Files
2026-07-13 13:39:12 +08:00

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);
}