mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
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.
312 lines
9.1 KiB
TypeScript
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,
|
|
});
|
|
}
|