Files
2026-07-13 13:39:12 +08:00

277 lines
8.3 KiB
TypeScript

import type { PersistedEvalRun } from "../../src/lib/db/evals.ts";
import { listModelEvalRunsForRouting } from "../../src/lib/db/evals.ts";
import { parseModel } from "./model.ts";
type EvalRoutingLogger = {
info?: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
debug?: (...args: unknown[]) => void;
};
type EvalRoutingTarget = {
modelStr: string;
};
type EvalRoutingConfig = {
enabled: boolean;
suiteIds: string[];
maxAgeHours: number;
minCases: number;
qualityWeight: number;
latencyWeight: number;
cacheTtlMs: number;
};
type TargetScore = {
score: number;
passRate: number;
avgLatencyMs: number;
totalCases: number;
runs: number;
};
const DEFAULT_EVAL_ROUTING_CONFIG: EvalRoutingConfig = {
enabled: false,
suiteIds: [],
maxAgeHours: 24 * 30,
minCases: 1,
qualityWeight: 0.85,
latencyWeight: 0.15,
cacheTtlMs: 60_000,
};
const MAX_EVAL_ROUTING_CACHE_ENTRIES = 200;
const evalRoutingCache = new Map<string, { expiresAt: number; runs: PersistedEvalRun[] }>();
function pruneEvalRoutingCache(now = Date.now()): void {
for (const [key, entry] of evalRoutingCache) {
if (entry.expiresAt <= now) evalRoutingCache.delete(key);
}
while (evalRoutingCache.size > MAX_EVAL_ROUTING_CACHE_ENTRIES) {
const oldestKey = evalRoutingCache.keys().next().value;
if (!oldestKey) break;
evalRoutingCache.delete(oldestKey);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toStringList(value: unknown, maxItems: number): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.map((entry) => String(entry || "").trim()).filter(Boolean))].slice(
0,
maxItems
);
}
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function normalizeEvalRoutingConfig(rawConfig: unknown): EvalRoutingConfig {
const raw = isRecord(rawConfig) ? rawConfig : {};
const qualityWeight = clampNumber(
raw.qualityWeight,
DEFAULT_EVAL_ROUTING_CONFIG.qualityWeight,
0,
1
);
const latencyWeight = clampNumber(
raw.latencyWeight,
DEFAULT_EVAL_ROUTING_CONFIG.latencyWeight,
0,
1
);
const weightTotal = qualityWeight + latencyWeight;
return {
enabled: raw.enabled === true,
suiteIds: toStringList(raw.suiteIds, 50),
maxAgeHours: clampNumber(raw.maxAgeHours, DEFAULT_EVAL_ROUTING_CONFIG.maxAgeHours, 1, 24 * 365),
minCases: Math.floor(
clampNumber(raw.minCases, DEFAULT_EVAL_ROUTING_CONFIG.minCases, 1, 100_000)
),
qualityWeight: weightTotal > 0 ? qualityWeight / weightTotal : 1,
latencyWeight: weightTotal > 0 ? latencyWeight / weightTotal : 0,
cacheTtlMs: Math.floor(
clampNumber(raw.cacheTtlMs, DEFAULT_EVAL_ROUTING_CONFIG.cacheTtlMs, 1_000, 300_000)
),
};
}
function getTargetAliases(modelStr: string): string[] {
const parsed = parseModel(modelStr);
const modelId = typeof parsed.model === "string" ? parsed.model.trim() : "";
return [...new Set([modelStr.trim(), modelId].filter(Boolean))];
}
function buildCacheKey(targetIds: string[], config: EvalRoutingConfig): string {
return JSON.stringify({
targetIds: [...targetIds].sort(),
suiteIds: [...config.suiteIds].sort(),
maxAgeHours: config.maxAgeHours,
minCases: config.minCases,
});
}
function getEvalRuns(targetIds: string[], config: EvalRoutingConfig): PersistedEvalRun[] {
const now = Date.now();
pruneEvalRoutingCache(now);
const cacheKey = buildCacheKey(targetIds, config);
const cached = evalRoutingCache.get(cacheKey);
if (cached && cached.expiresAt > now) return cached.runs;
const runs = listModelEvalRunsForRouting({
targetIds,
suiteIds: config.suiteIds,
maxAgeHours: config.maxAgeHours,
});
evalRoutingCache.set(cacheKey, { expiresAt: now + config.cacheTtlMs, runs });
pruneEvalRoutingCache(now);
return runs;
}
function dedupeLatestRunsBySuite(runs: PersistedEvalRun[]): PersistedEvalRun[] {
const latest = new Map<string, PersistedEvalRun>();
for (const run of runs) {
const key = run.suiteId;
const current = latest.get(key);
if (!current || new Date(run.createdAt).getTime() > new Date(current.createdAt).getTime()) {
latest.set(key, run);
}
}
return Array.from(latest.values());
}
function calculateTargetScore(
runs: PersistedEvalRun[],
config: EvalRoutingConfig,
bestLatencyMs: number | null
): TargetScore | null {
const validRuns = dedupeLatestRunsBySuite(runs).filter(
(run) => run.summary.total >= config.minCases
);
if (validRuns.length === 0) return null;
const totalCases = validRuns.reduce((sum, run) => sum + run.summary.total, 0);
if (totalCases < config.minCases) return null;
const passRate =
validRuns.reduce((sum, run) => sum + run.summary.passRate * run.summary.total, 0) / totalCases;
const latencyWeight = validRuns.reduce(
(sum, run) => sum + Math.max(0, run.avgLatencyMs) * run.summary.total,
0
);
const avgLatencyMs = latencyWeight / totalCases;
const qualityScore = Math.max(0, Math.min(1, passRate / 100));
const latencyScore =
bestLatencyMs && avgLatencyMs > 0
? Math.max(0, Math.min(1, bestLatencyMs / avgLatencyMs))
: 0.5;
const score = qualityScore * config.qualityWeight + latencyScore * config.latencyWeight;
return {
score,
passRate,
avgLatencyMs,
totalCases,
runs: validRuns.length,
};
}
export function orderTargetsByEvalScores<T extends EvalRoutingTarget>(
targets: T[],
rawConfig: unknown,
log: EvalRoutingLogger = {}
): T[] {
const config = normalizeEvalRoutingConfig(rawConfig);
if (!config.enabled || targets.length <= 1) return targets;
const aliasesByIndex = targets.map((target) => getTargetAliases(target.modelStr));
const targetIds = [...new Set(aliasesByIndex.flat())];
if (targetIds.length === 0) return targets;
let runs: PersistedEvalRun[];
try {
runs = getEvalRuns(targetIds, config);
} catch (error) {
log.warn?.("COMBO", "Eval-driven routing skipped because eval history could not be loaded", {
error: error instanceof Error ? error.message : String(error),
});
return targets;
}
if (runs.length === 0) return targets;
const runsByTargetId = new Map<string, PersistedEvalRun[]>();
for (const run of runs) {
const targetId = run.target.id;
if (!targetId) continue;
const bucket = runsByTargetId.get(targetId) || [];
bucket.push(run);
runsByTargetId.set(targetId, bucket);
}
const runsByIndex = aliasesByIndex.map((aliases) => {
const byId = new Map<string, PersistedEvalRun>();
for (const alias of aliases) {
for (const run of runsByTargetId.get(alias) || []) {
byId.set(run.id, run);
}
}
return Array.from(byId.values());
});
const candidateLatencies = runsByIndex
.flatMap((targetRuns) => dedupeLatestRunsBySuite(targetRuns))
.filter((run) => run.summary.total >= config.minCases && run.avgLatencyMs > 0)
.map((run) => run.avgLatencyMs);
const bestLatencyMs = candidateLatencies.length > 0 ? Math.min(...candidateLatencies) : null;
const entries = targets.map((target, index) => ({
target,
index,
score: calculateTargetScore(runsByIndex[index] || [], config, bestLatencyMs),
}));
const scoredCount = entries.filter((entry) => entry.score).length;
if (scoredCount === 0) return targets;
entries.sort((left, right) => {
if (left.score && right.score) {
const delta = right.score.score - left.score.score;
if (Math.abs(delta) > 0.0001) return delta;
return left.index - right.index;
}
if (left.score) return -1;
if (right.score) return 1;
return left.index - right.index;
});
log.info?.(
"COMBO",
`Eval-driven routing: ranked ${scoredCount}/${targets.length} targets by eval history`
);
log.debug?.(
"COMBO",
`Eval-driven routing scores: ${entries
.filter((entry) => entry.score)
.map(
(entry) =>
`${entry.target.modelStr}=${entry.score?.score.toFixed(3)} pass=${entry.score?.passRate.toFixed(1)} cases=${entry.score?.totalCases}`
)
.join(", ")}`
);
return entries.map((entry) => entry.target);
}
export function resetEvalRoutingCache(): void {
evalRoutingCache.clear();
}