mirror of
https://github.com/launchdarkly/ai-tooling.git
synced 2026-09-14 20:00:40 +08:00
211 lines
6.9 KiB
JavaScript
211 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Run skill eval suites under one or more agent (system-under-test) models.
|
|
*
|
|
* The default eval flow (`npm run eval:all`) runs each suite once on the model
|
|
* named by AGENT_MODEL (claude-sonnet-4-6 by default). This script lets you
|
|
* compare the same suites across multiple models — e.g. "does my SKILL.md
|
|
* still pass on Haiku, or did I overfit to Sonnet?" — without juggling .env edits.
|
|
*
|
|
* Usage:
|
|
* node scripts/run-models.js --model=haiku
|
|
* node scripts/run-models.js --model=sonnet --only=configs-create
|
|
* node scripts/run-models.js --models=haiku,sonnet,opus
|
|
*
|
|
* Output:
|
|
* - <suite>/results.<alias>.json per (model, suite)
|
|
* - Summary table comparing pass-counts and average scores across all pairs
|
|
* - Does NOT touch eval-scores.json — that baseline is only written by `eval:all`
|
|
*
|
|
* To promote a model run to the canonical scores, copy
|
|
* `<suite>/results.<alias>.json` -> `<suite>/results.json` then run `eval:aggregate`.
|
|
*/
|
|
|
|
const { spawnSync } = require("node:child_process");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const { SUITES } = require("./_manifest");
|
|
const { resolveModel, aliasFor, MODEL_ALIASES } = require("./_models");
|
|
|
|
const EVALS_DIR = path.resolve(__dirname, "..");
|
|
const PROMPTFOO_BIN = path.join(EVALS_DIR, "node_modules", ".bin", "promptfoo");
|
|
|
|
function parseArgs(argv) {
|
|
const args = { models: [], only: null };
|
|
for (const arg of argv.slice(2)) {
|
|
if (arg.startsWith("--model=")) {
|
|
args.models.push(arg.slice("--model=".length));
|
|
} else if (arg.startsWith("--models=")) {
|
|
args.models.push(
|
|
...arg
|
|
.slice("--models=".length)
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean),
|
|
);
|
|
} else if (arg.startsWith("--only=")) {
|
|
args.only = arg
|
|
.slice("--only=".length)
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
} else if (arg === "--help" || arg === "-h") {
|
|
console.log(usage());
|
|
process.exit(0);
|
|
} else {
|
|
console.error(`run-models.js: unknown arg "${arg}"`);
|
|
console.log(usage());
|
|
process.exit(1);
|
|
}
|
|
}
|
|
if (args.models.length === 0) {
|
|
console.error("run-models.js: at least one --model=<alias|id> is required");
|
|
console.log(usage());
|
|
process.exit(1);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
"Usage: node scripts/run-models.js --model=<alias|id> [--only=<slug,...>]",
|
|
" node scripts/run-models.js --models=haiku,sonnet,opus",
|
|
"",
|
|
"Aliases:",
|
|
...Object.entries(MODEL_ALIASES).map(([a, id]) => ` ${a.padEnd(8)}${id}`),
|
|
"",
|
|
"Examples:",
|
|
" npm run eval:haiku",
|
|
" npm run eval:matrix",
|
|
" node scripts/run-models.js --model=haiku --only=configs-create",
|
|
].join("\n");
|
|
}
|
|
|
|
function selectSuites(only) {
|
|
if (!only || only.length === 0) return SUITES.slice();
|
|
const set = new Set(only);
|
|
const matched = SUITES.filter((s) => set.has(s.suite));
|
|
const unknown = only.filter((s) => !SUITES.find((suite) => suite.suite === s));
|
|
if (unknown.length > 0) {
|
|
console.error(`run-models.js: unknown suites: ${unknown.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
function runSuiteWithModel(suite, modelId, alias) {
|
|
const outputPath = path.join(EVALS_DIR, suite.suite, `results.${alias}.json`);
|
|
console.log(`\n[run-models] suite=${suite.suite} model=${alias} (${modelId})`);
|
|
|
|
const result = spawnSync(
|
|
PROMPTFOO_BIN,
|
|
[
|
|
"eval",
|
|
"-c", "shared/defaults.yaml",
|
|
"-c", `${suite.suite}/promptfooconfig.yaml`,
|
|
"-o", outputPath,
|
|
"--env-file", ".env",
|
|
"--no-cache",
|
|
],
|
|
{ cwd: EVALS_DIR, stdio: "inherit", env: { ...process.env, AGENT_MODEL: modelId } },
|
|
);
|
|
|
|
if (result.error) {
|
|
console.error(`[run-models] launch error: ${result.error.message}`);
|
|
return false;
|
|
}
|
|
if (result.status !== 0 && result.status !== 100) {
|
|
console.error(`[run-models] suite ${suite.suite} exited with status ${result.status}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function readResults(suite, alias) {
|
|
const p = path.join(EVALS_DIR, suite.suite, `results.${alias}.json`);
|
|
if (!fs.existsSync(p)) return { error: `missing results.${alias}.json` };
|
|
try {
|
|
return { parsed: JSON.parse(fs.readFileSync(p, "utf-8")) };
|
|
} catch (e) {
|
|
return { error: `unparseable: ${e.message}` };
|
|
}
|
|
}
|
|
|
|
function summarise(parsed) {
|
|
const rawResults = parsed.results?.results || parsed.results || [];
|
|
const arr = Array.isArray(rawResults) ? rawResults : [];
|
|
const successes = arr.filter((r) => r && r.success).length;
|
|
const total = arr.length;
|
|
const scores = arr
|
|
.map((r) => r?.score ?? r?.gradingResult?.score)
|
|
.filter((s) => typeof s === "number");
|
|
const avgScore =
|
|
scores.length > 0
|
|
? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 100)
|
|
: null;
|
|
return { successes, total, avgScore };
|
|
}
|
|
|
|
function pad(s, n) {
|
|
s = String(s);
|
|
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
}
|
|
|
|
function printMatrix(rows, aliases, suites) {
|
|
const suiteWidth = Math.max(6, ...suites.map((s) => s.suite.length));
|
|
const header = " " + pad("suite", suiteWidth) + " " + aliases.map((a) => pad(a, 16)).join(" ");
|
|
const sep = " " + "-".repeat(suiteWidth) + " " + aliases.map(() => "-".repeat(16)).join(" ");
|
|
console.log("\n[run-models] Summary (passed/total · avg_score%):");
|
|
console.log(header);
|
|
console.log(sep);
|
|
for (const suite of suites) {
|
|
const cells = aliases.map((alias) => {
|
|
const cell = rows[`${alias}::${suite.suite}`];
|
|
if (!cell) return pad("(no results)", 16);
|
|
if (cell.error) return pad("error", 16);
|
|
return pad(`${cell.successes}/${cell.total} · ${cell.avgScore ?? "-"}%`, 16);
|
|
});
|
|
console.log(" " + pad(suite.suite, suiteWidth) + " " + cells.join(" "));
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv);
|
|
const suites = selectSuites(args.only);
|
|
const modelEntries = args.models.map((m) => ({
|
|
id: resolveModel(m),
|
|
alias: aliasFor(resolveModel(m)),
|
|
}));
|
|
|
|
console.log("[run-models] models = " + modelEntries.map((m) => `${m.alias}(${m.id})`).join(", "));
|
|
console.log("[run-models] suites = " + suites.map((s) => s.suite).join(", "));
|
|
|
|
let launchFailures = 0;
|
|
for (const m of modelEntries) {
|
|
for (const suite of suites) {
|
|
if (!runSuiteWithModel(suite, m.id, m.alias)) launchFailures++;
|
|
}
|
|
}
|
|
|
|
const rows = {};
|
|
let aggFailures = 0;
|
|
for (const m of modelEntries) {
|
|
for (const suite of suites) {
|
|
const key = `${m.alias}::${suite.suite}`;
|
|
const { parsed, error } = readResults(suite, m.alias);
|
|
rows[key] = error ? { error } : summarise(parsed);
|
|
if (error) aggFailures++;
|
|
}
|
|
}
|
|
|
|
printMatrix(rows, modelEntries.map((m) => m.alias), suites);
|
|
|
|
if (launchFailures > 0 || aggFailures > 0) {
|
|
console.error(`\n[run-models] ${launchFailures} launch failure(s), ${aggFailures} missing result(s)`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|