auto-model-router 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +127 -46
- package/bun.lock +606 -0
- package/omp-extension/router-embed.ts +14 -6
- package/omp-extension/router-toast.ts +6 -1
- package/omp-extension/toast-logic.ts +7 -0
- package/package.json +2 -1
- package/research/analyze-ledger.ts +173 -0
- package/research/apply-cost-tuning.ts +73 -0
- package/research/cost-analysis.ts +150 -0
- package/research/feed-check.ts +64 -0
- package/research/model-recommendations.ts +86 -0
- package/research/project-yield.ts +96 -0
- package/research/run-eval.ts +133 -0
- package/research/status.ts +55 -0
- package/research/tier-fill.ts +109 -0
- package/research/tier-map.ts +123 -0
- package/src/catalog/benchmark-feeds.ts +397 -0
- package/src/catalog/openrouter-catalog.ts +30 -0
- package/src/config/defaults.ts +30 -0
- package/src/config/load.ts +2 -0
- package/src/config/schema.ts +34 -0
- package/src/config/types.ts +106 -0
- package/src/cost/ledger.ts +27 -3
- package/src/cost/types.ts +30 -0
- package/src/eval/calibrate.ts +131 -0
- package/src/eval/grade.ts +115 -0
- package/src/eval/judge.ts +71 -0
- package/src/eval/run.ts +126 -0
- package/src/eval/tasks.ts +272 -0
- package/src/index.ts +0 -1
- package/src/router/candidates.ts +13 -6
- package/src/router/explore.ts +59 -0
- package/src/router/select.ts +54 -4
- package/src/router/tier-plan.ts +57 -1
- package/src/router/types.ts +13 -0
- package/src/server/turn.ts +10 -2
- package/src/util/sqlite.ts +79 -1
- package/src/wire/openai/request.ts +5 -0
- package/src/wire/types.ts +7 -0
- package/test/benchmark-feeds.test.ts +222 -0
- package/test/escalate.test.ts +1 -0
- package/test/eval.test.ts +184 -0
- package/test/exploration.test.ts +251 -0
- package/test/failover.test.ts +5 -0
- package/test/hold-exploration.test.ts +124 -0
- package/test/tier-plan.test.ts +55 -1
- package/test/toast-logic.test.ts +32 -0
- package/test/tokens.test.ts +8 -0
- package/test/trust-attribution.test.ts +110 -2
- package/test/turn.test.ts +46 -0
- package/test/wire-request.test.ts +11 -0
- package/tools/smoke.ts +2 -0
- package/tools/sync-marketplace-version.ts +60 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External benchmark backfill.
|
|
3
|
+
*
|
|
4
|
+
* OpenRouter embeds Artificial Analysis scores for the models it has bench data
|
|
5
|
+
* for, but returns the rest unscored — GLM, MiniMax, smaller vendors — which
|
|
6
|
+
* strands them below every tier floor above `trivial` (see openrouter-catalog.ts
|
|
7
|
+
* `joinBenchmarks`). These feeds fill the axes a model is MISSING, from the same
|
|
8
|
+
* two sources agentmanager uses:
|
|
9
|
+
*
|
|
10
|
+
* - Artificial Analysis (`/api/v2/data/llms/models`, `x-api-key`): the broad
|
|
11
|
+
* baseline, but only when a key is configured.
|
|
12
|
+
* - BenchLM (`/api/data/leaderboard`, keyless): covers the models AA omits.
|
|
13
|
+
*
|
|
14
|
+
* Three rules, all load-bearing:
|
|
15
|
+
*
|
|
16
|
+
* - FILL, NEVER OVERWRITE. A score OpenRouter already published wins; the feeds
|
|
17
|
+
* only supply axes that are absent. Two suites measure the same idea on
|
|
18
|
+
* different tests, so letting one overwrite the other would make a model's
|
|
19
|
+
* score jump with whichever feed refreshed last.
|
|
20
|
+
* - PER AXIS, AA BEFORE BENCHLM. AA is the stronger source and fills first;
|
|
21
|
+
* BenchLM fills whatever axis AA still left empty.
|
|
22
|
+
* - MATCH EXACTLY OR NOT AT ALL. Matching is on a normalized model-name key,
|
|
23
|
+
* with the creator used only to break a tie between two rows that share a
|
|
24
|
+
* key. A fuzzy match would let a 7B inherit a 72B's score and then be handed
|
|
25
|
+
* the hard task; an unmatched model stays honestly unscored.
|
|
26
|
+
*
|
|
27
|
+
* Everything here is best-effort: any fetch or parse failure yields an empty
|
|
28
|
+
* feed, and the catalog keeps its published scores rather than failing.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Database } from "bun:sqlite";
|
|
32
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
33
|
+
import { createLogger, type Logger } from "../util/log.ts";
|
|
34
|
+
import type { QualityAxis } from "../config/types.ts";
|
|
35
|
+
|
|
36
|
+
export const AA_MODELS_URL = "https://artificialanalysis.ai/api/v2/data/llms/models";
|
|
37
|
+
export const BENCHLM_URL = "https://benchlm.ai/api/data/leaderboard";
|
|
38
|
+
|
|
39
|
+
export type FeedSource = "artificial_analysis" | "benchlm" | "local";
|
|
40
|
+
|
|
41
|
+
/** One model's scores from one feed, on the router's three axes (0-100). */
|
|
42
|
+
export interface FeedScore {
|
|
43
|
+
/** Normalized model-name key, e.g. `glm-5-3-flash`. The match key. */
|
|
44
|
+
key: string;
|
|
45
|
+
/** Normalized creator/author. May be "". Used only to disambiguate a key tie. */
|
|
46
|
+
creator: string;
|
|
47
|
+
coding?: number;
|
|
48
|
+
intelligence?: number;
|
|
49
|
+
agentic?: number;
|
|
50
|
+
source: FeedSource;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FillResult {
|
|
54
|
+
/** Models that gained at least one score. */
|
|
55
|
+
modelsFilled: number;
|
|
56
|
+
/** Fills per axis. */
|
|
57
|
+
axes: Record<QualityAxis, number>;
|
|
58
|
+
/** Fills per source. */
|
|
59
|
+
sources: Record<FeedSource, number>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const AXES: readonly QualityAxis[] = ["coding", "intelligence", "agentic"];
|
|
63
|
+
|
|
64
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A finite number in [0, 100], or null. Scores outside the range mean the field is not what we think. */
|
|
69
|
+
function score100(value: unknown): number | null {
|
|
70
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
71
|
+
if (value < 0 || value > 100) return null;
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function axisValue(f: FeedScore, axis: QualityAxis): number | undefined {
|
|
76
|
+
if (axis === "coding") return f.coding;
|
|
77
|
+
if (axis === "intelligence") return f.intelligence;
|
|
78
|
+
return f.agentic;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A model name reduced to something comparable across OpenRouter slugs and the
|
|
83
|
+
* feeds' own names. Provider prefix and release/packaging words are routing,
|
|
84
|
+
* not identity, and go; a parameter count (7b vs 72b) is identity and stays.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeModelKey(name: string): string {
|
|
87
|
+
let s = name.toLowerCase().trim();
|
|
88
|
+
if (s.startsWith("~")) s = s.slice(1);
|
|
89
|
+
// `/` separates provider from model; a bare `:` is a CLI/tag separator.
|
|
90
|
+
if (s.includes("/")) s = s.slice(s.lastIndexOf("/") + 1);
|
|
91
|
+
else if (s.includes(":")) s = s.slice(s.indexOf(":") + 1);
|
|
92
|
+
// Delivery/release words stack (`:preview-cloud`), so strip until stable.
|
|
93
|
+
const packaging = /[:@-](?:cloud|free|latest|online|nitro|beta|preview)$/;
|
|
94
|
+
while (packaging.test(s)) s = s.replace(packaging, "");
|
|
95
|
+
s = s.replace(/[.\s_:]+/g, "-");
|
|
96
|
+
return s.replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Creator reduced for a tie-break comparison. Never used to reject a lone match. */
|
|
100
|
+
function normalizeCreator(name: string): string {
|
|
101
|
+
return name
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.trim()
|
|
104
|
+
.replace(/[.\s_]+/g, "-")
|
|
105
|
+
.replace(/-+/g, "-")
|
|
106
|
+
.replace(/^-|-$/g, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Author segment of an OpenRouter slug (before the first `/`, tilde stripped). */
|
|
110
|
+
function authorOf(slug: string): string {
|
|
111
|
+
const bare = slug.startsWith("~") ? slug.slice(1) : slug;
|
|
112
|
+
const slash = bare.indexOf("/");
|
|
113
|
+
return normalizeCreator(slash === -1 ? "" : bare.slice(0, slash));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------- parse
|
|
117
|
+
|
|
118
|
+
/** Parse the Artificial Analysis `data[]` payload into feed scores. */
|
|
119
|
+
export function parseAaModels(body: unknown): FeedScore[] {
|
|
120
|
+
const root = asRecord(body);
|
|
121
|
+
const data = root === null ? null : root.data;
|
|
122
|
+
if (!Array.isArray(data)) return [];
|
|
123
|
+
const out: FeedScore[] = [];
|
|
124
|
+
for (const raw of data) {
|
|
125
|
+
const rec = asRecord(raw);
|
|
126
|
+
if (rec === null) continue;
|
|
127
|
+
const slug = typeof rec.slug === "string" ? rec.slug : null;
|
|
128
|
+
if (slug === null || slug.length === 0) continue;
|
|
129
|
+
const evals = asRecord(rec.evaluations);
|
|
130
|
+
if (evals === null) continue;
|
|
131
|
+
const coding = score100(evals.artificial_analysis_coding_index);
|
|
132
|
+
const intelligence = score100(evals.artificial_analysis_intelligence_index);
|
|
133
|
+
const agentic = score100(evals.artificial_analysis_agentic_index);
|
|
134
|
+
if (coding === null && intelligence === null && agentic === null) continue;
|
|
135
|
+
const creatorRec = asRecord(rec.model_creator);
|
|
136
|
+
const creator = creatorRec !== null && typeof creatorRec.slug === "string" ? normalizeCreator(creatorRec.slug) : "";
|
|
137
|
+
const entry: FeedScore = { key: normalizeModelKey(slug), creator, source: "artificial_analysis" };
|
|
138
|
+
if (coding !== null) entry.coding = coding;
|
|
139
|
+
if (intelligence !== null) entry.intelligence = intelligence;
|
|
140
|
+
if (agentic !== null) entry.agentic = agentic;
|
|
141
|
+
out.push(entry);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse the BenchLM `models[]` payload. Only `supported` rows are used: an
|
|
148
|
+
* `estimated` row is BenchLM's own inference, not a measurement, and applying
|
|
149
|
+
* it at benchmark grade would make the grade mean nothing.
|
|
150
|
+
*/
|
|
151
|
+
export function parseBenchlmModels(body: unknown): FeedScore[] {
|
|
152
|
+
const root = asRecord(body);
|
|
153
|
+
const models = root === null ? null : root.models;
|
|
154
|
+
if (!Array.isArray(models)) return [];
|
|
155
|
+
const out: FeedScore[] = [];
|
|
156
|
+
for (const raw of models) {
|
|
157
|
+
const rec = asRecord(raw);
|
|
158
|
+
if (rec === null) continue;
|
|
159
|
+
if (rec.evidenceStatus !== "supported") continue;
|
|
160
|
+
const name = typeof rec.model === "string" ? rec.model : null;
|
|
161
|
+
if (name === null || name.length === 0) continue;
|
|
162
|
+
const scores = asRecord(rec.categoryScores);
|
|
163
|
+
if (scores === null) continue;
|
|
164
|
+
// BenchLM's `reasoning` category is the closest proxy for AA's composite
|
|
165
|
+
// intelligence index; coding and agentic map straight across.
|
|
166
|
+
const coding = score100(scores.coding);
|
|
167
|
+
const intelligence = score100(scores.reasoning);
|
|
168
|
+
const agentic = score100(scores.agentic);
|
|
169
|
+
if (coding === null && intelligence === null && agentic === null) continue;
|
|
170
|
+
const creator = typeof rec.creator === "string" ? normalizeCreator(rec.creator) : "";
|
|
171
|
+
const entry: FeedScore = { key: normalizeModelKey(name), creator, source: "benchlm" };
|
|
172
|
+
if (coding !== null) entry.coding = coding;
|
|
173
|
+
if (intelligence !== null) entry.intelligence = intelligence;
|
|
174
|
+
if (agentic !== null) entry.agentic = agentic;
|
|
175
|
+
out.push(entry);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------- fetch
|
|
181
|
+
|
|
182
|
+
/** The subset of `fetch` these feeds use: call it, get a Response. Lets a test pass a plain stub. */
|
|
183
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
184
|
+
|
|
185
|
+
interface FetchOpts {
|
|
186
|
+
fetchImpl?: FetchLike;
|
|
187
|
+
timeoutMs?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function fetchJson(url: string, headers: Record<string, string>, opts: FetchOpts): Promise<unknown | null> {
|
|
191
|
+
const impl = opts.fetchImpl ?? fetch;
|
|
192
|
+
try {
|
|
193
|
+
const res = await impl(url, { headers, signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000) });
|
|
194
|
+
if (!res.ok) return null;
|
|
195
|
+
return await res.json();
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function fetchAaScores(apiKey: string, opts: FetchOpts = {}): Promise<FeedScore[]> {
|
|
202
|
+
if (apiKey.trim() === "") return [];
|
|
203
|
+
const body = await fetchJson(AA_MODELS_URL, { "x-api-key": apiKey.trim() }, opts);
|
|
204
|
+
return body === null ? [] : parseAaModels(body);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function fetchBenchlmScores(opts: FetchOpts = {}): Promise<FeedScore[]> {
|
|
208
|
+
const body = await fetchJson(`${BENCHLM_URL}?mode=bench-align-v5&limit=200`, {}, opts);
|
|
209
|
+
return body === null ? [] : parseBenchlmModels(body);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------- apply
|
|
213
|
+
|
|
214
|
+
function pick(candidates: FeedScore[], source: FeedSource, author: string): FeedScore | null {
|
|
215
|
+
const sourced = candidates.filter((c) => c.source === source);
|
|
216
|
+
if (sourced.length === 0) return null;
|
|
217
|
+
if (sourced.length === 1) return sourced[0] ?? null;
|
|
218
|
+
// A shared key with several rows: only the one whose creator matches, and
|
|
219
|
+
// only if that is unique. Anything else is ambiguous and left unfilled.
|
|
220
|
+
const byCreator = sourced.filter((c) => c.creator !== "" && c.creator === author);
|
|
221
|
+
return byCreator.length === 1 ? (byCreator[0] ?? null) : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Mutate raw OpenRouter records in place, filling absent quality axes from the
|
|
226
|
+
* feeds. Scores are written into `benchmarks.artificial_analysis.*_index` so
|
|
227
|
+
* `normalizeCatalogModel` reads them unchanged, and provenance is recorded under
|
|
228
|
+
* `benchmarks.fill_sources` (ignored by normalization, kept for diagnostics).
|
|
229
|
+
*/
|
|
230
|
+
export function applyFeedScores(rawModels: unknown[], feeds: FeedScore[]): FillResult {
|
|
231
|
+
const result: FillResult = {
|
|
232
|
+
modelsFilled: 0,
|
|
233
|
+
axes: { coding: 0, intelligence: 0, agentic: 0 },
|
|
234
|
+
sources: { artificial_analysis: 0, benchlm: 0, local: 0 },
|
|
235
|
+
};
|
|
236
|
+
if (feeds.length === 0) return result;
|
|
237
|
+
|
|
238
|
+
const byKey = new Map<string, FeedScore[]>();
|
|
239
|
+
for (const f of feeds) {
|
|
240
|
+
const list = byKey.get(f.key);
|
|
241
|
+
if (list === undefined) byKey.set(f.key, [f]);
|
|
242
|
+
else list.push(f);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const raw of rawModels) {
|
|
246
|
+
const rec = asRecord(raw);
|
|
247
|
+
if (rec === null) continue;
|
|
248
|
+
const id = typeof rec.id === "string" ? rec.id : null;
|
|
249
|
+
if (id === null) continue;
|
|
250
|
+
const candidates = byKey.get(normalizeModelKey(id));
|
|
251
|
+
if (candidates === undefined) continue;
|
|
252
|
+
const author = authorOf(id);
|
|
253
|
+
|
|
254
|
+
const bm = asRecord(rec.benchmarks) ?? {};
|
|
255
|
+
const aa = asRecord(bm.artificial_analysis) ?? {};
|
|
256
|
+
const fillSources: Record<string, string> = {};
|
|
257
|
+
let filledThis = false;
|
|
258
|
+
|
|
259
|
+
for (const axis of AXES) {
|
|
260
|
+
if (score100(aa[`${axis}_index`]) !== null) continue; // published; never overwrite
|
|
261
|
+
const aaHit = pick(candidates, "artificial_analysis", author);
|
|
262
|
+
let value = aaHit === null ? undefined : axisValue(aaHit, axis);
|
|
263
|
+
let source: FeedSource = "artificial_analysis";
|
|
264
|
+
if (value === undefined) {
|
|
265
|
+
const blHit = pick(candidates, "benchlm", author);
|
|
266
|
+
value = blHit === null ? undefined : axisValue(blHit, axis);
|
|
267
|
+
source = "benchlm";
|
|
268
|
+
}
|
|
269
|
+
if (value === undefined) {
|
|
270
|
+
// Our own calibrated eval is the weakest source: only where neither
|
|
271
|
+
// published nor third-party feeds have anything.
|
|
272
|
+
const localHit = pick(candidates, "local", author);
|
|
273
|
+
value = localHit === null ? undefined : axisValue(localHit, axis);
|
|
274
|
+
source = "local";
|
|
275
|
+
}
|
|
276
|
+
if (value === undefined) continue;
|
|
277
|
+
aa[`${axis}_index`] = value;
|
|
278
|
+
fillSources[axis] = source;
|
|
279
|
+
result.axes[axis] += 1;
|
|
280
|
+
result.sources[source] += 1;
|
|
281
|
+
filledThis = true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (filledThis) {
|
|
285
|
+
bm.artificial_analysis = aa;
|
|
286
|
+
const priorSources = asRecord(bm.fill_sources) ?? {};
|
|
287
|
+
bm.fill_sources = { ...priorSources, ...fillSources };
|
|
288
|
+
rec.benchmarks = bm;
|
|
289
|
+
result.modelsFilled += 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------- cache-aware refresh
|
|
296
|
+
|
|
297
|
+
interface RefreshOpts extends FetchOpts {
|
|
298
|
+
log?: Logger;
|
|
299
|
+
now?: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
interface CacheRow {
|
|
303
|
+
payload: string;
|
|
304
|
+
fetched_at_ms: number;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The feed scores, from the `benchmark_cache` table when fresh, else re-fetched
|
|
309
|
+
* and persisted. Cadence is `cfg.benchmarks.refreshMs` (~daily), deliberately
|
|
310
|
+
* decoupled from the minute-scale catalog refresh so the endpoints are not hit
|
|
311
|
+
* on every availability poll. A fetch that returns nothing falls back to the
|
|
312
|
+
* stale cache rather than discarding usable scores.
|
|
313
|
+
*/
|
|
314
|
+
export async function refreshFeedScores(cfg: RouterConfig, db: Database, opts: RefreshOpts = {}): Promise<FeedScore[]> {
|
|
315
|
+
const log = opts.log ?? createLogger(cfg.logLevel);
|
|
316
|
+
const now = opts.now ?? Date.now();
|
|
317
|
+
const bm = cfg.benchmarks;
|
|
318
|
+
|
|
319
|
+
const row = db.query("SELECT payload, fetched_at_ms FROM benchmark_cache WHERE id = 1").get() as CacheRow | null;
|
|
320
|
+
const cached: FeedScore[] | null = row === null ? null : parseFeedScores(row.payload);
|
|
321
|
+
if (row !== null && cached !== null && now - row.fetched_at_ms < bm.refreshMs) return cached;
|
|
322
|
+
|
|
323
|
+
const feedOpts: FetchOpts = { timeoutMs: bm.timeoutMs };
|
|
324
|
+
if (opts.fetchImpl !== undefined) feedOpts.fetchImpl = opts.fetchImpl;
|
|
325
|
+
const [aa, bl] = await Promise.all([
|
|
326
|
+
bm.artificialAnalysisApiKey.trim() === ""
|
|
327
|
+
? Promise.resolve<FeedScore[]>([])
|
|
328
|
+
: fetchAaScores(bm.artificialAnalysisApiKey, feedOpts),
|
|
329
|
+
bm.benchlm ? fetchBenchlmScores(feedOpts) : Promise.resolve<FeedScore[]>([]),
|
|
330
|
+
]);
|
|
331
|
+
const merged = [...aa, ...bl];
|
|
332
|
+
|
|
333
|
+
if (merged.length === 0) {
|
|
334
|
+
if (cached !== null) {
|
|
335
|
+
log.warn("benchmark feeds returned nothing; reusing the cached feed", { cached: cached.length });
|
|
336
|
+
return cached;
|
|
337
|
+
}
|
|
338
|
+
log.warn("benchmark feeds returned nothing and no cache exists; catalog keeps published scores");
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
db.query(
|
|
343
|
+
`INSERT INTO benchmark_cache (id, payload, fetched_at_ms) VALUES (1, ?, ?)
|
|
344
|
+
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, fetched_at_ms = excluded.fetched_at_ms`,
|
|
345
|
+
).run(JSON.stringify(merged), now);
|
|
346
|
+
log.debug("refreshed benchmark feeds", { artificial_analysis: aa.length, benchlm: bl.length });
|
|
347
|
+
return merged;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Validate a persisted `FeedScore[]` blob, skipping any entry that drifted. */
|
|
351
|
+
function parseFeedScores(payload: string): FeedScore[] | null {
|
|
352
|
+
let parsed: unknown;
|
|
353
|
+
try {
|
|
354
|
+
parsed = JSON.parse(payload);
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
if (!Array.isArray(parsed)) return null;
|
|
359
|
+
const out: FeedScore[] = [];
|
|
360
|
+
for (const item of parsed) {
|
|
361
|
+
const rec = asRecord(item);
|
|
362
|
+
if (rec === null || typeof rec.key !== "string") continue;
|
|
363
|
+
if (rec.source !== "artificial_analysis" && rec.source !== "benchlm" && rec.source !== "local") continue;
|
|
364
|
+
const entry: FeedScore = {
|
|
365
|
+
key: rec.key,
|
|
366
|
+
creator: typeof rec.creator === "string" ? rec.creator : "",
|
|
367
|
+
source: rec.source,
|
|
368
|
+
};
|
|
369
|
+
const coding = score100(rec.coding);
|
|
370
|
+
if (coding !== null) entry.coding = coding;
|
|
371
|
+
const intelligence = score100(rec.intelligence);
|
|
372
|
+
if (intelligence !== null) entry.intelligence = intelligence;
|
|
373
|
+
const agentic = score100(rec.agentic);
|
|
374
|
+
if (agentic !== null) entry.agentic = agentic;
|
|
375
|
+
out.push(entry);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Local eval scores from the `local_scores` table (written by the eval runner),
|
|
382
|
+
* or [] when absent/unreadable. No TTL: these change only when the eval is
|
|
383
|
+
* re-run, and are gated by `benchmarks.useLocalScores` at the call site.
|
|
384
|
+
*/
|
|
385
|
+
export function loadLocalScores(db: Database): FeedScore[] {
|
|
386
|
+
const row = db.query("SELECT payload FROM local_scores WHERE id = 1").get() as { payload: string } | null;
|
|
387
|
+
if (row === null) return [];
|
|
388
|
+
return parseFeedScores(row.payload) ?? [];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Persist local eval scores (source `local`) for `doRefresh` to pick up when enabled. */
|
|
392
|
+
export function saveLocalScores(db: Database, scores: readonly FeedScore[], now = Date.now()): void {
|
|
393
|
+
db.query(
|
|
394
|
+
`INSERT INTO local_scores (id, payload, fetched_at_ms) VALUES (1, ?, ?)
|
|
395
|
+
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, fetched_at_ms = excluded.fetched_at_ms`,
|
|
396
|
+
).run(JSON.stringify(scores), now);
|
|
397
|
+
}
|
|
@@ -13,6 +13,7 @@ import type { RouterConfig } from "../config/types.ts";
|
|
|
13
13
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
14
14
|
import { createLogger } from "../util/log.ts";
|
|
15
15
|
import type { CatalogModel, CatalogSnapshot, CatalogSource, Modality, Price, PriceTier, QualityScores } from "./types.ts";
|
|
16
|
+
import { applyFeedScores, loadLocalScores, refreshFeedScores } from "./benchmark-feeds.ts";
|
|
16
17
|
|
|
17
18
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
18
19
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
@@ -366,6 +367,35 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
|
|
|
366
367
|
}
|
|
367
368
|
}
|
|
368
369
|
|
|
370
|
+
// Backfill quality scores OpenRouter never published (GLM, MiniMax, and
|
|
371
|
+
// smaller vendors), from the external benchmark feeds. Best-effort: a feed
|
|
372
|
+
// failure leaves the catalog on published scores. The feeds are cached on
|
|
373
|
+
// their own slow cadence, so this is cheap on the minute-scale refresh.
|
|
374
|
+
if (cfg.benchmarks.enabled) {
|
|
375
|
+
try {
|
|
376
|
+
const feeds = await refreshFeedScores(cfg, db, { log });
|
|
377
|
+
// Local eval scores are last-resort and gated: they change routing, so
|
|
378
|
+
// they apply only when the operator opts in.
|
|
379
|
+
const local = cfg.benchmarks.useLocalScores ? loadLocalScores(db) : [];
|
|
380
|
+
const filled = applyFeedScores(raw, [...feeds, ...local]);
|
|
381
|
+
if (filled.modelsFilled > 0) {
|
|
382
|
+
log.debug("backfilled missing benchmarks from external feeds", {
|
|
383
|
+
models: filled.modelsFilled,
|
|
384
|
+
coding: filled.axes.coding,
|
|
385
|
+
intelligence: filled.axes.intelligence,
|
|
386
|
+
agentic: filled.axes.agentic,
|
|
387
|
+
aa: filled.sources.artificial_analysis,
|
|
388
|
+
benchlm: filled.sources.benchlm,
|
|
389
|
+
local: filled.sources.local,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
} catch (err) {
|
|
393
|
+
log.warn("benchmark backfill failed; catalog keeps published scores", {
|
|
394
|
+
error: err instanceof Error ? err.message : String(err),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
369
399
|
const models = normalizeAll(raw);
|
|
370
400
|
// A guardrail can narrow the key-scoped list to zero usable models (or a
|
|
371
401
|
// transient upstream blip can return an empty payload). Treat that as a
|
package/src/config/defaults.ts
CHANGED
|
@@ -24,6 +24,17 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
24
24
|
// guardrail changes are picked up without waiting for traffic + TTL.
|
|
25
25
|
catalogRefreshMs: 5 * 60 * 1000,
|
|
26
26
|
},
|
|
27
|
+
benchmarks: {
|
|
28
|
+
// Keyless BenchLM alone fills real gaps, so this is on by default; the AA
|
|
29
|
+
// feed only actually fires once a key is present (config or env).
|
|
30
|
+
enabled: true,
|
|
31
|
+
artificialAnalysisApiKey: "",
|
|
32
|
+
benchlm: true,
|
|
33
|
+
refreshMs: 24 * 60 * 60 * 1000,
|
|
34
|
+
timeoutMs: 30_000,
|
|
35
|
+
// Off: our own eval scores change routing, so they never apply until asked.
|
|
36
|
+
useLocalScores: false,
|
|
37
|
+
},
|
|
27
38
|
tiers: {
|
|
28
39
|
// minQuality 0 ⇒ unscored models are eligible here; the floor does the
|
|
29
40
|
// quality work on every other tier (qualityExponent 0 ⇒ cheapest above floor).
|
|
@@ -103,6 +114,22 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
103
114
|
cacheWarmTtlMs: 300_000,
|
|
104
115
|
maxDowngradePerTurn: 1,
|
|
105
116
|
},
|
|
117
|
+
exploration: {
|
|
118
|
+
// Opt-in. Exploration knowingly routes some turns below the tier that
|
|
119
|
+
// would otherwise be used; escalation bounds the damage, but it is
|
|
120
|
+
// still a real cost paid on real traffic.
|
|
121
|
+
enabled: false,
|
|
122
|
+
// Weighted by scarcity and by spend, not uniformly: `simple` turns are
|
|
123
|
+
// abundant and cheap to be wrong about, `hard` turns are rare and hold
|
|
124
|
+
// most of the money, so they need a far higher rate to yield any
|
|
125
|
+
// sample at all within a useful number of days.
|
|
126
|
+
rates: { simple: 0.03, moderate: 0.15, hard: 0.2 },
|
|
127
|
+
// Conservative default: never sacrifice a live prompt cache without
|
|
128
|
+
// the operator choosing to. `always` is what actually reaches the
|
|
129
|
+
// held population that carries the spend.
|
|
130
|
+
stickyPolicy: "cold-cache",
|
|
131
|
+
holdTurns: { enabled: false, values: [2, 3, 4] },
|
|
132
|
+
},
|
|
106
133
|
cache: {
|
|
107
134
|
injectBreakpoints: true,
|
|
108
135
|
// Anthropic allows 4 breakpoints; OpenRouter translates for other vendors.
|
|
@@ -132,5 +159,8 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
132
159
|
// On by default: an absolute floor that no available model meets is how the
|
|
133
160
|
// router ends up serving every turn from the cheapest tier.
|
|
134
161
|
adaptiveTierFloors: true,
|
|
162
|
+
// Off by default: fixed per-tier ceilings ship as the baseline. Enable to make
|
|
163
|
+
// price ceilings self-tune to the key's catalog (see RouterConfig doc).
|
|
164
|
+
adaptivePriceCeilings: false,
|
|
135
165
|
logLevel: "info",
|
|
136
166
|
};
|
package/src/config/load.ts
CHANGED
|
@@ -87,6 +87,8 @@ export function loadConfig(opts?: { path?: string; overrides?: Partial<RouterCon
|
|
|
87
87
|
};
|
|
88
88
|
const envApiKey = process.env.OPENROUTER_API_KEY;
|
|
89
89
|
if (envApiKey !== undefined && envApiKey !== "") putSection("openrouter", "apiKey", envApiKey);
|
|
90
|
+
const envAaKey = process.env.ARTIFICIAL_ANALYSIS_API_KEY;
|
|
91
|
+
if (envAaKey !== undefined && envAaKey !== "") putSection("benchmarks", "artificialAnalysisApiKey", envAaKey);
|
|
90
92
|
const envPort = process.env.AUTO_MODEL_ROUTER_PORT;
|
|
91
93
|
if (envPort !== undefined && envPort !== "") {
|
|
92
94
|
const port = Number.parseInt(envPort, 10);
|
package/src/config/schema.ts
CHANGED
|
@@ -32,6 +32,15 @@ const openrouter = z.strictObject({
|
|
|
32
32
|
catalogRefreshMs: z.number().nonnegative().optional(),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
const benchmarks = z.strictObject({
|
|
36
|
+
enabled: z.boolean().optional(),
|
|
37
|
+
artificialAnalysisApiKey: z.string().optional(),
|
|
38
|
+
benchlm: z.boolean().optional(),
|
|
39
|
+
refreshMs: z.number().nonnegative().optional(),
|
|
40
|
+
timeoutMs: z.number().positive().optional(),
|
|
41
|
+
useLocalScores: z.boolean().optional(),
|
|
42
|
+
});
|
|
43
|
+
|
|
35
44
|
const tierConfig = z.strictObject({
|
|
36
45
|
minQuality: z.number().min(0).max(100).optional(),
|
|
37
46
|
maxInputPerMtok: z.number().nonnegative().optional(),
|
|
@@ -88,6 +97,28 @@ const hysteresis = z.strictObject({
|
|
|
88
97
|
maxDowngradePerTurn: z.number().int().nonnegative().optional(),
|
|
89
98
|
});
|
|
90
99
|
|
|
100
|
+
const exploration = z.strictObject({
|
|
101
|
+
enabled: z.boolean().optional(),
|
|
102
|
+
// Spelled out per tier rather than z.record so an unknown tier name is a
|
|
103
|
+
// config error instead of a silently ignored key.
|
|
104
|
+
rates: z
|
|
105
|
+
.strictObject({
|
|
106
|
+
trivial: z.number().min(0).max(1).optional(),
|
|
107
|
+
simple: z.number().min(0).max(1).optional(),
|
|
108
|
+
moderate: z.number().min(0).max(1).optional(),
|
|
109
|
+
hard: z.number().min(0).max(1).optional(),
|
|
110
|
+
})
|
|
111
|
+
.optional(),
|
|
112
|
+
stickyPolicy: z.enum(["never", "cold-cache", "always"]).optional(),
|
|
113
|
+
holdTurns: z
|
|
114
|
+
.strictObject({
|
|
115
|
+
enabled: z.boolean().optional(),
|
|
116
|
+
// Non-empty and positive: an empty set or a 0 would silently disable
|
|
117
|
+
// the experiment while reading as enabled.
|
|
118
|
+
values: z.array(z.number().int().positive()).min(1).optional(),
|
|
119
|
+
})
|
|
120
|
+
.optional(),
|
|
121
|
+
});
|
|
91
122
|
const cache = z.strictObject({
|
|
92
123
|
injectBreakpoints: z.boolean().optional(),
|
|
93
124
|
maxBreakpoints: z.number().int().positive().optional(),
|
|
@@ -129,6 +160,7 @@ const profile = z.strictObject({
|
|
|
129
160
|
export const configInputSchema = z.strictObject({
|
|
130
161
|
server: server.optional(),
|
|
131
162
|
openrouter: openrouter.optional(),
|
|
163
|
+
benchmarks: benchmarks.optional(),
|
|
132
164
|
tiers: z
|
|
133
165
|
.strictObject({
|
|
134
166
|
trivial: tierConfig.optional(),
|
|
@@ -150,11 +182,13 @@ export const configInputSchema = z.strictObject({
|
|
|
150
182
|
classifier: classifier.optional(),
|
|
151
183
|
escalation: escalation.optional(),
|
|
152
184
|
hysteresis: hysteresis.optional(),
|
|
185
|
+
exploration: exploration.optional(),
|
|
153
186
|
cache: cache.optional(),
|
|
154
187
|
budget: budget.optional(),
|
|
155
188
|
profiles: z.array(profile).optional(),
|
|
156
189
|
ledger: ledger.optional(),
|
|
157
190
|
adaptiveTierFloors: z.boolean().optional(),
|
|
191
|
+
adaptivePriceCeilings: z.boolean().optional(),
|
|
158
192
|
logLevel: logLevel.optional(),
|
|
159
193
|
});
|
|
160
194
|
|