auto-model-router 0.4.3 → 0.4.5

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.
@@ -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,
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Harness-side model switch: the router advises a tier for a prompt BEFORE
3
+ * the harness dispatches it, so the harness can move its active model to one
4
+ * the router cannot proxy (a Claude subscription model, say) for hard work
5
+ * and back to the router for the rest.
6
+ *
7
+ * Why this shape: proxying a subscription upstream would mean translating
8
+ * the OpenAI wire to that provider's and carrying its OAuth token through a
9
+ * third-party process. Advising is cheaper and keeps the token where it
10
+ * belongs. The cost of a native turn lands on the subscription, not the
11
+ * ledger; the router only sees the turns it serves.
12
+ *
13
+ * The advice is the heuristic classifier over the user's prompt text alone
14
+ * (the harness has not built the request yet), plus the session's last
15
+ * routed tier for context. No adjudicator call, no dispatch, nothing recorded.
16
+ */
17
+
18
+ import type { RouterConfig } from "../config/types.ts";
19
+ import type { Ledger } from "../cost/types.ts";
20
+ import { scoreHeuristic } from "../router/classify.ts";
21
+ import { extractFeatures } from "../router/features.ts";
22
+ import { estimateTokens } from "../tokens/estimate.ts";
23
+ import type { TaskType, Tier } from "../router/types.ts";
24
+ import type { NormRequest } from "../wire/types.ts";
25
+
26
+ export interface AdviseRequest {
27
+ ompSessionId: string;
28
+ harnessId: string;
29
+ /** The user's prompt as submitted. */
30
+ text: string;
31
+ }
32
+
33
+ export interface Advice {
34
+ tier: Tier;
35
+ task: TaskType;
36
+ confidence: number;
37
+ score: number;
38
+ reasons: string[];
39
+ /** The tier this session's last routed turn ran at, when the router served one. */
40
+ lastTier: Tier | null;
41
+ }
42
+
43
+ const SYSTEM_STUB = "You are a coding agent.";
44
+
45
+ function requestOf(req: AdviseRequest): NormRequest {
46
+ const text = req.text;
47
+ return {
48
+ protocol: "openai-chat",
49
+ conversationKey: `advise:${req.ompSessionId}`,
50
+ harnessId: req.harnessId,
51
+ ompSessionId: req.ompSessionId,
52
+ agentdoxScope: "",
53
+ isSubagent: false,
54
+ requestedModel: "auto",
55
+ messages: [
56
+ { role: "system", text: SYSTEM_STUB, images: 0, textBytes: Buffer.byteLength(SYSTEM_STUB), toolCalls: [] },
57
+ { role: "user", text, images: 0, textBytes: Buffer.byteLength(text), toolCalls: [] },
58
+ ],
59
+ tools: [],
60
+ forcedToolChoice: false,
61
+ stream: false,
62
+ hasImages: false,
63
+ promptBytes: Buffer.byteLength(SYSTEM_STUB) + Buffer.byteLength(text),
64
+ renderUpstreamBody: () => ({}),
65
+ };
66
+ }
67
+
68
+ /** Classifies a prompt the way the first turn of a conversation would be, without dispatching anything. */
69
+ export function advise(cfg: RouterConfig, ledger: Ledger | null, req: AdviseRequest): Advice {
70
+ const norm = requestOf(req);
71
+ const features = extractFeatures(norm, estimateTokens(norm.promptBytes, "unknown", ledger));
72
+ const cls = scoreHeuristic(features, cfg);
73
+ const last = req.ompSessionId === "" ? null : (ledger?.latestForSession?.(req.ompSessionId)?.tier ?? null);
74
+ return {
75
+ tier: cls.tier,
76
+ task: cls.task,
77
+ confidence: cls.confidence,
78
+ score: cls.score,
79
+ reasons: cls.reasons,
80
+ lastTier: last === null ? null : (last as Tier),
81
+ };
82
+ }
@@ -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
  }
@@ -7,6 +7,7 @@ import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
7
7
  import { createLedger } from "../cost/ledger.ts";
8
8
  import { createSessionOverrides } from "./overrides.ts";
9
9
  import { createDigester } from "./digest.ts";
10
+ import { advise } from "./advise.ts";
10
11
  import { TIER_ORDER, type Tier } from "../router/types.ts";
11
12
  import { baselinePrices, buildUsageReport } from "../cost/report.ts";
12
13
  import { buildDailySummary, createKv, markSummaryShown, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
@@ -17,7 +18,7 @@ import { UpstreamError } from "../upstream/types.ts";
17
18
  import { apiKeySource, ollamaKeySource } from "../config/load.ts";
18
19
  import { ollamaMeter } from "../upstream/ollama-usage.ts";
19
20
  import { routerConfigPath } from "../cli/config-cmd.ts";
20
- import { watchConfig } from "../config/hot-reload.ts";
21
+ import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
21
22
  import type { RouterConfig } from "../config/types.ts";
22
23
  import { createLogger } from "../util/log.ts";
23
24
  import { openDb } from "../util/sqlite.ts";
@@ -202,15 +203,16 @@ export function startServer(cfg: RouterConfig): StartedServer {
202
203
 
203
204
  // Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
204
205
  // 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 };
206
+ // the shared config object at call time. Construction-captured settings
207
+ // (server socket, upstream clients, agentdox bridge, ledger file) are
208
+ // pinned by path (PINNED_CONFIG_PATHS); editing those still requires a
209
+ // restart. The blocks are deep-copied so a reload cannot mutate the pin.
210
+ const pinned = structuredClone(cfg);
209
211
  const configWatcher = watchConfig(
210
212
  routerConfigPath(),
211
213
  cfg,
212
214
  pinned,
213
- ["server", "openrouter", "ollama", "context", "ledger"],
215
+ PINNED_CONFIG_PATHS,
214
216
  {
215
217
  onReload: ({ changed }) => {
216
218
  log.info("config reloaded", { changed: changed.join(", ") });
@@ -269,6 +271,20 @@ export function startServer(cfg: RouterConfig): StartedServer {
269
271
  }, 60_000);
270
272
  pruneTimer.unref();
271
273
 
274
+ // Ledger retention: hourly, and once at boot so a lowered setting takes
275
+ // effect without waiting. Reads the live config, so it hot-reloads.
276
+ const retain = (): void => {
277
+ try {
278
+ const dropped = ledger.prune?.(cfg.ledger.retentionDays) ?? 0;
279
+ if (dropped > 0) log.info("pruned ledger rows past retention", { dropped, retentionDays: cfg.ledger.retentionDays });
280
+ } catch (err) {
281
+ log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
282
+ }
283
+ };
284
+ const retentionTimer = setInterval(retain, 3_600_000);
285
+ retentionTimer.unref();
286
+ setTimeout(retain, 5_000).unref();
287
+
272
288
  // Periodically refetch the (key-scoped) catalog in the background so
273
289
  // guardrail/preference changes are picked up without needing traffic and a
274
290
  // TTL expiry. catalogRefreshMs === 0 disables this.
@@ -400,6 +416,25 @@ export function startServer(cfg: RouterConfig): StartedServer {
400
416
  const harnessId = url.searchParams.get("harness") ?? "";
401
417
  return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
402
418
  }
419
+ if (req.method === "GET" && url.pathname === "/v1/router/advise/policy") {
420
+ const h = cfg.harnessSwitch;
421
+ return json({ enabled: h.enabled, models: h.models, minConfidence: h.minConfidence });
422
+ }
423
+ if (req.method === "POST" && url.pathname === "/v1/router/advise") {
424
+ // Harness-side switch: classify a prompt before the harness builds
425
+ // the request. Heuristic only; nothing is dispatched or recorded.
426
+ const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
427
+ if (body === null || typeof body.text !== "string") {
428
+ return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "text required" });
429
+ }
430
+ return json(
431
+ advise(cfg, ledger, {
432
+ ompSessionId: typeof body.ompSessionId === "string" ? body.ompSessionId : "",
433
+ harnessId: typeof body.harnessId === "string" ? body.harnessId : "",
434
+ text: body.text.slice(0, 16_000),
435
+ }),
436
+ );
437
+ }
403
438
  if (req.method === "GET" && url.pathname === "/v1/router/summary") {
404
439
  // The last 24 hours in a few lines. `auto=1` is the session-start
405
440
  // caller: it gets `due: false` unless report.dailySummary is on, no
@@ -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);
@@ -172,6 +172,7 @@ describe("WIZARD_SECTIONS coverage", () => {
172
172
  ]),
173
173
  ...["coding", "vision", "documentation", "data", "chat"].flatMap((t) => [`tasks.${t}.minQuality`, `tasks.${t}.requireImage`, `tasks.${t}.prefer`]),
174
174
  ...["trivial", "simple", "moderate", "hard"].map((t) => `exploration.rates.${t}`),
175
+ ...["trivial", "simple", "moderate", "hard"].map((t) => `harnessSwitch.models.${t}`),
175
176
  ]);
176
177
  const known = new Set(leaves(DEFAULT_CONFIG));
177
178
  const unknown = WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path)).filter((p) => !known.has(p) && !KNOWN_OPTIONAL.has(p));
@@ -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 });
@@ -77,9 +77,10 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
77
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
79
  report: { baselines: [], dailySummary: false },
80
+ harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
80
81
  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
82
  profiles: [],
82
- ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
83
+ ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
83
84
  adaptiveTierFloors: true,
84
85
  adaptivePriceCeilings: false,
85
86
  logLevel: "silent",
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
+ import { TIER_ORDER } from "../src/router/types.ts";
5
+ import { advise } from "../src/server/advise.ts";
6
+ import { decideSwitch, nativeModelFor, parseSwitchPolicy, type SwitchPolicy } from "../omp-extension/switch-logic.ts";
7
+
8
+ /**
9
+ * Harness-side model switch: the router's prompt-only advice, and the
10
+ * extension's decision about moving omp's active model.
11
+ */
12
+
13
+ describe("advise", () => {
14
+ test("classifies a prompt without a ledger or dispatch and reports the shape the extension reads", () => {
15
+ const a = advise(DEFAULT_CONFIG, null, { ompSessionId: "s", harnessId: "", text: "Redesign the routing pipeline so escalation and failover share one retry loop; consider cache costs and write the migration plan." });
16
+ expect(TIER_ORDER).toContain(a.tier);
17
+ expect(a.confidence).toBeGreaterThanOrEqual(0);
18
+ expect(a.confidence).toBeLessThanOrEqual(1);
19
+ expect(a.reasons.length).toBeGreaterThan(0);
20
+ expect(a.lastTier).toBeNull();
21
+ const terse = advise(DEFAULT_CONFIG, null, { ompSessionId: "", harnessId: "", text: "ok" });
22
+ expect(TIER_ORDER.indexOf(terse.tier)).toBeLessThanOrEqual(TIER_ORDER.indexOf(a.tier));
23
+ });
24
+ });
25
+
26
+ describe("switch policy", () => {
27
+ test("parses defensively and maps a tier to the nearest configured tier at or below it", () => {
28
+ expect(parseSwitchPolicy(null).enabled).toBe(false);
29
+ const p = parseSwitchPolicy({ enabled: true, models: { moderate: "anthropic/claude-sonnet-5", hard: "anthropic/claude-opus-4-8", simple: "no-slash" }, minConfidence: 0.5 });
30
+ expect(p.models).toEqual({ moderate: "anthropic/claude-sonnet-5", hard: "anthropic/claude-opus-4-8" });
31
+ expect(nativeModelFor(p, "hard")).toBe("anthropic/claude-opus-4-8");
32
+ expect(nativeModelFor(p, "moderate")).toBe("anthropic/claude-sonnet-5");
33
+ expect(nativeModelFor(p, "simple")).toBeUndefined();
34
+ expect(nativeModelFor(p, "trivial")).toBeUndefined();
35
+ });
36
+ });
37
+
38
+ describe("decideSwitch", () => {
39
+ const policy: SwitchPolicy = { enabled: true, models: { hard: "anthropic/claude-opus-4-8" }, minConfidence: 0.6 };
40
+ const router = "auto-model-router/auto";
41
+
42
+ test("moves up from the router for confident hard work, back for lighter work, and never past a manual choice", () => {
43
+ const up = decideSwitch({ policy, advised: { tier: "hard", confidence: 0.8 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null });
44
+ expect(up).toMatchObject({ action: "up", model: "anthropic/claude-opus-4-8" });
45
+ // Low confidence: stay.
46
+ expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.3 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
47
+ // Unmapped tier while on the router: stay.
48
+ expect(decideSwitch({ policy, advised: { tier: "moderate", confidence: 0.9 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
49
+ // On our own switch, hard again: already there.
50
+ expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.9 }, active: "anthropic/claude-opus-4-8", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router }).action).toBe("none");
51
+ // On our own switch, lighter work: back to the router model we left.
52
+ expect(decideSwitch({ policy, advised: { tier: "simple", confidence: 0.9 }, active: "anthropic/claude-opus-4-8", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router })).toMatchObject({ action: "back", model: router });
53
+ // The user chose a model by hand: leave it alone whatever the advice.
54
+ expect(decideSwitch({ policy, advised: { tier: "hard", confidence: 0.9 }, active: "openai/gpt-5", activeIsRouter: false, switchedTo: null, returnTo: null }).action).toBe("none");
55
+ expect(decideSwitch({ policy, advised: { tier: "trivial", confidence: 0.9 }, active: "openai/gpt-5", activeIsRouter: false, switchedTo: "anthropic/claude-opus-4-8", returnTo: router }).action).toBe("none");
56
+ // Disabled: nothing moves.
57
+ expect(decideSwitch({ policy: { ...policy, enabled: false }, advised: { tier: "hard", confidence: 0.9 }, active: router, activeIsRouter: true, switchedTo: null, returnTo: null }).action).toBe("none");
58
+ });
59
+ });
@@ -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
+ });