Files
OmniRoute/src/lib/evals/runtime.ts
diegosouzapw 9a8404b733 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.
2026-04-23 16:57:43 -03:00

312 lines
9.1 KiB
TypeScript

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