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
@@ -0,0 +1,81 @@
1
+ /**
2
+ * User verdicts on routed turns, from omp (`/router feedback good|bad`).
3
+ *
4
+ * The router otherwise learns only from escalation signals. A person saying
5
+ * a cheap model's answer was wrong — or that it was fine — is the label the
6
+ * de-escalation question needs. Each verdict is tied to the ledger row it
7
+ * judged, so it aggregates by served model, tier and task.
8
+ */
9
+
10
+ import type { Database } from "bun:sqlite";
11
+
12
+ export type Verdict = "good" | "bad";
13
+
14
+ export interface FeedbackRecord {
15
+ ledgerId: string;
16
+ ompSessionId: string;
17
+ slug: string;
18
+ tier: string;
19
+ verdict: Verdict;
20
+ note: string;
21
+ }
22
+
23
+ export interface FeedbackCounts {
24
+ good: number;
25
+ bad: number;
26
+ }
27
+
28
+ export interface FeedbackStore {
29
+ record(rec: FeedbackRecord, nowMs?: number): string;
30
+ /** Verdict counts per served slug since `sinceMs`. */
31
+ countsBySlug(sinceMs: number, harnessId?: string): Map<string, FeedbackCounts>;
32
+ /** Verdicts for one ledger row (a user may re-judge). */
33
+ forLedgerId(ledgerId: string): Array<{ verdict: Verdict; note: string; createdAtMs: number }>;
34
+ }
35
+
36
+ export function createFeedbackStore(db: Database): FeedbackStore {
37
+ const insert = db.query(
38
+ `INSERT INTO feedback (id, ledger_id, omp_session_id, slug, tier, verdict, note, created_at_ms)
39
+ VALUES ($id, $ledgerId, $ompSessionId, $slug, $tier, $verdict, $note, $createdAtMs)`,
40
+ );
41
+ const bySlug = db.query(
42
+ `SELECT f.slug, f.verdict, COUNT(*) AS n FROM feedback f
43
+ LEFT JOIN ledger l ON l.id = f.ledger_id
44
+ WHERE f.created_at_ms >= $since AND ($harness = '' OR l.harness_id = $harness)
45
+ GROUP BY f.slug, f.verdict`,
46
+ );
47
+ const forRow = db.query("SELECT verdict, note, created_at_ms FROM feedback WHERE ledger_id = ? ORDER BY created_at_ms DESC");
48
+ return {
49
+ record(rec, nowMs = Date.now()) {
50
+ const id = crypto.randomUUID();
51
+ insert.run({
52
+ $id: id,
53
+ $ledgerId: rec.ledgerId,
54
+ $ompSessionId: rec.ompSessionId,
55
+ $slug: rec.slug,
56
+ $tier: rec.tier,
57
+ $verdict: rec.verdict,
58
+ $note: rec.note.slice(0, 500),
59
+ $createdAtMs: nowMs,
60
+ });
61
+ return id;
62
+ },
63
+ countsBySlug(sinceMs, harnessId = "") {
64
+ const out = new Map<string, FeedbackCounts>();
65
+ for (const r of bySlug.all({ $since: sinceMs, $harness: harnessId }) as { slug: string; verdict: string; n: number }[]) {
66
+ const c = out.get(r.slug) ?? { good: 0, bad: 0 };
67
+ if (r.verdict === "good") c.good += r.n;
68
+ else c.bad += r.n;
69
+ out.set(r.slug, c);
70
+ }
71
+ return out;
72
+ },
73
+ forLedgerId(ledgerId) {
74
+ return (forRow.all(ledgerId) as { verdict: Verdict; note: string; created_at_ms: number }[]).map((r) => ({
75
+ verdict: r.verdict,
76
+ note: r.note,
77
+ createdAtMs: r.created_at_ms,
78
+ }));
79
+ },
80
+ };
81
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  Ledger,
25
25
  LedgerEntry,
26
26
  LedgerSignals,
27
+ ModelCacheReliability,
27
28
  ModelLatency,
28
29
  ModelTrust,
29
30
  UsageCounts,
@@ -38,6 +39,14 @@ const MAX_SANE_BYTES_PER_TOKEN = 8;
38
39
  const MIN_ESCALATION_SAMPLES = 10;
39
40
  /** The escalation-cost aggregate scans a window of rows; memoised for this long. */
40
41
  const ESCALATION_COST_MEMO_MS = 60_000;
42
+
43
+ /**
44
+ * Cache reliability is one window-function pass over the newest rows, memoised
45
+ * for a minute: the previous kept turn of each conversation is found with LAG,
46
+ * and a row counts when that turn was on the same model within the warm TTL.
47
+ */
48
+ const CACHE_RELIABILITY_ROWS = 6_000;
49
+ const CACHE_RELIABILITY_MEMO_MS = 60_000;
41
50
  const DAY_MS = 86_400_000;
42
51
 
43
52
  // Row shapes below are fixed by our own schema in util/sqlite.ts.
@@ -236,7 +245,42 @@ function toEntry(row: LedgerRow): LedgerEntry {
236
245
  };
237
246
  }
238
247
 
248
+ export interface CacheReliabilityRow {
249
+ slug: string;
250
+ samples: number;
251
+ hit: number;
252
+ }
253
+
254
+ /**
255
+ * Observed cache hit rates when a warm cache was expected, per served slug.
256
+ * `sinceMs` bounds the rows scanned (0 ⇒ the newest `limitRows`). Rows whose
257
+ * cache count the router estimated (`usage.cachedEstimated`) are excluded.
258
+ */
259
+ export function queryCacheReliability(db: Database, opts: { warmTtlMs: number; sinceMs?: number; limitRows?: number }): CacheReliabilityRow[] {
260
+ const sinceMs = opts.sinceMs ?? 0;
261
+ const limitRows = opts.limitRows ?? CACHE_RELIABILITY_ROWS;
262
+ return db
263
+ .query(
264
+ `WITH recent AS (
265
+ SELECT conversation_key AS ck, created_at_ms AS t, COALESCE(served_slug, slug) AS s,
266
+ json_extract(usage, '$.promptTokens') AS p, json_extract(usage, '$.cachedTokens') AS c,
267
+ COALESCE(json_extract(usage, '$.cachedEstimated'), 0) AS est
268
+ FROM ledger WHERE wasted = 0 AND error IS NULL AND created_at_ms >= $since
269
+ ORDER BY created_at_ms DESC LIMIT $limit),
270
+ seq AS (
271
+ SELECT s, p, c, est, t,
272
+ LAG(s) OVER w AS prev_s, LAG(p) OVER w AS prev_p, LAG(t) OVER w AS prev_t
273
+ FROM recent WINDOW w AS (PARTITION BY ck ORDER BY t))
274
+ SELECT s AS slug, COUNT(*) AS samples, AVG(MIN(1.0, c * 1.0 / MIN(prev_p, p))) AS hit
275
+ FROM seq
276
+ WHERE prev_s = s AND p > 1000 AND prev_p > 1000 AND t - prev_t <= $ttl AND est = 0
277
+ GROUP BY s`,
278
+ )
279
+ .all({ $since: sinceMs, $limit: limitRows, $ttl: opts.warmTtlMs }) as CacheReliabilityRow[];
280
+ }
281
+
239
282
  export function createLedger(db: Database, cfg: RouterConfig): Ledger {
283
+ let cacheMemo: { atMs: number; map: Map<string, ModelCacheReliability> } | null = null;
240
284
  // Prepared once: record() runs on every turn.
241
285
  const insertStmt = db.query(
242
286
  `INSERT INTO ledger (
@@ -276,6 +320,10 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
276
320
  );
277
321
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
278
322
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
323
+ const providerSpendStmt = db.query(
324
+ "SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
325
+ );
326
+ const sessionStmt = db.query("SELECT * FROM ledger WHERE omp_session_id = ? AND wasted = 0 ORDER BY created_at_ms DESC LIMIT ?");
279
327
  // What an escalated retry actually bills, per prompt token, over a window.
280
328
  // attempt > 0 rows are the re-dispatches that followed a rejected attempt;
281
329
  // errored ones carry no usage and are excluded.
@@ -435,6 +483,18 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
435
483
  return out;
436
484
  },
437
485
 
486
+ cacheReliability(slug: string): ModelCacheReliability | null {
487
+ const now = Date.now();
488
+ if (cacheMemo === null || now - cacheMemo.atMs > CACHE_RELIABILITY_MEMO_MS) {
489
+ const map = new Map<string, ModelCacheReliability>();
490
+ for (const r of queryCacheReliability(db, { warmTtlMs: cfg.hysteresis.cacheWarmTtlMs })) {
491
+ map.set(r.slug, { slug: r.slug, samples: r.samples, hitRate: Math.min(1, Math.max(0, r.hit)) });
492
+ }
493
+ cacheMemo = { atMs: now, map };
494
+ }
495
+ return cacheMemo.map.get(slug) ?? null;
496
+ },
497
+
438
498
  escalationCost(windowDays: number): EscalationCost | null {
439
499
  const now = Date.now();
440
500
  if (escalationMemo !== null && escalationMemo.windowDays === windowDays && now - escalationMemo.atMs < ESCALATION_COST_MEMO_MS) {
@@ -459,5 +519,18 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
459
519
  const rows = recentStmt.all(limit) as LedgerRow[];
460
520
  return rows.map(toEntry);
461
521
  },
522
+ providerSpendSince(slugPrefix: string, sinceMs: number): number {
523
+ const row = providerSpendStmt.get(sinceMs, `${slugPrefix}%`) as { total: number } | null;
524
+ return row?.total ?? 0;
525
+ },
526
+ latestForSession(ompSessionId: string): LedgerEntry | null {
527
+ if (ompSessionId === "") return null;
528
+ const row = sessionStmt.get(ompSessionId, 1) as LedgerRow | null;
529
+ return row === null ? null : toEntry(row);
530
+ },
531
+ entriesForSession(ompSessionId: string, limit: number): LedgerEntry[] {
532
+ if (ompSessionId === "") return [];
533
+ return (sessionStmt.all(ompSessionId, Math.max(1, limit)) as LedgerRow[]).map(toEntry);
534
+ },
462
535
  };
463
536
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Database } from "bun:sqlite";
12
+ import { createFeedbackStore, type FeedbackCounts } from "./feedback.ts";
12
13
 
13
14
  export interface ReportTotals {
14
15
  dispatches: number;
@@ -50,6 +51,8 @@ export interface ModelRow extends ReportRow {
50
51
  provider: string;
51
52
  /** Dispatch counts per tier, e.g. `{ trivial: 12, moderate: 3 }`. */
52
53
  tiers: Record<string, number>;
54
+ /** User verdicts from /router good|bad on turns this model served, in the window. */
55
+ feedback: FeedbackCounts;
53
56
  }
54
57
 
55
58
  export interface DayRow {
@@ -71,6 +74,50 @@ export interface UsageReport {
71
74
  models: ModelRow[];
72
75
  tiers: ReportRow[];
73
76
  days: DayRow[];
77
+ /** Mean prompt composition over rows that recorded it; null when none did. */
78
+ anatomy: AnatomyShare | null;
79
+ /** What the window would have cost on one model throughout, per configured baseline. */
80
+ baselines: BaselineRow[];
81
+ }
82
+
83
+ export interface BaselineRow {
84
+ slug: string;
85
+ usd: number;
86
+ /** 1 − routed spend ÷ baseline spend; negative when the router cost more. */
87
+ savedShare: number;
88
+ }
89
+
90
+ /** Resolves configured baseline slugs against a catalog lookup; unknown slugs are skipped. */
91
+ export function baselinePrices(slugs: readonly string[], find: (slug: string) => { price: { prompt: number; completion: number; cacheRead?: number } } | undefined): BaselinePrice[] {
92
+ const out: BaselinePrice[] = [];
93
+ for (const slug of slugs) {
94
+ const m = find(slug);
95
+ if (m === undefined) continue;
96
+ out.push({ slug, prompt: m.price.prompt, completion: m.price.completion, ...(m.price.cacheRead === undefined ? {} : { cacheRead: m.price.cacheRead }) });
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** A baseline's prices per token (the catalog's `Price`, or a subset of it). */
102
+ export interface BaselinePrice {
103
+ slug: string;
104
+ prompt: number;
105
+ completion: number;
106
+ cacheRead?: number;
107
+ }
108
+
109
+ /** Shares of prompt bytes, 0-1, averaged over the window's dispatches. */
110
+ export interface AnatomyShare {
111
+ rows: number;
112
+ avgMessages: number;
113
+ system: number;
114
+ user: number;
115
+ assistant: number;
116
+ tool: number;
117
+ /** Tool schemas relative to prompt bytes (they ride in the tools param, not the messages). */
118
+ schemas: number;
119
+ olderHalf: number;
120
+ staleTool: number;
74
121
  }
75
122
 
76
123
  const USD = "COALESCE(reported_usd, predicted_usd)";
@@ -129,7 +176,10 @@ function toRow(r: RawRow, windowSpend: number): ReportRow {
129
176
  * Builds the report for the last `windowDays`. `harnessId` narrows to one
130
177
  * harness (the `X-Omp-Harness` header); empty means everything.
131
178
  */
132
- export function buildUsageReport(db: Database, opts: { windowDays: number; harnessId?: string; nowMs?: number }): UsageReport {
179
+ export function buildUsageReport(
180
+ db: Database,
181
+ opts: { windowDays: number; harnessId?: string; nowMs?: number; baselines?: readonly BaselinePrice[] },
182
+ ): UsageReport {
133
183
  const nowMs = opts.nowMs ?? Date.now();
134
184
  const windowDays = Math.max(1, opts.windowDays);
135
185
  const sinceMs = nowMs - windowDays * 86_400_000;
@@ -196,10 +246,12 @@ export function buildUsageReport(db: Database, opts: { windowDays: number; harne
196
246
  rec[m.tier] = m.n;
197
247
  mixByModel.set(m.key, rec);
198
248
  }
249
+ const feedbackBySlug = createFeedbackStore(db).countsBySlug(sinceMs, harnessId);
199
250
  const models: ModelRow[] = modelRows.map((r) => ({
200
251
  ...toRow(r, windowSpend),
201
252
  provider: r.key.startsWith("ollama/") ? "ollama" : "openrouter",
202
253
  tiers: mixByModel.get(r.key) ?? {},
254
+ feedback: feedbackBySlug.get(r.key) ?? { good: 0, bad: 0 },
203
255
  }));
204
256
 
205
257
  const tiers = (
@@ -221,6 +273,42 @@ export function buildUsageReport(db: Database, opts: { windowDays: number; harne
221
273
  cacheHitRate: d.prompt_tokens > 0 ? d.cached_tokens / d.prompt_tokens : 0,
222
274
  }));
223
275
 
276
+ const an = db
277
+ .query(
278
+ `SELECT COUNT(*) AS rows, AVG(json_extract(features, '$.anatomy.messages')) AS msgs,
279
+ AVG(json_extract(features, '$.anatomy.systemBytes')) AS sys, AVG(json_extract(features, '$.anatomy.userBytes')) AS usr,
280
+ AVG(json_extract(features, '$.anatomy.assistantBytes')) AS asst, AVG(json_extract(features, '$.anatomy.toolBytes')) AS tool,
281
+ AVG(json_extract(features, '$.toolSchemaBytes')) AS schemas,
282
+ AVG(json_extract(features, '$.anatomy.olderHalfBytes')) AS older, AVG(json_extract(features, '$.anatomy.staleToolBytes')) AS stale
283
+ FROM ledger WHERE ${where} AND json_extract(features, '$.anatomy.messages') IS NOT NULL`,
284
+ )
285
+ .get(bind) as { rows: number; msgs: number | null; sys: number | null; usr: number | null; asst: number | null; tool: number | null; schemas: number | null; older: number | null; stale: number | null };
286
+ let anatomy: AnatomyShare | null = null;
287
+ if (an.rows > 0) {
288
+ const total = (an.sys ?? 0) + (an.usr ?? 0) + (an.asst ?? 0) + (an.tool ?? 0);
289
+ const share = (v: number | null): number => (total > 0 ? (v ?? 0) / total : 0);
290
+ anatomy = {
291
+ rows: an.rows,
292
+ avgMessages: Math.round(an.msgs ?? 0),
293
+ system: share(an.sys),
294
+ user: share(an.usr),
295
+ assistant: share(an.asst),
296
+ tool: share(an.tool),
297
+ schemas: share(an.schemas),
298
+ olderHalf: share(an.older),
299
+ staleTool: share(an.stale),
300
+ };
301
+ }
302
+
303
+ // Counterfactual: the window's tokens on one model throughout, at list
304
+ // price with the window's own cache hit rate (cached tokens read at the
305
+ // baseline's cache rate, or full price when it publishes none).
306
+ const baselines: BaselineRow[] = (opts.baselines ?? []).map((b) => {
307
+ const fresh = Math.max(0, t.prompt_tokens - t.cached_tokens);
308
+ const usd = fresh * b.prompt + t.cached_tokens * (b.cacheRead ?? b.prompt) + t.completion_tokens * b.completion;
309
+ return { slug: b.slug, usd, savedShare: usd > 0 ? 1 - t.spend / usd : 0 };
310
+ });
311
+
224
312
  return {
225
313
  generatedAtMs: nowMs,
226
314
  windowDays,
@@ -244,6 +332,8 @@ export function buildUsageReport(db: Database, opts: { windowDays: number; harne
244
332
  models,
245
333
  tiers,
246
334
  days,
335
+ anatomy,
336
+ baselines,
247
337
  };
248
338
  }
249
339
 
@@ -294,6 +384,19 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
294
384
  `spend ${usd(t.spendUsd)} over ${num(t.dispatches)} dispatches in ${num(t.conversations)} conversations · ${usd(t.dispatches > 0 ? t.spendUsd / t.dispatches : 0)}/dispatch`,
295
385
  `prompt ${num(t.promptTokens)} tok (cache hit ${pct(t.cacheHitRate, t.cacheEstimated)}) · 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)`,
296
386
  ];
387
+ if (r.baselines.length > 0 && t.dispatches > 0) {
388
+ summary.push(
389
+ `same traffic on one model: ${r.baselines
390
+ .map((b) => `${b.slug} ${usd(b.usd)} (router ${b.savedShare >= 0 ? "saved" : "cost extra"} ${pct(Math.abs(b.savedShare))})`)
391
+ .join(" · ")}`,
392
+ );
393
+ }
394
+ const a = r.anatomy;
395
+ if (a !== null) {
396
+ summary.push(
397
+ `prompt anatomy (mean of ${num(a.rows)}): tool results ${pct(a.tool)} · assistant ${pct(a.assistant)} · user ${pct(a.user)} · system ${pct(a.system)} · tool schemas +${pct(a.schemas)} · older half ${pct(a.olderHalf)} · stale tool results ${pct(a.staleTool)} · ${num(a.avgMessages)} messages`,
398
+ );
399
+ }
297
400
  const tables: ReportTable[] = [];
298
401
  if (r.providers.length > 0) {
299
402
  tables.push({
@@ -307,7 +410,7 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
307
410
  tables.push({
308
411
  id: "models",
309
412
  title: `models (top ${Math.min(maxModels, r.models.length)} of ${r.models.length} by spend)`,
310
- headers: ["model", "dispatches", "spend", "share", "cache", "ttft", "speed", "tiers"],
413
+ headers: ["model", "dispatches", "spend", "share", "cache", "ttft", "speed", "feedback", "tiers"],
311
414
  rows: r.models.slice(0, maxModels).map((m) => [
312
415
  m.key,
313
416
  num(m.dispatches),
@@ -316,6 +419,7 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
316
419
  pct(m.cacheHitRate, m.cacheEstimated),
317
420
  ms(m.avgTtftMs),
318
421
  tps(m.tokensPerSec),
422
+ m.feedback.good + m.feedback.bad === 0 ? "" : `+${m.feedback.good}/-${m.feedback.bad}`,
319
423
  Object.entries(m.tiers)
320
424
  .sort((a, b) => b[1] - a[1])
321
425
  .map(([k, v]) => `${k}:${v}`)
package/src/cost/types.ts CHANGED
@@ -200,9 +200,24 @@ export interface ModelLatency {
200
200
  tokensPerSec: number;
201
201
  }
202
202
 
203
+ /**
204
+ * How often a model's prompt cache actually hit when the router expected it
205
+ * warm: the previous kept turn of the conversation was on the same model
206
+ * within `hysteresis.cacheWarmTtlMs`. Provider-side misses (a model whose
207
+ * cache is flaky, or absent) show up here as a low rate. Router-estimated
208
+ * cache counts (Ollama) are excluded: they are constructed, not observed.
209
+ */
210
+ export interface ModelCacheReliability {
211
+ slug: string;
212
+ samples: number;
213
+ /** Mean cached / expected-cached over those samples, 0-1. */
214
+ hitRate: number;
215
+ }
216
+
203
217
  export interface LedgerSignals {
204
218
  trust: ModelTrust | null;
205
219
  latency: ModelLatency | null;
220
+ cache?: ModelCacheReliability | null;
206
221
  }
207
222
 
208
223
  /** Measured price of a probe escalation: what the retry billed per prompt token of the failed turn. */
@@ -241,7 +256,20 @@ export interface Ledger {
241
256
  * escalation-cost term in candidate scoring is inert without it.
242
257
  */
243
258
  escalationCost?(windowDays: number): EscalationCost | null;
259
+ /**
260
+ * Observed cache hit rate when a warm cache was expected (see
261
+ * ModelCacheReliability). Null until any sample exists. Optional so fakes
262
+ * need not implement it; the stay/switch comparison assumes a reliable
263
+ * cache without it.
264
+ */
265
+ cacheReliability?(slug: string): ModelCacheReliability | null;
244
266
  /** Observed chars-per-token ratio for a tokenizer family; null until calibrated. */
245
267
  tokenRatio(tokenizer: string): number | null;
246
268
  recentEntries(limit: number): LedgerEntry[];
269
+ /** Spend since an instant on slugs with a prefix (`ollama/`), for provider-level reconciliation. Optional. */
270
+ providerSpendSince?(slugPrefix: string, sinceMs: number): number;
271
+ /** Newest kept (non-wasted) entry for an omp session, for /router why and feedback. Optional so fakes need not implement it. */
272
+ latestForSession?(ompSessionId: string): LedgerEntry | null;
273
+ /** Newest entries for an omp session, newest first. Optional. */
274
+ entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
247
275
  }
@@ -111,12 +111,21 @@ function expectedWaitMs(latency: ModelLatency, expectedCompletionTokens: number)
111
111
  * tiny cost and capped at LATENCY_EXCESS_CAP, so the model stays cheapest. That is
112
112
  * `filters.maxExpectedWaitMs`'s job — a hard drop, applied in buildCandidates.
113
113
  */
114
- function latencyMultiplier(latency: ModelLatency | null, filters: FilterConfig, expectedCompletionTokens: number): number {
115
- if (latency === null || filters.latencyWeight <= 0 || latency.samples < filters.latencyMinSamples) return 1;
114
+ /**
115
+ * The latency weight in force for a turn: the continuation weight on a
116
+ * tool-result continuation when one is configured, else the general one.
117
+ * A person waits on first token only when the turn is theirs.
118
+ */
119
+ export function latencyWeightFor(filters: FilterConfig, isToolResultContinuation: boolean): number {
120
+ return isToolResultContinuation && filters.latencyWeightContinuation !== undefined ? filters.latencyWeightContinuation : filters.latencyWeight;
121
+ }
122
+
123
+ function latencyMultiplier(latency: ModelLatency | null, filters: FilterConfig, expectedCompletionTokens: number, weight = filters.latencyWeight): number {
124
+ if (latency === null || weight <= 0 || latency.samples < filters.latencyMinSamples) return 1;
116
125
  const waitMs = expectedWaitMs(latency, expectedCompletionTokens);
117
126
  const refWaitMs = filters.latencyReferenceMs + (expectedCompletionTokens / filters.latencyReferenceTokensPerSec) * 1000;
118
127
  const excess = refWaitMs > 0 ? Math.max(0, (waitMs - refWaitMs) / refWaitMs) : 0;
119
- return 1 + filters.latencyWeight * Math.min(excess, LATENCY_EXCESS_CAP);
128
+ return 1 + weight * Math.min(excess, LATENCY_EXCESS_CAP);
120
129
  }
121
130
 
122
131
  export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candidate[]; rejected: Rejection[] } {
@@ -320,7 +329,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
320
329
  // 20% of the time really costs ~25% more in retries. Latency does the same
321
330
  // for slowness (TTFT over the reference). qualityExponent 0 makes this
322
331
  // "cheapest above the floor"; the floor does the quality work.
323
- const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
332
+ const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens, latencyWeightFor(filters, features.isToolResultContinuation));
324
333
  // Escalation-cost term: the trust divisor prices a failure as a retry of
325
334
  // THIS model, but a probe escalation re-dispatches the whole prompt on
326
335
  // the next tier's model — measured at ~700x a cheap model's own turn cost.
@@ -10,6 +10,7 @@ import type { Ledger } from "../cost/types.ts";
10
10
  import { estimateTokens } from "../tokens/estimate.ts";
11
11
  import type { UpstreamClient } from "../upstream/types.ts";
12
12
  import { sha256Hex } from "../util/hash.ts";
13
+ import { loadLearnedModel, predictRisk } from "./learned.ts";
13
14
  import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
14
15
  import type { Classification, Features, TaskType, Tier } from "./types.ts";
15
16
 
@@ -303,6 +304,15 @@ export async function classify(
303
304
  ): Promise<Classification> {
304
305
  const heuristic = scoreHeuristic(f, cfg);
305
306
  const cc = cfg.classifier;
307
+ // Advisory learned risk: recorded beside the decision, never acted on here.
308
+ if (cc.learnedModelPath !== "") {
309
+ const model = await loadLearnedModel(cc.learnedModelPath);
310
+ if (model !== null) {
311
+ const risk = predictRisk(model, f);
312
+ heuristic.learnedRisk = risk;
313
+ heuristic.reasons.push(`learned: p(escalate)=${risk.toFixed(3)}`);
314
+ }
315
+ }
306
316
  if (cc.ambiguityThreshold <= 0 || heuristic.confidence >= cc.ambiguityThreshold) return heuristic;
307
317
 
308
318
  const digest = buildDigest(req, f);
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { NormMessage, NormRequest } from "../wire/types.ts";
11
- import type { Features } from "./types.ts";
11
+ import type { Features, PromptAnatomy } from "./types.ts";
12
12
 
13
13
  /**
14
14
  * Complexity signals. Deliberately small: each hit pushes the turn toward a
@@ -224,6 +224,24 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
224
224
  codeBlocks === 0 &&
225
225
  (terminators === null ? 0 : terminators.length) <= 1;
226
226
 
227
+ // Prompt anatomy: where the bytes sit. Cheap (one pass over textBytes) and
228
+ // content-free, so it is safe to record on every row.
229
+ const anatomy: PromptAnatomy = { messages: 0, systemBytes: 0, userBytes: 0, assistantBytes: 0, toolBytes: 0, olderHalfBytes: 0, staleToolBytes: 0 };
230
+ const nonSystem = messages.filter((m) => m.role !== "system");
231
+ const olderHalfEnd = Math.floor(nonSystem.length / 2);
232
+ const staleEnd = Math.max(0, nonSystem.length - 20);
233
+ nonSystem.forEach((m, i) => {
234
+ if (i < olderHalfEnd) anatomy.olderHalfBytes += m.textBytes;
235
+ if (m.role === "tool" && i < staleEnd) anatomy.staleToolBytes += m.textBytes;
236
+ });
237
+ for (const m of messages) {
238
+ anatomy.messages++;
239
+ if (m.role === "system") anatomy.systemBytes += m.textBytes;
240
+ else if (m.role === "user") anatomy.userBytes += m.textBytes;
241
+ else if (m.role === "assistant") anatomy.assistantBytes += m.textBytes;
242
+ else anatomy.toolBytes += m.textBytes;
243
+ }
244
+
227
245
  return {
228
246
  promptTokens,
229
247
  newContentTokens,
@@ -246,5 +264,6 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
246
264
  requestedReasoning: req.reasoning,
247
265
  questionCount,
248
266
  isTerseInstruction,
267
+ anatomy,
249
268
  };
250
269
  }
@@ -52,7 +52,7 @@ export function createRouter(deps: RouterDeps): Router {
52
52
  return {
53
53
  async route(
54
54
  req: NormRequest,
55
- opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[] },
55
+ opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[]; forceTier?: Tier; forceSlug?: string },
56
56
  ): Promise<Decision> {
57
57
  const state = conversations.get(req.conversationKey) ?? conversations.load(req.conversationKey);
58
58
  const snapshot = await catalog.get();
@@ -78,6 +78,16 @@ export function createRouter(deps: RouterDeps): Router {
78
78
  score: 1,
79
79
  reasons: [`escalated from ${opts.escalateFrom} after attempt ${opts.attempt - 1} was rejected`],
80
80
  };
81
+ } else if (opts.forceTier !== undefined) {
82
+ // A session override from omp: the user chose the tier for a while.
83
+ classification = {
84
+ tier: opts.forceTier,
85
+ task: classifyTask(features),
86
+ confidence: 1,
87
+ source: "forced",
88
+ score: 1,
89
+ reasons: [`tier ${opts.forceTier} forced by session override (/router tier)`],
90
+ };
81
91
  } else {
82
92
  classification = await classify(req, features, config, { upstream, ledger, catalog });
83
93
  }
@@ -93,6 +103,7 @@ export function createRouter(deps: RouterDeps): Router {
93
103
  cfg: config,
94
104
  nowMs: Date.now(),
95
105
  ...(opts.excludeSlugs === undefined ? {} : { excludeSlugs: opts.excludeSlugs }),
106
+ ...(opts.forceSlug === undefined ? {} : { forceSlug: opts.forceSlug }),
96
107
  });
97
108
  },
98
109
  };