#!/usr/bin/env bun import { Command, InvalidArgumentError } from "commander"; import { loadCases, loadSelectedCases } from "../core/cases"; import { BACKEND_VALIDATION_MODES, parseBackendValidationMode, } from "../core/backendValidation"; import { EVAL_MODELS, type EvalModelSpec, formatRunModelLabel, getCliEvalModel, getEvalModelHelpText, resolveEvalModel, } from "../core/models"; import { appendHistoryRecord, buildRunResult, formatRunSummary, resolveRunOutputPath, writeRunArtifacts, writeRunResult, } from "../core/results"; import { runSuite } from "../core/runSuite"; import { EVAL_MODES, type EvalMode } from "../core/types"; import { DEFAULT_JUDGE_MODEL } from "../core/judge"; // createCliModeRunner is imported lazily in runCliBenchmark so the non-cli modes // (global/flow/script/app) don't pull in the wmill CLI toolchain and its JSR deps // (e.g. @cliffy/*) just to load this entrypoint. import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime"; import { resolveWindmillBackendSettings } from "../core/windmillBackendSettings"; import { assertWindmillBackendReachable } from "../adapters/frontend/windmillBackend"; async function main() { const program = new Command() .name("bun run cli --") .description( "Run AI eval cases against the current production prompts and guidance", ) .showHelpAfterError() .showSuggestionAfterError() .addHelpText( "after", [ "", "Examples:", " bun run cli -- models", " bun run cli -- cases", " bun run cli -- cases flow", " bun run cli -- run flow", " bun run cli -- run flow --model 4o", " bun run cli -- run flow --models haiku,opus,4o", " bun run cli -- run flow flow-test0-sum-two-numbers --verbose", " bun run cli -- run flow --record", " bun run cli -- run flow --backend-validation preview", " bun run cli -- run flow flow-test5-simple-modification --runs 3", " bun run cli -- run global global-test1-script-create", " bun run cli -- run cli bun-hello-script", "", "Models:", getEvalModelHelpText(), ].join("\n"), ); program .command("models") .description("List available model aliases") .action(() => { handleModels(); }); program .command("cases") .description("List available cases") .argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode) .action(async (mode?: EvalMode) => { await handleCases(mode); }); program .command("run") .description("Run one benchmark mode") .argument("", "cli, flow, script, app, or global", parseMode) .argument("[caseIds...]", "specific case ids to run") .option( "--runs ", "number of attempts per case", parsePositiveInteger, 1, ) .option("--output ", "write the result JSON to this path") .option( "--model ", `model alias (${EVAL_MODELS.map((entry) => entry.id).join(", ")})`, ) .option( "--models ", "comma-separated model aliases to run sequentially", ) .option("--verbose", "stream assistant output during frontend runs") .option("--skip-judge", "skip LLM judge scoring for this run") .option( "--execution-only", "only require the model/proxy/frontend loop to complete", ) .option( "--record", "append a compact summary line to ai_evals/history/.jsonl", ) .option( "--backend-validation ", `backend smoke validation (${BACKEND_VALIDATION_MODES.join(", ")})`, ) .action( async ( mode: EvalMode, caseIds: string[], options: { runs: number; output?: string; model?: string; models?: string; verbose?: boolean; skipJudge?: boolean; executionOnly?: boolean; record?: boolean; backendValidation?: string; }, ) => { await handleRun({ mode, caseIds, runs: options.runs, outputPath: options.output, model: options.model, models: options.models, verbose: options.verbose ?? false, skipJudge: options.skipJudge ?? false, executionOnly: options.executionOnly ?? false, record: options.record ?? false, backendValidation: options.backendValidation, }); }, ); await program.parseAsync(process.argv); } async function handleCases(mode?: EvalMode) { const modes = mode ? [mode] : [...EVAL_MODES]; for (const entry of modes) { const cases = await loadCases(entry); process.stdout.write(`${entry} (${cases.length})\n`); for (const evalCase of cases) { process.stdout.write(`- ${evalCase.id}\n`); } process.stdout.write("\n"); } } function handleModels() { process.stdout.write("Available models\n"); for (const model of EVAL_MODELS) { const supports = [ ...(model.frontend ? ["flow", "script", "app", "global"] : []), ...(model.cli ? ["cli"] : []), ]; const aliases = [ model.id, ...model.aliases.filter((alias) => alias !== model.id), ]; process.stdout.write(`- ${model.id}: ${model.label}\n`); process.stdout.write(` aliases: ${aliases.join(", ")}\n`); process.stdout.write(` modes: ${supports.join(", ")}\n`); } process.stdout.write(`\nJudge model: ${DEFAULT_JUDGE_MODEL}\n`); } async function handleRun(input: { mode: EvalMode; caseIds: string[]; runs: number; outputPath?: string; model?: string; models?: string; verbose: boolean; skipJudge: boolean; executionOnly: boolean; record: boolean; backendValidation?: string; }) { if (input.record && input.caseIds.length > 0) { throw new Error( "--record only supports full-suite runs; omit case ids to record history", ); } if (input.model && input.models) { throw new Error("Use either --model or --models, not both"); } const selectedCases = await loadSelectedCases(input.mode, input.caseIds); const models = resolveRequestedModels(input.mode, input.model, input.models); const backendValidation = parseBackendValidationMode( input.backendValidation ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION, ); if (input.outputPath && models.length > 1) { throw new Error("--output only supports a single model run"); } if ( backendValidation !== "off" && input.mode !== "flow" && input.mode !== "script" ) { throw new Error( "--backend-validation currently supports only flow and script modes", ); } if (input.mode !== "cli") { await assertWindmillBackendReachable(resolveWindmillBackendSettings()); } const summaries: Array<{ label: string; passRate: number; averagePassedDurationMs: number | null; }> = []; for (const [index, model] of models.entries()) { const runModel = formatRunModelLabel(input.mode, model); if (models.length > 1) { process.stdout.write( `${index > 0 ? "\n" : ""}=== ${input.mode} ${model.id} (${runModel}) ===\n`, ); } process.stderr.write(`Starting ${input.mode} benchmark...\n`); const result = input.mode === "cli" ? await runCliBenchmark( selectedCases, input.runs, getCliEvalModel(model), runModel, input.skipJudge, input.executionOnly, ) : await runFrontendBenchmarkAdapter({ mode: input.mode, caseIds: input.caseIds, runs: input.runs, model: model.id, verbose: input.verbose, skipJudge: input.skipJudge, executionOnly: input.executionOnly, backendValidation, }); const resolvedOutputPath = models.length === 1 ? resolveRunOutputPath(input.mode, input.outputPath) : resolveRunOutputPath(input.mode); const artifactsPath = await writeRunArtifacts(result, resolvedOutputPath); const resultPath = await writeRunResult(result, resolvedOutputPath); const historyPath = input.record ? await appendHistoryRecord(result) : null; process.stdout.write(`${formatRunSummary(result)}\n`); process.stdout.write(`Saved: ${resultPath}\n`); if (artifactsPath) { process.stdout.write(`Artifacts: ${artifactsPath}\n`); } if (historyPath) { process.stdout.write(`Recorded: ${historyPath}\n`); } summaries.push({ label: `${model.id} (${runModel})`, passRate: result.passRate, averagePassedDurationMs: result.averagePassedDurationMs ?? null, }); } if (summaries.length > 1) { process.stdout.write("\nModel summary\n"); for (const summary of summaries) { process.stdout.write( `- ${summary.label}: ${formatPercent(summary.passRate)} | passed avg ${formatNullableDuration(summary.averagePassedDurationMs)}\n`, ); } } } async function runCliBenchmark( cases: Awaited>, runs: number, model: ReturnType, runModel: string, skipJudge: boolean, executionOnly: boolean, ) { const { createCliModeRunner } = await import("../modes/cli"); const judgeModel = skipJudge || executionOnly ? null : DEFAULT_JUDGE_MODEL; const caseResults = await runSuite({ modeRunner: createCliModeRunner(model), cases, runs, runModel, judgeModel, executionOnly, }); return buildRunResult({ mode: "cli", runs, runModel, judgeModel, caseResults, }); } function parseMode(value: string): EvalMode { if (EVAL_MODES.includes(value as EvalMode)) { return value as EvalMode; } throw new InvalidArgumentError( `mode must be one of: ${EVAL_MODES.join(", ")}`, ); } function parseOptionalMode(value: string | undefined): EvalMode | undefined { return value ? parseMode(value) : undefined; } function parsePositiveInteger(value: string): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { throw new InvalidArgumentError("must be a positive integer"); } return parsed; } function resolveRequestedModels( mode: EvalMode, singleModel?: string, multipleModels?: string, ): EvalModelSpec[] { if (!multipleModels) { return [resolveEvalModel(mode, singleModel)]; } const aliases = multipleModels .split(",") .map((value) => value.trim()) .filter(Boolean); if (aliases.length === 0) { throw new Error("--models requires at least one model alias"); } const seen = new Set(); const models: EvalModelSpec[] = []; for (const alias of aliases) { const model = resolveEvalModel(mode, alias); if (seen.has(model.id)) { continue; } seen.add(model.id); models.push(model); } return models; } function formatPercent(value: number): string { return `${(value * 100).toFixed(1)}%`; } function formatNullableDuration(value: number | null): string { return value === null ? "n/a" : `${Math.round(value)}ms`; } void main().catch((error) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exit(1); });