auto-model-router 0.4.3 → 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.
package/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # SQLite fixtures must never get line-ending conversion (core.autocrlf is on for this checkout).
2
+ *.db binary
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.4.3",
10
+ "version": "0.4.4",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.4.3",
17
+ "version": "0.4.4",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -562,7 +562,9 @@ as the placeholder — empty input keeps it, `-` clears an optional field,
562
562
  credentials show as `set`/`unset` and are never echoed. `Save and exit` writes the merged config
563
563
  (schema-checked and backed up first). Tier, task, filter, classifier,
564
564
  hysteresis, exploration, compaction, cache and budget changes hot-reload;
565
- restart omp for `server`, `openrouter`, `ollama`, `context` and `ledger`.
565
+ restart omp for `server` (except `subagentProfile`), `openrouter`, `context`,
566
+ `ledger.path` and the Ollama connection keys; `ollama.costBias`,
567
+ `ollama.biasUntilUsage` and `ledger.retentionDays` hot-reload too.
566
568
 
567
569
  ### Via `auto-model-router config` (text wizard / CLI)
568
570
 
@@ -822,6 +824,12 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
822
824
  | `maxCostUsd` | `0.02` | Skip when the digest itself would cost more. |
823
825
  | `timeoutMs` | `25000` | The raw result stands if the cheap model is slower. |
824
826
 
827
+ Quality signal: when the agent later calls the same tool with the same
828
+ primary argument (re-reads a digested file, re-runs a digested grep), the
829
+ router marks that digest's ledger row wasted. The report's `digests` line
830
+ shows the re-run rate; a high rate means the digest is dropping what the
831
+ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
832
+
825
833
  ### `report` — usage-report options
826
834
 
827
835
  | Key | Default | Meaning |
@@ -838,6 +846,7 @@ is a ledger row (`requestedModel` `digest`) and the report totals them.
838
846
  | `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
839
847
  | `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
840
848
  | `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
849
+ | `retentionDays` | `365` | Delete ledger rows older than this, checked hourly; `0` keeps everything. The ledger grows about 2.5 MB a day under steady use. Freed pages are reused, so the file stops growing rather than shrinking. |
841
850
 
842
851
  ### Top-level
843
852
 
package/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [test]
2
+ preload = ["./test/support/preload.ts"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -23,6 +23,8 @@ export interface CompositeBias {
23
23
  /** Plan usage fraction at which the bias switches off (list price). */
24
24
  biasUntilUsage: number;
25
25
  usage: OllamaUsageSource;
26
+ /** When given, read on every use instead of the static pair, so a config hot reload applies. */
27
+ live?: () => { costBias: number; biasUntilUsage: number };
26
28
  }
27
29
 
28
30
  export function createCompositeCatalog(
@@ -39,7 +41,8 @@ export function createCompositeCatalog(
39
41
 
40
42
  /** The multiplier in force from the latest usage reading (no network). */
41
43
  function currentBias(): number {
42
- return effectiveOllamaBias(bias.costBias, bias.biasUntilUsage, bias.usage.peek());
44
+ const b = bias.live?.() ?? bias;
45
+ return effectiveOllamaBias(b.costBias, b.biasUntilUsage, bias.usage.peek());
43
46
  }
44
47
 
45
48
  function combine(base: CatalogSnapshot, models: readonly CatalogModel[]): CatalogSnapshot {
@@ -344,6 +344,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
344
344
  { path: "ledger.fallbackBlend.inputPerMtok", label: "Fallback blend input $/Mtok", kind: "number", min: 0 },
345
345
  { path: "ledger.fallbackBlend.outputPerMtok", label: "Fallback blend output $/Mtok", kind: "number", min: 0 },
346
346
  { path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
347
+ { path: "ledger.retentionDays", label: "Ledger retention", kind: "number", min: 0, hint: "days; 0 keeps everything" },
347
348
  ],
348
349
  },
349
350
  {
@@ -337,6 +337,7 @@ export const DEFAULT_CONFIG: RouterConfig = {
337
337
  // so early cost reporting never underreports.
338
338
  fallbackBlend: { inputPerMtok: 1.5, outputPerMtok: 7.5 },
339
339
  conversationTtlMs: 7 * 24 * 60 * 60 * 1000,
340
+ retentionDays: 365,
340
341
  },
341
342
  // On by default: an absolute floor that no available model meets is how the
342
343
  // router ends up serving every turn from the cheapest tier.
@@ -90,15 +90,47 @@ export interface WatchConfigOptions {
90
90
  }
91
91
 
92
92
  /**
93
- * Watches `path` and applies valid changes to `live` in place. `frozen` blocks
94
- * (top-level names) are re-copied from `pinned` after every reload so file
95
- * edits to construction-captured blocks cannot silently diverge.
93
+ * Config paths captured at construction, so a file edit cannot reach the
94
+ * running process: the socket, the upstream clients, the agentdox bridge, the
95
+ * ledger file. Everything else, including `ollama.costBias`,
96
+ * `ollama.biasUntilUsage`, `server.subagentProfile` and `ledger.retentionDays`,
97
+ * is read at call time and hot-reloads. A bare block name pins the whole
98
+ * block; `block.key` pins one key and lets its siblings through.
99
+ */
100
+ export const PINNED_CONFIG_PATHS: readonly string[] = [
101
+ "server.host",
102
+ "server.port",
103
+ "server.apiKey",
104
+ "server.harnessId",
105
+ "server.maxConcurrentTurns",
106
+ "openrouter",
107
+ "ollama.enabled",
108
+ "ollama.baseUrl",
109
+ "ollama.apiKey",
110
+ "ollama.timeoutMs",
111
+ "ollama.catalogTtlMs",
112
+ "ollama.includeLocal",
113
+ "ollama.prices",
114
+ "ollama.twins",
115
+ "ollama.usagePollMs",
116
+ "ollama.quotaCooldownMs",
117
+ "ollama.rateLimitCooldownMs",
118
+ "ollama.planCreditsUsd",
119
+ "context",
120
+ "ledger.path",
121
+ ];
122
+
123
+ /**
124
+ * Watches `path` and applies valid changes to `live` in place. `frozen`
125
+ * entries are re-copied from `pinned` after every reload so file edits to
126
+ * construction-captured settings cannot silently diverge: a top-level name
127
+ * pins the whole block, `block.key` pins one key of it.
96
128
  */
97
129
  export function watchConfig(
98
130
  path: string,
99
131
  live: RouterConfig,
100
132
  pinned: RouterConfig,
101
- frozen: readonly (keyof RouterConfig)[],
133
+ frozen: readonly string[],
102
134
  opts: WatchConfigOptions = {},
103
135
  ): ConfigWatcher {
104
136
  let closed = false;
@@ -119,14 +151,31 @@ export function watchConfig(
119
151
  }
120
152
  lastError = "";
121
153
 
122
- const frozenSet = new Set(frozen);
154
+ const frozenBlocks = new Set(frozen.filter((f) => !f.includes(".")));
155
+ const frozenKeys = new Map<string, string[]>();
156
+ for (const f of frozen) {
157
+ const dot = f.indexOf(".");
158
+ if (dot < 0) continue;
159
+ const block = f.slice(0, dot);
160
+ frozenKeys.set(block, [...(frozenKeys.get(block) ?? []), f.slice(dot + 1)]);
161
+ }
123
162
  const changed: string[] = [];
124
163
  const next = result.cfg as unknown as Record<string, unknown>;
164
+ const pinnedRec = pinned as unknown as Record<string, unknown>;
125
165
  for (const key of Object.keys(next)) {
126
- // Frozen blocks belong to construction: keep the pinned values.
127
- const value = frozenSet.has(key as keyof RouterConfig)
128
- ? (pinned as unknown as Record<string, unknown>)[key]
129
- : next[key];
166
+ // Frozen blocks belong to construction: keep the pinned values. A
167
+ // partially frozen block takes the file's siblings and the pinned keys.
168
+ let value = frozenBlocks.has(key) ? pinnedRec[key] : next[key];
169
+ const keys = frozenKeys.get(key);
170
+ if (keys !== undefined && !frozenBlocks.has(key) && value !== null && typeof value === "object") {
171
+ const merged: Record<string, unknown> = { ...(value as Record<string, unknown>) };
172
+ const pinnedBlock = (pinnedRec[key] ?? {}) as Record<string, unknown>;
173
+ for (const k of keys) {
174
+ if (pinnedBlock[k] === undefined) delete merged[k];
175
+ else merged[k] = pinnedBlock[k];
176
+ }
177
+ value = merged;
178
+ }
130
179
  const before = JSON.stringify((live as unknown as Record<string, unknown>)[key]);
131
180
  const after = JSON.stringify(value);
132
181
  if (before !== after) changed.push(key);
@@ -229,6 +229,7 @@ const ledger = z.strictObject({
229
229
  blendMinSamples: z.number().int().nonnegative().optional(),
230
230
  fallbackBlend: fallbackBlend.optional(),
231
231
  conversationTtlMs: z.number().positive().optional(),
232
+ retentionDays: z.number().int().nonnegative().optional(),
232
233
  });
233
234
 
234
235
  // Complete entries: arrays replace wholesale, so a partial profile would
@@ -657,6 +657,13 @@ export interface LedgerConfig {
657
657
  fallbackBlend: { inputPerMtok: number; outputPerMtok: number };
658
658
  /** Drop conversation state untouched for longer than this, ms. */
659
659
  conversationTtlMs: number;
660
+ /**
661
+ * Delete ledger rows older than this many days (checked hourly). 0 keeps
662
+ * everything. The ledger grows ~2.5 MB a day under steady use; trust,
663
+ * reports and replay only read windows well inside a year. Freed pages
664
+ * are reused, so the file stops growing rather than shrinking.
665
+ */
666
+ retentionDays: number;
660
667
  }
661
668
 
662
669
  /**
@@ -375,6 +375,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
375
375
  );
376
376
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
377
377
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
378
+ const pruneStmt = db.query("DELETE FROM ledger WHERE created_at_ms < ?");
379
+ const wasteStmt = db.query("UPDATE ledger SET wasted = 1 WHERE id = ?");
378
380
  const providerSpendStmt = db.query(
379
381
  "SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
380
382
  );
@@ -623,6 +625,13 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
623
625
  const row = providerSpendStmt.get(sinceMs, `${slugPrefix}%`) as { total: number } | null;
624
626
  return row?.total ?? 0;
625
627
  },
628
+ prune(retentionDays: number, nowMs = Date.now()): number {
629
+ if (retentionDays <= 0) return 0;
630
+ return pruneStmt.run(nowMs - retentionDays * DAY_MS).changes;
631
+ },
632
+ markWasted(id: string): void {
633
+ wasteStmt.run(id);
634
+ },
626
635
  latestForSession(ompSessionId: string): LedgerEntry | null {
627
636
  if (ompSessionId === "") return null;
628
637
  const row = sessionStmt.get(ompSessionId, 1) as LedgerRow | null;
@@ -34,6 +34,12 @@ export interface ReportTotals {
34
34
  digests: number;
35
35
  digestSpendUsd: number;
36
36
  digestInputTokens: number;
37
+ /** Digests the agent went back on: the same tool re-run with the same primary argument afterwards (row marked wasted). */
38
+ digestReruns: number;
39
+ /** Forecast accuracy over clean kept rows with a reported cost: mean |predicted − reported| ÷ reported, and the share over-predicted. */
40
+ forecastSamples: number;
41
+ forecastMeanError: number;
42
+ forecastOverShare: number;
37
43
  }
38
44
 
39
45
  export interface ReportRow {
@@ -134,6 +140,8 @@ const COMP = "json_extract(usage, '$.completionTokens')";
134
140
  const PROVIDER = "CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ELSE 'openrouter' END";
135
141
  const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
136
142
  const EST = "json_extract(usage, '$.cachedEstimated') = 1";
143
+ /** Rows a forecast can be judged on: a reported cost, a prediction, clean and kept, not a side call. */
144
+ const FORECASTABLE = "reported_usd > 0 AND predicted_usd IS NOT NULL AND wasted = 0 AND error IS NULL AND requested_model <> 'digest'";
137
145
 
138
146
  const ROW_SELECT = `
139
147
  COUNT(*) AS dispatches,
@@ -213,6 +221,10 @@ export function buildUsageReport(
213
221
  SUM(CASE WHEN requested_model = 'digest' THEN 1 ELSE 0 END) AS digests,
214
222
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${USD} ELSE 0 END), 0) AS digest_spend,
215
223
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${PT} ELSE 0 END), 0) AS digest_input,
224
+ SUM(CASE WHEN requested_model = 'digest' AND wasted = 1 THEN 1 ELSE 0 END) AS digest_reruns,
225
+ SUM(CASE WHEN ${FORECASTABLE} THEN 1 ELSE 0 END) AS fc_n,
226
+ COALESCE(SUM(CASE WHEN ${FORECASTABLE} THEN ABS(predicted_usd - reported_usd) / reported_usd END), 0) AS fc_err,
227
+ SUM(CASE WHEN ${FORECASTABLE} AND predicted_usd > reported_usd THEN 1 ELSE 0 END) AS fc_over,
216
228
  SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
217
229
  SUM(CASE WHEN instr(reasons, 'failover:') > 0 THEN 1 ELSE 0 END) AS failovers,
218
230
  SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
@@ -232,6 +244,10 @@ export function buildUsageReport(
232
244
  digests: number | null;
233
245
  digest_spend: number;
234
246
  digest_input: number;
247
+ digest_reruns: number | null;
248
+ fc_n: number | null;
249
+ fc_err: number;
250
+ fc_over: number | null;
235
251
  escalations: number | null;
236
252
  failovers: number | null;
237
253
  errors: number | null;
@@ -354,6 +370,10 @@ export function buildUsageReport(
354
370
  digests: t.digests ?? 0,
355
371
  digestSpendUsd: t.digest_spend,
356
372
  digestInputTokens: t.digest_input,
373
+ digestReruns: t.digest_reruns ?? 0,
374
+ forecastSamples: t.fc_n ?? 0,
375
+ forecastMeanError: (t.fc_n ?? 0) > 0 ? t.fc_err / (t.fc_n ?? 1) : 0,
376
+ forecastOverShare: (t.fc_n ?? 0) > 0 ? (t.fc_over ?? 0) / (t.fc_n ?? 1) : 0,
357
377
  },
358
378
  providers,
359
379
  models,
@@ -419,7 +439,12 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
419
439
  );
420
440
  }
421
441
  if (t.digests > 0) {
422
- summary.push(`digests: ${num(t.digests)} tool results condensed (${num(t.digestInputTokens)} tok read by a cheap model) for ${usd(t.digestSpendUsd)}`);
442
+ summary.push(
443
+ `digests: ${num(t.digests)} tool results condensed (${num(t.digestInputTokens)} tok read by a cheap model) for ${usd(t.digestSpendUsd)} · re-run rate ${pct(t.digestReruns / t.digests)} (${num(t.digestReruns)} fetched again in full)`,
444
+ );
445
+ }
446
+ if (t.forecastSamples > 0) {
447
+ summary.push(`forecast: mean error ${pct(t.forecastMeanError)} of reported cost over ${num(t.forecastSamples)} turns · ${pct(t.forecastOverShare)} over-predicted`);
423
448
  }
424
449
  if (t.subagentDispatches > 0) {
425
450
  summary.push(`subagents: ${num(t.subagentDispatches)} dispatches, ${usd(t.subagentSpendUsd)} (${pct(t.spendUsd > 0 ? t.subagentSpendUsd / t.spendUsd : 0)} of spend)`);
package/src/cost/types.ts CHANGED
@@ -303,4 +303,8 @@ export interface Ledger {
303
303
  latestForSession?(ompSessionId: string): LedgerEntry | null;
304
304
  /** Newest entries for an omp session, newest first. Optional. */
305
305
  entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
306
+ /** Deletes rows older than `retentionDays` (0 ⇒ none); returns how many. Optional. */
307
+ prune?(retentionDays: number, nowMs?: number): number;
308
+ /** Marks one row wasted after the fact (a digest the agent went back on). Optional. */
309
+ markWasted?(id: string): void;
306
310
  }
@@ -64,7 +64,7 @@ export function validatePlan(
64
64
  * for the resource a call operates on (a `path`, `id`, `query`, ...). Used to
65
65
  * detect when a later call supersedes an earlier read of the same resource.
66
66
  */
67
- function primaryArg(argsJson: string): string | null {
67
+ export function primaryArg(argsJson: string): string | null {
68
68
  try {
69
69
  const parsed: unknown = JSON.parse(argsJson);
70
70
  if (parsed !== null && typeof parsed === "object") {
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { CatalogSnapshot } from "../catalog/types.ts";
10
10
  import type { ProfileConfig, RouterConfig } from "../config/types.ts";
11
- import { priceAt } from "../cost/forecast.ts";
11
+ import { forecast, priceAt } from "../cost/forecast.ts";
12
12
  import type { Ledger } from "../cost/types.ts";
13
13
  import { explorationDraw } from "./explore.ts";
14
14
  import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
@@ -610,13 +610,33 @@ export function select(args: SelectArgs): Decision {
610
610
  }
611
611
  const stripAssistantReasoning = !(chosen.model.supportsReasoning && REASONING_REPLAY_AUTHORS[chosen.model.author] === true);
612
612
 
613
+ // The recorded forecast is the EXPECTED price of this dispatch, not the
614
+ // cold worst case candidates are ranked on. When the chosen model's cache
615
+ // is warm, the previous prompt's tokens are priced as cache reads at the
616
+ // model's measured hit rate; coldUsd stays the cold figure the budget
617
+ // guards used. Before this every recorded forecast was cold while nine
618
+ // turns in ten were warm: 89% over-predicted, median error 220%.
619
+ let expectedForecast = chosen.forecast;
620
+ if (warmSlug !== null && chosen.model.slug === warmSlug && effFeatures.promptTokens > 0 && state.lastPromptTokens > 0) {
621
+ const cachedShare = Math.min(1, state.lastPromptTokens / effFeatures.promptTokens);
622
+ let images = 0;
623
+ if (req.hasImages) for (const m of req.messages) images += m.images;
624
+ const warm = forecast(chosen.model, {
625
+ promptTokens: effFeatures.promptTokens,
626
+ completionTokens: EXPECTED_COMPLETION_TOKENS,
627
+ cacheHitRate: cacheHitExpectation(chosen.model.slug).rate * cachedShare,
628
+ images,
629
+ });
630
+ expectedForecast = { ...warm, coldUsd: chosen.forecast.coldUsd };
631
+ }
632
+
613
633
  return {
614
634
  slug: chosen.model.slug,
615
635
  fallbacks,
616
636
  tier: chosenTier,
617
637
  classification: cls,
618
638
  features,
619
- forecast: chosen.forecast,
639
+ forecast: expectedForecast,
620
640
  sessionId: state.sessionId,
621
641
  sticky,
622
642
  cacheBreakpointMessageIndices,
@@ -23,6 +23,8 @@ import type { DigestRequest, DigestResult } from "./digest.ts";
23
23
 
24
24
  export interface CompactionDigester {
25
25
  digest(req: DigestRequest): Promise<DigestResult>;
26
+ /** See Digester.noteToolCalls; optional so a fake need not implement it. */
27
+ noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
26
28
  }
27
29
 
28
30
  export interface DigestCompactionArgs {
@@ -23,6 +23,7 @@ import type { DigestConfig, RouterConfig } from "../config/types.ts";
23
23
  import { computeCost, forecast } from "../cost/forecast.ts";
24
24
  import type { Ledger, LedgerEntry } from "../cost/types.ts";
25
25
  import { buildCandidates } from "../router/candidates.ts";
26
+ import { primaryArg } from "../router/compaction.ts";
26
27
  import { extractFeatures } from "../router/features.ts";
27
28
  import { TIER_ORDER, type Tier } from "../router/types.ts";
28
29
  import { estimateTokens } from "../tokens/estimate.ts";
@@ -107,8 +108,31 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
107
108
  };
108
109
  }
109
110
 
110
- export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest): Promise<DigestResult> } {
111
+ /** A digest the agent may still go back on: same tool, same primary argument, within RERUN_WINDOW_MS. */
112
+ interface RecentDigest {
113
+ tool: string;
114
+ arg: string | null;
115
+ atMs: number;
116
+ ledgerId: string;
117
+ rerun: boolean;
118
+ }
119
+ const RERUN_WINDOW_MS = 2 * 3_600_000;
120
+ const RECENT_PER_SESSION = 50;
121
+
122
+ export interface Digester {
123
+ digest(req: DigestRequest): Promise<DigestResult>;
124
+ /**
125
+ * Quality signal: the tool calls a session just made. One that repeats a
126
+ * recent digest (same tool, same primary argument) means the agent went
127
+ * back for the full output; that digest's ledger row is marked wasted and
128
+ * the report shows the re-run rate. Returns how many were marked.
129
+ */
130
+ noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
131
+ }
132
+
133
+ export function createDigester(deps: DigesterDeps): Digester {
111
134
  const { cfg, catalog, ledger, upstream, log } = deps;
135
+ const recent = new Map<string, RecentDigest[]>();
112
136
 
113
137
  /** Cheapest simple-tier model that fits the prompt, or the configured one. */
114
138
  async function pickModel(req: NormRequest, promptTokens: number): Promise<CatalogModel | null> {
@@ -229,6 +253,11 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
229
253
  }
230
254
  if (error !== null) return { digested: false, reason: `digest model failed: ${error}` };
231
255
  if (text === "" || text.length >= inputBytes * 0.9) return { digested: false, reason: "digest did not shrink the output" };
256
+ if (req.ompSessionId !== "") {
257
+ const list = recent.get(req.ompSessionId) ?? [];
258
+ list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false });
259
+ recent.set(req.ompSessionId, list.slice(-RECENT_PER_SESSION));
260
+ }
232
261
  return {
233
262
  digested: true,
234
263
  text: `${digestMarker(req.toolName, req.input, model.slug, inputBytes, text.length)}\n${text}`,
@@ -239,5 +268,30 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
239
268
  ms,
240
269
  };
241
270
  },
271
+ noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
272
+ const list = recent.get(ompSessionId);
273
+ if (list === undefined || list.length === 0) return 0;
274
+ let marked = 0;
275
+ for (const c of calls) {
276
+ const tool = c.name.toLowerCase();
277
+ const arg = primaryArg(c.argsJson);
278
+ if (arg === null) continue;
279
+ for (const d of list) {
280
+ if (d.rerun || d.tool !== tool || d.arg !== arg || nowMs - d.atMs > RERUN_WINDOW_MS) continue;
281
+ d.rerun = true;
282
+ marked++;
283
+ try {
284
+ ledger.markWasted?.(d.ledgerId);
285
+ } catch (err) {
286
+ log.debug("digest re-run mark failed", { error: err instanceof Error ? err.message : String(err) });
287
+ }
288
+ log.info("digest re-run: the agent fetched the full output after all", { tool, arg: arg.slice(0, 80) });
289
+ }
290
+ }
291
+ const kept = list.filter((d) => nowMs - d.atMs <= RERUN_WINDOW_MS);
292
+ if (kept.length === 0) recent.delete(ompSessionId);
293
+ else recent.set(ompSessionId, kept);
294
+ return marked;
295
+ },
242
296
  };
243
297
  }
@@ -17,7 +17,7 @@ import { UpstreamError } from "../upstream/types.ts";
17
17
  import { apiKeySource, ollamaKeySource } from "../config/load.ts";
18
18
  import { ollamaMeter } from "../upstream/ollama-usage.ts";
19
19
  import { routerConfigPath } from "../cli/config-cmd.ts";
20
- import { watchConfig } from "../config/hot-reload.ts";
20
+ import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
21
21
  import type { RouterConfig } from "../config/types.ts";
22
22
  import { createLogger } from "../util/log.ts";
23
23
  import { openDb } from "../util/sqlite.ts";
@@ -202,15 +202,16 @@ export function startServer(cfg: RouterConfig): StartedServer {
202
202
 
203
203
  // Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
204
204
  // effect on the next turn without a restart, because every consumer reads
205
- // the shared config object at call time. Construction-captured blocks
206
- // (server socket, OpenRouter client, agentdox bridge) are pinned — editing
207
- // those still requires a restart, and the watcher says so explicitly.
208
- const pinned = { ...cfg };
205
+ // the shared config object at call time. Construction-captured settings
206
+ // (server socket, upstream clients, agentdox bridge, ledger file) are
207
+ // pinned by path (PINNED_CONFIG_PATHS); editing those still requires a
208
+ // restart. The blocks are deep-copied so a reload cannot mutate the pin.
209
+ const pinned = structuredClone(cfg);
209
210
  const configWatcher = watchConfig(
210
211
  routerConfigPath(),
211
212
  cfg,
212
213
  pinned,
213
- ["server", "openrouter", "ollama", "context", "ledger"],
214
+ PINNED_CONFIG_PATHS,
214
215
  {
215
216
  onReload: ({ changed }) => {
216
217
  log.info("config reloaded", { changed: changed.join(", ") });
@@ -269,6 +270,20 @@ export function startServer(cfg: RouterConfig): StartedServer {
269
270
  }, 60_000);
270
271
  pruneTimer.unref();
271
272
 
273
+ // Ledger retention: hourly, and once at boot so a lowered setting takes
274
+ // effect without waiting. Reads the live config, so it hot-reloads.
275
+ const retain = (): void => {
276
+ try {
277
+ const dropped = ledger.prune?.(cfg.ledger.retentionDays) ?? 0;
278
+ if (dropped > 0) log.info("pruned ledger rows past retention", { dropped, retentionDays: cfg.ledger.retentionDays });
279
+ } catch (err) {
280
+ log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
281
+ }
282
+ };
283
+ const retentionTimer = setInterval(retain, 3_600_000);
284
+ retentionTimer.unref();
285
+ setTimeout(retain, 5_000).unref();
286
+
272
287
  // Periodically refetch the (key-scoped) catalog in the background so
273
288
  // guardrail/preference changes are picked up without needing traffic and a
274
289
  // TTL expiry. catalogRefreshMs === 0 disables this.
@@ -54,6 +54,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
54
54
  costBias: cfg.ollama.costBias,
55
55
  biasUntilUsage: cfg.ollama.biasUntilUsage,
56
56
  usage: ollamaUsage,
57
+ live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
57
58
  }),
58
59
  ollama,
59
60
  ollamaUsage,
@@ -114,6 +114,17 @@ export async function runTurn(
114
114
  const log = createLogger(config.logLevel);
115
115
  const state = conversations.load(req.conversationKey);
116
116
  const turnNumber = state.turn + 1;
117
+ // Digest quality signal: the calls the agent just made, matched against
118
+ // recent digests of this session (a re-run of a digested read means the
119
+ // digest was not enough). The last assistant message holds this turn's calls.
120
+ if (deps.digester?.noteToolCalls !== undefined && req.ompSessionId !== "") {
121
+ for (let i = req.messages.length - 1; i >= 0; i--) {
122
+ const m = req.messages[i];
123
+ if (m === undefined || m.role !== "assistant") continue;
124
+ if (m.toolCalls.length > 0) deps.digester.noteToolCalls(req.ompSessionId, m.toolCalls.map((c) => ({ name: c.name, argsJson: c.argsJson })));
125
+ break;
126
+ }
127
+ }
117
128
  // Request header wins; the configured default covers harnesses that send none.
118
129
  const doxScope = req.agentdoxScope !== "" ? req.agentdoxScope : config.context.defaultScope;
119
130
  const doxActive = bridge.enabled && doxScope !== "";
@@ -93,7 +93,7 @@ describe("planCacheBreakpoints", () => {
93
93
  test("milestones follow post-compaction sizes", () => {
94
94
  const req = loop(30);
95
95
  const tail = req.messages.length - 1;
96
- const plan = planCompaction(req.messages, BASE.compaction, req.promptBytes * 0.3, req.promptBytes);
96
+ const plan = planCompaction(req.messages, { ...BASE.compaction, enabled: true }, req.promptBytes * 0.3, req.promptBytes);
97
97
  expect(plan.edits.length).toBeGreaterThan(0);
98
98
  const options = cfg({ maxBreakpoints: 64, milestoneTokens: 4_000 });
99
99
  const raw = planCacheBreakpoints(req, MODEL, options).filter((i) => i !== 0 && i !== tail);
@@ -184,6 +184,28 @@ describe("createDigester", () => {
184
184
  db.close();
185
185
  });
186
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
+
187
209
  test("a pinned digest model is used as-is", async () => {
188
210
  const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
189
211
  const cfg = cfgWith({ model: pinned });
@@ -79,7 +79,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
79
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
+ });
@@ -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: [
@@ -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
+ });
@@ -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,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
+ }
@@ -281,3 +281,27 @@ describe("ledger.softFailureSpikes", () => {
281
281
  }
282
282
  });
283
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
+ });
package/test/turn.test.ts CHANGED
@@ -79,7 +79,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
79
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",
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Regenerates the old-schema ledger fixtures that test/migrations.test.ts
4
+ * opens with the CURRENT bootstrap.
5
+ *
6
+ * bun tools/gen-migration-fixtures.ts
7
+ *
8
+ * For each release tag that changed the schema, the bootstrap of THAT tag is
9
+ * taken from git, run against a fresh file, and a dummy row is inserted into
10
+ * every table (filling each NOT NULL column without a default by its declared
11
+ * type), so the migrations have data to carry, not just DDL. The WAL is folded
12
+ * back into the main file and the result lands in test/fixtures/migrations/
13
+ * as router-v<user_version>.db. Small (a few dozen KB each); commit them.
14
+ *
15
+ * Re-run only when adding a NEW historical version: rewriting existing
16
+ * fixtures would erase the very thing the test guards.
17
+ */
18
+
19
+ import { Database } from "bun:sqlite";
20
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { $ } from "bun";
24
+
25
+ /** One tag per schema version that shipped. */
26
+ const TAGS = ["v0.1.0", "v0.1.4", "v0.2.0", "v0.2.10", "v0.2.22", "v0.2.28", "v0.3.0"];
27
+ const OUT_DIR = join(import.meta.dir, "..", "test", "fixtures", "migrations");
28
+ mkdirSync(OUT_DIR, { recursive: true });
29
+ const work = mkdtempSync(join(tmpdir(), "amr-migrations-"));
30
+
31
+ function dummy(type: string, name: string): string | number {
32
+ const t = type.toUpperCase();
33
+ if (name === "id" || name === "key") return `fixture-${name}`;
34
+ if (name === "created_at_ms" || name === "updated_at_ms" || name === "fetched_at_ms") return 1_756_000_000_000;
35
+ if (t.includes("INT") || t.includes("REAL")) return 1;
36
+ if (name === "usage") return JSON.stringify({ promptTokens: 100, cachedTokens: 0, cacheWriteTokens: 0, completionTokens: 10, reasoningTokens: 0, images: 0 });
37
+ if (name === "reasons") return JSON.stringify(["fixture"]);
38
+ if (name === "payload") return JSON.stringify({ data: [] });
39
+ return `fixture-${name}`;
40
+ }
41
+
42
+ for (const tag of TAGS) {
43
+ const src = await $`git show ${tag}:src/util/sqlite.ts`.text();
44
+ const modPath = join(work, `sqlite-${tag}.ts`);
45
+ await Bun.write(modPath, src);
46
+ const dbPath = join(work, `${tag}.db`);
47
+ const mod = (await import(modPath)) as { openDb(path: string): Database };
48
+ const db = mod.openDb(dbPath);
49
+ const version = (db.query("PRAGMA user_version").get() as { user_version: number }).user_version;
50
+ const tables = (db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all() as { name: string }[]).map((r) => r.name);
51
+ for (const table of tables) {
52
+ const cols = db.query(`PRAGMA table_info(${table})`).all() as { name: string; type: string; notnull: number; dflt_value: string | null; pk: number }[];
53
+ // NOT NULL columns without a default, plus text primary keys (SQLite lets
54
+ // a TEXT PRIMARY KEY be NULL, but the router never writes one that way).
55
+ const fill = cols.filter((c) => (c.notnull === 1 && c.dflt_value === null && !(c.pk === 1 && c.type.toUpperCase().includes("INT"))) || (c.pk === 1 && !c.type.toUpperCase().includes("INT")));
56
+ // A ledger row with an error string exercises the v4 error_kind backfill.
57
+ const values = fill.map((c) => (table === "ledger" && c.name === "error" ? "upstream_error: 502" : dummy(c.type, c.name)));
58
+ if (fill.length === 0) continue;
59
+ db.run(`INSERT INTO ${table} (${fill.map((c) => c.name).join(", ")}) VALUES (${fill.map(() => "?").join(", ")})`, values);
60
+ if (table === "ledger" && cols.some((c) => c.name === "error")) db.run(`UPDATE ledger SET error = 'upstream_error: 502'`);
61
+ }
62
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
63
+ db.run("PRAGMA journal_mode = DELETE");
64
+ db.close();
65
+ const out = join(OUT_DIR, `router-v${version}.db`);
66
+ await Bun.write(out, Bun.file(dbPath));
67
+ console.log(`${tag} → ${out} (user_version ${version}, tables: ${tables.join(", ")})`);
68
+ }
69
+ rmSync(work, { recursive: true, force: true });