auto-model-router 0.2.31 → 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 (68) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +211 -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 +28 -0
  26. package/src/config/types.ts +127 -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 +90 -11
  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 +11 -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 +176 -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
  68. package/tools/build-site.ts +1 -0
@@ -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,9 +76,25 @@ 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
 
92
+ /** Expected total wait: time to first token plus streaming the expected completion at measured throughput. */
93
+ function expectedWaitMs(latency: ModelLatency, expectedCompletionTokens: number): number {
94
+ const streamMs = latency.tokensPerSec > 0 ? (expectedCompletionTokens / latency.tokensPerSec) * 1000 : 0;
95
+ return latency.ttftMs + streamMs;
96
+ }
97
+
74
98
  /**
75
99
  * Latency penalty as a multiplier on effective cost (>= 1; 1 = no penalty).
76
100
  *
@@ -81,13 +105,15 @@ const LATENCY_EXCESS_CAP = 3;
81
105
  * model of equal quality and price outranks a sluggish one. Capturing throughput,
82
106
  * not just TTFT, is what catches a model that starts fast but streams slowly
83
107
  * (deepseek-v4-flash: ~2s TTFT yet ~20 tok/s → ~38s total). Inert when the weight
84
- * is 0 or the model has too few streamed samples to judge; when throughput is
85
- * unmeasured it degrades to a TTFT-only comparison against the reference wait.
108
+ * is 0 or the model has too few streamed samples to judge.
109
+ *
110
+ * The penalty CANNOT discipline a slow-but-cheap model: it is multiplicative on a
111
+ * tiny cost and capped at LATENCY_EXCESS_CAP, so the model stays cheapest. That is
112
+ * `filters.maxExpectedWaitMs`'s job — a hard drop, applied in buildCandidates.
86
113
  */
87
114
  function latencyMultiplier(latency: ModelLatency | null, filters: FilterConfig, expectedCompletionTokens: number): number {
88
115
  if (latency === null || filters.latencyWeight <= 0 || latency.samples < filters.latencyMinSamples) return 1;
89
- const streamMs = latency.tokensPerSec > 0 ? (expectedCompletionTokens / latency.tokensPerSec) * 1000 : 0;
90
- const waitMs = latency.ttftMs + streamMs;
116
+ const waitMs = expectedWaitMs(latency, expectedCompletionTokens);
91
117
  const refWaitMs = filters.latencyReferenceMs + (expectedCompletionTokens / filters.latencyReferenceTokensPerSec) * 1000;
92
118
  const excess = refWaitMs > 0 ? Math.max(0, (waitMs - refWaitMs) / refWaitMs) : 0;
93
119
  return 1 + filters.latencyWeight * Math.min(excess, LATENCY_EXCESS_CAP);
@@ -222,8 +248,14 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
222
248
  continue;
223
249
  }
224
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;
225
255
  const trust =
226
- 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;
227
259
  if (!relaxTrust && trust !== null && trust.attempts >= filters.minTrustSamples && trust.successRate < filters.minTrust) {
228
260
  rejected.push({
229
261
  slug,
@@ -233,6 +265,35 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
233
265
  continue;
234
266
  }
235
267
 
268
+ // Fetch latency ONCE for both the ceiling gate here and the scoring
269
+ // multiplier below. Absolute latency ceiling: a hard drop, mirroring the
270
+ // price ceiling, for models PROVEN slow (>= latencyMinSamples). The penalty
271
+ // alone cannot demote a slow-but-cheap model (see latencyMultiplier); this
272
+ // gate can. Only measured models are dropped, so a new model still gets its
273
+ // cold-start turns to accumulate samples. Relaxed with trust in rescue.
274
+ const needLatency = filters.latencyWeight > 0 || filters.maxExpectedWaitMs !== undefined;
275
+ const latency = needLatency
276
+ ? (signals?.get(slug)?.latency ??
277
+ ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ??
278
+ null)
279
+ : null;
280
+ if (
281
+ !relaxTrust &&
282
+ filters.maxExpectedWaitMs !== undefined &&
283
+ latency !== null &&
284
+ latency.samples >= filters.latencyMinSamples
285
+ ) {
286
+ const waitMs = expectedWaitMs(latency, expectedCompletionTokens);
287
+ if (waitMs > filters.maxExpectedWaitMs) {
288
+ rejected.push({
289
+ slug,
290
+ reason: "over_latency_ceiling",
291
+ detail: `expected wait ${Math.round(waitMs)}ms > ceiling ${filters.maxExpectedWaitMs}ms (ttft ${Math.round(latency.ttftMs)}ms, ${latency.tokensPerSec.toFixed(0)} tok/s over ${latency.samples} samples)`,
292
+ });
293
+ continue;
294
+ }
295
+ }
296
+
236
297
  // Every candidate is priced COLD, deliberately, and this has been measured
237
298
  // rather than assumed. Two reasons:
238
299
  // 1. `coldUsd` feeds the budget guard in select.ts, and a budget must
@@ -259,12 +320,26 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
259
320
  // 20% of the time really costs ~25% more in retries. Latency does the same
260
321
  // for slowness (TTFT over the reference). qualityExponent 0 makes this
261
322
  // "cheapest above the floor"; the floor does the quality work.
262
- const latency =
263
- filters.latencyWeight > 0
264
- ? (ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ?? null)
265
- : null;
266
323
  const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
267
- 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;
268
343
  // Score is assigned in a SECOND PASS below: both qualityNormalization and
269
344
  // capabilityFloorUsd are properties of the candidate SET, not of one
270
345
  // model, so no per-model value can be computed here. Placeholder only.
@@ -279,6 +354,10 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
279
354
  : `trust ${trustScore.toFixed(2)} over ${trust.attempts} attempts`,
280
355
  `expected $${fc.expectedUsd.toFixed(6)}`,
281
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)`);
282
361
  if (latencyMult > 1 && latency !== null) {
283
362
  reasons.push(
284
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));