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 { 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; 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 | 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) : null; const message = firstChoice && firstChoice.message && typeof firstChoice.message === "object" ? (firstChoice.message as Record) : 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).content); if (text.length > 0) { return text.join("\n").trim(); } } return ""; } function extractErrorMessage(payload: Record | null, status: number): string { const error = payload && payload.error && typeof payload.error === "object" ? (payload.error as Record) : 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, 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, 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) : {}; 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 | null = null; try { payload = (await response.json()) as Record; } 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): 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 { 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 = {}; const caseMetrics: Record = {}; for (const evalCase of suite.cases || []) { const execution = await executeEvalCase( (evalCase || {}) as Record, 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>, outputs, }); }