auto-model-router 0.3.4 → 0.4.0

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 (42) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +24 -2
  3. package/omp-extension/report-logic.ts +93 -0
  4. package/omp-extension/router-configure.ts +128 -2
  5. package/package.json +1 -1
  6. package/src/catalog/ollama-catalog.ts +30 -2
  7. package/src/cli/config-wizard.ts +8 -0
  8. package/src/cli/report.ts +7 -2
  9. package/src/config/defaults.ts +9 -0
  10. package/src/config/schema.ts +5 -0
  11. package/src/config/types.ts +41 -0
  12. package/src/cost/feedback.ts +81 -0
  13. package/src/cost/ledger.ts +73 -0
  14. package/src/cost/report.ts +106 -2
  15. package/src/cost/types.ts +28 -0
  16. package/src/router/candidates.ts +13 -4
  17. package/src/router/classify.ts +10 -0
  18. package/src/router/features.ts +20 -1
  19. package/src/router/index.ts +12 -1
  20. package/src/router/learned.ts +202 -0
  21. package/src/router/select.ts +64 -8
  22. package/src/router/types.ts +31 -1
  23. package/src/server/http.ts +82 -5
  24. package/src/server/overrides.ts +83 -0
  25. package/src/server/providers.ts +10 -2
  26. package/src/server/turn.ts +16 -1
  27. package/src/upstream/ollama-usage.ts +79 -2
  28. package/src/util/sqlite.ts +30 -0
  29. package/test/config-wizard.test.ts +2 -1
  30. package/test/controls.test.ts +223 -0
  31. package/test/failover.test.ts +3 -2
  32. package/test/features.test.ts +31 -0
  33. package/test/learned.test.ts +61 -0
  34. package/test/ollama.test.ts +74 -2
  35. package/test/report-hub.test.ts +4 -2
  36. package/test/report-logic.test.ts +3 -0
  37. package/test/report.test.ts +43 -0
  38. package/test/select.test.ts +126 -1
  39. package/test/trust-attribution.test.ts +42 -0
  40. package/test/turn.test.ts +33 -2
  41. package/tools/replay.ts +266 -156
  42. package/tools/train-classifier.ts +111 -0
@@ -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.3.4",
10
+ "version": "0.4.0",
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.3.4",
17
+ "version": "0.4.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -500,10 +500,12 @@ What it shows, for the window:
500
500
  | Block | Columns |
501
501
  | --- | --- |
502
502
  | totals | spend, dispatches, conversations, $/dispatch, prompt and completion tokens, cache hit rate, model switches, escalations, failovers, errors (aborted separately) |
503
+ | prompt anatomy | mean share of prompt bytes by role (tool results, assistant, user, system), tool schemas beside them, the older half of the conversation, and tool results older than the newest 20 messages — what compaction can reach. Recorded per turn from v0.3.5. |
503
504
  | providers | per upstream (`openrouter`, `ollama`): dispatches, spend, share, cache hit, mean TTFT, tokens/s, escalations, errors |
504
- | models | per served slug (top 12 by spend): the same plus the tier mix it was routed for |
505
+ | models | per served slug (top 12 by spend): the same plus user feedback (`+good/-bad` from `/router good\|bad`) and the tier mix it was routed for |
505
506
  | tiers | per tier: dispatches, spend, share, cache hit, mean prompt tokens, escalations |
506
507
  | by day | UTC calendar days: dispatches, spend, cache hit |
508
+ | same traffic on one model | the window's tokens priced on each `report.baselines` model at list price with the window's cache hit rate, and what share the router saved against it |
507
509
 
508
510
  Spend follows the ledger's rule — the provider's reported cost when it gave
509
511
  one, else the usage-priced figure the router computed, else the forecast.
@@ -530,6 +532,10 @@ Status); the subcommands go straight there:
530
532
  | `/router config` | Section picker over **every** config key: Server, OpenRouter, Ollama Cloud, Benchmarks, Tiers, Tasks, Filters, Classifier, Escalation, Hysteresis, Exploration, Cache, Compaction, Context (agentdox), Budget, Ledger, Logging, Profiles. Only `ollama.prices` and `ollama.twins` (maps) stay YAML-only. |
531
533
  | `/router report` | Usage analytics in a fullscreen hub styled like `/models`: pick a view in the sidebar, set the window (24h / 7d / 30d / 90d) and the harness scope there too. `/router report 30d --all` presets them. See [Usage reports](#usage-reports). |
532
534
  | `/router status` | The router's `/health`: key sources, catalog size and age, Ollama availability, plan usage and cost bias, agentdox bridge. |
535
+ | `/router why` | Explain this session's last routed turn: model and provider, tier, classification source and confidence, cost, cache hit, latency, the full decision trail and classifier reasons, any feedback already given. |
536
+ | `/router good` / `/router bad [note]` | Judge that turn. Recorded against the model that served it (`POST /v1/router/feedback`), shown per model in the report's `feedback` column, and the label the de-escalation work needs. `/router feedback good\|bad` is the same. |
537
+ | `/router pin <model\|off>` | Route this session to one model until cleared (admitted past price, quality and trust filters; tool support and context window still apply). Escalations and failovers after the first attempt still run. |
538
+ | `/router tier <tier\|off> [turns]` | Force a tier for N committed turns (default 10; 0 = until cleared). Shown with no argument. Overrides are per omp session, live in the router process only, and lapse after 12 idle hours. |
533
539
 
534
540
  Picking a section lists its fields with their current values (pending edits
535
541
  marked), so you see the settings before choosing one to change. Each field
@@ -663,6 +669,8 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
663
669
  | `contextHeadroom` | `1.25` | Fraction of context kept free (a model must fit prompt × this). |
664
670
  | `latencyWeight` | `0` | How hard to penalise slow models in scoring (soft multiplier on effective cost). `0` disables it. |
665
671
  | `latencyMinSamples` | `20` | Streamed samples before latency is judged against a model. |
672
+ | `cacheReliabilityMinSamples` | `10` | Warm-expected samples before a model's observed cache hit rate discounts its "stay warm" price in the stay/switch comparison. A model whose cache misses when it should be warm (measured: 5-6% on glm/gemini, 11% on ling, 50% on nex) is kept less eagerly. `0` assumes every cache is reliable. |
673
+ | `latencyWeightContinuation` | unset | Latency weight on tool-result continuations (the agent loop's own follow-ups). Unset ⇒ `latencyWeight` everywhere; lower it to spend speed only where a person waits on first token. |
666
674
  | `maxExpectedWaitMs` | unset | Absolute expected-wait ceiling (ms): a hard drop for models *proven* slower (≥ `latencyMinSamples`), regardless of price. The soft penalty is multiplicative and capped, so it cannot demote a slow-but-cheap model — this can. New models keep their cold-start turns; relaxed with trust in tier rescue. Undefined ⇒ off. |
667
675
  | `escalationCostWeight` | `0` | Price a model's measured escalation rate at what an escalated retry actually bills (the ledger's $/prompt-token of `attempt > 0` rows), 0–1. The trust divisor reads a 4% escalation rate as a 4% surcharge; the real cost is a whole re-dispatch on the next tier's model. `0` disables the term. |
668
676
 
@@ -671,6 +679,7 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
671
679
  | Key | Default | Meaning |
672
680
  | --- | --- | --- |
673
681
  | `ambiguityThreshold` | `0.6` | Below this heuristic confidence, the adjudicator model decides the tier. |
682
+ | `learnedModelPath` | unset | A model written by `bun tools/train-classifier.ts` (logistic regression over the ledger's recorded features, label = the turn escalated). When set, every decision records `learned: p(escalate)=…`. Advisory only: it never moves a tier until replay shows it should. |
674
683
  | `model` | `qwen/qwen3.7-flash` | Adjudicator model slug. |
675
684
  | `maxCostFraction` | `0.02` | Adjudicator cost cap as a fraction of the turn's budget. |
676
685
  | `maxCostUsd` | `0.002` | Absolute adjudicator cost cap, USD. |
@@ -749,6 +758,7 @@ shrunk results stay shrunk (rewriting them would break the prompt cache).
749
758
  | `perTurnUsd` | unset | Per-turn cap (checked against the cold forecast). |
750
759
  | `perConversationUsd` | unset | Per-conversation cap. |
751
760
  | `perDayUsd` | unset | Rolling 24h cap, scoped per harness when `harnessId` is set. |
761
+ | `perMonthUsd` | unset | Calendar-month (UTC) target. Paced: the daily cap becomes min(`perDayUsd`, remaining ÷ days left), so a month running ahead tightens automatically. The breach reason names the pace. |
752
762
  | `onExceeded` | `downgrade` | `downgrade` = pick the cheapest viable model; `reject` = fail the turn. |
753
763
 
754
764
  ### `profiles` — the virtual models omp sees
@@ -764,6 +774,12 @@ Each profile is a complete entry (arrays replace wholesale):
764
774
  | `maxTokens` | `32000` | Advertised max output tokens. |
765
775
  | `budget` | unset | Per-profile budget overrides. |
766
776
 
777
+ ### `report` — usage-report options
778
+
779
+ | Key | Default | Meaning |
780
+ | --- | --- | --- |
781
+ | `baselines` | `anthropic/claude-opus-5`, `anthropic/claude-sonnet-5` | Models the report prices the window's traffic on as a single-model counterfactual. Unknown slugs are skipped. |
782
+
767
783
  ### `ledger` — cost measurement
768
784
 
769
785
  | Key | Default | Meaning |
@@ -827,7 +843,13 @@ What happens once it is on:
827
843
  flags these rows (`usage.cachedEstimated`) and reports show their cache
828
844
  rate as `~N%`. `/router status` shows ollama.com's own dollar reading as
829
845
  the cross-check: the plan is read from `POST /api/me` and its published
830
- allowance applied (`planCreditsUsd` overrides it).
846
+ allowance applied (`planCreditsUsd` overrides it). **The estimate is
847
+ calibrated against the meter:** every usage poll records the meter beside
848
+ the ledger's Ollama total, and once the span carries ~$0.50 of metered
849
+ spend the ratio (clamped to 0.5–2×) scales every new Ollama cost the
850
+ router records, so the ledger tracks the bill rather than the list price.
851
+ Status shows the factor and, at the last week's burn, how many days of
852
+ credits remain.
831
853
  - **Same economics, same failover.** Candidates from both providers are ranked
832
854
  together; `costBias` tilts the comparison while a plan's included credits
833
855
  would otherwise go unused. **Credit-aware by default:** the router reads the
@@ -76,6 +76,8 @@ export interface HealthSnapshot {
76
76
  lastTrip?: { kind?: string; atMs?: number; message?: string } | null;
77
77
  usage?: { monthlyUsedFraction?: number | null; activityCostUsd?: number | null; plan?: string | null; fetchedAtMs?: number | null } | null;
78
78
  meter?: { usedUsd?: number; creditsUsd?: number; plan?: string | null } | null;
79
+ calibration?: { factor?: number; meterDeltaUsd?: number; ledgerDeltaUsd?: number; spanHours?: number } | null;
80
+ runway?: { dailyBurnUsd?: number; creditsLeftUsd?: number; days?: number | null } | null;
79
81
  costBias?: { configured?: number; effective?: number; biasUntilUsage?: number };
80
82
  } | null;
81
83
  catalog?: {
@@ -111,8 +113,99 @@ export function renderStatus(baseUrl: string, h: HealthSnapshot, nowMs = Date.no
111
113
  const bias = o.costBias === undefined ? "" : ` · cost bias ×${o.costBias.effective ?? o.costBias.configured ?? 1} (until ${((o.costBias.biasUntilUsage ?? 1) * 100).toFixed(0)}%)`;
112
114
  const trip = o.lastTrip !== undefined && o.lastTrip !== null ? ` · last trip ${o.lastTrip.kind ?? "?"}${o.lastTrip.atMs ? ` ${mins(nowMs - o.lastTrip.atMs)} ago` : ""}` : "";
113
115
  out.push(`ollama cloud: ${o.models ?? 0} models · ${avail} · key ${o.apiKeySource ?? "?"} · ${usage}${bias}${trip}`);
116
+ const c = o.calibration;
117
+ const rw = o.runway;
118
+ const calText = c !== undefined && c !== null && c.factor !== undefined ? `ledger estimate ×${c.factor.toFixed(2)} to match the meter (${(c.spanHours ?? 0).toFixed(0)}h span)` : "ledger estimate uncalibrated (needs ~$0.50 of metered spend)";
119
+ const rwText = rw !== undefined && rw !== null ? ` · burn $${(rw.dailyBurnUsd ?? 0).toFixed(2)}/day · ${rw.days === null || rw.days === undefined ? "credits left: unknown burn" : `~${Math.round(rw.days)} days of credits left`}` : "";
120
+ if (o.meter !== undefined && o.meter !== null) out.push(`ollama billing: ${calText}${rwText}`);
114
121
  }
115
122
  const a = h.agentdox;
116
123
  out.push(a === undefined || a === null ? "agentdox: off" : `agentdox: ${a.url ?? "?"} scope ${a.defaultScope ?? "?"}${a.recordTurns === true ? " · recording turns" : ""}`);
117
124
  return out.join("\n");
118
125
  }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // /router why, feedback, pin, tier
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /** The decision fields `/router why` renders, as `/v1/router/decisions` returns them. */
132
+ export interface WhyEntry {
133
+ id: string;
134
+ createdAtMs: number;
135
+ turn: number;
136
+ slug: string;
137
+ servedSlug: string | null;
138
+ tier: string;
139
+ classificationSource: string;
140
+ confidence: number | null;
141
+ task: string | null;
142
+ reasons: string[];
143
+ classifierReasons: string[] | null;
144
+ reportedUsd: number | null;
145
+ predictedUsd: number;
146
+ usage: { promptTokens: number; cachedTokens: number; completionTokens: number; cachedEstimated?: boolean };
147
+ latencyMs: number;
148
+ ttftMs: number | null;
149
+ attempt: number;
150
+ escalationSignal: string | null;
151
+ feedback?: Array<{ verdict: string; note: string; createdAtMs: number }>;
152
+ }
153
+
154
+ const money = (v: number): string => (v >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(4)}`);
155
+
156
+ /** Plain-text explanation of one routed turn. */
157
+ export function renderWhy(e: WhyEntry, nowMs = Date.now()): string {
158
+ const served = e.servedSlug ?? e.slug;
159
+ const provider = served.startsWith("ollama/") ? "ollama" : "openrouter";
160
+ const cache = e.usage.promptTokens > 0 ? `${((100 * e.usage.cachedTokens) / e.usage.promptTokens).toFixed(0)}%${e.usage.cachedEstimated === true ? " est." : ""}` : "–";
161
+ const age = Math.max(0, nowMs - e.createdAtMs);
162
+ const ago = age < 60_000 ? `${Math.round(age / 1000)}s ago` : age < 3_600_000 ? `${Math.round(age / 60_000)}m ago` : `${(age / 3_600_000).toFixed(1)}h ago`;
163
+ const out: string[] = [];
164
+ out.push(`turn ${e.turn} · ${ago} · ${provider} · ${served}${e.servedSlug !== null && e.servedSlug !== e.slug ? ` (asked ${e.slug})` : ""} [${e.tier}${e.attempt > 0 ? `, attempt ${e.attempt + 1}` : ""}]`);
165
+ out.push(
166
+ `classified ${e.tier} by ${e.classificationSource}${e.confidence !== null ? ` at ${(e.confidence * 100).toFixed(0)}% confidence` : ""}${e.task !== null ? ` · task ${e.task}` : ""}`,
167
+ );
168
+ out.push(
169
+ `cost ${money(e.reportedUsd ?? e.predictedUsd)}${e.reportedUsd === null ? " (forecast)" : ""} · prompt ${e.usage.promptTokens.toLocaleString("en-US")} tok (cache ${cache}) · completion ${e.usage.completionTokens.toLocaleString("en-US")} tok · ttft ${e.ttftMs === null ? "–" : `${(e.ttftMs / 1000).toFixed(1)}s`} · total ${(e.latencyMs / 1000).toFixed(1)}s`,
170
+ );
171
+ if (e.escalationSignal !== null) out.push(`escalation signal: ${e.escalationSignal}`);
172
+ out.push("", "decision trail:");
173
+ for (const r of e.reasons) out.push(` - ${r}`);
174
+ if (e.classifierReasons !== null && e.classifierReasons.length > 0) {
175
+ out.push("", "classifier:");
176
+ for (const r of e.classifierReasons) out.push(` - ${r}`);
177
+ }
178
+ if (e.feedback !== undefined && e.feedback.length > 0) {
179
+ out.push("", "feedback:");
180
+ for (const f of e.feedback) out.push(` - ${f.verdict}${f.note !== "" ? `: ${f.note}` : ""}`);
181
+ }
182
+ out.push("", `judge it: /router good or /router bad [note]`);
183
+ return out.join("\n");
184
+ }
185
+
186
+ export type OverrideRequest = { kind: "pin"; slug: string | null } | { kind: "tier"; tier: string | null; turns: number } | { kind: "show" } | { kind: "clear" } | { kind: "error"; message: string };
187
+
188
+ const TIERS = ["trivial", "simple", "moderate", "hard"];
189
+
190
+ /** Parses `/router pin <slug|off>` and `/router tier <tier|off> [turns]`. */
191
+ export function parseOverrideArgs(verb: "pin" | "tier", text: string): OverrideRequest {
192
+ const parts = text.trim().split(/\s+/).filter((t) => t !== "");
193
+ const first = parts[0] ?? "";
194
+ if (first === "") return { kind: "show" };
195
+ if (["off", "clear", "none"].includes(first.toLowerCase())) return verb === "pin" ? { kind: "pin", slug: null } : { kind: "tier", tier: null, turns: 0 };
196
+ if (verb === "pin") return { kind: "pin", slug: first };
197
+ const tier = first.toLowerCase();
198
+ if (!TIERS.includes(tier)) return { kind: "error", message: `unknown tier "${first}" (${TIERS.join(" | ")} | off)` };
199
+ const turns = parts[1] === undefined ? 10 : Number.parseInt(parts[1], 10);
200
+ if (!Number.isInteger(turns) || turns < 0) return { kind: "error", message: `turns must be a whole number (0 = until cleared), got "${parts[1]}"` };
201
+ return { kind: "tier", tier, turns };
202
+ }
203
+
204
+ /** One-line description of a live override, for notifications. */
205
+ export function describeOverride(o: { slug: string | null; tier: string | null; turnsLeft: number } | null): string {
206
+ if (o === null || (o.slug === null && o.tier === null)) return "no override on this session";
207
+ const parts: string[] = [];
208
+ if (o.slug !== null) parts.push(`pinned to ${o.slug}`);
209
+ if (o.tier !== null) parts.push(`tier forced to ${o.tier}`);
210
+ return `${parts.join(", ")} · ${o.turnsLeft === 0 ? "until cleared" : `${o.turnsLeft} turn${o.turnsLeft === 1 ? "" : "s"} left`}`;
211
+ }
@@ -12,6 +12,15 @@
12
12
  * them. Headless sessions get the text instead.
13
13
  * /router status the router's /health: keys, catalog, Ollama
14
14
  * availability and plan usage, agentdox.
15
+ * /router why explain this session's last routed turn: model,
16
+ * tier, confidence, cost, cache, the decision trail.
17
+ * /router good | bad [note]
18
+ * judge that turn; recorded against the model that
19
+ * served it (/router feedback good|bad is the same).
20
+ * /router pin <model|off> route this session to one model until cleared.
21
+ * /router tier <tier|off> [turns]
22
+ * force a tier for N turns (default 10; 0 = until
23
+ * cleared). Escalations and failovers still apply.
15
24
  *
16
25
  * Configuration walks the same sections and fields as `auto-model-router
17
26
  * config` (reusing `WIZARD_SECTIONS` / `PROFILE_FIELDS` from the router's
@@ -49,7 +58,17 @@ import { matchesKey, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
49
58
 
50
59
  import { editProfile, editSectionMenu, type ConfigUi, type SelectOption } from "./configure-logic.ts";
51
60
  import { ReportHub } from "./report-hub.ts";
52
- import { fetchReport, parseReportArgs, renderStatus, type HealthSnapshot, type ReportRequest } from "./report-logic.ts";
61
+ import {
62
+ describeOverride,
63
+ fetchReport,
64
+ parseOverrideArgs,
65
+ parseReportArgs,
66
+ renderStatus,
67
+ renderWhy,
68
+ type HealthSnapshot,
69
+ type ReportRequest,
70
+ type WhyEntry,
71
+ } from "./report-logic.ts";
53
72
  import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
54
73
 
55
74
  // This harness's id, matching the X-Omp-Harness header the router records.
@@ -76,10 +95,28 @@ export default function (pi: ExtensionAPI): void {
76
95
  case "status":
77
96
  case "health":
78
97
  return status(pi, ctx);
98
+ case "why":
99
+ case "explain":
100
+ return why(pi, ctx);
101
+ case "good":
102
+ case "bad":
103
+ return feedback(ctx, verb.toLowerCase() as "good" | "bad", tail);
104
+ case "feedback": {
105
+ const [v = "", ...note] = rest;
106
+ if (v !== "good" && v !== "bad") {
107
+ ctx.ui.notify("usage: /router feedback good|bad [note]", "warn");
108
+ return;
109
+ }
110
+ return feedback(ctx, v, note.join(" "));
111
+ }
112
+ case "pin":
113
+ return override(ctx, "pin", tail);
114
+ case "tier":
115
+ return override(ctx, "tier", tail);
79
116
  case "":
80
117
  break;
81
118
  default:
82
- ctx.ui.notify(`unknown /router subcommand "${verb}" (config | report [7d] [--all] | status)`, "warn");
119
+ ctx.ui.notify(`unknown /router subcommand "${verb}" (config | report | status | why | good | bad | pin | tier)`, "warn");
83
120
  return;
84
121
  }
85
122
 
@@ -87,11 +124,15 @@ export default function (pi: ExtensionAPI): void {
87
124
  { label: "Configure", description: "edit any router setting" },
88
125
  { label: "Report", description: "usage analytics; window and scope adjustable inside" },
89
126
  { label: "Status", description: "keys, catalog, Ollama, agentdox" },
127
+ { label: "Why", description: "explain this session's last routed turn" },
128
+ { label: "Override", description: "pin a model or force a tier for this session" },
90
129
  ]);
91
130
  if (chosen === undefined) return;
92
131
  if (chosen === "Configure") return configure(ctx);
93
132
  if (chosen === "Status") return status(pi, ctx);
94
133
  if (chosen === "Report") return report(pi, ctx, "");
134
+ if (chosen === "Why") return why(pi, ctx);
135
+ if (chosen === "Override") return override(ctx, "tier", "");
95
136
  },
96
137
  });
97
138
  }
@@ -173,6 +214,91 @@ async function report(pi: ExtensionAPI, ctx: ExtensionContext, argText: string):
173
214
  post(pi, renderUsageReport(data));
174
215
  }
175
216
 
217
+ /** POST JSON to the router; throws on a non-2xx with the router's message. */
218
+ async function routerPost<T>(path: string, body: unknown): Promise<T> {
219
+ const res = await fetch(`${routerBaseUrl()}${path}`, {
220
+ method: "POST",
221
+ headers: { ...routerAuthHeaders(), "content-type": "application/json" },
222
+ body: JSON.stringify(body),
223
+ signal: AbortSignal.timeout(5_000),
224
+ });
225
+ const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
226
+ if (!res.ok) {
227
+ const err = json?.error as { message?: string } | undefined;
228
+ throw new Error(err?.message ?? `router returned ${res.status}`);
229
+ }
230
+ return json as T;
231
+ }
232
+
233
+ async function why(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
234
+ const session = ctx.sessionManager.getSessionId();
235
+ try {
236
+ const res = await fetch(`${routerBaseUrl()}/v1/router/decisions?limit=1&session=${encodeURIComponent(session)}`, {
237
+ headers: routerAuthHeaders(),
238
+ signal: AbortSignal.timeout(5_000),
239
+ });
240
+ if (!res.ok) throw new Error(`router returned ${res.status}`);
241
+ const body = (await res.json()) as { entries: WhyEntry[] };
242
+ const entry = body.entries[0];
243
+ if (entry === undefined) {
244
+ ctx.ui.notify("no routed turn in this session yet", "info");
245
+ return;
246
+ }
247
+ post(pi, renderWhy(entry));
248
+ } catch (err) {
249
+ ctx.ui.notify(`router unreachable: ${err instanceof Error ? err.message : String(err)}`, "error");
250
+ }
251
+ }
252
+
253
+ async function feedback(ctx: ExtensionContext, verdict: "good" | "bad", note: string): Promise<void> {
254
+ try {
255
+ const r = await routerPost<{ slug: string; tier: string }>("/v1/router/feedback", { ompSessionId: ctx.sessionManager.getSessionId(), verdict, note });
256
+ ctx.ui.notify(`recorded ${verdict} for ${r.slug} [${r.tier}]${note !== "" ? `: ${note}` : ""}`, "info");
257
+ } catch (err) {
258
+ ctx.ui.notify(`feedback not recorded: ${err instanceof Error ? err.message : String(err)}`, "error");
259
+ }
260
+ }
261
+
262
+ async function override(ctx: ExtensionContext, verb: "pin" | "tier", text: string): Promise<void> {
263
+ const session = ctx.sessionManager.getSessionId();
264
+ let req = parseOverrideArgs(verb, text);
265
+ if (req.kind === "show" && verb === "tier" && text === "") {
266
+ // Menu path: pick a tier interactively.
267
+ const chosen = await ctx.ui.select("Force a tier for this session", [
268
+ { label: "trivial", description: "cheapest models" },
269
+ { label: "simple", description: "" },
270
+ { label: "moderate", description: "" },
271
+ { label: "hard", description: "strongest models" },
272
+ { label: "off", description: "clear any override" },
273
+ ]);
274
+ if (chosen === undefined) return;
275
+ req = parseOverrideArgs("tier", chosen);
276
+ }
277
+ if (req.kind === "error") {
278
+ ctx.ui.notify(req.message, "warn");
279
+ return;
280
+ }
281
+ try {
282
+ if (req.kind === "show") {
283
+ const res = await fetch(`${routerBaseUrl()}/v1/router/override?session=${encodeURIComponent(session)}`, { headers: routerAuthHeaders(), signal: AbortSignal.timeout(5_000) });
284
+ const body = (await res.json()) as { override: { slug: string | null; tier: string | null; turnsLeft: number } | null };
285
+ ctx.ui.notify(describeOverride(body.override), "info");
286
+ return;
287
+ }
288
+ const payload: Record<string, unknown> = { ompSessionId: session };
289
+ if (req.kind === "clear") payload.clear = true;
290
+ else if (req.kind === "pin") payload.slug = req.slug;
291
+ else {
292
+ payload.tier = req.tier;
293
+ payload.turns = req.turns;
294
+ }
295
+ const r = await routerPost<{ override: { slug: string | null; tier: string | null; turnsLeft: number } | null }>("/v1/router/override", payload);
296
+ ctx.ui.notify(describeOverride(r.override), "info");
297
+ } catch (err) {
298
+ ctx.ui.notify(`override not applied: ${err instanceof Error ? err.message : String(err)}`, "error");
299
+ }
300
+ }
301
+
176
302
  async function status(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
177
303
  try {
178
304
  post(pi, await loadStatus());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -22,6 +22,7 @@
22
22
  * price and twin; the dispatch client strips the `ollama/` prefix.
23
23
  */
24
24
 
25
+ import type { Database } from "bun:sqlite";
25
26
  import type { OllamaConfig } from "../config/types.ts";
26
27
  import type { Logger } from "../util/log.ts";
27
28
  import { normalizeModelKey } from "./benchmark-feeds.ts";
@@ -202,13 +203,35 @@ export function ollamaApiRoot(baseUrl: string): string {
202
203
  return baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
203
204
  }
204
205
 
205
- export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: FetchLike = fetch): OllamaCatalogSource {
206
+ /** The persisted Ollama model set, or [] when none has been built yet. */
207
+ export function loadOllamaCatalogCache(db: Database): { models: CatalogModel[]; fetchedAtMs: number } {
208
+ const row = db.query("SELECT payload, fetched_at_ms FROM ollama_catalog_cache WHERE id = 1").get() as
209
+ | { payload: string; fetched_at_ms: number }
210
+ | null;
211
+ if (row === null) return { models: [], fetchedAtMs: 0 };
212
+ try {
213
+ const parsed = JSON.parse(row.payload) as unknown;
214
+ return { models: Array.isArray(parsed) ? (parsed as CatalogModel[]) : [], fetchedAtMs: row.fetched_at_ms };
215
+ } catch {
216
+ return { models: [], fetchedAtMs: 0 };
217
+ }
218
+ }
219
+
220
+ export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: FetchLike = fetch, db?: Database): OllamaCatalogSource {
206
221
  const root = ollamaApiRoot(cfg.baseUrl);
207
222
  const direct = isOllamaDotCom(cfg.baseUrl);
208
223
  const headers: Record<string, string> = {};
209
224
  if (cfg.apiKey !== "") headers.authorization = `Bearer ${cfg.apiKey}`;
210
- let models: CatalogModel[] = [];
225
+ // Hydrate from disk so a restart peeks a real set before the first listing;
226
+ // listedAtMs stays 0 so the first get() still refreshes.
227
+ let models: CatalogModel[] = db === undefined ? [] : loadOllamaCatalogCache(db).models;
211
228
  let listedAtMs = 0;
229
+ const persist = db === undefined
230
+ ? null
231
+ : db.query(
232
+ `INSERT INTO ollama_catalog_cache (id, payload, fetched_at_ms) VALUES (1, ?, ?)
233
+ ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, fetched_at_ms = excluded.fetched_at_ms`,
234
+ );
212
235
  let inflight: Promise<CatalogModel[]> | null = null;
213
236
  // `/api/show` results are stable per id; fetched once per process.
214
237
  const shown = new Map<string, { contextLength: number | null; capabilities: string[] }>();
@@ -257,6 +280,11 @@ export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: F
257
280
  log.warn("ollama listing yielded no priced cloud models; keeping the previous set", { listed: listings.length });
258
281
  } else {
259
282
  models = built;
283
+ try {
284
+ persist?.run(JSON.stringify(models), Date.now());
285
+ } catch (err) {
286
+ log.debug("ollama catalog persist failed", { error: err instanceof Error ? err.message : String(err) });
287
+ }
260
288
  }
261
289
  listedAtMs = Date.now();
262
290
  } catch (err) {
@@ -190,7 +190,9 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
190
190
  { path: "filters.latencyReferenceMs", label: "Latency reference TTFT", kind: "number", min: 1, hint: "ms" },
191
191
  { path: "filters.latencyReferenceTokensPerSec", label: "Latency reference speed", kind: "number", min: 1, hint: "tok/s" },
192
192
  { path: "filters.latencyMinSamples", label: "Latency min samples", kind: "number", min: 0 },
193
+ { path: "filters.cacheReliabilityMinSamples", label: "Cache reliability min samples", kind: "number", min: 0, hint: "0=assume caches reliable" },
193
194
  { path: "filters.maxExpectedWaitMs", label: "Max expected wait", kind: "number", min: 1, optional: true, hint: "ms, hard ceiling" },
195
+ { path: "filters.latencyWeightContinuation", label: "Latency weight on tool continuations", kind: "number", min: 0, optional: true, hint: "unset = latencyWeight" },
194
196
  { path: "filters.escalationCostWeight", label: "Escalation cost weight", kind: "number", min: 0, max: 1 },
195
197
  ],
196
198
  },
@@ -199,6 +201,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
199
201
  fields: [
200
202
  { path: "classifier.ambiguityThreshold", label: "Ambiguity threshold", kind: "number", min: 0, max: 1 },
201
203
  { path: "classifier.model", label: "Adjudicator model", kind: "string", optional: true },
204
+ { path: "classifier.learnedModelPath", label: "Learned risk model file", kind: "string", optional: true, hint: "from tools/train-classifier.ts; advisory" },
202
205
  { path: "classifier.maxCostFraction", label: "Max cost fraction", kind: "number", min: 0, max: 1 },
203
206
  { path: "classifier.maxCostUsd", label: "Max adjudication cost $", kind: "number", min: 0 },
204
207
  { path: "classifier.timeoutMs", label: "Adjudicator timeout", kind: "number", min: 1, hint: "ms" },
@@ -300,9 +303,14 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
300
303
  { path: "budget.perTurnUsd", label: "Per-turn cap $", kind: "number", min: 0, optional: true },
301
304
  { path: "budget.perConversationUsd", label: "Per-conversation cap $", kind: "number", min: 0, optional: true },
302
305
  { path: "budget.perDayUsd", label: "Per-day cap $", kind: "number", min: 0, optional: true },
306
+ { path: "budget.perMonthUsd", label: "Per-month target $", kind: "number", min: 0, optional: true, hint: "paced into a daily cap" },
303
307
  { path: "budget.onExceeded", label: "On exceeded", kind: "enum", options: ["downgrade", "reject"] },
304
308
  ],
305
309
  },
310
+ {
311
+ title: "Report",
312
+ fields: [{ path: "report.baselines", label: "Counterfactual baseline models", kind: "stringArray", hint: "comma-separated slugs" }],
313
+ },
306
314
  {
307
315
  title: "Ledger",
308
316
  fields: [
package/src/cli/report.ts CHANGED
@@ -7,7 +7,8 @@
7
7
 
8
8
  import { existsSync } from "node:fs";
9
9
  import { loadConfig } from "../config/load.ts";
10
- import { buildUsageReport, renderUsageReport } from "../cost/report.ts";
10
+ import { createCatalog } from "../catalog/openrouter-catalog.ts";
11
+ import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
11
12
  import { openDb } from "../util/sqlite.ts";
12
13
  import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
13
14
 
@@ -28,7 +29,11 @@ export async function reportCommand(args: CliArgs): Promise<void> {
28
29
 
29
30
  const db = openDb(cfg.ledger.path);
30
31
  try {
31
- const report = buildUsageReport(db, { windowDays: days, harnessId });
32
+ // Baseline prices from the cached catalog: no network for a report.
33
+ const dead = { dispatch: () => Promise.reject(new Error("offline")), complete: () => Promise.reject(new Error("offline")), fetchModels: () => Promise.reject(new Error("offline")), fetchModelsForUser: () => Promise.reject(new Error("offline")) };
34
+ const snapshot = createCatalog(cfg, dead, db).peek();
35
+ const baselines = baselinePrices(cfg.report.baselines, (s) => snapshot?.models.find((m) => m.slug === s));
36
+ const report = buildUsageReport(db, { windowDays: days, harnessId, baselines });
32
37
  if (args.flags.has("json")) console.log(JSON.stringify(report, null, 2));
33
38
  else console.log(renderUsageReport(report));
34
39
  } finally {
@@ -118,6 +118,9 @@ export const DEFAULT_CONFIG: RouterConfig = {
118
118
  // slow models (e.g. deepseek-v4-flash ~20 tok/s) fall under it.
119
119
  latencyReferenceTokensPerSec: 30,
120
120
  latencyMinSamples: 20,
121
+ // Discount a warm model's stay price by its observed hit rate once this
122
+ // many warm-expected samples exist. See FilterConfig.cacheReliabilityMinSamples.
123
+ cacheReliabilityMinSamples: 10,
121
124
  // Off: pricing a model's measured escalation rate at what an escalated
122
125
  // retry actually costs changes rankings, so it is opt-in after a replay
123
126
  // run prices it. See FilterConfig.escalationCostWeight.
@@ -127,6 +130,8 @@ export const DEFAULT_CONFIG: RouterConfig = {
127
130
  ambiguityThreshold: 0.6,
128
131
  // Cheapest competent slug in the catalog; adjudication prompts are tiny.
129
132
  model: "qwen/qwen3.7-flash",
133
+ // Off until a model is trained; see tools/train-classifier.ts.
134
+ learnedModelPath: "",
130
135
  maxCostFraction: 0.02,
131
136
  maxCostUsd: 0.002,
132
137
  timeoutMs: 4_000,
@@ -281,6 +286,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
281
286
  elideSupersededReads: true,
282
287
  collapseDuplicateResults: true,
283
288
  },
289
+ report: {
290
+ // The frontier pair most omp users would otherwise run on.
291
+ baselines: ["anthropic/claude-opus-5", "anthropic/claude-sonnet-5"],
292
+ },
284
293
  budget: {
285
294
  // No caps by default; at a configured ceiling, downgrade rather than fail.
286
295
  onExceeded: "downgrade",
@@ -96,13 +96,16 @@ const filters = z.strictObject({
96
96
  latencyReferenceMs: z.number().positive().optional(),
97
97
  latencyReferenceTokensPerSec: z.number().positive().optional(),
98
98
  latencyMinSamples: z.number().int().nonnegative().optional(),
99
+ cacheReliabilityMinSamples: z.number().int().nonnegative().optional(),
99
100
  maxExpectedWaitMs: z.number().positive().optional(),
101
+ latencyWeightContinuation: z.number().nonnegative().optional(),
100
102
  escalationCostWeight: z.number().min(0).max(1).optional(),
101
103
  });
102
104
 
103
105
  const classifier = z.strictObject({
104
106
  ambiguityThreshold: z.number().min(0).max(1).optional(),
105
107
  model: z.string().min(1).optional(),
108
+ learnedModelPath: z.string().optional(),
106
109
  maxCostFraction: z.number().min(0).max(1).optional(),
107
110
  maxCostUsd: z.number().nonnegative().optional(),
108
111
  timeoutMs: z.number().positive().optional(),
@@ -205,6 +208,7 @@ const budget = z.strictObject({
205
208
  perTurnUsd: z.number().nonnegative().optional(),
206
209
  perConversationUsd: z.number().nonnegative().optional(),
207
210
  perDayUsd: z.number().nonnegative().optional(),
211
+ perMonthUsd: z.number().nonnegative().optional(),
208
212
  onExceeded: z.enum(["downgrade", "reject"]).optional(),
209
213
  });
210
214
 
@@ -265,6 +269,7 @@ export const configInputSchema = z.strictObject({
265
269
  compaction: compaction.optional(),
266
270
  budget: budget.optional(),
267
271
  profiles: z.array(profile).optional(),
272
+ report: z.strictObject({ baselines: z.array(z.string()).optional() }).optional(),
268
273
  ledger: ledger.optional(),
269
274
  adaptiveTierFloors: z.boolean().optional(),
270
275
  adaptivePriceCeilings: z.boolean().optional(),
@@ -264,6 +264,14 @@ export interface FilterConfig {
264
264
  latencyReferenceTokensPerSec: number;
265
265
  /** Streamed samples required before latency is scored against a model. */
266
266
  latencyMinSamples: number;
267
+ /**
268
+ * Warm-expected samples a model needs before its observed cache hit rate
269
+ * (ledger `cacheReliability`) discounts the "stay warm" price in the
270
+ * stay/switch comparison. Below it, and when 0, a cache is assumed fully
271
+ * reliable. Measured 2026-09-06: same-model short-gap turns still ran cold
272
+ * 5-6% on glm/gemini, 11% on ling and 50% on nex.
273
+ */
274
+ cacheReliabilityMinSamples: number;
267
275
  /**
268
276
  * Absolute expected-wait ceiling (ms). A hard drop, mirroring the price
269
277
  * ceiling: any model whose expected total wait (TTFT + streaming the expected
@@ -277,6 +285,13 @@ export interface FilterConfig {
277
285
  * Undefined ⇒ off (the default).
278
286
  */
279
287
  maxExpectedWaitMs?: number;
288
+ /**
289
+ * Latency weight for tool-result continuations (the agent loop's own
290
+ * follow-ups, where no person is waiting on first token). Unset ⇒
291
+ * `latencyWeight` applies to every turn. Lower it to spend speed only on
292
+ * user-facing turns.
293
+ */
294
+ latencyWeightContinuation?: number;
280
295
  /**
281
296
  * How much of a model's measured escalation risk to price into its effective
282
297
  * cost, 0-1. 0 (the default) disables the term.
@@ -305,6 +320,13 @@ export interface ClassifierConfig {
305
320
  ambiguityThreshold: number;
306
321
  /** Slug used for adjudication. Must be cheap and fast. */
307
322
  model: string;
323
+ /**
324
+ * Path of a model written by `tools/train-classifier.ts`. When set, every
325
+ * heuristic classification also carries the learned P(escalate) in its
326
+ * reasons (`learned: p(escalate)=…`) and `Classification.learnedRisk`.
327
+ * Advisory: it never moves a tier. Empty ⇒ off.
328
+ */
329
+ learnedModelPath: string;
308
330
  /** Skip adjudication when it would exceed this fraction of the forecast turn cost. */
309
331
  maxCostFraction: number;
310
332
  /** Absolute per-call ceiling, USD. */
@@ -508,6 +530,17 @@ export interface CacheConfig {
508
530
  milestoneTokens: number;
509
531
  }
510
532
 
533
+ /** Usage-report options. */
534
+ export interface ReportConfig {
535
+ /**
536
+ * Models to price the window's traffic on as if every turn had used that
537
+ * one model, at its list price with the window's own cache hit rate: the
538
+ * "what the router saved" counterfactual. Slugs missing from the catalog
539
+ * are skipped.
540
+ */
541
+ baselines: string[];
542
+ }
543
+
511
544
  export interface BudgetConfig {
512
545
  /** Reject or downgrade when a turn's cold forecast exceeds this, USD. */
513
546
  perTurnUsd?: number;
@@ -515,6 +548,13 @@ export interface BudgetConfig {
515
548
  perConversationUsd?: number;
516
549
  /** Rolling 24h ceiling, USD. */
517
550
  perDayUsd?: number;
551
+ /**
552
+ * Calendar-month (UTC) target, USD. Paced: the per-day ceiling becomes
553
+ * min(perDayUsd, remaining ÷ days left in the month), so a month that runs
554
+ * ahead of pace tightens automatically instead of failing on its last day.
555
+ * Scoped per harness like perDayUsd.
556
+ */
557
+ perMonthUsd?: number;
518
558
  /** At the ceiling: drop to the cheapest viable model, or fail the request outright. */
519
559
  onExceeded: "downgrade" | "reject";
520
560
  }
@@ -676,6 +716,7 @@ export interface RouterConfig {
676
716
  context: ContextConfig;
677
717
  compaction: CompactionConfig;
678
718
  budget: BudgetConfig;
719
+ report: ReportConfig;
679
720
  profiles: ProfileConfig[];
680
721
  ledger: LedgerConfig;
681
722
  /**