auto-model-router 0.2.16 → 0.2.21

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,131 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
5
+ import type { RouterConfig } from "../src/config/types.ts";
6
+ import { readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
7
+
8
+ const DIR = join(import.meta.dir, ".tmp-hot-reload");
9
+ const CFG = join(DIR, "config.yml");
10
+
11
+ beforeAll(() => {
12
+ rmSync(DIR, { recursive: true, force: true });
13
+ mkdirSync(DIR, { recursive: true });
14
+ });
15
+ afterAll(() => {
16
+ rmSync(DIR, { recursive: true, force: true });
17
+ });
18
+
19
+ /** A clone of the shipped defaults serialized as YAML via JSON (the schema accepts JSON). */
20
+ function yamlOf(partial: Record<string, unknown>): string {
21
+ const lines: string[] = [];
22
+ for (const [k, v] of Object.entries(partial)) {
23
+ if (typeof v === "object" && v !== null) {
24
+ lines.push(`${k}:`);
25
+ for (const [k2, v2] of Object.entries(v)) {
26
+ lines.push(` ${k2}: ${JSON.stringify(v2).replaceAll('"', v2 === true || v2 === false || typeof v2 === "number" ? "" : '"')}`);
27
+ }
28
+ } else {
29
+ lines.push(`${k}: ${JSON.stringify(v)}`);
30
+ }
31
+ }
32
+ return lines.join("\n");
33
+ }
34
+
35
+ /** Waits out the watcher's debounce. */
36
+ const settle = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 400));
37
+
38
+ describe("readValidatedConfig", () => {
39
+ test("accepts a valid partial and merges over defaults (removed knobs revert)", () => {
40
+ writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 0.5 } }));
41
+ const result = readValidatedConfig(CFG);
42
+ expect(result.ok).toBe(true);
43
+ if (!result.ok) return;
44
+ expect(result.cfg.filters.latencyWeight).toBe(0.5);
45
+ // Untouched knobs carry the shipped default, not garbage.
46
+ expect(result.cfg.filters.contextHeadroom).toBe(DEFAULT_CONFIG.filters.contextHeadroom);
47
+ // A tier not mentioned in the file keeps its default shape.
48
+ expect(result.cfg.tiers.simple).toEqual(DEFAULT_CONFIG.tiers.simple);
49
+ });
50
+
51
+ test("rejects a schema violation and names the path", () => {
52
+ writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: -5 } } }));
53
+ const result = readValidatedConfig(CFG);
54
+ expect(result.ok).toBe(false);
55
+ if (result.ok) return;
56
+ expect(result.error).toContain("capabilityFloorUsd");
57
+ });
58
+
59
+ test("rejects malformed YAML", () => {
60
+ writeFileSync(CFG, "filters: [unclosed");
61
+ const result = readValidatedConfig(CFG);
62
+ expect(result.ok).toBe(false);
63
+ });
64
+
65
+ test("reports a missing file", () => {
66
+ const result = readValidatedConfig(join(DIR, "nope.yml"));
67
+ expect(result.ok).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe("watchConfig", () => {
72
+ const live: RouterConfig = structuredClone(DEFAULT_CONFIG);
73
+ let watcher: ConfigWatcher | null = null;
74
+ const reloads: string[][] = [];
75
+ const errors: string[] = [];
76
+
77
+ beforeAll(() => {
78
+ writeFileSync(CFG, "");
79
+ watcher = watchConfig(CFG, live, structuredClone(DEFAULT_CONFIG), ["server", "openrouter", "context", "ledger"], {
80
+ onReload: ({ changed }) => reloads.push(changed),
81
+ onError: (message) => errors.push(message),
82
+ });
83
+ });
84
+ afterAll(() => watcher?.close());
85
+
86
+ test("a valid edit mutates the live object in place, no restart", async () => {
87
+ writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: 0.35 } } }));
88
+ await settle();
89
+ expect(live.tiers.hard.capabilityFloorUsd).toBe(0.35);
90
+ expect(reloads.flat()).toContain("tiers");
91
+ });
92
+
93
+ test("a second edit replaces the value and reverting restores the default", async () => {
94
+ writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: 0.65 } } }));
95
+ await settle();
96
+ expect(live.tiers.hard.capabilityFloorUsd).toBe(0.65);
97
+ // Deleting the knob reverts to the shipped default, mirroring a restart.
98
+ writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 0.4 } }));
99
+ await settle();
100
+ expect(live.tiers.hard.capabilityFloorUsd).toBeUndefined();
101
+ expect(live.filters.latencyWeight).toBe(0.4);
102
+ });
103
+
104
+ test("frozen blocks are pinned: file edits to them cannot reach the live object", async () => {
105
+ writeFileSync(CFG, yamlOf({ server: { port: 1, host: "10.9.9.9" }, filters: { latencyWeight: 0.3 } }));
106
+ await settle();
107
+ expect(live.server.port).toBe(DEFAULT_CONFIG.server.port);
108
+ expect(live.server.host).toBe(DEFAULT_CONFIG.server.host);
109
+ // The non-frozen sibling still applied.
110
+ expect(live.filters.latencyWeight).toBe(0.3);
111
+ });
112
+
113
+ test("an invalid file is rejected and the running config keeps serving", async () => {
114
+ const before = structuredClone(live.filters);
115
+ const tierBefore = structuredClone(live.tiers.hard);
116
+ // capabilityFloorUsd must be strictly positive: -1 is a schema violation.
117
+ writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: -1 } } }));
118
+ await settle();
119
+ expect(errors.length).toBeGreaterThan(0);
120
+ expect(errors.at(-1)).toContain("capabilityFloorUsd");
121
+ // The live object keeps the last-good values.
122
+ expect(live.tiers.hard.capabilityFloorUsd).toBe(tierBefore.capabilityFloorUsd);
123
+ expect(live.filters.latencyWeight).toBe(before.latencyWeight);
124
+ });
125
+ test("close() stops watching: later edits are ignored", async () => {
126
+ watcher?.close();
127
+ writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 9.9 } }));
128
+ await settle();
129
+ expect(live.filters.latencyWeight).not.toBe(9.9);
130
+ });
131
+ });
@@ -0,0 +1,58 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
3
+ import type { RouterConfig } from "../src/config/types.ts";
4
+ import { startServer, type StartedServer } from "../src/server/http.ts";
5
+
6
+ describe("HTTP server resilience against dead streams", () => {
7
+ let handle: StartedServer;
8
+ let baseUrl: string;
9
+
10
+ beforeAll(() => {
11
+ const cfg: RouterConfig = {
12
+ ...DEFAULT_CONFIG,
13
+ server: { host: "127.0.0.1", port: 0 },
14
+ ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
15
+ context: { ...DEFAULT_CONFIG.context, enabled: false },
16
+ logLevel: "silent",
17
+ };
18
+ handle = startServer(cfg);
19
+ baseUrl = `http://127.0.0.1:${handle.server.port}`;
20
+ });
21
+ afterAll(async () => {
22
+ await handle.stop();
23
+ });
24
+
25
+ test("server answers /v1/models cleanly initially", async () => {
26
+ const res = await fetch(`${baseUrl}/v1/models`);
27
+ expect(res.status).toBe(200);
28
+ const json = (await res.json()) as { data: unknown[] };
29
+ expect(Array.isArray(json.data)).toBe(true);
30
+ });
31
+
32
+ test("cancelling a streaming /v1/chat/completions client does not crash the server", async () => {
33
+ const res = await fetch(`${baseUrl}/v1/chat/completions`, {
34
+ method: "POST",
35
+ headers: { "content-type": "application/json" },
36
+ body: JSON.stringify({
37
+ model: "auto",
38
+ stream: true,
39
+ messages: [{ role: "user", content: "hello" }],
40
+ }),
41
+ });
42
+
43
+ // Immediately cancel the reader mid-stream (simulates client disconnect)
44
+ const reader = res.body?.getReader();
45
+ expect(reader).toBeDefined();
46
+ await reader?.cancel("client abruptly dropped");
47
+
48
+ // Allow microtasks and I/O ticks to settle without real wall-clock delays
49
+ await new Promise<void>((resolve) => {
50
+ setImmediate(() => resolve());
51
+ });
52
+ // and answers subsequent requests cleanly.
53
+ const modelsRes = await fetch(`${baseUrl}/v1/models`);
54
+ expect(modelsRes.status).toBe(200);
55
+ const json = (await modelsRes.json()) as { data: unknown[] };
56
+ expect(Array.isArray(json.data)).toBe(true);
57
+ });
58
+ });
@@ -2,14 +2,18 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import { joinBenchmarks, normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
4
  import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
5
- import { loadConfig } from "../src/config/load.ts";
5
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
6
6
  import { buildCandidates } from "../src/router/candidates.ts";
7
7
  import { extractFeatures } from "../src/router/features.ts";
8
8
  import { computeTierPlan, effectivePriceCeiling, effectiveQualityFloor, tierPlanFor } from "../src/router/tier-plan.ts";
9
9
  import { TIER_ORDER } from "../src/router/types.ts";
10
10
  import { parseChatRequest } from "../src/wire/openai/request.ts";
11
11
 
12
- const BASE = loadConfig({});
12
+ // SHIPPED defaults, deliberately NOT loadConfig({}): that reads the developer's
13
+ // live ~/.auto-model-router/config.yml, so an enabled machine-wide knob (e.g.
14
+ // tiers.hard.capabilityFloorUsd during the 0.2.20 rollout) silently changed
15
+ // these expectations and made the suite machine-dependent.
16
+ const BASE = DEFAULT_CONFIG;
13
17
 
14
18
  /** Raw `/models`-shaped record with a controllable coding score and price. */
15
19
  function raw(id: string, coding: number | null, inPerMtok: number): Record<string, unknown> {
@@ -354,3 +358,94 @@ describe("adaptive price ceilings", () => {
354
358
  expect(on.rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
355
359
  });
356
360
  });
361
+
362
+ describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
363
+ const req = parseChatRequest(
364
+ {
365
+ model: "auto",
366
+ tools: [{ type: "function", function: { name: "read", description: "Read", parameters: { type: "object", properties: {} } } }],
367
+ messages: [{ role: "user", content: "implement nested transaction savepoints" }],
368
+ },
369
+ new Headers(),
370
+ );
371
+ const features = extractFeatures(req, 100);
372
+
373
+ // The real catalog's shape: quality in a narrow band, price spanning ~100x.
374
+ // `hard` has a quality floor of 72, so cheap/1 is deliberately below it —
375
+ // it must be rejected, and mid/2 is the cheapest ELIGIBLE model, the one
376
+ // raw quality-per-dollar ranking picks at any sane exponent.
377
+ const spread = snapshot(
378
+ models([
379
+ ["cheap/1", 70, 0.05],
380
+ ["mid/2", 74, 1.0],
381
+ ["good/3", 76, 3.0],
382
+ ["best/4", 78, 5.0],
383
+ ]),
384
+ );
385
+
386
+ const run = (tierOverride: Partial<(typeof BASE)["tiers"]["hard"]>) =>
387
+ buildCandidates({
388
+ req,
389
+ features,
390
+ tier: "hard",
391
+ task: "coding",
392
+ snapshot: spread,
393
+ ledger: null,
394
+ cfg: { ...BASE, tiers: { ...BASE.tiers, hard: { ...BASE.tiers.hard, ...tierOverride } } },
395
+ expectedCompletionTokens: 512,
396
+ warmSlug: null,
397
+ });
398
+
399
+ test("raw scoring at the shipped exponent picks the cheapest ELIGIBLE model", () => {
400
+ const { candidates, rejected } = run({ qualityExponent: 3 });
401
+ expect(candidates[0]?.model.slug).toBe("mid/2");
402
+ // cheap/1 is under the hard floor of 72 and never competes.
403
+ expect(rejected.some((r) => r.slug === "cheap/1" && r.reason === "below_quality_floor")).toBe(true);
404
+ });
405
+
406
+ test("normalization lets a single-digit exponent buy the best model, which raw cannot", () => {
407
+ // Raw at the same exponent still cannot reach it: that is the defect.
408
+ expect(run({ qualityExponent: 12 }).candidates[0]?.model.slug).toBe("mid/2");
409
+ // Normalised, the same 12 selects the top-quality model.
410
+ const normalised = run({ qualityExponent: 12, qualityNormalization: true });
411
+ expect(normalised.candidates[0]?.model.slug).toBe("best/4");
412
+ expect(normalised.candidates[0]?.reasons.some((r) => r.includes("quality normalised"))).toBe(true);
413
+ });
414
+
415
+ test("normalization is monotone in the exponent: higher never picks a weaker model", () => {
416
+ let lastQuality = 0;
417
+ for (const qualityExponent of [1, 4, 8, 12, 20]) {
418
+ const top = run({ qualityExponent, qualityNormalization: true }).candidates[0];
419
+ expect(top).toBeDefined();
420
+ expect(top?.qualityScore ?? 0).toBeGreaterThanOrEqual(lastQuality);
421
+ lastQuality = top?.qualityScore ?? 0;
422
+ }
423
+ });
424
+
425
+ test("capability floor takes the best model inside the cap, ignoring the ratio", () => {
426
+ // mid/2 costs ~$0.0016 and good/3 ~$0.0049, so this cap admits both but
427
+ // excludes best/4 (~$0.0082). The ranked winner is mid/2 (cheapest).
428
+ const cap = 0.005;
429
+ const capped = run({ capabilityFloorUsd: cap });
430
+ const top = capped.candidates[0];
431
+ expect(top).toBeDefined();
432
+ expect(top?.forecast.coldUsd ?? 1).toBeLessThanOrEqual(cap);
433
+ // It must be the highest-quality affordable one, not the cheapest: good/3.
434
+ expect(top?.model.slug).toBe("good/3");
435
+ expect(top?.reasons.some((r) => r.includes("capability floor"))).toBe(true);
436
+ });
437
+
438
+ test("capability floor is strictly an upgrade: an unaffordable cap changes nothing", () => {
439
+ const base = run({}).candidates.map((c) => c.model.slug);
440
+ // A cap below every candidate's cost promotes nobody.
441
+ const tiny = run({ capabilityFloorUsd: 1e-9 }).candidates.map((c) => c.model.slug);
442
+ expect(tiny).toEqual(base);
443
+ });
444
+
445
+ test("both modes stay inert by default, so shipped behaviour is unchanged", () => {
446
+ const shipped = run({});
447
+ expect(shipped.candidates[0]?.model.slug).toBe("mid/2");
448
+ expect(shipped.candidates.every((c) => !c.reasons.some((r) => r.includes("normalised")))).toBe(true);
449
+ expect(shipped.candidates.every((c) => !c.reasons.some((r) => r.includes("capability floor")))).toBe(true);
450
+ });
451
+ });
package/test/turn.test.ts CHANGED
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
70
70
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
71
71
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
72
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
73
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
74
74
  compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
75
75
  budget: { onExceeded: "downgrade" },
76
76
  profiles: [],
@@ -70,6 +70,37 @@ describe("createStreamingSink", () => {
70
70
  { error: { message: "boom", type: "server_error", code: "upstream_error" } },
71
71
  ]);
72
72
  });
73
+
74
+ test("chunk, error, and finish survive a client-cancelled body without throwing", async () => {
75
+ const { sink, response } = createStreamingSink("auto");
76
+ const reader = response.body?.getReader();
77
+ expect(reader).toBeDefined();
78
+ // Client disconnects mid-stream: reader cancels, controller closes.
79
+ await reader?.cancel("client closed connection");
80
+
81
+ // None of these may throw ERR_INVALID_STATE:
82
+ expect(() => {
83
+ sink.chunk(
84
+ chunk({
85
+ id: "gen-cancelled",
86
+ model: "openai/gpt-5.5",
87
+ choices: [{ index: 0, delta: { content: "trailing" }, finish_reason: null }],
88
+ }),
89
+ );
90
+ }).not.toThrow();
91
+
92
+ expect(() => {
93
+ sink.finish(SUMMARY);
94
+ }).not.toThrow();
95
+
96
+ // Repeated calls (e.g. error after chunk on dead stream) must also stay safe:
97
+ const { sink: sink2, response: response2 } = createStreamingSink("auto");
98
+ const reader2 = response2.body?.getReader();
99
+ await reader2?.cancel();
100
+ expect(() => {
101
+ sink2.error({ status: 500, code: "server_error", message: "late failure" });
102
+ }).not.toThrow();
103
+ });
73
104
  });
74
105
 
75
106
  describe("createBufferedSink", () => {
package/tools/replay.ts CHANGED
@@ -26,20 +26,29 @@
26
26
  * - `explorationDraw` keys on `conversationKey:turn`, both recorded, so
27
27
  * exploration reproduces deterministically and cancels out in a diff.
28
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
+ *
29
34
  * WHAT IT DOES NOT MODEL — read this before trusting a conclusion
30
35
  * - `messages` are not recorded, so compaction cannot be re-planned. Replay
31
36
  * forces `compaction.enabled=false` and feeds the POST-compaction prompt
32
37
  * size (`usage.promptTokens`), i.e. the prompt selection actually saw.
33
- * - Conversation state is not recoverable historically (only the current row
34
- * survives), so replay uses a neutral state: no sticky tier, no warm cache,
35
- * no accumulated spend. Hysteresis, cache-warmth tie-breaks and the
36
- * per-conversation budget guard are therefore NOT exercised.
38
+ * - `stickyUntilTurn` was never persisted per turn, so the hysteresis hold
39
+ * window is absent. This is the main residual gap.
37
40
  * - `requestedReasoning` is the one `Features` field the ledger omits; it
38
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.
39
44
  *
40
- * Because of those gaps, the report leads with a FIDELITY figure: how often the
41
- * baseline variant reproduces the model that actually served. Low fidelity means
42
- * the unmodelled parts dominate and any delta below is weak evidence.
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.
43
52
  */
44
53
 
45
54
  import { Database } from "bun:sqlite";
@@ -106,7 +115,11 @@ function withOverrides(cfg: RouterConfig, sets: readonly string[]): RouterConfig
106
115
  node = child as Record<string, unknown>;
107
116
  }
108
117
  const leaf = path[path.length - 1];
109
- if (leaf === undefined || !(leaf in node)) throw new Error(`--set path not found: ${entry}`);
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.
110
123
  node[leaf] = value;
111
124
  }
112
125
  return next;
@@ -124,6 +137,7 @@ interface Row {
124
137
  usage: string;
125
138
  reported_usd: number | null;
126
139
  predicted_usd: number;
140
+ created_at_ms: number;
127
141
  }
128
142
 
129
143
  /** Rebuilds the classifier input. The ledger stores 20 of 21 Features fields. */
@@ -164,26 +178,48 @@ function requestOf(row: Row, f: Features): NormRequest {
164
178
  };
165
179
  }
166
180
 
167
- /** Neutral state: no sticky tier, no warm cache, no prior spend. See header. */
168
- function stateOf(row: Row): ConversationState {
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 {
169
196
  return {
170
197
  key: row.conversation_key,
171
198
  sessionId: `omp-${row.conversation_key}`,
172
199
  turn: row.turn,
173
- currentSlug: null,
174
- currentTier: null,
200
+ currentSlug: prior?.slug ?? null,
201
+ currentTier: (prior?.tier as Tier | undefined) ?? null,
175
202
  stickyUntilTurn: 0,
176
203
  escalations: 0,
177
- spentUsd: 0,
178
- lastPromptTokens: 0,
179
- cacheWarmSlug: null,
180
- cacheWarmAtMs: 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,
181
208
  contextVersion: null,
182
209
  contextFetchedAtMs: 0,
183
- updatedAtMs: 0,
210
+ updatedAtMs: prior?.atMs ?? 0,
184
211
  };
185
212
  }
186
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
+
187
223
  /**
188
224
  * Re-prices a decision against the tokens the turn ACTUALLY used, via the real
189
225
  * `computeCost` so price tiers, the cache split and reasoning/request fees are
@@ -224,14 +260,18 @@ const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
224
260
  const ledger = createLedger(db, cfgA);
225
261
 
226
262
  const predicate = args.where === "" ? "" : ` AND (${args.where})`;
227
- const rows = db
228
- .query(
229
- `SELECT id, conversation_key, turn, requested_model, harness_id, served_slug, tier, features, usage, reported_usd, predicted_usd
230
- FROM ledger
231
- WHERE features IS NOT NULL AND wasted = 0${predicate}
232
- ORDER BY created_at_ms DESC LIMIT ?`,
233
- )
234
- .all(args.limit) as Row[];
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();
235
275
 
236
276
  if (rows.length === 0) {
237
277
  console.error("no rows matched; widen --where or --limit");
@@ -253,7 +293,7 @@ interface Outcome {
253
293
  usd: number;
254
294
  }
255
295
 
256
- function run(cfg: RouterConfig, row: Row, usage: UsageCounts): Outcome {
296
+ function run(cfg: RouterConfig, row: Row, usage: UsageCounts, prior: PriorTurn | undefined): Outcome {
257
297
  const f = featuresOf(row, usage.promptTokens);
258
298
  const req = requestOf(row, f);
259
299
  const decision: Decision = select({
@@ -261,7 +301,7 @@ function run(cfg: RouterConfig, row: Row, usage: UsageCounts): Outcome {
261
301
  features: f,
262
302
  classification: scoreHeuristic(f, cfg),
263
303
  profile: profileOf(cfg, row.requested_model),
264
- state: stateOf(row),
304
+ state: stateOf(row, prior),
265
305
  snapshot,
266
306
  ledger,
267
307
  cfg,
@@ -285,11 +325,24 @@ let comparable = 0;
285
325
  const flips: { id: string; tier: string; from: string; to: string; delta: number }[] = [];
286
326
  const bump = (m: Map<string, number>, k: string) => m.set(k, (m.get(k) ?? 0) + 1);
287
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
+
288
332
  for (const row of rows) {
289
333
  const u = JSON.parse(row.usage) as UsageCounts;
290
334
  if (!(u.promptTokens > 0)) continue;
291
- const a = run(cfgA, row, u);
292
- const b = run(cfgB, row, u);
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
+ });
293
346
  bump(tallyA, a.slug);
294
347
  bump(tallyB, b.slug);
295
348
  bump(tierA, a.tier);