auto-model-router 0.2.32 → 0.3.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 (67) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +208 -29
  3. package/docs/review-2026-09-05.md +267 -0
  4. package/omp-extension/configure-logic.ts +71 -15
  5. package/omp-extension/pi-coding-agent.d.ts +79 -2
  6. package/omp-extension/report-hub.ts +376 -0
  7. package/omp-extension/report-logic.ts +115 -0
  8. package/omp-extension/router-configure.ts +203 -51
  9. package/omp-extension/router-url.ts +52 -0
  10. package/omp-extension/toast-logic.ts +14 -2
  11. package/package.json +1 -1
  12. package/src/catalog/composite.ts +97 -0
  13. package/src/catalog/ollama-catalog.ts +309 -0
  14. package/src/catalog/ollama-prices.ts +85 -0
  15. package/src/catalog/openrouter-catalog.ts +39 -1
  16. package/src/catalog/types.ts +31 -1
  17. package/src/cli/args.ts +1 -0
  18. package/src/cli/config-wizard.ts +189 -28
  19. package/src/cli/explain.ts +2 -4
  20. package/src/cli/models.ts +2 -4
  21. package/src/cli/report.ts +37 -0
  22. package/src/config/defaults.ts +43 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +27 -0
  26. package/src/config/types.ts +114 -2
  27. package/src/cost/ledger.ts +73 -4
  28. package/src/cost/report.ts +340 -0
  29. package/src/cost/types.ts +33 -1
  30. package/src/index.ts +5 -8
  31. package/src/router/candidates.ts +52 -4
  32. package/src/router/classify.ts +33 -6
  33. package/src/router/features.ts +13 -1
  34. package/src/router/select.ts +55 -8
  35. package/src/router/state.ts +6 -2
  36. package/src/router/tier-plan.ts +49 -11
  37. package/src/router/types.ts +10 -0
  38. package/src/server/http.ts +47 -6
  39. package/src/server/providers.ts +54 -0
  40. package/src/server/turn.ts +122 -34
  41. package/src/tokens/estimate.ts +16 -0
  42. package/src/upstream/multi.ts +26 -0
  43. package/src/upstream/ollama-usage.ts +157 -0
  44. package/src/upstream/ollama.ts +275 -0
  45. package/src/upstream/openrouter.ts +19 -1
  46. package/src/upstream/types.ts +2 -0
  47. package/src/util/sqlite.ts +25 -1
  48. package/test/catalog.test.ts +44 -0
  49. package/test/classify.test.ts +41 -5
  50. package/test/compaction.test.ts +1 -0
  51. package/test/config-wizard.test.ts +77 -1
  52. package/test/configure-logic.test.ts +129 -33
  53. package/test/embed-lifecycle.test.ts +1 -0
  54. package/test/failover.test.ts +148 -3
  55. package/test/features.test.ts +35 -0
  56. package/test/http-resilience.test.ts +24 -0
  57. package/test/ollama.test.ts +506 -0
  58. package/test/omp-credentials.test.ts +43 -1
  59. package/test/report-hub.test.ts +341 -0
  60. package/test/report-logic.test.ts +92 -0
  61. package/test/report.test.ts +217 -0
  62. package/test/select.test.ts +151 -1
  63. package/test/tier-plan.test.ts +159 -1
  64. package/test/toast-logic.test.ts +11 -2
  65. package/test/tokens.test.ts +71 -1
  66. package/test/trust-attribution.test.ts +2 -2
  67. package/test/turn.test.ts +124 -7
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Usage analytics over the ledger, for `/router report` in omp and the
3
+ * `auto-model-router report` CLI (and `GET /v1/router/report`).
4
+ *
5
+ * Everything here is SQL over the `ledger` table the router already writes;
6
+ * nothing is sampled or estimated beyond what the rows carry. Costs are the
7
+ * ledger's own rule: reported when the provider gave one, else the
8
+ * usage-priced figure the orchestrator computed, else the forecast.
9
+ */
10
+
11
+ import type { Database } from "bun:sqlite";
12
+
13
+ export interface ReportTotals {
14
+ dispatches: number;
15
+ conversations: number;
16
+ spendUsd: number;
17
+ /** Cached prompt tokens over all prompt tokens, 0-1. */
18
+ cacheHitRate: number;
19
+ promptTokens: number;
20
+ completionTokens: number;
21
+ escalations: number;
22
+ failovers: number;
23
+ errors: number;
24
+ aborted: number;
25
+ /** Turns that switched model mid-conversation. */
26
+ modelSwitches: number;
27
+ }
28
+
29
+ export interface ReportRow {
30
+ key: string;
31
+ dispatches: number;
32
+ spendUsd: number;
33
+ /** Share of window spend, 0-1. */
34
+ share: number;
35
+ cacheHitRate: number;
36
+ avgPromptTokens: number;
37
+ /** Mean time to first token, ms, over streamed non-error rows; null without samples. */
38
+ avgTtftMs: number | null;
39
+ /** Completion tokens per second after first token; null without samples. */
40
+ tokensPerSec: number | null;
41
+ escalations: number;
42
+ errors: number;
43
+ }
44
+
45
+ export interface ModelRow extends ReportRow {
46
+ provider: string;
47
+ /** Dispatch counts per tier, e.g. `{ trivial: 12, moderate: 3 }`. */
48
+ tiers: Record<string, number>;
49
+ }
50
+
51
+ export interface DayRow {
52
+ /** UTC calendar day, `YYYY-MM-DD`. */
53
+ day: string;
54
+ dispatches: number;
55
+ spendUsd: number;
56
+ cacheHitRate: number;
57
+ }
58
+
59
+ export interface UsageReport {
60
+ generatedAtMs: number;
61
+ windowDays: number;
62
+ sinceMs: number;
63
+ /** Restricted to one harness when given; empty ⇒ all. */
64
+ harnessId: string;
65
+ totals: ReportTotals;
66
+ providers: ReportRow[];
67
+ models: ModelRow[];
68
+ tiers: ReportRow[];
69
+ days: DayRow[];
70
+ }
71
+
72
+ const USD = "COALESCE(reported_usd, predicted_usd)";
73
+ const PT = "json_extract(usage, '$.promptTokens')";
74
+ const CT = "json_extract(usage, '$.cachedTokens')";
75
+ const COMP = "json_extract(usage, '$.completionTokens')";
76
+ const PROVIDER = "CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ELSE 'openrouter' END";
77
+ const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
78
+
79
+ const ROW_SELECT = `
80
+ COUNT(*) AS dispatches,
81
+ COALESCE(SUM(${USD}), 0) AS spend,
82
+ COALESCE(SUM(${PT}), 0) AS prompt_tokens,
83
+ COALESCE(SUM(${CT}), 0) AS cached_tokens,
84
+ COALESCE(AVG(${PT}), 0) AS avg_prompt_tokens,
85
+ AVG(CASE WHEN ${STREAMED} THEN ttft_ms END) AS ttft_ms,
86
+ SUM(CASE WHEN ${STREAMED} AND latency_ms > ttft_ms AND ${COMP} > 0 THEN ${COMP} END) AS ctok_sum,
87
+ SUM(CASE WHEN ${STREAMED} AND latency_ms > ttft_ms AND ${COMP} > 0 THEN latency_ms - ttft_ms END) AS elapsed_ms,
88
+ SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
89
+ SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors`;
90
+
91
+ interface RawRow {
92
+ key: string;
93
+ dispatches: number;
94
+ spend: number;
95
+ prompt_tokens: number;
96
+ cached_tokens: number;
97
+ avg_prompt_tokens: number;
98
+ ttft_ms: number | null;
99
+ ctok_sum: number | null;
100
+ elapsed_ms: number | null;
101
+ escalations: number;
102
+ errors: number;
103
+ }
104
+
105
+ function toRow(r: RawRow, windowSpend: number): ReportRow {
106
+ return {
107
+ key: r.key,
108
+ dispatches: r.dispatches,
109
+ spendUsd: r.spend,
110
+ share: windowSpend > 0 ? r.spend / windowSpend : 0,
111
+ cacheHitRate: r.prompt_tokens > 0 ? r.cached_tokens / r.prompt_tokens : 0,
112
+ avgPromptTokens: Math.round(r.avg_prompt_tokens),
113
+ avgTtftMs: r.ttft_ms === null ? null : Math.round(r.ttft_ms),
114
+ tokensPerSec: r.elapsed_ms !== null && r.elapsed_ms > 0 && r.ctok_sum !== null ? (r.ctok_sum * 1000) / r.elapsed_ms : null,
115
+ escalations: r.escalations,
116
+ errors: r.errors,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Builds the report for the last `windowDays`. `harnessId` narrows to one
122
+ * harness (the `X-Omp-Harness` header); empty means everything.
123
+ */
124
+ export function buildUsageReport(db: Database, opts: { windowDays: number; harnessId?: string; nowMs?: number }): UsageReport {
125
+ const nowMs = opts.nowMs ?? Date.now();
126
+ const windowDays = Math.max(1, opts.windowDays);
127
+ const sinceMs = nowMs - windowDays * 86_400_000;
128
+ const harnessId = opts.harnessId ?? "";
129
+ const where = harnessId === "" ? "created_at_ms >= $since" : "created_at_ms >= $since AND harness_id = $harness";
130
+ const bind = harnessId === "" ? { $since: sinceMs } : { $since: sinceMs, $harness: harnessId };
131
+
132
+ const t = db
133
+ .query(
134
+ `SELECT COUNT(*) AS dispatches,
135
+ COUNT(DISTINCT conversation_key) AS conversations,
136
+ COALESCE(SUM(${USD}), 0) AS spend,
137
+ COALESCE(SUM(${PT}), 0) AS prompt_tokens,
138
+ COALESCE(SUM(${CT}), 0) AS cached_tokens,
139
+ COALESCE(SUM(${COMP}), 0) AS completion_tokens,
140
+ SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
141
+ SUM(CASE WHEN instr(reasons, 'failover:') > 0 THEN 1 ELSE 0 END) AS failovers,
142
+ SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
143
+ SUM(CASE WHEN error = 'request aborted' THEN 1 ELSE 0 END) AS aborted
144
+ FROM ledger WHERE ${where}`,
145
+ )
146
+ .get(bind) as {
147
+ dispatches: number;
148
+ conversations: number;
149
+ spend: number;
150
+ prompt_tokens: number;
151
+ cached_tokens: number;
152
+ completion_tokens: number;
153
+ escalations: number | null;
154
+ failovers: number | null;
155
+ errors: number | null;
156
+ aborted: number | null;
157
+ };
158
+
159
+ // Model switches: consecutive non-wasted rows of one conversation on
160
+ // different slugs. Computed in JS over a slim projection; the window is
161
+ // bounded, and SQLite window functions would make the query less portable.
162
+ const seq = db
163
+ .query(`SELECT conversation_key AS ck, slug FROM ledger WHERE ${where} AND wasted = 0 ORDER BY conversation_key, created_at_ms`)
164
+ .all(bind) as { ck: string; slug: string }[];
165
+ let switches = 0;
166
+ for (let i = 1; i < seq.length; i++) {
167
+ const a = seq[i - 1]!;
168
+ const b = seq[i]!;
169
+ if (a.ck === b.ck && a.slug !== b.slug) switches++;
170
+ }
171
+
172
+ const windowSpend = t.spend;
173
+ const providers = (
174
+ db.query(`SELECT ${PROVIDER} AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
175
+ ).map((r) => toRow(r, windowSpend));
176
+
177
+ const modelRows = db
178
+ .query(`SELECT COALESCE(served_slug, slug) AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`)
179
+ .all(bind) as RawRow[];
180
+ const tierMix = db
181
+ .query(`SELECT COALESCE(served_slug, slug) AS key, tier, COUNT(*) AS n FROM ledger WHERE ${where} GROUP BY key, tier`)
182
+ .all(bind) as { key: string; tier: string; n: number }[];
183
+ const mixByModel = new Map<string, Record<string, number>>();
184
+ for (const m of tierMix) {
185
+ const rec = mixByModel.get(m.key) ?? {};
186
+ rec[m.tier] = m.n;
187
+ mixByModel.set(m.key, rec);
188
+ }
189
+ const models: ModelRow[] = modelRows.map((r) => ({
190
+ ...toRow(r, windowSpend),
191
+ provider: r.key.startsWith("ollama/") ? "ollama" : "openrouter",
192
+ tiers: mixByModel.get(r.key) ?? {},
193
+ }));
194
+
195
+ const tiers = (
196
+ db.query(`SELECT tier AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
197
+ ).map((r) => toRow(r, windowSpend));
198
+
199
+ const days = (
200
+ db
201
+ .query(
202
+ `SELECT date(created_at_ms / 1000, 'unixepoch') AS day, COUNT(*) AS dispatches, COALESCE(SUM(${USD}), 0) AS spend,
203
+ COALESCE(SUM(${PT}), 0) AS prompt_tokens, COALESCE(SUM(${CT}), 0) AS cached_tokens
204
+ FROM ledger WHERE ${where} GROUP BY day ORDER BY day`,
205
+ )
206
+ .all(bind) as { day: string; dispatches: number; spend: number; prompt_tokens: number; cached_tokens: number }[]
207
+ ).map((d) => ({
208
+ day: d.day,
209
+ dispatches: d.dispatches,
210
+ spendUsd: d.spend,
211
+ cacheHitRate: d.prompt_tokens > 0 ? d.cached_tokens / d.prompt_tokens : 0,
212
+ }));
213
+
214
+ return {
215
+ generatedAtMs: nowMs,
216
+ windowDays,
217
+ sinceMs,
218
+ harnessId,
219
+ totals: {
220
+ dispatches: t.dispatches,
221
+ conversations: t.conversations,
222
+ spendUsd: t.spend,
223
+ cacheHitRate: t.prompt_tokens > 0 ? t.cached_tokens / t.prompt_tokens : 0,
224
+ promptTokens: t.prompt_tokens,
225
+ completionTokens: t.completion_tokens,
226
+ escalations: t.escalations ?? 0,
227
+ failovers: t.failovers ?? 0,
228
+ errors: t.errors ?? 0,
229
+ aborted: t.aborted ?? 0,
230
+ modelSwitches: switches,
231
+ },
232
+ providers,
233
+ models,
234
+ tiers,
235
+ days,
236
+ };
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Rendering (shared by the omp command and the CLI)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ const usd = (v: number): string => (v >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(4)}`);
244
+ const pct = (v: number): string => `${(v * 100).toFixed(0)}%`;
245
+ const num = (v: number): string => v.toLocaleString("en-US");
246
+ const ms = (v: number | null): string => (v === null ? "–" : v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`);
247
+ const tps = (v: number | null): string => (v === null ? "–" : `${v.toFixed(0)} tok/s`);
248
+
249
+ /** One aligned table of a report: header row, then data rows. */
250
+ export interface ReportTable {
251
+ /** Stable id: `providers` | `models` | `tiers` | `days`. */
252
+ id: string;
253
+ title: string;
254
+ headers: string[];
255
+ rows: string[][];
256
+ }
257
+
258
+ /** Everything a renderer needs, already formatted: summary lines and tables. */
259
+ export interface ReportView {
260
+ /** `last 7d · harness omp · 2026-09-06 14:18Z`. */
261
+ heading: string;
262
+ summary: string[];
263
+ tables: ReportTable[];
264
+ }
265
+
266
+ /**
267
+ * Column-aligns a table into fixed-width lines: header, rule, rows. The first
268
+ * column is left-aligned, the rest right-aligned. Cells are plain text, so
269
+ * the caller can style whole lines without breaking the alignment.
270
+ */
271
+ export function formatTable(headers: string[], rows: string[][]): string[] {
272
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
273
+ const line = (cells: string[]): string => cells.map((c, i) => (i === 0 ? c.padEnd(widths[i]!) : c.padStart(widths[i]!))).join(" ");
274
+ return [line(headers), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)];
275
+ }
276
+
277
+ /** Formats a report into summary lines and tables, shared by every renderer. */
278
+ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): ReportView {
279
+ const maxModels = opts.maxModels ?? 12;
280
+ const t = r.totals;
281
+ const heading = `last ${r.windowDays}d${r.harnessId === "" ? "" : ` · harness ${r.harnessId}`} · ${new Date(r.generatedAtMs).toISOString().slice(0, 16).replace("T", " ")}Z`;
282
+ const summary = [
283
+ `spend ${usd(t.spendUsd)} over ${num(t.dispatches)} dispatches in ${num(t.conversations)} conversations · ${usd(t.dispatches > 0 ? t.spendUsd / t.dispatches : 0)}/dispatch`,
284
+ `prompt ${num(t.promptTokens)} tok (cache hit ${pct(t.cacheHitRate)}) · completion ${num(t.completionTokens)} tok · switches ${num(t.modelSwitches)} · escalations ${num(t.escalations)} · failovers ${num(t.failovers)} · errors ${num(t.errors)} (${num(t.aborted)} aborted)`,
285
+ ];
286
+ const tables: ReportTable[] = [];
287
+ if (r.providers.length > 0) {
288
+ tables.push({
289
+ id: "providers",
290
+ title: "providers",
291
+ headers: ["provider", "dispatches", "spend", "share", "cache", "ttft", "speed", "esc", "err"],
292
+ rows: r.providers.map((p) => [p.key, num(p.dispatches), usd(p.spendUsd), pct(p.share), pct(p.cacheHitRate), ms(p.avgTtftMs), tps(p.tokensPerSec), num(p.escalations), num(p.errors)]),
293
+ });
294
+ }
295
+ if (r.models.length > 0) {
296
+ tables.push({
297
+ id: "models",
298
+ title: `models (top ${Math.min(maxModels, r.models.length)} of ${r.models.length} by spend)`,
299
+ headers: ["model", "dispatches", "spend", "share", "cache", "ttft", "speed", "tiers"],
300
+ rows: r.models.slice(0, maxModels).map((m) => [
301
+ m.key,
302
+ num(m.dispatches),
303
+ usd(m.spendUsd),
304
+ pct(m.share),
305
+ pct(m.cacheHitRate),
306
+ ms(m.avgTtftMs),
307
+ tps(m.tokensPerSec),
308
+ Object.entries(m.tiers)
309
+ .sort((a, b) => b[1] - a[1])
310
+ .map(([k, v]) => `${k}:${v}`)
311
+ .join(" "),
312
+ ]),
313
+ });
314
+ }
315
+ if (r.tiers.length > 0) {
316
+ tables.push({
317
+ id: "tiers",
318
+ title: "tiers",
319
+ headers: ["tier", "dispatches", "spend", "share", "cache", "avg prompt", "esc"],
320
+ rows: r.tiers.map((x) => [x.key, num(x.dispatches), usd(x.spendUsd), pct(x.share), pct(x.cacheHitRate), num(x.avgPromptTokens), num(x.escalations)]),
321
+ });
322
+ }
323
+ if (r.days.length > 1) {
324
+ tables.push({
325
+ id: "days",
326
+ title: "by day (UTC)",
327
+ headers: ["day", "dispatches", "spend", "cache"],
328
+ rows: r.days.map((d) => [d.day, num(d.dispatches), usd(d.spendUsd), pct(d.cacheHitRate)]),
329
+ });
330
+ }
331
+ return { heading, summary, tables };
332
+ }
333
+
334
+ /** Plain-text rendering: fixed-width tables, no markup, fits a TUI panel. */
335
+ export function renderUsageReport(r: UsageReport, opts: { maxModels?: number } = {}): string {
336
+ const v = reportView(r, opts);
337
+ const out: string[] = [`auto-model-router · ${v.heading}`, "", ...v.summary];
338
+ for (const t of v.tables) out.push("", t.title, ...formatTable(t.headers, t.rows));
339
+ return out.join("\n");
340
+ }
package/src/cost/types.ts CHANGED
@@ -5,9 +5,14 @@
5
5
  * - **predicted**: our arithmetic over the catalog, computed *before* dispatch.
6
6
  * Drives routing and budget enforcement.
7
7
  * - **reported**: `usage.cost` returned by OpenRouter, authoritative after the
8
- * fact. Drives the ledger, `stats`, and prediction-error calibration.
8
+ * fact. Drives the ledger, `stats`, and prediction-error calibration. A
9
+ * provider that returns usage but no cost (Ollama) has its ACTUAL tokens
10
+ * priced at the catalog rate and recorded here — still after the fact, and
11
+ * still not the forecast.
9
12
  */
10
13
 
14
+ import type { CatalogModel } from "../catalog/types.ts";
15
+
11
16
  /** Token counts for one upstream generation. */
12
17
  export interface UsageCounts {
13
18
  /** Total prompt tokens, *including* `cachedTokens` (OpenAI/OpenRouter convention). */
@@ -135,6 +140,12 @@ export interface LedgerEntry {
135
140
  error: string | null;
136
141
  /** Prompt tokens removed by compaction before dispatch. 0 when none. NULL before v12. */
137
142
  promptTokensSaved: number;
143
+ /**
144
+ * The catalog model that served, for the cost split. The ledger can price
145
+ * OpenRouter slugs from its own cached catalog payload; a model from another
146
+ * provider (Ollama) exists only in memory, so the orchestrator hands it over.
147
+ */
148
+ priceModel?: CatalogModel;
138
149
  }
139
150
 
140
151
  /** Rolling blended rate used to keep omp's cost display honest. */
@@ -183,6 +194,18 @@ export interface ModelLatency {
183
194
  tokensPerSec: number;
184
195
  }
185
196
 
197
+ export interface LedgerSignals {
198
+ trust: ModelTrust | null;
199
+ latency: ModelLatency | null;
200
+ }
201
+
202
+ /** Measured price of a probe escalation: what the retry billed per prompt token of the failed turn. */
203
+ export interface EscalationCost {
204
+ usdPerPromptToken: number;
205
+ samples: number;
206
+ windowDays: number;
207
+ }
208
+
186
209
  export interface Ledger {
187
210
  record(entry: LedgerEntry): void;
188
211
  /** Total reported (or predicted, when reported is null) spend for a conversation. */
@@ -203,6 +226,15 @@ export interface Ledger {
203
226
  * how fast the body streams once it starts.
204
227
  */
205
228
  latency(slug: string, harnessId?: string): ModelLatency | null;
229
+ /** Batch trust and latency for one candidate set; one query per signal kind. Optional — callers can fall back to per-slug calls. */
230
+ signals?(slugs: readonly string[], harnessId?: string): Map<string, LedgerSignals>;
231
+ /**
232
+ * What an escalated retry actually bills per prompt token, measured over
233
+ * the last `windowDays` of attempt > 0 rows. Null until enough escalated
234
+ * attempts exist to measure. Optional so fakes need not implement it; the
235
+ * escalation-cost term in candidate scoring is inert without it.
236
+ */
237
+ escalationCost?(windowDays: number): EscalationCost | null;
206
238
  /** Observed chars-per-token ratio for a tokenizer family; null until calibrated. */
207
239
  tokenRatio(tokenizer: string): number | null;
208
240
  recentEntries(limit: number): LedgerEntry[];
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { parseArgv } from "./cli/args.ts";
12
12
  import { configCommand } from "./cli/config-cmd.ts";
13
13
  import { explainCommand } from "./cli/explain.ts";
14
14
  import { modelsCommand } from "./cli/models.ts";
15
+ import { reportCommand } from "./cli/report.ts";
15
16
  import { serveCommand } from "./cli/serve.ts";
16
17
  import { statsCommand } from "./cli/stats.ts";
17
18
 
@@ -21,6 +22,7 @@ Usage: auto-model-router <command> [options]
21
22
 
22
23
  serve Run the router as a standalone process (for non-omp harnesses)
23
24
  stats Show routed spend, per-model share, and escalation rates
25
+ report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
24
26
  models Show what each complexity tier would consider, and why
25
27
  explain Route a saved request without dispatching it, and explain the decision
26
28
  config Interactive wizard over the router's own config.yml
@@ -33,6 +35,7 @@ Global options:
33
35
 
34
36
  serve --port <n> --host <addr> --log <level>
35
37
  stats --days <n> --json
38
+ report --days <n> --harness <id> --json
36
39
  models --tier <trivial|simple|moderate|hard> --limit <n> --json
37
40
  explain --file <request.json> --json (reads stdin when --file is absent)
38
41
  config --print --write --path <models.yml> --config <router-config.yml>
@@ -67,14 +70,8 @@ async function main(): Promise<number> {
67
70
  case "stats":
68
71
  await statsCommand(args);
69
72
  return 0;
70
- case "models":
71
-
72
- case "serve":
73
- // Resolves once listening; the server itself keeps the loop alive.
74
- await serveCommand(args);
75
- return 0;
76
- case "stats":
77
- await statsCommand(args);
73
+ case "report":
74
+ await reportCommand(args);
78
75
  return 0;
79
76
  case "models":
80
77
  await modelsCommand(args);
@@ -7,12 +7,20 @@
7
7
  import type { CatalogModel, CatalogSnapshot } from "../catalog/types.ts";
8
8
  import type { FilterConfig, QualityAxis, RouterConfig } from "../config/types.ts";
9
9
  import { forecast, priceAt } from "../cost/forecast.ts";
10
- import type { Ledger, ModelLatency } from "../cost/types.ts";
10
+ import type { Ledger, LedgerSignals, ModelLatency } from "../cost/types.ts";
11
11
  import type { NormRequest } from "../wire/types.ts";
12
12
  import { effectivePriceCeiling, effectiveQualityFloor, tierPlanFor } from "./tier-plan.ts";
13
13
  import type { Candidate, Features, Rejection, TaskType, Tier } from "./types.ts";
14
14
 
15
15
  export interface BuildCandidatesArgs {
16
+ /** Pre-fetched trust/latency signals for all candidate slugs. When provided, buildCandidates uses these instead of per-slug ledger calls. */
17
+ signals?: Map<string, LedgerSignals>;
18
+ /**
19
+ * Measured cost of an escalated retry per prompt token (from
20
+ * `Ledger.escalationCost`). With `filters.escalationCostWeight` > 0 a
21
+ * model's measured escalation rate is priced at this; absent ⇒ term inert.
22
+ */
23
+ escalationUsdPerPromptToken?: number;
16
24
  req: NormRequest;
17
25
  features: Features;
18
26
  tier: Tier;
@@ -68,6 +76,16 @@ function resolveQuality(model: CatalogModel, axis: QualityAxis): { score: number
68
76
  /** Neutral trust prior for models our ledger has never observed. */
69
77
  const UNMEASURED_TRUST = 0.9;
70
78
 
79
+ /**
80
+ * Prior on the escalation rate, as pseudo-counts: an unobserved model is
81
+ * assumed to escalate ESCALATION_PRIOR times in ESCALATION_PRIOR_N attempts
82
+ * (0.25%), so a new cheap model is not priced out before it has been tried,
83
+ * and a model with a handful of attempts is pulled toward that rather than
84
+ * toward 0% or 100%.
85
+ */
86
+ const ESCALATION_PRIOR = 0.05;
87
+ const ESCALATION_PRIOR_N = 20;
88
+
71
89
  /** Excess-ratio cap so one very slow model cannot be penalised into oblivion. */
72
90
  const LATENCY_EXCESS_CAP = 3;
73
91
 
@@ -230,8 +248,14 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
230
248
  continue;
231
249
  }
232
250
 
251
+ // Use pre-fetched signals when available (batch lookup, one query per
252
+ // signal kind for the entire candidate set instead of one per model).
253
+ // Falls back to per-slug calls when signals is not provided (e.g. tests).
254
+ const signals = args.signals;
233
255
  const trust =
234
- ledger?.trust(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ?? null;
256
+ signals?.get(slug)?.trust ??
257
+ ledger?.trust(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ??
258
+ null;
235
259
  if (!relaxTrust && trust !== null && trust.attempts >= filters.minTrustSamples && trust.successRate < filters.minTrust) {
236
260
  rejected.push({
237
261
  slug,
@@ -249,7 +273,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
249
273
  // cold-start turns to accumulate samples. Relaxed with trust in rescue.
250
274
  const needLatency = filters.latencyWeight > 0 || filters.maxExpectedWaitMs !== undefined;
251
275
  const latency = needLatency
252
- ? (ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ?? null)
276
+ ? (signals?.get(slug)?.latency ??
277
+ ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ??
278
+ null)
253
279
  : null;
254
280
  if (
255
281
  !relaxTrust &&
@@ -295,7 +321,25 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
295
321
  // for slowness (TTFT over the reference). qualityExponent 0 makes this
296
322
  // "cheapest above the floor"; the floor does the quality work.
297
323
  const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
298
- const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5)) * latencyMult;
324
+ // Escalation-cost term: the trust divisor prices a failure as a retry of
325
+ // THIS model, but a probe escalation re-dispatches the whole prompt on
326
+ // the next tier's model — measured at ~700x a cheap model's own turn cost.
327
+ // Price the measured rate at what an escalated retry actually bills.
328
+ const escalationRate =
329
+ trust !== null && trust.attempts > 0
330
+ ? (trust.escalations + ESCALATION_PRIOR) / (trust.attempts + ESCALATION_PRIOR_N)
331
+ : ESCALATION_PRIOR / ESCALATION_PRIOR_N;
332
+ const escalationUsd =
333
+ filters.escalationCostWeight > 0 && args.escalationUsdPerPromptToken !== undefined
334
+ ? filters.escalationCostWeight * escalationRate * args.escalationUsdPerPromptToken * features.promptTokens
335
+ : 0;
336
+ // Provider bias: an Ollama plan's included credits are money already
337
+ // spent, so an operator may value them below list price in ranking. The
338
+ // ledger still records list price.
339
+ // The snapshot carries the LIVE bias (credit-aware); the static config
340
+ // value is the fallback for snapshots built without one.
341
+ const providerBias = snapshot.providerBias?.[model.provider] ?? (model.provider === "ollama" ? cfg.ollama.costBias : 1);
342
+ const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5) + escalationUsd) * latencyMult * providerBias;
299
343
  // Score is assigned in a SECOND PASS below: both qualityNormalization and
300
344
  // capabilityFloorUsd are properties of the candidate SET, not of one
301
345
  // model, so no per-model value can be computed here. Placeholder only.
@@ -310,6 +354,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
310
354
  : `trust ${trustScore.toFixed(2)} over ${trust.attempts} attempts`,
311
355
  `expected $${fc.expectedUsd.toFixed(6)}`,
312
356
  ];
357
+ if (escalationUsd > 0) {
358
+ reasons.push(`escalation risk +$${escalationUsd.toFixed(6)} (rate ${(escalationRate * 100).toFixed(2)}% × measured retry cost)`);
359
+ }
360
+ if (providerBias !== 1) reasons.push(`provider bias ×${providerBias} (ollama.costBias, plan credits remaining)`);
313
361
  if (latencyMult > 1 && latency !== null) {
314
362
  reasons.push(
315
363
  `latency penalty ×${latencyMult.toFixed(2)} (ttft ${Math.round(latency.ttftMs)}ms, ${latency.tokensPerSec.toFixed(0)} tok/s over ${latency.samples} samples)`,
@@ -23,7 +23,11 @@ import type { Classification, Features, TaskType, Tier } from "./types.ts";
23
23
  * - Failure signals cost money twice: a cheap model that flounders gets
24
24
  * escalated and the turn is paid for twice. So a failed tool result, a
25
25
  * repeated tool call, complexity keywords, and an explicit reasoning
26
- * request carry the dominant POSITIVE weights.
26
+ * request carry the dominant POSITIVE weights — on a FRESH turn. On a
27
+ * tool-result continuation the failed-tool and circular-call weights are
28
+ * damped by `classifier.mechanicalRetryFactor` (0.2 shipped), because a
29
+ * mechanical retry loop was buying the hard tier for work that never
30
+ * needed it; there they are a nudge, not a driver.
27
31
  */
28
32
  const BASE = 0.3;
29
33
  const W_TOOL_CONTINUATION = -0.28; // dominant negative
@@ -31,8 +35,8 @@ const W_COMPLEXITY_KEYWORD = 0.1;
31
35
  const CAP_COMPLEXITY = 0.3;
32
36
  const W_TRIVIALITY_KEYWORD = -0.09;
33
37
  const CAP_TRIVIALITY = -0.27;
34
- const W_TOOL_FAILED = 0.26; // dominant positive: retry loops are expensive
35
- const W_CIRCULAR_LOOP = 0.24; // a re-issued (circular) tool call: the model is stuck
38
+ const W_TOOL_FAILED = 0.26; // dominant positive on a fresh turn; damped on continuations
39
+ const W_CIRCULAR_LOOP = 0.24; // a re-issued (circular) tool call: the model is stuck; damped on continuations
36
40
  const W_TERSE = -0.1;
37
41
  const W_CODE_BLOCK = 0.04;
38
42
  const CAP_CODE = 0.08;
@@ -118,8 +122,25 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
118
122
  Math.max(f.trivialityKeywords.length * W_TRIVIALITY_KEYWORD, CAP_TRIVIALITY),
119
123
  `triviality keywords [${f.trivialityKeywords.join(", ")}]`,
120
124
  );
121
- if (f.lastToolFailed) add(W_TOOL_FAILED, "last tool result failed");
122
- if (f.circularToolCall) add(W_CIRCULAR_LOOP, "circular tool call (re-issued a prior call; stuck)");
125
+ // Stuck-loop signals on a tool-result continuation are damped: a retry after
126
+ // a failed call is the MOST mechanical turn there is (no new user intent,
127
+ // same prompt prefix, the harness just re-asks), and the flat weight let an
128
+ // automated retry loop buy the hard tier ($7.02 of one measured day vs $0.19
129
+ // for the same rows as moderate picks). A circular call measured the same
130
+ // way: hard escalations driven by it NEVER shortened the loop (chains from
131
+ // hard and from moderate both averaged 5.74 turns) — the loop ends when the
132
+ // underlying state changes, not because a pricier model re-read the same
133
+ // result. Off a continuation (a fresh user turn, the failure the human just
134
+ // saw) both keep their full weight: that genuinely changes what the turn
135
+ // needs.
136
+ const factor = cfg.classifier.mechanicalRetryFactor;
137
+ const damped = f.isToolResultContinuation && factor < 1;
138
+ const damp = (w: number): number => (damped ? w * factor : w);
139
+ const dampNote = damped ? ` (mechanical retry, damped x${factor})` : "";
140
+ if (f.lastToolFailed) add(damp(W_TOOL_FAILED), `last tool result failed${dampNote}`);
141
+ if (f.circularToolCall) {
142
+ add(damp(W_CIRCULAR_LOOP), damped ? `circular tool call${dampNote}` : "circular tool call (re-issued a prior call; stuck)");
143
+ }
123
144
  const rw = reasoningWeight(f.requestedReasoning, cfg);
124
145
  if (rw > 0) add(rw, `client requested reasoning=${f.requestedReasoning ?? ""}`);
125
146
  if (f.isTerseInstruction) add(W_TERSE, "terse instruction");
@@ -137,7 +158,13 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
137
158
  `autonomous loop depth ${f.toolLoopDepth} (sustained task)`,
138
159
  );
139
160
  }
140
- if (f.hasImages) add(W_IMAGES, "image input");
161
+ // Only an image the human JUST supplied is visual work. A screenshot that
162
+ // entered the conversation long ago rides along in every later mechanical
163
+ // continuation (measured: 5,603 of ~9,000 heuristic rows in a week carried
164
+ // one, and 339 of them sat a tier higher on this +0.04 alone). Capability —
165
+ // the served model must still accept image input — is enforced separately
166
+ // on `req.hasImages` in candidate selection, exactly as `classifyTask` does.
167
+ if (f.hasNewImage) add(W_IMAGES, "new image input");
141
168
  if (f.toolCount > 0) add(W_TOOLS_OFFERED, `${f.toolCount} tools offered`);
142
169
 
143
170
  score = Math.min(1, Math.max(0, score));
@@ -170,9 +170,21 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
170
170
  }
171
171
  }
172
172
 
173
+ // The tool run to judge for failure: the trailing run on a continuation, or
174
+ // the run that sits immediately behind the newest user turn — the failure
175
+ // the human just saw and is now responding to. The classifier weights the
176
+ // two differently (a mechanical retry is damped, a user-visible failure is
177
+ // not), so the second case has to be detectable here or that branch is dead.
173
178
  let lastToolFailed = false;
179
+ let failureScanFrom = -1;
174
180
  if (isToolResultContinuation) {
175
- scanResults: for (let i = messages.length - 1; i >= 0; i--) {
181
+ failureScanFrom = messages.length - 1;
182
+ } else if (tail?.role === "user") {
183
+ const start = trailingRunStart(messages, (m) => m.role === "user");
184
+ if (messages[start - 1]?.role === "tool") failureScanFrom = start - 1;
185
+ }
186
+ if (failureScanFrom >= 0) {
187
+ scanResults: for (let i = failureScanFrom; i >= 0; i--) {
176
188
  const m = messages[i];
177
189
  if (m === undefined || m.role !== "tool") break;
178
190
  for (const re of TOOL_FAILURE_MARKERS) {