auto-model-router 0.2.15 → 0.2.20

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.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Offline decision replay — re-run REAL routing over recorded ledger rows.
3
+ *
4
+ * Every routing change is behavior-changing and cost-relevant, so the standing
5
+ * rule is to validate on the ledger before enabling. This is the tool for that:
6
+ * it feeds recorded `features` back through the real `scoreHeuristic` and
7
+ * `select`, under two config variants, and diffs the decisions.
8
+ *
9
+ * bun tools/replay.ts --limit 500
10
+ * bun tools/replay.ts --set tiers.hard.minQuality=70
11
+ * bun tools/replay.ts --set filters.latencyWeight=0 --verbose
12
+ * bun tools/replay.ts --where "task='coding'" --set classifier.ambiguityThreshold=0
13
+ *
14
+ * `--set` overrides variant B; `--a` overrides the baseline too (default:
15
+ * config as it currently stands on disk). Read-only: opens the ledger DB
16
+ * readonly and never writes.
17
+ *
18
+ * WHAT IT MODELS FAITHFULLY
19
+ * - The recorded `features` blob is the exact classifier input from that turn,
20
+ * so no re-tokenization or re-derivation is involved.
21
+ * - The real catalog snapshot is hydrated from `catalog_cache` via `peek()`,
22
+ * so pricing, context windows, capabilities and joined benchmark scores are
23
+ * the ones that were actually in play. No network.
24
+ * - The real `Ledger` supplies trust and latency, so the trust divisor and the
25
+ * throughput multiplier behave as they do live.
26
+ * - `explorationDraw` keys on `conversationKey:turn`, both recorded, so
27
+ * exploration reproduces deterministically and cancels out in a diff.
28
+ *
29
+ * Conversation state is reconstructed from the PRECEDING recorded dispatch in
30
+ * the same conversation — prior slug, prior tier, cache warmth, cumulative
31
+ * spend — rather than simulated, so cache-warmth behaviour is exercised. Rows
32
+ * are replayed chronologically for that reason.
33
+ *
34
+ * WHAT IT DOES NOT MODEL — read this before trusting a conclusion
35
+ * - `messages` are not recorded, so compaction cannot be re-planned. Replay
36
+ * forces `compaction.enabled=false` and feeds the POST-compaction prompt
37
+ * size (`usage.promptTokens`), i.e. the prompt selection actually saw.
38
+ * - `stickyUntilTurn` was never persisted per turn, so the hysteresis hold
39
+ * window is absent. This is the main residual gap.
40
+ * - `requestedReasoning` is the one `Features` field the ledger omits; it
41
+ * replays as undefined.
42
+ * - Module constants are not config, so things like CAP_AUTONOMOUS_LOOP cannot
43
+ * be A/B'd via `--set` — only `RouterConfig` paths can.
44
+ *
45
+ * Because of those gaps the report leads with a FIDELITY figure. Read it with
46
+ * care: it conflates replay error with genuine code change, since replay always
47
+ * runs CURRENT code against rows served by whatever code was live then. Measured
48
+ * on rows served by matching code it is 90% model / 77% tier; across older
49
+ * history it drops to ~55%, and that drop is the shipped classifier changes
50
+ * showing up, not the tool being wrong. Isolate a population with `--where` when
51
+ * measuring one change.
52
+ */
53
+
54
+ import { Database } from "bun:sqlite";
55
+
56
+ import { createCatalog } from "../src/catalog/openrouter-catalog.ts";
57
+ import type { CatalogModel } from "../src/catalog/types.ts";
58
+ import { loadConfig } from "../src/config/load.ts";
59
+ import type { RouterConfig } from "../src/config/types.ts";
60
+ import { computeCost } from "../src/cost/forecast.ts";
61
+ import { createLedger } from "../src/cost/ledger.ts";
62
+ import type { UsageCounts } from "../src/cost/types.ts";
63
+ import { scoreHeuristic } from "../src/router/classify.ts";
64
+ import { select } from "../src/router/select.ts";
65
+ import type { ConversationState, Decision, Features, Tier } from "../src/router/types.ts";
66
+ import type { UpstreamClient } from "../src/upstream/types.ts";
67
+ import type { NormMessage, NormRequest, NormTool } from "../src/wire/types.ts";
68
+
69
+ interface Args {
70
+ limit: number;
71
+ where: string;
72
+ setB: string[];
73
+ setA: string[];
74
+ verbose: boolean;
75
+ db: string;
76
+ }
77
+
78
+ function parseArgs(argv: string[]): Args {
79
+ const a: Args = { limit: 500, where: "", setB: [], setA: [], verbose: false, db: "" };
80
+ for (let i = 0; i < argv.length; i++) {
81
+ const k = argv[i];
82
+ const v = argv[i + 1];
83
+ if (k === "--limit" && v !== undefined) (a.limit = Number.parseInt(v, 10)), i++;
84
+ else if (k === "--where" && v !== undefined) (a.where = v), i++;
85
+ else if (k === "--set" && v !== undefined) (a.setB.push(v), i++);
86
+ else if (k === "--a" && v !== undefined) (a.setA.push(v), i++);
87
+ else if (k === "--db" && v !== undefined) (a.db = v), i++;
88
+ else if (k === "--verbose") a.verbose = true;
89
+ }
90
+ return a;
91
+ }
92
+
93
+ /** Coerce a CLI string to the JSON-ish type the config field expects. */
94
+ function coerce(raw: string): unknown {
95
+ if (raw === "true") return true;
96
+ if (raw === "false") return false;
97
+ if (raw === "null") return null;
98
+ const n = Number(raw);
99
+ if (raw.trim() !== "" && !Number.isNaN(n)) return n;
100
+ return raw;
101
+ }
102
+
103
+ /** Applies `a.b.c=value` overrides onto a deep clone, so variants never alias. */
104
+ function withOverrides(cfg: RouterConfig, sets: readonly string[]): RouterConfig {
105
+ const next = structuredClone(cfg);
106
+ for (const entry of sets) {
107
+ const eq = entry.indexOf("=");
108
+ if (eq < 0) throw new Error(`--set expects path=value, got: ${entry}`);
109
+ const path = entry.slice(0, eq).split(".");
110
+ const value = coerce(entry.slice(eq + 1));
111
+ let node: Record<string, unknown> = next as unknown as Record<string, unknown>;
112
+ for (const seg of path.slice(0, -1)) {
113
+ const child = node[seg];
114
+ if (typeof child !== "object" || child === null) throw new Error(`--set path not found: ${entry}`);
115
+ node = child as Record<string, unknown>;
116
+ }
117
+ const leaf = path[path.length - 1];
118
+ if (leaf === undefined) throw new Error(`--set expects a key, got: ${entry}`);
119
+ // An absent leaf is legitimate and required: optional config fields are
120
+ // simply missing until set (exactOptionalPropertyTypes), and introducing
121
+ // one is exactly what a variant does. A wrong PARENT path still throws,
122
+ // in the walk above, which is what catches typos.
123
+ node[leaf] = value;
124
+ }
125
+ return next;
126
+ }
127
+
128
+ interface Row {
129
+ id: string;
130
+ conversation_key: string;
131
+ turn: number;
132
+ requested_model: string;
133
+ harness_id: string;
134
+ served_slug: string | null;
135
+ tier: string;
136
+ features: string;
137
+ usage: string;
138
+ reported_usd: number | null;
139
+ predicted_usd: number;
140
+ created_at_ms: number;
141
+ }
142
+
143
+ /** Rebuilds the classifier input. The ledger stores 20 of 21 Features fields. */
144
+ function featuresOf(row: Row, promptTokens: number): Features {
145
+ const f = JSON.parse(row.features) as Partial<Features>;
146
+ return { ...(f as Features), promptTokens, requestedReasoning: undefined };
147
+ }
148
+
149
+ /**
150
+ * Minimal request carrying only what `select`/`buildCandidates` read: tool count
151
+ * and schema bytes, image presence, harness id (trust/latency scoping),
152
+ * conversation key and profile id.
153
+ */
154
+ function requestOf(row: Row, f: Features): NormRequest {
155
+ const perTool = f.toolCount > 0 ? Math.round(f.toolSchemaBytes / f.toolCount) : 0;
156
+ const tools: NormTool[] = Array.from({ length: f.toolCount }, (_v, i) => ({
157
+ name: `t${i}`,
158
+ description: "",
159
+ schemaBytes: perTool,
160
+ }));
161
+ const messages: NormMessage[] = [
162
+ { role: "user", text: "", images: f.hasImages ? 1 : 0, textBytes: f.promptTokens * 4, toolCalls: [] },
163
+ ];
164
+ return {
165
+ protocol: "openai-chat",
166
+ conversationKey: row.conversation_key,
167
+ harnessId: row.harness_id,
168
+ ompSessionId: "",
169
+ agentdoxScope: "",
170
+ requestedModel: row.requested_model,
171
+ messages,
172
+ tools,
173
+ forcedToolChoice: false,
174
+ stream: true,
175
+ hasImages: f.hasImages,
176
+ promptBytes: f.promptTokens * 4,
177
+ renderUpstreamBody: () => ({}),
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Conversation state reconstructed from the PRECEDING recorded dispatch in the
183
+ * same conversation, not simulated.
184
+ *
185
+ * A neutral state cannot validate anything that depends on cache warmth — every
186
+ * candidate looks cold, so a warm-cache change shows zero effect. But the
187
+ * ledger does carry what the previous dispatch actually did, so warmth is
188
+ * recoverable: `cacheWarmSlug` is the slug it served, `lastPromptTokens` its
189
+ * prompt size. Deriving state from the RECORDED outcome rather than the
190
+ * replayed one also stops replay error compounding down a conversation.
191
+ *
192
+ * Still not modelled: `stickyUntilTurn`, which was never persisted per turn, so
193
+ * the hysteresis hold window remains absent.
194
+ */
195
+ function stateOf(row: Row, prior: PriorTurn | undefined): ConversationState {
196
+ return {
197
+ key: row.conversation_key,
198
+ sessionId: `omp-${row.conversation_key}`,
199
+ turn: row.turn,
200
+ currentSlug: prior?.slug ?? null,
201
+ currentTier: (prior?.tier as Tier | undefined) ?? null,
202
+ stickyUntilTurn: 0,
203
+ escalations: 0,
204
+ spentUsd: prior?.spentUsd ?? 0,
205
+ lastPromptTokens: prior?.promptTokens ?? 0,
206
+ cacheWarmSlug: prior?.cachedTokens !== undefined && prior.cachedTokens > 0 ? prior.slug : null,
207
+ cacheWarmAtMs: prior?.atMs ?? 0,
208
+ contextVersion: null,
209
+ contextFetchedAtMs: 0,
210
+ updatedAtMs: prior?.atMs ?? 0,
211
+ };
212
+ }
213
+
214
+ interface PriorTurn {
215
+ slug: string | null;
216
+ tier: string;
217
+ promptTokens: number;
218
+ cachedTokens: number;
219
+ spentUsd: number;
220
+ atMs: number;
221
+ }
222
+
223
+ /**
224
+ * Re-prices a decision against the tokens the turn ACTUALLY used, via the real
225
+ * `computeCost` so price tiers, the cache split and reasoning/request fees are
226
+ * handled exactly as they are live.
227
+ *
228
+ * Deliberately NOT the router's own forecast: `candidates.ts` hardcodes
229
+ * `cacheHitRate: 0`, so forecasts overstate absolute cost ~2.8x. Pricing both
230
+ * variants off recorded usage keeps the delta apples-to-apples and grounded.
231
+ */
232
+ function repriceUsd(model: CatalogModel | undefined, usage: UsageCounts): number {
233
+ if (model === undefined) return 0;
234
+ return computeCost(model, usage).total;
235
+ }
236
+
237
+ const DEAD_UPSTREAM: UpstreamClient = {
238
+ dispatch: () => Promise.reject(new Error("replay is offline")),
239
+ complete: () => Promise.reject(new Error("replay is offline")),
240
+ fetchModels: () => Promise.reject(new Error("replay is offline")),
241
+ fetchModelsForUser: () => Promise.reject(new Error("replay is offline")),
242
+ };
243
+
244
+ const args = parseArgs(process.argv.slice(2));
245
+ const baseCfg = await loadConfig();
246
+ // Compaction cannot be re-planned without messages; see the header.
247
+ const forced = ["compaction.enabled=false"];
248
+ const cfgA = withOverrides(baseCfg, [...forced, ...args.setA]);
249
+ const cfgB = withOverrides(baseCfg, [...forced, ...args.setB]);
250
+
251
+ const dbPath = args.db !== "" ? args.db : baseCfg.ledger.path;
252
+ const db = new Database(dbPath, { readonly: true });
253
+ const catalog = createCatalog(cfgA, DEAD_UPSTREAM, db);
254
+ const snapshot = catalog.peek();
255
+ if (snapshot === null) {
256
+ console.error(`no cached catalog in ${dbPath}; run the router once so it populates catalog_cache`);
257
+ process.exit(2);
258
+ }
259
+ const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
260
+ const ledger = createLedger(db, cfgA);
261
+
262
+ const predicate = args.where === "" ? "" : ` AND (${args.where})`;
263
+ // Newest-first to honour --limit, then flipped to chronological so each row can
264
+ // see the dispatch that preceded it in its conversation.
265
+ const rows = (
266
+ db
267
+ .query(
268
+ `SELECT id, conversation_key, turn, requested_model, harness_id, served_slug, tier, features, usage, reported_usd, predicted_usd, created_at_ms
269
+ FROM ledger
270
+ WHERE features IS NOT NULL AND wasted = 0${predicate}
271
+ ORDER BY created_at_ms DESC LIMIT ?`,
272
+ )
273
+ .all(args.limit) as Row[]
274
+ ).reverse();
275
+
276
+ if (rows.length === 0) {
277
+ console.error("no rows matched; widen --where or --limit");
278
+ process.exit(2);
279
+ }
280
+
281
+ /** Profile resolution mirrors router/index.ts, which does not export it. */
282
+ function profileOf(cfg: RouterConfig, requested: string) {
283
+ const exact = cfg.profiles.find((p) => p.id === requested);
284
+ if (exact !== undefined) return exact;
285
+ const first = cfg.profiles[0];
286
+ if (first === undefined) throw new Error("no router profiles configured");
287
+ return first;
288
+ }
289
+
290
+ interface Outcome {
291
+ tier: Tier;
292
+ slug: string;
293
+ usd: number;
294
+ }
295
+
296
+ function run(cfg: RouterConfig, row: Row, usage: UsageCounts, prior: PriorTurn | undefined): Outcome {
297
+ const f = featuresOf(row, usage.promptTokens);
298
+ const req = requestOf(row, f);
299
+ const decision: Decision = select({
300
+ req,
301
+ features: f,
302
+ classification: scoreHeuristic(f, cfg),
303
+ profile: profileOf(cfg, row.requested_model),
304
+ state: stateOf(row, prior),
305
+ snapshot,
306
+ ledger,
307
+ cfg,
308
+ nowMs: Date.now(),
309
+ });
310
+ return { tier: decision.tier, slug: decision.slug, usd: repriceUsd(bySlug.get(decision.slug), usage) };
311
+ }
312
+
313
+ const tallyA = new Map<string, number>();
314
+ const tallyB = new Map<string, number>();
315
+ const tallyRec = new Map<string, number>();
316
+ const tierA = new Map<string, number>();
317
+ const tierB = new Map<string, number>();
318
+ const tierRec = new Map<string, number>();
319
+ let usdA = 0;
320
+ let usdB = 0;
321
+ let usdRec = 0;
322
+ let fidelitySlug = 0;
323
+ let fidelityTier = 0;
324
+ let comparable = 0;
325
+ const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
326
+ const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
327
+
328
+ // Carries the RECORDED outcome of each conversation's previous dispatch forward,
329
+ // so cache warmth and the prior slug are real rather than assumed absent.
330
+ const priorByConv = new Map<string, PriorTurn>();
331
+
332
+ for (const row of rows) {
333
+ const u = JSON.parse(row.usage) as UsageCounts;
334
+ if (!(u.promptTokens > 0)) continue;
335
+ const prior = priorByConv.get(row.conversation_key);
336
+ const a = run(cfgA, row, u, prior);
337
+ const b = run(cfgB, row, u, prior);
338
+ priorByConv.set(row.conversation_key, {
339
+ slug: row.served_slug,
340
+ tier: row.tier,
341
+ promptTokens: u.promptTokens,
342
+ cachedTokens: u.cachedTokens,
343
+ spentUsd: (prior?.spentUsd ?? 0) + (row.reported_usd ?? row.predicted_usd),
344
+ atMs: row.created_at_ms,
345
+ });
346
+ bump(tallyA, a.slug);
347
+ bump(tallyB, b.slug);
348
+ bump(tierA, a.tier);
349
+ bump(tierB, b.tier);
350
+ // The recorded outcome: what the router ACTUALLY did, under whatever code and
351
+ // config were live then. This is the yardstick for fidelity, and it is also
352
+ // how a shipped classifier change shows up — replay runs current code.
353
+ if (row.served_slug !== null) bump(tallyRec, row.served_slug);
354
+ bump(tierRec, row.tier);
355
+ usdA += a.usd;
356
+ usdB += b.usd;
357
+ usdRec += row.reported_usd ?? row.predicted_usd;
358
+ comparable++;
359
+ if (row.served_slug !== null && row.served_slug === a.slug) fidelitySlug++;
360
+ if (row.tier === a.tier) fidelityTier++;
361
+ if (a.slug !== b.slug || a.tier !== b.tier) {
362
+ flips.push({ id: row.id.slice(0, 8), tier: `${a.tier}->${b.tier}`, from: a.slug, to: b.slug, delta: b.usd - a.usd });
363
+ }
364
+ }
365
+
366
+ const pct = (n: number, d: number) => (d === 0 ? "0.0" : ((100 * n) / d).toFixed(1));
367
+ console.log(`\nreplayed ${comparable} dispatches from ${dbPath}`);
368
+ console.log(`variant A overrides: ${args.setA.length ? args.setA.join(" ") : "(config as-is)"}`);
369
+ console.log(`variant B overrides: ${args.setB.length ? args.setB.join(" ") : "(none — A and B identical)"}`);
370
+ console.log(`\nFIDELITY vs what actually ran:`);
371
+ console.log(` same model ${fidelitySlug}/${comparable} (${pct(fidelitySlug, comparable)}%) same tier ${fidelityTier}/${comparable} (${pct(fidelityTier, comparable)}%)`);
372
+ console.log(" Divergence is expected where code has changed since those rows were served");
373
+ console.log(" (replay runs CURRENT code); the rest is the unmodelled neutral state.");
374
+ console.log(" Low fidelity => treat the A/B delta below as weak evidence.");
375
+
376
+ function table(label: string, rec: Map<string, number>, A: Map<string, number>, B: Map<string, number>) {
377
+ const keys = [...new Set([...rec.keys(), ...A.keys(), ...B.keys()])].sort((x, y) => (B.get(y) ?? 0) - (B.get(x) ?? 0));
378
+ console.log(`\n${label.padEnd(32)}${"actual".padStart(8)}${"A".padStart(7)}${"B".padStart(7)}${"B-A".padStart(7)}`);
379
+ for (const k of keys) {
380
+ const r = rec.get(k) ?? 0;
381
+ const a = A.get(k) ?? 0;
382
+ const b = B.get(k) ?? 0;
383
+ const d = b - a;
384
+ console.log(` ${k.padEnd(30)}${String(r).padStart(8)}${String(a).padStart(7)}${String(b).padStart(7)}${(d > 0 ? `+${d}` : String(d)).padStart(7)}`);
385
+ }
386
+ }
387
+ table("tier", tierRec, tierA, tierB);
388
+ table("model", tallyRec, tallyA, tallyB);
389
+
390
+ console.log(`\nspend, re-priced on RECORDED usage via the real computeCost:`);
391
+ console.log(` actual (billed) $${usdRec.toFixed(4)} per dispatch $${(usdRec / comparable).toFixed(5)}`);
392
+ console.log(` A $${usdA.toFixed(4)} per dispatch $${(usdA / comparable).toFixed(5)}`);
393
+ console.log(` B $${usdB.toFixed(4)} per dispatch $${(usdB / comparable).toFixed(5)}`);
394
+ const delta = usdB - usdA;
395
+ console.log(` B vs A $${delta.toFixed(4)} (${delta === 0 ? "no change" : `${((100 * delta) / (usdA || 1)).toFixed(1)}%`})`);
396
+ console.log(`\ndecisions changed: ${flips.length}/${comparable} (${pct(flips.length, comparable)}%)`);
397
+ if (args.verbose) {
398
+ for (const f of flips.slice(0, 40)) {
399
+ console.log(` ${f.id} ${f.tier.padEnd(22)} ${f.from} -> ${f.to} ${f.delta >= 0 ? "+" : ""}$${f.delta.toFixed(5)}`);
400
+ }
401
+ if (flips.length > 40) console.log(` … ${flips.length - 40} more`);
402
+ }
403
+ db.close();