fix(evals): persist run history and secure eval management routes

Store eval executions with target metadata, expose aggregated scorecard
and recent run history endpoints, and return dashboard-ready eval data
including target options and API key metadata.

Also require management auth for eval read endpoints and preserve
per-case latency, errors, and output snippets so historical results are
more reliable and easier to inspect.
This commit is contained in:
diegosouzapw
2026-04-23 14:40:01 -03:00
parent 47468636e4
commit 9a8404b733
9 changed files with 1441 additions and 388 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getSuite } from "@/lib/evals/evalRunner";
export async function GET(request, { params }) {
export async function GET(request: Request, { params }: { params: Promise<{ suiteId: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { suiteId } = await params;
const suite = getSuite(suiteId);

View File

@@ -1,18 +1,45 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { listSuites, runSuite } from "@/lib/evals/evalRunner";
import { getEvalScorecard, listEvalRuns, getApiKeys } from "@/lib/localDb";
import { listSuites, runSuite, createScorecard } from "@/lib/evals/evalRunner";
import { buildEvalTargetOptions, runEvalSuiteAgainstTarget } from "@/lib/evals/runtime";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { evalRunSuiteSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const suites = listSuites();
return NextResponse.json(suites);
} catch (error) {
const [suites, recentRuns, scorecard, targets, apiKeys] = await Promise.all([
Promise.resolve(listSuites()),
Promise.resolve(listEvalRuns({ limit: 20 })),
Promise.resolve(getEvalScorecard({ limit: 50 })),
buildEvalTargetOptions(),
getApiKeys(),
]);
return NextResponse.json({
suites,
recentRuns,
scorecard,
targets,
apiKeys: apiKeys.map((key) => ({
id: key.id,
name: key.name,
isActive: key.isActive !== false,
})),
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(request) {
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody;
try {
rawBody = await request.json();
@@ -33,10 +60,52 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { suiteId, outputs } = validation.data;
const result = runSuite(suiteId, outputs);
return NextResponse.json(result);
} catch (error) {
const { suiteId, outputs, target, compareTarget, apiKeyId } = validation.data;
if (outputs && Object.keys(outputs).length > 0) {
const result = runSuite(suiteId, outputs);
return NextResponse.json(result);
}
const targetsToRun = [target || { type: "suite-default" as const, id: null }];
if (compareTarget) {
targetsToRun.push(compareTarget);
}
const runGroupId = targetsToRun.length > 1 ? randomUUID() : null;
const runs = await Promise.all(
targetsToRun.map((entry) =>
runEvalSuiteAgainstTarget({
suiteId,
target: entry,
apiKeyId,
runGroupId,
})
)
);
const scorecard =
runs.length > 0
? createScorecard(
runs.map((run) => ({
suiteId: `${run.suiteId}:${run.target.key}`,
suiteName: `${run.suiteName} · ${run.target.label}`,
results: run.results,
summary: run.summary,
}))
)
: null;
return NextResponse.json({
suiteId,
runGroupId,
runs,
scorecard,
recentRuns: listEvalRuns({ limit: 20 }),
historyScorecard: getEvalScorecard({ limit: 50 }),
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getEvalScorecard, listEvalRuns } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const url = new URL(request.url);
const suiteId = url.searchParams.get("suiteId")?.trim() || undefined;
const limitValue = Number.parseInt(url.searchParams.get("limit") || "", 10);
const limit = Number.isFinite(limitValue) && limitValue > 0 ? Math.min(limitValue, 100) : 50;
return NextResponse.json({
scorecard: getEvalScorecard({ suiteId, limit }),
runs: listEvalRuns({ suiteId, limit }),
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -95,6 +95,8 @@ export function evaluateCase(evalCase: any, actualOutput: string) {
try {
let passed = false;
const details: Record<string, any> = {};
details.actualSnippet =
typeof actualOutput === "string" ? actualOutput.slice(0, 240) : String(actualOutput ?? "");
switch (evalCase.expected.strategy) {
case "exact":
@@ -159,9 +161,14 @@ export function evaluateCase(evalCase: any, actualOutput: string) {
*
* @param {string} suiteId
* @param {Record<string, string>} outputs - Map of caseId → actualOutput
* @param {Record<string, { durationMs?: number, error?: string }>} [caseMetrics]
* @returns {{ suiteId: string, suiteName: string, results: EvalResult[], summary: { total: number, passed: number, failed: number, passRate: number } }}
*/
export function runSuite(suiteId: string, outputs: Record<string, string>) {
export function runSuite(
suiteId: string,
outputs: Record<string, string>,
caseMetrics: Record<string, { durationMs?: number; error?: string }> = {}
) {
const suite = suites.get(suiteId);
if (!suite) {
throw new Error(`Suite not found: ${suiteId}`);
@@ -169,7 +176,18 @@ export function runSuite(suiteId: string, outputs: Record<string, string>) {
const results = suite.cases.map((c) => {
const output = outputs[c.id] || "";
return evaluateCase(c, output);
const result = evaluateCase(c, output);
const metrics = caseMetrics[c.id];
if (metrics && Number.isFinite(Number(metrics.durationMs))) {
result.durationMs = Math.max(0, Math.round(Number(metrics.durationMs)));
}
if (metrics?.error && !result.error) {
result.error = metrics.error;
}
return result;
});
const passed = results.filter((r) => r.passed).length;

311
src/lib/evals/runtime.ts Normal file
View File

@@ -0,0 +1,311 @@
import { POST as postChatCompletion } from "@/app/api/v1/chat/completions/route";
import type { PersistedEvalRun, EvalTargetType } from "@/lib/db/evals";
import { saveEvalRun } from "@/lib/db/evals";
import { getApiKeyById, getCombos } from "@/lib/localDb";
import { getSuite, listSuites, runSuite } from "./evalRunner";
export interface EvalTargetInput {
type: EvalTargetType;
id?: string | null;
}
export interface EvalTargetOption {
key: string;
type: EvalTargetType;
id: string | null;
label: string;
description: string;
}
function getNormalizedTargetId(target: EvalTargetInput): string | null {
return typeof target.id === "string" && target.id.trim().length > 0 ? target.id.trim() : null;
}
export function getEvalTargetLabel(target: EvalTargetInput): string {
const id = getNormalizedTargetId(target);
if (target.type === "combo") {
return `Combo: ${id || "Unknown"}`;
}
if (target.type === "model") {
return `Model: ${id || "Unknown"}`;
}
return "Suite defaults";
}
export function normalizeEvalTarget(target?: EvalTargetInput | null): EvalTargetInput {
if (!target || target.type === "suite-default") {
return { type: "suite-default", id: null };
}
return {
type: target.type === "combo" ? "combo" : "model",
id: getNormalizedTargetId(target),
};
}
export async function buildEvalTargetOptions(): Promise<EvalTargetOption[]> {
const [suites, combos] = await Promise.all([Promise.resolve(listSuites()), getCombos()]);
const models = [
...new Set(
suites
.flatMap((suite) => suite.cases || [])
.map((evalCase) => evalCase.model)
.filter((model): model is string => typeof model === "string" && model.trim().length > 0)
),
].sort((left, right) => left.localeCompare(right));
const comboOptions = (Array.isArray(combos) ? combos : [])
.map((combo) => ({
key: `combo:${combo.name}`,
type: "combo" as const,
id: typeof combo.name === "string" ? combo.name : null,
label: `Combo: ${combo.name}`,
description:
typeof combo.strategy === "string" && combo.strategy.trim().length > 0
? `Runs through combo strategy "${combo.strategy}"`
: "Runs through the combo router",
}))
.filter((option) => option.id);
return [
{
key: "suite-default:__default__",
type: "suite-default",
id: null,
label: "Suite defaults",
description: "Use each case's built-in model",
},
...models.map((model) => ({
key: `model:${model}`,
type: "model" as const,
id: model,
label: `Model: ${model}`,
description: "Force every case through one direct model",
})),
...comboOptions,
];
}
function extractTextParts(value: unknown): string[] {
if (typeof value === "string") {
return value.trim().length > 0 ? [value] : [];
}
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry) => {
if (typeof entry === "string") {
return entry.trim().length > 0 ? [entry] : [];
}
if (!entry || typeof entry !== "object") {
return [];
}
const record = entry as Record<string, unknown>;
if (typeof record.text === "string" && record.text.trim().length > 0) {
return [record.text];
}
if (
record.type === "output_text" &&
typeof record.text === "string" &&
record.text.trim().length > 0
) {
return [record.text];
}
return [];
});
}
function extractChatOutput(payload: Record<string, unknown> | null): string {
if (!payload) return "";
const choices = Array.isArray(payload.choices) ? payload.choices : [];
const firstChoice =
choices.length > 0 && choices[0] && typeof choices[0] === "object"
? (choices[0] as Record<string, unknown>)
: null;
const message =
firstChoice && firstChoice.message && typeof firstChoice.message === "object"
? (firstChoice.message as Record<string, unknown>)
: null;
const chatText = extractTextParts(message?.content);
if (chatText.length > 0) {
return chatText.join("\n").trim();
}
const output = Array.isArray(payload.output) ? payload.output : [];
for (const item of output) {
if (!item || typeof item !== "object") continue;
const text = extractTextParts((item as Record<string, unknown>).content);
if (text.length > 0) {
return text.join("\n").trim();
}
}
return "";
}
function extractErrorMessage(payload: Record<string, unknown> | null, status: number): string {
const error =
payload && payload.error && typeof payload.error === "object"
? (payload.error as Record<string, unknown>)
: null;
const message =
(error && typeof error.message === "string" && error.message.trim().length > 0
? error.message.trim()
: null) ||
(payload && typeof payload.message === "string" && payload.message.trim().length > 0
? payload.message.trim()
: null);
return message || `HTTP ${status}`;
}
function resolveCaseModel(evalCase: Record<string, unknown>, target: EvalTargetInput): string {
const targetId = getNormalizedTargetId(target);
const caseModel =
typeof evalCase.model === "string" && evalCase.model.trim().length > 0 ? evalCase.model : null;
if (target.type === "model" || target.type === "combo") {
return targetId || caseModel || "gpt-4o";
}
return caseModel || "gpt-4o";
}
async function executeEvalCase(
evalCase: Record<string, unknown>,
target: EvalTargetInput,
apiKey: string | null
): Promise<{ output: string; durationMs: number; error?: string }> {
const input =
evalCase.input && typeof evalCase.input === "object" && !Array.isArray(evalCase.input)
? (evalCase.input as Record<string, unknown>)
: {};
const model = resolveCaseModel(evalCase, target);
const headers = new Headers({
"Content-Type": "application/json",
});
if (apiKey) {
headers.set("Authorization", `Bearer ${apiKey}`);
}
const request = new Request("http://localhost/api/v1/chat/completions", {
method: "POST",
headers,
body: JSON.stringify({
...input,
model,
stream: false,
max_tokens:
typeof input.max_tokens === "number" && Number.isFinite(input.max_tokens)
? input.max_tokens
: 512,
}),
});
const startedAt = Date.now();
const response = await postChatCompletion(request);
const durationMs = Date.now() - startedAt;
let payload: Record<string, unknown> | null = null;
try {
payload = (await response.json()) as Record<string, unknown>;
} catch {
payload = null;
}
if (!response.ok) {
const error = extractErrorMessage(payload, response.status);
return {
output: `[ERROR] ${error}`,
durationMs,
error,
};
}
const output = extractChatOutput(payload);
return {
output: output || "[No content returned]",
durationMs,
};
}
function getAverageLatency(caseMetrics: Record<string, { durationMs?: number }>): number {
const durations = Object.values(caseMetrics)
.map((metric) => Number(metric.durationMs))
.filter((duration) => Number.isFinite(duration) && duration >= 0);
if (durations.length === 0) return 0;
return Math.round(durations.reduce((sum, duration) => sum + duration, 0) / durations.length);
}
export async function runEvalSuiteAgainstTarget(input: {
suiteId: string;
target?: EvalTargetInput | null;
apiKeyId?: string;
runGroupId?: string | null;
}): Promise<PersistedEvalRun> {
const suite = getSuite(input.suiteId);
if (!suite) {
throw new Error(`Suite not found: ${input.suiteId}`);
}
const normalizedTarget = normalizeEvalTarget(input.target);
const targetLabel = getEvalTargetLabel(normalizedTarget);
let resolvedApiKey: string | null = null;
if (typeof input.apiKeyId === "string" && input.apiKeyId.trim().length > 0) {
const keyRecord = await getApiKeyById(input.apiKeyId);
if (!keyRecord || typeof keyRecord.key !== "string" || keyRecord.key.trim().length === 0) {
throw new Error("Selected API key was not found");
}
if (keyRecord.isActive === false) {
throw new Error("Selected API key is inactive");
}
resolvedApiKey = keyRecord.key;
}
const outputs: Record<string, string> = {};
const caseMetrics: Record<string, { durationMs?: number; error?: string }> = {};
for (const evalCase of suite.cases || []) {
const execution = await executeEvalCase(
(evalCase || {}) as Record<string, unknown>,
normalizedTarget,
resolvedApiKey
);
outputs[evalCase.id] = execution.output;
caseMetrics[evalCase.id] = {
durationMs: execution.durationMs,
...(execution.error ? { error: execution.error } : {}),
};
}
const evaluated = runSuite(input.suiteId, outputs, caseMetrics);
return saveEvalRun({
runGroupId: input.runGroupId || null,
suiteId: evaluated.suiteId,
suiteName: evaluated.suiteName,
target: {
type: normalizedTarget.type,
id: getNormalizedTargetId(normalizedTarget),
label: targetLabel,
},
apiKeyId: input.apiKeyId || null,
avgLatencyMs: getAverageLatency(caseMetrics),
summary: evaluated.summary,
results: evaluated.results as Array<Record<string, unknown>>,
outputs,
});
}

View File

@@ -93,6 +93,21 @@ export {
resetApiKeyState,
} from "./db/apiKeys";
export {
// Evals
saveEvalRun,
listEvalRuns,
getEvalScorecard,
serializeEvalTargetKey,
} from "./db/evals";
export type {
EvalTargetType,
EvalTargetDescriptor,
EvalRunSummary,
PersistedEvalRun,
} from "./db/evals";
export {
// Settings
getSettings,

View File

@@ -0,0 +1,100 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-evals-history-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const evalsDb = await import("../../src/lib/db/evals.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
resetDb();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("eval run history persists target metadata and newest-first ordering", () => {
const older = evalsDb.saveEvalRun({
suiteId: "golden-set",
suiteName: "Golden Set",
target: { type: "model", id: "gpt-4o", label: "Model: gpt-4o" },
summary: { total: 2, passed: 2, failed: 0, passRate: 100 },
avgLatencyMs: 120,
results: [{ caseId: "c1", caseName: "Case 1", passed: true, durationMs: 120 }],
outputs: { c1: "ok" },
createdAt: "2026-04-23T10:00:00.000Z",
});
const newer = evalsDb.saveEvalRun({
suiteId: "golden-set",
suiteName: "Golden Set",
target: { type: "combo", id: "cost-optimized", label: "Combo: cost-optimized" },
summary: { total: 2, passed: 1, failed: 1, passRate: 50 },
avgLatencyMs: 240,
results: [{ caseId: "c1", caseName: "Case 1", passed: false, durationMs: 240 }],
outputs: { c1: "[ERROR] upstream failed" },
createdAt: "2026-04-23T11:00:00.000Z",
});
const runs = evalsDb.listEvalRuns({ limit: 10 });
assert.equal(runs.length, 2);
assert.equal(runs[0].id, newer.id);
assert.equal(runs[1].id, older.id);
assert.equal(runs[0].target.key, "combo:cost-optimized");
assert.equal(runs[1].target.key, "model:gpt-4o");
assert.equal(runs[0].summary.passRate, 50);
assert.equal(runs[1].outputs.c1, "ok");
});
test("scorecard keeps only the latest run per suite and target scope", () => {
evalsDb.saveEvalRun({
suiteId: "golden-set",
suiteName: "Golden Set",
target: { type: "model", id: "gpt-4o", label: "Model: gpt-4o" },
summary: { total: 2, passed: 1, failed: 1, passRate: 50 },
avgLatencyMs: 150,
results: [],
createdAt: "2026-04-23T09:00:00.000Z",
});
evalsDb.saveEvalRun({
suiteId: "golden-set",
suiteName: "Golden Set",
target: { type: "model", id: "gpt-4o", label: "Model: gpt-4o" },
summary: { total: 2, passed: 2, failed: 0, passRate: 100 },
avgLatencyMs: 100,
results: [],
createdAt: "2026-04-23T10:00:00.000Z",
});
evalsDb.saveEvalRun({
suiteId: "golden-set",
suiteName: "Golden Set",
target: { type: "combo", id: "balanced", label: "Combo: balanced" },
summary: { total: 2, passed: 1, failed: 1, passRate: 50 },
avgLatencyMs: 220,
results: [],
createdAt: "2026-04-23T10:30:00.000Z",
});
const scorecard = evalsDb.getEvalScorecard({ limit: 10 });
assert.ok(scorecard);
assert.equal(scorecard.suites, 2);
assert.equal(scorecard.totalCases, 4);
assert.equal(scorecard.totalPassed, 3);
assert.equal(scorecard.overallPassRate, 75);
});

View File

@@ -0,0 +1,95 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-evals-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
interface EvalsRoutePayload {
suites: unknown[];
targets: Array<{ type: string }>;
apiKeys: Array<{ id: string; name: string; key?: string }>;
recentRuns: Array<{ target: { key: string } }>;
}
interface ScorecardRoutePayload {
scorecard: { overallPassRate: number } | null;
runs: unknown[];
}
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const evalsRoute = await import("../../src/app/api/evals/route.ts");
const evalsScorecardRoute = await import("../../src/app/api/evals/scorecard/route.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
resetDb();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("evals GET returns suites, target options, api key metadata, and persisted history", async () => {
const apiKey = await localDb.createApiKey("Dashboard Key", "machine-test");
localDb.saveEvalRun({
suiteId: "golden-set",
suiteName: "Golden Set",
target: { type: "combo", id: "cost-optimized", label: "Combo: cost-optimized" },
apiKeyId: apiKey.id,
summary: { total: 2, passed: 2, failed: 0, passRate: 100 },
avgLatencyMs: 180,
results: [],
createdAt: "2026-04-23T12:00:00.000Z",
});
const response = await evalsRoute.GET(new Request("http://localhost/api/evals"));
assert.equal(response.status, 200);
const payload = (await response.json()) as EvalsRoutePayload;
assert.ok(Array.isArray(payload.suites));
assert.ok(Array.isArray(payload.targets));
assert.ok(Array.isArray(payload.apiKeys));
assert.ok(Array.isArray(payload.recentRuns));
assert.equal(payload.apiKeys[0].id, apiKey.id);
assert.equal(payload.apiKeys[0].name, "Dashboard Key");
assert.equal(payload.apiKeys[0].key, undefined);
assert.equal(payload.recentRuns[0].target.key, "combo:cost-optimized");
assert.equal(
payload.targets.some((entry) => entry.type === "suite-default"),
true
);
});
test("eval scorecard route exposes stored runs and aggregated pass rate", async () => {
localDb.saveEvalRun({
suiteId: "golden-set",
suiteName: "Golden Set",
target: { type: "model", id: "gpt-4o", label: "Model: gpt-4o" },
summary: { total: 2, passed: 2, failed: 0, passRate: 100 },
avgLatencyMs: 120,
results: [],
createdAt: "2026-04-23T12:00:00.000Z",
});
const response = await evalsScorecardRoute.GET(
new Request("http://localhost/api/evals/scorecard?limit=10")
);
assert.equal(response.status, 200);
const payload = (await response.json()) as ScorecardRoutePayload;
assert.ok(payload.scorecard);
assert.equal(payload.scorecard.overallPassRate, 100);
assert.equal(Array.isArray(payload.runs), true);
assert.equal(payload.runs.length, 1);
});