mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
22
src/app/api/evals/scorecard/route.ts
Normal file
22
src/app/api/evals/scorecard/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
311
src/lib/evals/runtime.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
100
tests/unit/evals-history.test.ts
Normal file
100
tests/unit/evals-history.test.ts
Normal 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);
|
||||
});
|
||||
95
tests/unit/evals-route.test.ts
Normal file
95
tests/unit/evals-route.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user