auto-model-router 0.4.2 → 0.4.4

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.
Files changed (48) hide show
  1. package/.gitattributes +2 -0
  2. package/.omp-plugin/marketplace.json +2 -2
  3. package/README.md +31 -3
  4. package/bunfig.toml +2 -0
  5. package/omp-extension/report-logic.ts +46 -0
  6. package/omp-extension/router-configure.ts +55 -3
  7. package/package.json +1 -1
  8. package/src/catalog/composite.ts +4 -1
  9. package/src/cli/config-wizard.ts +8 -1
  10. package/src/config/defaults.ts +8 -0
  11. package/src/config/hot-reload.ts +58 -9
  12. package/src/config/schema.ts +5 -1
  13. package/src/config/types.ts +34 -0
  14. package/src/cost/ledger.ts +84 -8
  15. package/src/cost/report.ts +34 -4
  16. package/src/cost/summary.ts +231 -0
  17. package/src/cost/types.ts +35 -3
  18. package/src/router/candidates.ts +1 -1
  19. package/src/router/classify.ts +2 -2
  20. package/src/router/compaction.ts +2 -1
  21. package/src/router/learned.ts +11 -1
  22. package/src/router/select.ts +27 -3
  23. package/src/server/compaction-digest.ts +129 -0
  24. package/src/server/digest.ts +68 -4
  25. package/src/server/http.ts +50 -7
  26. package/src/server/providers.ts +1 -0
  27. package/src/server/turn.ts +38 -1
  28. package/src/util/sqlite.ts +7 -0
  29. package/src/wire/openai/request.ts +4 -0
  30. package/src/wire/types.ts +7 -0
  31. package/test/cache-control.test.ts +1 -1
  32. package/test/compaction.test.ts +40 -3
  33. package/test/digest.test.ts +44 -0
  34. package/test/failover.test.ts +4 -4
  35. package/test/hot-reload.test.ts +37 -1
  36. package/test/learned.test.ts +21 -1
  37. package/test/migrations.test.ts +84 -0
  38. package/test/report-hub.test.ts +1 -1
  39. package/test/report-logic.test.ts +11 -1
  40. package/test/report.test.ts +29 -1
  41. package/test/select.test.ts +26 -2
  42. package/test/summary.test.ts +171 -0
  43. package/test/support/preload.ts +19 -0
  44. package/test/tokens.test.ts +68 -0
  45. package/test/trust-attribution.test.ts +37 -0
  46. package/test/turn.test.ts +69 -4
  47. package/tools/gen-migration-fixtures.ts +69 -0
  48. package/tools/train-classifier.ts +75 -20
@@ -162,6 +162,50 @@ describe("createDigester", () => {
162
162
  db2.close();
163
163
  });
164
164
 
165
+ test("a compaction-sourced digest is gated on compaction.digestToolResults and judges the given tier", async () => {
166
+ const cfg = cfgWith({ enabled: false });
167
+ cfg.compaction.digestToolResults = true;
168
+ const db = openDb(":memory:");
169
+ const ledger = createLedger(db, cfg);
170
+ // No session rows at all: the tier comes from the request.
171
+ const { upstream, calls } = fakeUpstream(() => "Condensed.");
172
+ const d = createDigester({ cfg, catalog, ledger, upstream, log });
173
+ const base = { ompSessionId: "omp-9", harnessId: "", toolName: "read", input: { path: "x.ts" }, content: BIG, query: "q" };
174
+ // digest.enabled is off, so the tool_result path declines...
175
+ expect(await d.digest(base)).toMatchObject({ digested: false, reason: expect.stringContaining("disabled") });
176
+ // ...but compaction only needs compaction.digestToolResults, plus a tier at or above fromTier.
177
+ expect(await d.digest({ ...base, tier: "simple", source: "compaction" })).toMatchObject({ digested: false, reason: expect.stringContaining("below digest.fromTier") });
178
+ const r = await d.digest({ ...base, tier: "hard", source: "compaction" });
179
+ expect(r.digested).toBe(true);
180
+ expect(calls).toHaveLength(1);
181
+ expect(ledger.recentEntries(5).find((e) => e.requestedModel === "digest")?.reasons[0]).toContain("digest (compaction)");
182
+ cfg.compaction.digestToolResults = false;
183
+ expect(await d.digest({ ...base, tier: "hard", source: "compaction" })).toMatchObject({ digested: false });
184
+ db.close();
185
+ });
186
+
187
+ test("a later call of the same tool with the same primary argument marks the digest wasted", async () => {
188
+ const cfg = cfgWith();
189
+ const db = openDb(":memory:");
190
+ const ledger = createLedger(db, cfg);
191
+ seedSession(ledger, "hard");
192
+ const dg = createDigester({ cfg, catalog, ledger, upstream: fakeUpstream(() => "Condensed.").upstream, log });
193
+ const r = await dg.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: { path: "src/a.ts", offset: 1 }, content: BIG, query: "" });
194
+ expect(r.digested).toBe(true);
195
+ const row = () => ledger.recentEntries(10).find((e) => e.requestedModel === "digest")!;
196
+ expect(row().wasted).toBe(false);
197
+ // A different file, a different tool, another session: no match.
198
+ expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/b.ts"}' }, { name: "grep", argsJson: '{"pattern":"src/a.ts"}' }])).toBe(0);
199
+ expect(dg.noteToolCalls("omp-2", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
200
+ expect(row().wasted).toBe(false);
201
+ // The same read again (case-insensitive tool name, any other args): the agent wanted the full output.
202
+ expect(dg.noteToolCalls("omp-1", [{ name: "Read", argsJson: '{"path":"src/a.ts","limit":50}' }])).toBe(1);
203
+ expect(row().wasted).toBe(true);
204
+ // Marked once; a third read does not count again.
205
+ expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
206
+ db.close();
207
+ });
208
+
165
209
  test("a pinned digest model is used as-is", async () => {
166
210
  const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
167
211
  const cfg = cfgWith({ model: pinned });
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
46
46
  data: { axis: "intelligence", minQuality: 0 },
47
47
  chat: { axis: "intelligence", minQuality: 0 },
48
48
  },
49
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
50
50
  classifier: {
51
51
  ambiguityThreshold: 0,
52
52
  model: "test/adjudicator", learnedModelPath: "",
@@ -74,12 +74,12 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
74
74
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
75
75
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
76
76
  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 },
77
- compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1 },
77
+ compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
78
78
  budget: { onExceeded: "downgrade" },
79
- report: { baselines: [] },
79
+ report: { baselines: [], dailySummary: false },
80
80
  digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
81
81
  profiles: [],
82
- ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
82
+ ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
83
83
  adaptiveTierFloors: true,
84
84
  adaptivePriceCeilings: false,
85
85
  logLevel: "silent",
@@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
5
5
  import type { RouterConfig } from "../src/config/types.ts";
6
- import { readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
6
+ import { PINNED_CONFIG_PATHS, readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
7
7
 
8
8
  const DIR = join(import.meta.dir, ".tmp-hot-reload");
9
9
  const CFG = join(DIR, "config.yml");
@@ -129,3 +129,39 @@ describe("watchConfig", () => {
129
129
  expect(live.filters.latencyWeight).not.toBe(9.9);
130
130
  });
131
131
  });
132
+
133
+ describe("watchConfig pins by path", () => {
134
+ const CFG2 = join(DIR, "config-paths.yml");
135
+ const live: RouterConfig = structuredClone(DEFAULT_CONFIG);
136
+ let watcher: ConfigWatcher | null = null;
137
+
138
+ beforeAll(() => {
139
+ writeFileSync(CFG2, "");
140
+ const pinned = structuredClone(DEFAULT_CONFIG);
141
+ pinned.ollama.apiKey = "pinned-key";
142
+ watcher = watchConfig(CFG2, live, pinned, PINNED_CONFIG_PATHS);
143
+ });
144
+ afterAll(() => watcher?.close());
145
+
146
+ test("a pinned key inside a block keeps its construction value while its siblings hot-reload", async () => {
147
+ writeFileSync(CFG2, yamlOf({ ollama: { apiKey: "from-file", costBias: 0.25, biasUntilUsage: 0.5 }, server: { port: 1, subagentProfile: "auto" }, ledger: { retentionDays: 30 } }));
148
+ await settle();
149
+ expect(live.ollama.costBias).toBe(0.25);
150
+ expect(live.ollama.biasUntilUsage).toBe(0.5);
151
+ expect(live.ollama.apiKey).toBe("pinned-key");
152
+ expect(live.server.port).toBe(DEFAULT_CONFIG.server.port);
153
+ expect(live.server.subagentProfile).toBe("auto");
154
+ expect(live.ledger.retentionDays).toBe(30);
155
+ expect(live.ledger.path).toBe(DEFAULT_CONFIG.ledger.path);
156
+ });
157
+
158
+ test("the pinned path list names only real config keys", () => {
159
+ const root = DEFAULT_CONFIG as unknown as Record<string, Record<string, unknown>>;
160
+ for (const p of PINNED_CONFIG_PATHS) {
161
+ const [block = "", key] = p.split(".");
162
+ expect(block in root).toBe(true);
163
+ // Optional keys (server.apiKey, server.harnessId) are absent from the defaults but real.
164
+ if (key !== undefined && !["apiKey", "harnessId"].includes(key)) expect(key in root[block]!).toBe(true);
165
+ }
166
+ });
167
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { auc, FEATURE_NAMES, learnedVector, predictRisk, trainLogistic, type LearnedModel } from "../src/router/learned.ts";
3
+ import { auc, FEATURE_NAMES, LEARNED_MODEL_VERSION, learnedRiskName, learnedVector, loadLearnedModel, predictRisk, resetLearnedModels, trainLogistic, type LearnedModel } from "../src/router/learned.ts";
4
4
 
5
5
  /**
6
6
  * The learned escalation-risk model: a deterministic logistic regression that
@@ -59,3 +59,23 @@ describe("trainLogistic", () => {
59
59
  expect(() => trainLogistic([], [])).toThrow();
60
60
  });
61
61
  });
62
+
63
+ describe("learned label", () => {
64
+ test("a feedback-labelled model loads with its label and names its risk p(bad)", async () => {
65
+ const d = FEATURE_NAMES.length;
66
+ const base: LearnedModel = { version: LEARNED_MODEL_VERSION, trainedAtMs: 0, rows: 100, positives: 10, names: [...FEATURE_NAMES], means: new Array(d).fill(0), stds: new Array(d).fill(1), weights: new Array(d).fill(0), bias: 0, auc: 0.5 };
67
+ expect(learnedRiskName(base)).toBe("escalate");
68
+ expect(learnedRiskName({ ...base, label: "feedback" })).toBe("bad");
69
+ const path = `${import.meta.dir}/../.tmp-learned-feedback.json`;
70
+ await Bun.write(path, JSON.stringify({ ...base, label: "feedback" }));
71
+ try {
72
+ resetLearnedModels();
73
+ const loaded = await loadLearnedModel(path);
74
+ expect(loaded?.label).toBe("feedback");
75
+ expect(learnedRiskName(loaded!)).toBe("bad");
76
+ } finally {
77
+ resetLearnedModels();
78
+ await Bun.file(path).delete();
79
+ }
80
+ });
81
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
7
+ import { createFeedbackStore } from "../src/cost/feedback.ts";
8
+ import { createLedger } from "../src/cost/ledger.ts";
9
+ import { buildUsageReport } from "../src/cost/report.ts";
10
+ import { buildDailySummary, createKv } from "../src/cost/summary.ts";
11
+ import { createConversationStore } from "../src/router/state.ts";
12
+ import { openDb } from "../src/util/sqlite.ts";
13
+
14
+ /**
15
+ * Every ledger a past release wrote must open under the current bootstrap:
16
+ * the version lands on the current number, every prepared statement the
17
+ * router uses compiles against the migrated schema, the carried rows survive
18
+ * with their backfills, and the aggregates run. The fixtures come from
19
+ * tools/gen-migration-fixtures.ts (each tag's own bootstrap plus one row per
20
+ * table), so a column added without a guard, or a statement that assumes a
21
+ * column older files lack, fails here rather than on a user's install.
22
+ */
23
+
24
+ const FIXTURES = join(import.meta.dir, "fixtures", "migrations");
25
+ const files = readdirSync(FIXTURES).filter((f) => /^router-v\d+\.db$/.test(f)).sort((a, b) => Number(/\d+/.exec(a)![0]) - Number(/\d+/.exec(b)![0]));
26
+ const CURRENT_VERSION = 17;
27
+
28
+ describe("schema migrations from every shipped version", () => {
29
+ test("fixtures exist for the versions that shipped", () => {
30
+ expect(files.map((f) => Number(/\d+/.exec(f)![0]))).toEqual([4, 5, 10, 12, 13, 14, 16]);
31
+ });
32
+
33
+ for (const file of files) {
34
+ const from = Number(/\d+/.exec(file)![0]);
35
+ test(`v${from} → v${CURRENT_VERSION}: opens, migrates, keeps its rows, and every consumer runs`, () => {
36
+ const dir = mkdtempSync(join(tmpdir(), "amr-migrate-"));
37
+ const path = join(dir, "router.db");
38
+ copyFileSync(join(FIXTURES, file), path);
39
+ const cfg = structuredClone(DEFAULT_CONFIG);
40
+ cfg.ledger.path = path;
41
+ const db = openDb(path);
42
+ try {
43
+ expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(CURRENT_VERSION);
44
+ // Every column the current code writes exists after migration.
45
+ const ledgerCols = new Set((db.query("PRAGMA table_info(ledger)").all() as { name: string }[]).map((c) => c.name));
46
+ for (const c of ["harness_id", "error_kind", "omp_session_id", "features", "explored_from", "hold_arm", "prompt_tokens_saved"]) expect(ledgerCols.has(c)).toBe(true);
47
+ const convCols = new Set((db.query("PRAGMA table_info(conversations)").all() as { name: string }[]).map((c) => c.name));
48
+ for (const c of ["context_version", "compaction_plan", "compaction_plan_tokens", "upgrade_deferred_tier"]) expect(convCols.has(c)).toBe(true);
49
+ // The fixture's ledger row survived the ALTERs with its values.
50
+ const row = db.query("SELECT id, error, slug FROM ledger").get() as { id: string; error: string | null; slug: string } | null;
51
+ expect(row).toEqual({ id: "fixture-id", error: "upstream_error: 502", slug: "fixture-slug" });
52
+ // Every prepared statement compiles and every consumer runs on the migrated file.
53
+ const ledger = createLedger(db, cfg);
54
+ const conversations = createConversationStore(db);
55
+ createFeedbackStore(db);
56
+ createKv(db);
57
+ expect(ledger.recentEntries(5)).toHaveLength(1);
58
+ expect(ledger.trust("fixture-slug")).not.toBeNull();
59
+ expect(ledger.softFailureSpikes?.()).toEqual([]);
60
+ expect(ledger.latestForSession?.("nope")).toBeNull();
61
+ expect(conversations.load("fixture-key").key).toBe("fixture-key");
62
+ expect(buildUsageReport(db, { windowDays: 3650 }).totals.dispatches).toBe(1);
63
+ expect(buildDailySummary(db, {}).current.dispatches).toBe(0);
64
+ expect(ledger.prune?.(0)).toBe(0);
65
+ } finally {
66
+ db.close();
67
+ try {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ } catch {
70
+ // Windows keeps the file locked until the statements are collected; the temp dir is disposable.
71
+ }
72
+ }
73
+ });
74
+ }
75
+
76
+ test("a fresh database lands on the same version as a migrated one", () => {
77
+ const db = openDb(":memory:");
78
+ try {
79
+ expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(CURRENT_VERSION);
80
+ } finally {
81
+ db.close();
82
+ }
83
+ });
84
+ });
@@ -80,7 +80,7 @@ function report(over: Partial<UsageReport> = {}): UsageReport {
80
80
  subagentSpendUsd: 0,
81
81
  digests: 0,
82
82
  digestSpendUsd: 0,
83
- digestInputTokens: 0,
83
+ digestInputTokens: 0, digestReruns: 0, forecastSamples: 0, forecastMeanError: 0, forecastOverShare: 0,
84
84
  },
85
85
  providers: [row("openrouter", 2), row("ollama", 1)],
86
86
  models: [
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { fetchReport, parseReportArgs, renderStatus, type HealthSnapshot } from "../omp-extension/report-logic.ts";
3
+ import { fetchReport, parseReportArgs, renderSoftFailureSpikes, renderStatus, type HealthSnapshot } from "../omp-extension/report-logic.ts";
4
4
 
5
5
  describe("parseReportArgs", () => {
6
6
  test("defaults to 7 days scoped to the harness", () => {
@@ -71,8 +71,11 @@ describe("renderStatus", () => {
71
71
  costBias: { configured: 0.1, effective: 0.1, biasUntilUsage: 0.9 },
72
72
  },
73
73
  catalog: { models: 240, ageMs: 5 * 60_000, keyScoped: true, shrink: { fromModels: 300, toModels: 120, atMs: now } },
74
+ softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: [{ slug: "z-ai/glm-5.3", recentDispatches: 12, recentFailures: 5, recentRate: 5 / 12, baselineDispatches: 340, baselineRate: 0.08 }] },
74
75
  };
75
76
  const text = renderStatus("http://127.0.0.1:8788", h, now);
77
+ expect(text).toContain("soft failures SPIKING (1):");
78
+ expect(text).toContain(" z-ai/glm-5.3: 42% of 12 failed in the last 1h (7d baseline 8% of 340)");
76
79
  expect(text).toContain("configured (omp)");
77
80
  expect(text).toContain("240 models");
78
81
  expect(text).toContain("refreshed 5m ago");
@@ -86,6 +89,13 @@ describe("renderStatus", () => {
86
89
  expect(text).toContain("recording turns");
87
90
  });
88
91
 
92
+ test("reports a quiet hour and omits the line for routers that predate the check", () => {
93
+ expect(renderStatus("http://h", { status: "ok", softFailures: { spikes: [] } })).toContain("soft failures: no model spiking in the last hour");
94
+ expect(renderStatus("http://h", { status: "ok" })).not.toContain("soft failures");
95
+ expect(renderSoftFailureSpikes(null)).toEqual([]);
96
+ expect(renderSoftFailureSpikes([{ slug: "a/b", recentRate: 0.5, recentDispatches: 6 }], 30 * 60_000, 7)).toEqual(["a/b: 50% of 6 failed in the last 30m (7d baseline 0% of 0)"]);
97
+ });
98
+
89
99
  test("degrades cleanly when sections are absent", () => {
90
100
  const text = renderStatus("http://h", { status: "ok", apiKeyConfigured: false });
91
101
  expect(text).toContain("key MISSING");
@@ -229,7 +229,7 @@ describe("buildUsageReport", () => {
229
229
  subagentSpendUsd: 0,
230
230
  digests: 0,
231
231
  digestSpendUsd: 0,
232
- digestInputTokens: 0,
232
+ digestInputTokens: 0, digestReruns: 0, forecastSamples: 0, forecastMeanError: 0, forecastOverShare: 0,
233
233
  });
234
234
  expect(r.providers).toEqual([]);
235
235
  expect(r.models).toEqual([]);
@@ -291,3 +291,31 @@ describe("renderUsageReport", () => {
291
291
  db.close();
292
292
  });
293
293
  });
294
+
295
+ describe("digest re-runs and forecast accuracy", () => {
296
+ test("wasted digest rows count as re-runs; forecast error is judged on clean kept rows only", () => {
297
+ const { db, ledger } = seeded();
298
+ try {
299
+ ledger.record(entry({ requestedModel: "digest", conversationKey: "d1", reportedUsd: 0.001, predictedUsd: 0.001 }));
300
+ ledger.record(entry({ requestedModel: "digest", conversationKey: "d2", reportedUsd: 0.001, predictedUsd: 0.001, wasted: true }));
301
+ // Two clean turns: one predicted double, one predicted half.
302
+ ledger.record(entry({ predictedUsd: 0.02, reportedUsd: 0.01 }));
303
+ ledger.record(entry({ predictedUsd: 0.005, reportedUsd: 0.01 }));
304
+ // Excluded from the forecast judgement: wasted, errored, no reported cost.
305
+ ledger.record(entry({ predictedUsd: 1, reportedUsd: 0.01, wasted: true }));
306
+ ledger.record(entry({ predictedUsd: 1, reportedUsd: 0.01, error: "upstream_error: 500" }));
307
+ ledger.record(entry({ predictedUsd: 1, reportedUsd: null }));
308
+ const t = buildUsageReport(db, { windowDays: 1, nowMs: NOW }).totals;
309
+ expect(t.digests).toBe(2);
310
+ expect(t.digestReruns).toBe(1);
311
+ expect(t.forecastSamples).toBe(2);
312
+ expect(t.forecastMeanError).toBeCloseTo((1 + 0.5) / 2, 6);
313
+ expect(t.forecastOverShare).toBeCloseTo(0.5, 6);
314
+ const text = renderUsageReport(buildUsageReport(db, { windowDays: 1, nowMs: NOW }));
315
+ expect(text).toContain("re-run rate 50% (1 fetched again in full)");
316
+ expect(text).toContain("forecast: mean error 75% of reported cost over 2 turns · 50% over-predicted");
317
+ } finally {
318
+ db.close();
319
+ }
320
+ });
321
+ });
@@ -637,7 +637,7 @@ describe("context compaction", () => {
637
637
  keepHeadBytes: 20,
638
638
  keepTailBytes: 20,
639
639
  elideSupersededReads: true,
640
- collapseDuplicateResults: true,
640
+ collapseDuplicateResults: true, digestToolResults: false, digestMaxPerTurn: 2,
641
641
  },
642
642
  };
643
643
 
@@ -954,7 +954,7 @@ describe("compaction.replanGrowthRatio (review 2026-09-05 §7)", () => {
954
954
  keepHeadBytes: 20,
955
955
  keepTailBytes: 20,
956
956
  elideSupersededReads: false,
957
- collapseDuplicateResults: false,
957
+ collapseDuplicateResults: false, digestToolResults: false, digestMaxPerTurn: 2,
958
958
  },
959
959
  };
960
960
  }
@@ -1052,6 +1052,30 @@ describe("hysteresis.confirmUpgradesBelowConfidence", () => {
1052
1052
  });
1053
1053
  });
1054
1054
 
1055
+ describe("recorded forecast is the expected price, not the cold worst case", () => {
1056
+ const warmSlug = "x-ai/grok-4.6";
1057
+ test("a warm stay prices the previous prompt as cache reads; coldUsd keeps the cold figure", () => {
1058
+ const cfg: RouterConfig = { ...BASE, hysteresis: { ...BASE.hysteresis, switchMargin: 1e6 } };
1059
+ const d = run({
1060
+ tier: "hard",
1061
+ promptTokens: 80_000,
1062
+ cfg,
1063
+ st: state({ currentSlug: warmSlug, currentTier: "hard", cacheWarmSlug: warmSlug, cacheWarmAtMs: Date.now(), lastPromptTokens: 60_000 }),
1064
+ });
1065
+ expect(d.slug).toBe(warmSlug);
1066
+ // 60k of the 80k prompt is the cached prefix; no reliability sample ⇒ assumed reliable.
1067
+ expect(d.forecast.assumedCacheHitRate).toBeCloseTo(0.75, 6);
1068
+ expect(d.forecast.expectedUsd).toBeLessThan(d.forecast.coldUsd);
1069
+ expect(d.forecast.breakdown.cacheRead).toBeGreaterThan(0);
1070
+ });
1071
+
1072
+ test("a cold turn records the cold price", () => {
1073
+ const d = run({ tier: "hard", promptTokens: 80_000 });
1074
+ expect(d.forecast.assumedCacheHitRate).toBe(0);
1075
+ expect(d.forecast.expectedUsd).toBeLessThanOrEqual(d.forecast.coldUsd);
1076
+ });
1077
+ });
1078
+
1055
1079
  describe("cache reliability in the stay/switch comparison", () => {
1056
1080
  const warmSlug = "x-ai/grok-4.6";
1057
1081
  function ledgerWithReliability(rate: number | null, samples = 50): Ledger {
@@ -0,0 +1,171 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
+ import { createLedger } from "../src/cost/ledger.ts";
5
+ import { buildDailySummary, countTierChanges, createKv, DAILY_SUMMARY_INTERVAL_MS, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews } from "../src/cost/summary.ts";
6
+ import type { LedgerEntry } from "../src/cost/types.ts";
7
+ import { openDb } from "../src/util/sqlite.ts";
8
+ import { fetchSummary } from "../omp-extension/report-logic.ts";
9
+
10
+ /**
11
+ * The daily summary: the last 24h against the day before, top models, tier
12
+ * moves, the once-a-day gate in router_kv, and the text the transcript gets.
13
+ */
14
+
15
+ const HOUR = 3_600_000;
16
+ const NOW = Date.UTC(2026, 8, 7, 9, 0, 0);
17
+
18
+ function entry(over: Partial<LedgerEntry>): LedgerEntry {
19
+ return {
20
+ id: crypto.randomUUID(),
21
+ createdAtMs: NOW - HOUR,
22
+ conversationKey: "k",
23
+ sessionId: "s",
24
+ turn: 1,
25
+ requestedModel: "auto",
26
+ harnessId: "",
27
+ ompSessionId: "",
28
+ slug: "vendor/model",
29
+ servedSlug: "vendor/model",
30
+ tier: "simple",
31
+ classificationSource: "heuristic",
32
+ reasons: [],
33
+ features: null,
34
+ score: null,
35
+ confidence: null,
36
+ task: null,
37
+ classifierReasons: null,
38
+ exploredFrom: null,
39
+ holdArm: null,
40
+ predictedUsd: 0.01,
41
+ reportedUsd: 0.01,
42
+ usage: { promptTokens: 1000, cachedTokens: 500, cacheWriteTokens: 0, completionTokens: 100, reasoningTokens: 0, images: 0 },
43
+ attempt: 0,
44
+ escalationSignal: null,
45
+ latencyMs: 1_100,
46
+ ttftMs: 100,
47
+ finishReason: "stop",
48
+ wasted: false,
49
+ upstreamGenerationId: null,
50
+ error: null,
51
+ promptTokensSaved: null,
52
+ ...over,
53
+ } as LedgerEntry;
54
+ }
55
+
56
+ function seeded() {
57
+ const cfg = structuredClone(DEFAULT_CONFIG);
58
+ cfg.ledger.path = ":memory:";
59
+ const db = openDb(":memory:");
60
+ const ledger = createLedger(db, cfg);
61
+ return { db, ledger };
62
+ }
63
+
64
+ describe("buildDailySummary", () => {
65
+ test("compares the last 24h with the day before and names the top models and tier moves", () => {
66
+ const { db, ledger } = seeded();
67
+ try {
68
+ // Yesterday: one conversation climbing simple → hard, then dropping to moderate; a second model.
69
+ ledger.record(entry({ createdAtMs: NOW - 5 * HOUR, tier: "simple", turn: 1 }));
70
+ ledger.record(entry({ createdAtMs: NOW - 4 * HOUR, tier: "hard", turn: 2, slug: "vendor/big", servedSlug: "vendor/big", reportedUsd: 0.5 }));
71
+ ledger.record(entry({ createdAtMs: NOW - 3 * HOUR, tier: "moderate", turn: 3, escalationSignal: "empty_completion", wasted: true }));
72
+ ledger.record(entry({ createdAtMs: NOW - 3 * HOUR + 1, tier: "moderate", turn: 3, attempt: 1 }));
73
+ ledger.record(entry({ createdAtMs: NOW - 2 * HOUR, requestedModel: "digest", slug: "vendor/tiny", servedSlug: "vendor/tiny", conversationKey: "d", reportedUsd: 0.001 }));
74
+ // The day before: pricier.
75
+ for (let i = 0; i < 4; i++) ledger.record(entry({ createdAtMs: NOW - 30 * HOUR - i, conversationKey: "old", reportedUsd: 0.4 }));
76
+ const s = buildDailySummary(db, { nowMs: NOW, baselines: [{ slug: "anthropic/claude-opus-5", prompt: 15e-6, completion: 75e-6, cacheRead: 1.5e-6 }] });
77
+ expect(s.current.dispatches).toBe(5);
78
+ expect(s.current.spendUsd).toBeCloseTo(0.531, 6);
79
+ expect(s.current.escalations).toBe(1);
80
+ expect(s.current.digests).toBe(1);
81
+ expect(s.current.modelSwitches).toBe(2);
82
+ expect(s.previous.dispatches).toBe(4);
83
+ expect(s.previous.spendUsd).toBeCloseTo(1.6, 6);
84
+ expect(s.topModels[0]).toMatchObject({ slug: "vendor/big", dispatches: 1 });
85
+ expect(s.topModels[0]!.share).toBeCloseTo(0.5 / 0.531, 3);
86
+ expect(s.tierChanges).toEqual({ up: 1, down: 1 });
87
+ expect(s.baseline?.slug).toBe("anthropic/claude-opus-5");
88
+ expect(summaryHasNews(s)).toBe(true);
89
+
90
+ const text = renderDailySummary({ ...s, spikes: [{ slug: "vendor/big", recentDispatches: 8, recentFailures: 4, recentRate: 0.5, baselineDispatches: 90, baselineFailures: 3, baselineRate: 3 / 90 }], ollama: { plan: "pro", usedUsd: 25.2, creditsUsd: 60, runwayDays: 23.4 } });
91
+ expect(text).toContain("auto-model-router daily summary — last 24h (all harnesses)");
92
+ expect(text).toContain("spend $0.531 (prev 24h $1.60, −67%) · 5 turns · 2 conversations · $0.106/turn");
93
+ expect(text).toContain("cache hit 50% · 1 escalations · 0 errors · 2 model switches (1 tier up, 1 down)");
94
+ expect(text).toContain("top models: vendor/big $0.500 (94%, 1 turns)");
95
+ // Tiny seeded turns cost more than Opus would have at list price: the honest branch renders.
96
+ expect(text).toContain("cost 574% MORE than anthropic/claude-opus-5 ($0.079 at list)");
97
+ expect(renderDailySummary({ ...s, baseline: { slug: "anthropic/claude-opus-5", usd: 2.5, savedShare: 0.7876 } })).toContain("saved 79% vs anthropic/claude-opus-5 ($2.50 at list)");
98
+ expect(text).toContain("1 digests for $0.001");
99
+ expect(text).toContain("soft failures SPIKING (1):\n vendor/big: 50% of 8 failed in the last 1h (7d baseline 3% of 90)");
100
+ expect(text).toContain("ollama: pro plan $25.20 of $60 · ~23 days of credits left");
101
+ } finally {
102
+ db.close();
103
+ }
104
+ });
105
+
106
+ test("an empty day renders as such and carries no news unless a model is spiking", () => {
107
+ const { db } = seeded();
108
+ try {
109
+ const s = buildDailySummary(db, { nowMs: NOW });
110
+ expect(summaryHasNews(s)).toBe(false);
111
+ const text = renderDailySummary(s);
112
+ expect(text).toContain("no routed turns in the last 24h");
113
+ expect(text).toContain("soft failures: no model spiking in the last hour");
114
+ expect(text).not.toContain("ollama:");
115
+ expect(summaryHasNews({ ...s, spikes: [{ slug: "a/b", recentDispatches: 5, recentFailures: 3, recentRate: 0.6, baselineDispatches: 0, baselineFailures: 0, baselineRate: 0 }] })).toBe(true);
116
+ expect(countTierChanges(db, 0, "")).toEqual({ up: 0, down: 0 });
117
+ } finally {
118
+ db.close();
119
+ }
120
+ });
121
+
122
+ test("harness scope narrows both windows", () => {
123
+ const { db, ledger } = seeded();
124
+ try {
125
+ ledger.record(entry({ harnessId: "a" }));
126
+ ledger.record(entry({ harnessId: "b", reportedUsd: 5 }));
127
+ expect(buildDailySummary(db, { nowMs: NOW, harnessId: "a" }).current).toMatchObject({ dispatches: 1, spendUsd: 0.01 });
128
+ expect(buildDailySummary(db, { nowMs: NOW }).current.dispatches).toBe(2);
129
+ } finally {
130
+ db.close();
131
+ }
132
+ });
133
+ });
134
+
135
+ describe("once-a-day gate", () => {
136
+ test("router_kv marks the summary shown per harness for 20h, surviving a reopen", () => {
137
+ const db = openDb(":memory:");
138
+ try {
139
+ const kv = createKv(db);
140
+ expect(summaryDue(kv, "", NOW)).toBe(true);
141
+ markSummaryShown(kv, "", NOW);
142
+ expect(summaryDue(kv, "", NOW + HOUR)).toBe(false);
143
+ expect(summaryDue(kv, "other", NOW + HOUR)).toBe(true);
144
+ expect(summaryDue(kv, "", NOW + DAILY_SUMMARY_INTERVAL_MS)).toBe(true);
145
+ // Same table, fresh handle: the marker is durable.
146
+ expect(summaryDue(createKv(db), "", NOW + HOUR)).toBe(false);
147
+ kv.set("k", "v1");
148
+ kv.set("k", "v2");
149
+ expect(kv.get("k")).toBe("v2");
150
+ expect(kv.get("missing")).toBeNull();
151
+ } finally {
152
+ db.close();
153
+ }
154
+ });
155
+ });
156
+
157
+ describe("fetchSummary", () => {
158
+ test("passes harness and auto through and surfaces the router's verdict", async () => {
159
+ const calls: string[] = [];
160
+ const fetchImpl = async (url: string): Promise<Response> => {
161
+ calls.push(url);
162
+ return new Response(JSON.stringify({ due: false, reason: "posted in the last 20h", summary: null }), { status: 200 });
163
+ };
164
+ const r = await fetchSummary("http://h", "omp", true, {}, fetchImpl);
165
+ expect(r).toEqual({ due: false, reason: "posted in the last 20h", summary: null });
166
+ expect(calls).toEqual(["http://h/v1/router/summary?harness=omp&auto=1"]);
167
+ await fetchSummary("http://h", "", false, {}, fetchImpl);
168
+ expect(calls[1]).toBe("http://h/v1/router/summary");
169
+ await expect(fetchSummary("http://h", "", false, {}, async () => new Response("no", { status: 503 }))).rejects.toThrow("router returned 503");
170
+ });
171
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Test preload (bunfig.toml `[test] preload`): isolate every test from the
3
+ * developer's live router home BEFORE any module loads.
4
+ *
5
+ * Five test files build their config with `loadConfig({})`, which layers
6
+ * `$AUTO_MODEL_ROUTER_HOME/config.yml` over the defaults. Run alone they read
7
+ * the real config and fail on whatever the developer has tuned; run in the
8
+ * full suite they happened to pass because an earlier file had already
9
+ * pointed the home at a temp dir. Doing it here makes both cases the same.
10
+ * Tests that want a specific home (config.test.ts, embed-lifecycle) still
11
+ * set their own; this only supplies the default.
12
+ */
13
+ import { mkdtempSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ if (process.env.AUTO_MODEL_ROUTER_HOME === undefined) {
18
+ process.env.AUTO_MODEL_ROUTER_HOME = mkdtempSync(join(tmpdir(), "amr-test-home-"));
19
+ }
@@ -237,3 +237,71 @@ describe("ledger.escalationCost", () => {
237
237
  });
238
238
  });
239
239
 
240
+
241
+ describe("ledger.softFailureSpikes", () => {
242
+ test("flags a model whose last-hour failure rate is a spike against its own 7-day baseline", () => {
243
+ const db = openDb(":memory:");
244
+ try {
245
+ const ledger = createLedger(db, cfg);
246
+ const now = 1_800_000_000_000;
247
+ const H = 3_600_000;
248
+ // Baseline: 100 dispatches over the prior week at 5% soft failures.
249
+ for (let i = 0; i < 100; i++) {
250
+ ledger.record(entry({ createdAtMs: now - 2 * H - i * 60 * 60_000, escalationSignal: i % 20 === 0 ? "empty_completion" : null, wasted: i % 20 === 0 }));
251
+ }
252
+ // Last hour: 10 dispatches, 4 soft failures (40%): a spike.
253
+ for (let i = 0; i < 10; i++) {
254
+ ledger.record(entry({ createdAtMs: now - 5 * 60_000 - i * 60_000, escalationSignal: i < 4 ? "repeat_tool_call" : null, wasted: i < 4 }));
255
+ }
256
+ // A second model with plenty of failures but a matching baseline is not spiking.
257
+ for (let i = 0; i < 100; i++) {
258
+ ledger.record(entry({ slug: "x/steady", servedSlug: "x/steady", createdAtMs: now - 2 * H - i * 60 * 60_000, error: i % 2 === 0 ? "upstream_error: 502" : null }));
259
+ }
260
+ for (let i = 0; i < 10; i++) {
261
+ ledger.record(entry({ slug: "x/steady", servedSlug: "x/steady", createdAtMs: now - 5 * 60_000 - i * 60_000, error: i % 2 === 0 ? "upstream_error: 502" : null }));
262
+ }
263
+ // Aborted and quota errors are not attributable; digest rows are side calls.
264
+ for (let i = 0; i < 10; i++) {
265
+ ledger.record(entry({ slug: "x/aborted", servedSlug: "x/aborted", createdAtMs: now - 5 * 60_000 - i * 60_000, error: "aborted: client closed" }));
266
+ ledger.record(entry({ slug: "x/digest", servedSlug: "x/digest", requestedModel: "digest", createdAtMs: now - 5 * 60_000 - i * 60_000, error: "upstream_error: 500" }));
267
+ }
268
+ const spikes = ledger.softFailureSpikes?.(now) ?? [];
269
+ expect(spikes.map((s) => s.slug)).toEqual(["openai/gpt-5-mini"]);
270
+ const s = spikes[0]!;
271
+ expect(s.recentDispatches).toBe(10);
272
+ expect(s.recentFailures).toBe(4);
273
+ expect(s.recentRate).toBeCloseTo(0.4, 6);
274
+ expect(s.baselineDispatches).toBe(100);
275
+ expect(s.baselineFailures).toBe(5);
276
+ expect(s.baselineRate).toBeCloseTo(0.05, 6);
277
+ // Too few recent dispatches: nothing spikes, however high the rate.
278
+ expect(ledger.softFailureSpikes?.(now, 3 * 60_000)).toEqual([]);
279
+ } finally {
280
+ db.close();
281
+ }
282
+ });
283
+ });
284
+
285
+ describe("ledger.prune and markWasted", () => {
286
+ test("prune deletes rows past retention and 0 keeps everything; markWasted flips one row", () => {
287
+ const db = openDb(":memory:");
288
+ try {
289
+ const ledger = createLedger(db, cfg);
290
+ const now = 1_800_000_000_000;
291
+ const DAY = 86_400_000;
292
+ for (let i = 0; i < 5; i++) ledger.record(entry({ createdAtMs: now - i * 100 * DAY }));
293
+ expect(ledger.prune?.(0, now)).toBe(0);
294
+ expect(ledger.recentEntries(10)).toHaveLength(5);
295
+ expect(ledger.prune?.(365, now)).toBe(1); // only the 400-day-old row
296
+ expect(ledger.recentEntries(10)).toHaveLength(4);
297
+ expect(ledger.prune?.(150, now)).toBe(2); // 200 and 300 days old
298
+ const left = ledger.recentEntries(10);
299
+ expect(left).toHaveLength(2);
300
+ expect(left.every((e) => e.wasted === false)).toBe(true);
301
+ ledger.markWasted?.(left[0]!.id);
302
+ expect(ledger.recentEntries(10).find((e) => e.id === left[0]!.id)?.wasted).toBe(true);
303
+ } finally {
304
+ db.close();
305
+ }
306
+ });
307
+ });