auto-model-router 0.4.2 → 0.4.4

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 (48) hide show
  1. package/.gitattributes +2 -0
  2. package/.omp-plugin/marketplace.json +2 -2
  3. package/README.md +31 -3
  4. package/bunfig.toml +2 -0
  5. package/omp-extension/report-logic.ts +46 -0
  6. package/omp-extension/router-configure.ts +55 -3
  7. package/package.json +1 -1
  8. package/src/catalog/composite.ts +4 -1
  9. package/src/cli/config-wizard.ts +8 -1
  10. package/src/config/defaults.ts +8 -0
  11. package/src/config/hot-reload.ts +58 -9
  12. package/src/config/schema.ts +5 -1
  13. package/src/config/types.ts +34 -0
  14. package/src/cost/ledger.ts +84 -8
  15. package/src/cost/report.ts +34 -4
  16. package/src/cost/summary.ts +231 -0
  17. package/src/cost/types.ts +35 -3
  18. package/src/router/candidates.ts +1 -1
  19. package/src/router/classify.ts +2 -2
  20. package/src/router/compaction.ts +2 -1
  21. package/src/router/learned.ts +11 -1
  22. package/src/router/select.ts +27 -3
  23. package/src/server/compaction-digest.ts +129 -0
  24. package/src/server/digest.ts +68 -4
  25. package/src/server/http.ts +50 -7
  26. package/src/server/providers.ts +1 -0
  27. package/src/server/turn.ts +38 -1
  28. package/src/util/sqlite.ts +7 -0
  29. package/src/wire/openai/request.ts +4 -0
  30. package/src/wire/types.ts +7 -0
  31. package/test/cache-control.test.ts +1 -1
  32. package/test/compaction.test.ts +40 -3
  33. package/test/digest.test.ts +44 -0
  34. package/test/failover.test.ts +4 -4
  35. package/test/hot-reload.test.ts +37 -1
  36. package/test/learned.test.ts +21 -1
  37. package/test/migrations.test.ts +84 -0
  38. package/test/report-hub.test.ts +1 -1
  39. package/test/report-logic.test.ts +11 -1
  40. package/test/report.test.ts +29 -1
  41. package/test/select.test.ts +26 -2
  42. package/test/summary.test.ts +171 -0
  43. package/test/support/preload.ts +19 -0
  44. package/test/tokens.test.ts +68 -0
  45. package/test/trust-attribution.test.ts +37 -0
  46. package/test/turn.test.ts +69 -4
  47. package/tools/gen-migration-fixtures.ts +69 -0
  48. package/tools/train-classifier.ts +75 -20
@@ -27,6 +27,7 @@ import type {
27
27
  ModelCacheReliability,
28
28
  ModelLatency,
29
29
  ModelTrust,
30
+ SoftFailureSpike,
30
31
  UsageCounts,
31
32
  } from "./types.ts";
32
33
 
@@ -35,6 +36,20 @@ const MIN_CALIBRATION_SAMPLES = 20;
35
36
  /** Calibration samples outside this bytes-per-token band are provider accounting quirks, not tokenizer facts. */
36
37
  const MIN_SANE_BYTES_PER_TOKEN = 1.5;
37
38
  const MAX_SANE_BYTES_PER_TOKEN = 8;
39
+ /**
40
+ * Soft-failure spike detection (visibility only). A model is spiking when, over
41
+ * the recent window, it has at least SPIKE_MIN_DISPATCHES dispatches, at least
42
+ * SPIKE_MIN_FAILURES of them failed, its failure rate is at least
43
+ * SPIKE_MIN_RATE, and that rate is at least SPIKE_RATIO × its own baseline
44
+ * rate over the preceding window (a model with no baseline failures spikes on
45
+ * the absolute floor alone).
46
+ */
47
+ const SPIKE_RECENT_MS = 60 * 60_000;
48
+ const SPIKE_BASELINE_MS = 7 * 24 * 60 * 60_000;
49
+ const SPIKE_MIN_DISPATCHES = 5;
50
+ const SPIKE_MIN_FAILURES = 3;
51
+ const SPIKE_MIN_RATE = 0.25;
52
+ const SPIKE_RATIO = 2;
38
53
  /** Escalated attempts needed before their measured cost is trusted. */
39
54
  const MIN_ESCALATION_SAMPLES = 10;
40
55
  /** The escalation-cost aggregate scans a window of rows; memoised for this long. */
@@ -334,11 +349,23 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
334
349
  `SELECT ${FEEDBACK_SELECT} FROM feedback f JOIN ledger l ON l.id = f.ledger_id WHERE f.slug = ? AND l.harness_id = ? AND f.created_at_ms > ?`,
335
350
  );
336
351
  const allFeedbackStmt = db.query(`SELECT f.slug, ${FEEDBACK_SELECT} FROM feedback f WHERE f.created_at_ms > ? GROUP BY f.slug`);
337
- const feedbackFor = (slug: string, harnessId: string | undefined, cutoff: number): FeedbackRow | null => {
352
+ // Task-scoped variants (filters.feedbackByTask): the judged turn's task
353
+ // must match, or be unrecorded (older rows, or a turn that never classified).
354
+ const feedbackTaskStmt = db.query(
355
+ `SELECT ${FEEDBACK_SELECT} FROM feedback f JOIN ledger l ON l.id = f.ledger_id WHERE f.slug = ? AND (l.task = ? OR l.task IS NULL) AND f.created_at_ms > ?`,
356
+ );
357
+ const feedbackHarnessTaskStmt = db.query(
358
+ `SELECT ${FEEDBACK_SELECT} FROM feedback f JOIN ledger l ON l.id = f.ledger_id WHERE f.slug = ? AND l.harness_id = ? AND (l.task = ? OR l.task IS NULL) AND f.created_at_ms > ?`,
359
+ );
360
+ const feedbackFor = (slug: string, harnessId: string | undefined, cutoff: number, task?: string): FeedbackRow | null => {
338
361
  if (cfg.filters.feedbackWeight <= 0) return null;
339
- return harnessId !== undefined && harnessId !== ""
340
- ? (feedbackHarnessStmt.get(slug, harnessId, cutoff) as FeedbackRow | null)
341
- : (feedbackStmt.get(slug, cutoff) as FeedbackRow | null);
362
+ const byHarness = harnessId !== undefined && harnessId !== "";
363
+ if (cfg.filters.feedbackByTask && task !== undefined && task !== "") {
364
+ return byHarness
365
+ ? (feedbackHarnessTaskStmt.get(slug, harnessId, task, cutoff) as FeedbackRow | null)
366
+ : (feedbackTaskStmt.get(slug, task, cutoff) as FeedbackRow | null);
367
+ }
368
+ return byHarness ? (feedbackHarnessStmt.get(slug, harnessId, cutoff) as FeedbackRow | null) : (feedbackStmt.get(slug, cutoff) as FeedbackRow | null);
342
369
  };
343
370
  const latencyStmt = db.query(
344
371
  `SELECT ${LATENCY_SELECT} FROM (SELECT * FROM ledger WHERE slug = ? ORDER BY created_at_ms DESC LIMIT ${LATENCY_WINDOW_ROWS})`,
@@ -348,6 +375,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
348
375
  );
349
376
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
350
377
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
378
+ const pruneStmt = db.query("DELETE FROM ledger WHERE created_at_ms < ?");
379
+ const wasteStmt = db.query("UPDATE ledger SET wasted = 1 WHERE id = ?");
351
380
  const providerSpendStmt = db.query(
352
381
  "SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
353
382
  );
@@ -363,6 +392,19 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
363
392
  FROM ledger WHERE attempt > 0 AND error IS NULL AND created_at_ms >= ?`,
364
393
  );
365
394
  let escalationMemo: { atMs: number; windowDays: number; value: EscalationCost | null } | null = null;
395
+ // Per-model failure counts over two adjacent windows: [recentStart, now] and
396
+ // [baselineStart, recentStart). Wasted rows (the failed attempt a retry
397
+ // replaced) stay in: they ARE the soft failures being counted. Digest side
398
+ // calls are excluded: they are not the session's turns.
399
+ const softFailureStmt = db.query(
400
+ `SELECT COALESCE(served_slug, slug) AS slug,
401
+ SUM(CASE WHEN created_at_ms >= $recentStart THEN 1 ELSE 0 END) AS recent_n,
402
+ SUM(CASE WHEN created_at_ms >= $recentStart AND (escalation_signal IS NOT NULL OR (${ATTRIBUTABLE_ERROR})) THEN 1 ELSE 0 END) AS recent_f,
403
+ SUM(CASE WHEN created_at_ms < $recentStart THEN 1 ELSE 0 END) AS base_n,
404
+ SUM(CASE WHEN created_at_ms < $recentStart AND (escalation_signal IS NOT NULL OR (${ATTRIBUTABLE_ERROR})) THEN 1 ELSE 0 END) AS base_f
405
+ FROM ledger WHERE created_at_ms >= $baselineStart AND created_at_ms <= $now AND requested_model <> 'digest'
406
+ GROUP BY COALESCE(served_slug, slug)`,
407
+ );
366
408
  const cacheMetaStmt = db.query("SELECT fetched_at_ms FROM catalog_cache WHERE id = 1");
367
409
  const cachePayloadStmt = db.query("SELECT payload FROM catalog_cache WHERE id = 1");
368
410
 
@@ -466,7 +508,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
466
508
  return computeBlendedRate(db, cfg, windowDays);
467
509
  },
468
510
 
469
- trust(slug: string, harnessId?: string): ModelTrust | null {
511
+ trust(slug: string, harnessId?: string, task?: string): ModelTrust | null {
470
512
  // Read the window at CALL time, not at construction: hot reload mutates
471
513
  // the shared config object in place, so a pinned value would ignore an
472
514
  // edit until restart. 0 => cutoff 0 => every row qualifies.
@@ -476,7 +518,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
476
518
  ? (trustHarnessStmt.get(slug, harnessId, cutoff) as TrustRow | null)
477
519
  : (trustStmt.get(slug, cutoff) as TrustRow | null);
478
520
  if (row === null || row.attempts === 0) return null;
479
- return toTrust(slug, row, feedbackFor(slug, harnessId, cutoff), cfg.filters.feedbackWeight);
521
+ return toTrust(slug, row, feedbackFor(slug, harnessId, cutoff, task), cfg.filters.feedbackWeight);
480
522
  },
481
523
 
482
524
  allTrust(): ModelTrust[] {
@@ -497,7 +539,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
497
539
  if (row === null) return null;
498
540
  return toLatency(slug, row);
499
541
  },
500
- signals(slugs: readonly string[], harnessId?: string): Map<string, LedgerSignals> {
542
+ signals(slugs: readonly string[], harnessId?: string, task?: string): Map<string, LedgerSignals> {
501
543
  const cutoff = cfg.filters.trustWindowDays > 0 ? Date.now() - cfg.filters.trustWindowDays * DAY_MS : 0;
502
544
  const hasHarness = harnessId !== undefined && harnessId !== "";
503
545
  const out = new Map<string, LedgerSignals>();
@@ -509,7 +551,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
509
551
  ? (latencyHarnessStmt.get(slug, harnessId) as LatencyRow | null)
510
552
  : (latencyStmt.get(slug) as LatencyRow | null);
511
553
  out.set(slug, {
512
- trust: trustRow === null || trustRow.attempts === 0 ? null : toTrust(slug, trustRow, feedbackFor(slug, harnessId, cutoff), cfg.filters.feedbackWeight),
554
+ trust: trustRow === null || trustRow.attempts === 0 ? null : toTrust(slug, trustRow, feedbackFor(slug, harnessId, cutoff, task), cfg.filters.feedbackWeight),
513
555
  latency: latencyRow === null ? null : toLatency(slug, latencyRow),
514
556
  });
515
557
  }
@@ -552,10 +594,44 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
552
594
  const rows = recentStmt.all(limit) as LedgerRow[];
553
595
  return rows.map(toEntry);
554
596
  },
597
+ softFailureSpikes(nowMs = Date.now(), recentMs = SPIKE_RECENT_MS, baselineMs = SPIKE_BASELINE_MS): SoftFailureSpike[] {
598
+ const rows = softFailureStmt.all({ $now: nowMs, $recentStart: nowMs - recentMs, $baselineStart: nowMs - recentMs - baselineMs }) as {
599
+ slug: string;
600
+ recent_n: number;
601
+ recent_f: number;
602
+ base_n: number;
603
+ base_f: number;
604
+ }[];
605
+ const spikes: SoftFailureSpike[] = [];
606
+ for (const r of rows) {
607
+ if (r.recent_n < SPIKE_MIN_DISPATCHES || r.recent_f < SPIKE_MIN_FAILURES) continue;
608
+ const recentRate = r.recent_f / r.recent_n;
609
+ const baselineRate = r.base_n > 0 ? r.base_f / r.base_n : 0;
610
+ if (recentRate < SPIKE_MIN_RATE || recentRate < SPIKE_RATIO * baselineRate) continue;
611
+ spikes.push({
612
+ slug: r.slug,
613
+ recentDispatches: r.recent_n,
614
+ recentFailures: r.recent_f,
615
+ recentRate,
616
+ baselineDispatches: r.base_n,
617
+ baselineFailures: r.base_f,
618
+ baselineRate,
619
+ });
620
+ }
621
+ spikes.sort((a, b) => b.recentRate - a.recentRate || b.recentFailures - a.recentFailures);
622
+ return spikes;
623
+ },
555
624
  providerSpendSince(slugPrefix: string, sinceMs: number): number {
556
625
  const row = providerSpendStmt.get(sinceMs, `${slugPrefix}%`) as { total: number } | null;
557
626
  return row?.total ?? 0;
558
627
  },
628
+ prune(retentionDays: number, nowMs = Date.now()): number {
629
+ if (retentionDays <= 0) return 0;
630
+ return pruneStmt.run(nowMs - retentionDays * DAY_MS).changes;
631
+ },
632
+ markWasted(id: string): void {
633
+ wasteStmt.run(id);
634
+ },
559
635
  latestForSession(ompSessionId: string): LedgerEntry | null {
560
636
  if (ompSessionId === "") return null;
561
637
  const row = sessionStmt.get(ompSessionId, 1) as LedgerRow | null;
@@ -34,6 +34,12 @@ export interface ReportTotals {
34
34
  digests: number;
35
35
  digestSpendUsd: number;
36
36
  digestInputTokens: number;
37
+ /** Digests the agent went back on: the same tool re-run with the same primary argument afterwards (row marked wasted). */
38
+ digestReruns: number;
39
+ /** Forecast accuracy over clean kept rows with a reported cost: mean |predicted − reported| ÷ reported, and the share over-predicted. */
40
+ forecastSamples: number;
41
+ forecastMeanError: number;
42
+ forecastOverShare: number;
37
43
  }
38
44
 
39
45
  export interface ReportRow {
@@ -134,6 +140,8 @@ const COMP = "json_extract(usage, '$.completionTokens')";
134
140
  const PROVIDER = "CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ELSE 'openrouter' END";
135
141
  const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
136
142
  const EST = "json_extract(usage, '$.cachedEstimated') = 1";
143
+ /** Rows a forecast can be judged on: a reported cost, a prediction, clean and kept, not a side call. */
144
+ const FORECASTABLE = "reported_usd > 0 AND predicted_usd IS NOT NULL AND wasted = 0 AND error IS NULL AND requested_model <> 'digest'";
137
145
 
138
146
  const ROW_SELECT = `
139
147
  COUNT(*) AS dispatches,
@@ -185,14 +193,19 @@ function toRow(r: RawRow, windowSpend: number): ReportRow {
185
193
  */
186
194
  export function buildUsageReport(
187
195
  db: Database,
188
- opts: { windowDays: number; harnessId?: string; nowMs?: number; baselines?: readonly BaselinePrice[] },
196
+ opts: { windowDays: number; harnessId?: string; nowMs?: number; baselines?: readonly BaselinePrice[]; /** Exclusive upper bound; default open-ended. */ untilMs?: number },
189
197
  ): UsageReport {
190
198
  const nowMs = opts.nowMs ?? Date.now();
191
199
  const windowDays = Math.max(1, opts.windowDays);
192
200
  const sinceMs = nowMs - windowDays * 86_400_000;
193
201
  const harnessId = opts.harnessId ?? "";
194
- const where = harnessId === "" ? "created_at_ms >= $since" : "created_at_ms >= $since AND harness_id = $harness";
195
- const bind = harnessId === "" ? { $since: sinceMs } : { $since: sinceMs, $harness: harnessId };
202
+ const untilMs = opts.untilMs;
203
+ const where = [
204
+ "created_at_ms >= $since",
205
+ ...(untilMs === undefined ? [] : ["created_at_ms < $until"]),
206
+ ...(harnessId === "" ? [] : ["harness_id = $harness"]),
207
+ ].join(" AND ");
208
+ const bind = { $since: sinceMs, ...(untilMs === undefined ? {} : { $until: untilMs }), ...(harnessId === "" ? {} : { $harness: harnessId }) };
196
209
 
197
210
  const t = db
198
211
  .query(
@@ -208,6 +221,10 @@ export function buildUsageReport(
208
221
  SUM(CASE WHEN requested_model = 'digest' THEN 1 ELSE 0 END) AS digests,
209
222
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${USD} ELSE 0 END), 0) AS digest_spend,
210
223
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${PT} ELSE 0 END), 0) AS digest_input,
224
+ SUM(CASE WHEN requested_model = 'digest' AND wasted = 1 THEN 1 ELSE 0 END) AS digest_reruns,
225
+ SUM(CASE WHEN ${FORECASTABLE} THEN 1 ELSE 0 END) AS fc_n,
226
+ COALESCE(SUM(CASE WHEN ${FORECASTABLE} THEN ABS(predicted_usd - reported_usd) / reported_usd END), 0) AS fc_err,
227
+ SUM(CASE WHEN ${FORECASTABLE} AND predicted_usd > reported_usd THEN 1 ELSE 0 END) AS fc_over,
211
228
  SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
212
229
  SUM(CASE WHEN instr(reasons, 'failover:') > 0 THEN 1 ELSE 0 END) AS failovers,
213
230
  SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
@@ -227,6 +244,10 @@ export function buildUsageReport(
227
244
  digests: number | null;
228
245
  digest_spend: number;
229
246
  digest_input: number;
247
+ digest_reruns: number | null;
248
+ fc_n: number | null;
249
+ fc_err: number;
250
+ fc_over: number | null;
230
251
  escalations: number | null;
231
252
  failovers: number | null;
232
253
  errors: number | null;
@@ -349,6 +370,10 @@ export function buildUsageReport(
349
370
  digests: t.digests ?? 0,
350
371
  digestSpendUsd: t.digest_spend,
351
372
  digestInputTokens: t.digest_input,
373
+ digestReruns: t.digest_reruns ?? 0,
374
+ forecastSamples: t.fc_n ?? 0,
375
+ forecastMeanError: (t.fc_n ?? 0) > 0 ? t.fc_err / (t.fc_n ?? 1) : 0,
376
+ forecastOverShare: (t.fc_n ?? 0) > 0 ? (t.fc_over ?? 0) / (t.fc_n ?? 1) : 0,
352
377
  },
353
378
  providers,
354
379
  models,
@@ -414,7 +439,12 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
414
439
  );
415
440
  }
416
441
  if (t.digests > 0) {
417
- summary.push(`digests: ${num(t.digests)} tool results condensed (${num(t.digestInputTokens)} tok read by a cheap model) for ${usd(t.digestSpendUsd)}`);
442
+ summary.push(
443
+ `digests: ${num(t.digests)} tool results condensed (${num(t.digestInputTokens)} tok read by a cheap model) for ${usd(t.digestSpendUsd)} · re-run rate ${pct(t.digestReruns / t.digests)} (${num(t.digestReruns)} fetched again in full)`,
444
+ );
445
+ }
446
+ if (t.forecastSamples > 0) {
447
+ summary.push(`forecast: mean error ${pct(t.forecastMeanError)} of reported cost over ${num(t.forecastSamples)} turns · ${pct(t.forecastOverShare)} over-predicted`);
418
448
  }
419
449
  if (t.subagentDispatches > 0) {
420
450
  summary.push(`subagents: ${num(t.subagentDispatches)} dispatches, ${usd(t.subagentSpendUsd)} (${pct(t.spendUsd > 0 ? t.subagentSpendUsd / t.spendUsd : 0)} of spend)`);
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The daily summary: what the router did in the last 24 hours, in a few
3
+ * lines. Posted into the transcript once a day at omp session start
4
+ * (`report.dailySummary`) and on demand via `/router summary` or
5
+ * `GET /v1/router/summary`.
6
+ *
7
+ * Built from the same `buildUsageReport` the report hub uses, over a 1-day
8
+ * window, with the preceding day for comparison, plus the two live signals the
9
+ * report cannot carry: soft-failure spikes (the last hour) and the Ollama
10
+ * meter. The once-a-day gate is a marker in `router_kv`, keyed per harness, so
11
+ * several omp windows on one router share it and a restart does not repeat it.
12
+ */
13
+
14
+ import type { Database } from "bun:sqlite";
15
+ import { TIER_ORDER } from "../router/types.ts";
16
+ import { buildUsageReport, type BaselinePrice, type BaselineRow, type UsageReport } from "./report.ts";
17
+ import type { SoftFailureSpike } from "./types.ts";
18
+
19
+ /** One 24-hour window's headline numbers. */
20
+ export interface SummaryWindow {
21
+ spendUsd: number;
22
+ dispatches: number;
23
+ conversations: number;
24
+ cacheHitRate: number;
25
+ cacheEstimated: boolean;
26
+ escalations: number;
27
+ errors: number;
28
+ modelSwitches: number;
29
+ digests: number;
30
+ digestSpendUsd: number;
31
+ subagentSpendUsd: number;
32
+ }
33
+
34
+ export interface SummaryModel {
35
+ slug: string;
36
+ spendUsd: number;
37
+ share: number;
38
+ dispatches: number;
39
+ }
40
+
41
+ export interface SummaryOllama {
42
+ plan: string | null;
43
+ usedUsd: number;
44
+ creditsUsd: number;
45
+ /** Days of credits left at the recent burn; null when the burn is zero. */
46
+ runwayDays: number | null;
47
+ }
48
+
49
+ export interface DailySummary {
50
+ generatedAtMs: number;
51
+ sinceMs: number;
52
+ /** Empty ⇒ every harness. */
53
+ harnessId: string;
54
+ current: SummaryWindow;
55
+ /** The 24 hours before `sinceMs`. */
56
+ previous: SummaryWindow;
57
+ /** Top models by spend in the current window. */
58
+ topModels: SummaryModel[];
59
+ /** Tier moves between consecutive kept turns of one conversation. */
60
+ tierChanges: { up: number; down: number };
61
+ /** The first configured baseline the catalog knew, when any. */
62
+ baseline: BaselineRow | null;
63
+ spikes: SoftFailureSpike[];
64
+ ollama: SummaryOllama | null;
65
+ }
66
+
67
+ const DAY_MS = 86_400_000;
68
+ /** How many top models the summary names. */
69
+ const TOP_MODELS = 3;
70
+
71
+ function windowOf(r: UsageReport): SummaryWindow {
72
+ const t = r.totals;
73
+ return {
74
+ spendUsd: t.spendUsd,
75
+ dispatches: t.dispatches,
76
+ conversations: t.conversations,
77
+ cacheHitRate: t.cacheHitRate,
78
+ cacheEstimated: t.cacheEstimated,
79
+ escalations: t.escalations,
80
+ errors: t.errors,
81
+ modelSwitches: t.modelSwitches,
82
+ digests: t.digests,
83
+ digestSpendUsd: t.digestSpendUsd,
84
+ subagentSpendUsd: t.subagentSpendUsd,
85
+ };
86
+ }
87
+
88
+ /** Counts tier moves up and down between consecutive kept turns of each conversation since `sinceMs`. */
89
+ export function countTierChanges(db: Database, sinceMs: number, harnessId: string): { up: number; down: number } {
90
+ const where = harnessId === "" ? "created_at_ms >= $since" : "created_at_ms >= $since AND harness_id = $harness";
91
+ const bind = harnessId === "" ? { $since: sinceMs } : { $since: sinceMs, $harness: harnessId };
92
+ const seq = db
93
+ .query(`SELECT conversation_key AS ck, tier FROM ledger WHERE ${where} AND wasted = 0 AND requested_model <> 'digest' ORDER BY conversation_key, created_at_ms`)
94
+ .all(bind) as { ck: string; tier: string }[];
95
+ let up = 0;
96
+ let down = 0;
97
+ for (let i = 1; i < seq.length; i++) {
98
+ const a = seq[i - 1]!;
99
+ const b = seq[i]!;
100
+ if (a.ck !== b.ck) continue;
101
+ const ra = TIER_ORDER.indexOf(a.tier as (typeof TIER_ORDER)[number]);
102
+ const rb = TIER_ORDER.indexOf(b.tier as (typeof TIER_ORDER)[number]);
103
+ if (ra < 0 || rb < 0 || ra === rb) continue;
104
+ if (rb > ra) up++;
105
+ else down++;
106
+ }
107
+ return { up, down };
108
+ }
109
+
110
+ export function buildDailySummary(
111
+ db: Database,
112
+ opts: {
113
+ harnessId?: string;
114
+ nowMs?: number;
115
+ baselines?: readonly BaselinePrice[];
116
+ spikes?: readonly SoftFailureSpike[];
117
+ ollama?: SummaryOllama | null;
118
+ } = {},
119
+ ): DailySummary {
120
+ const nowMs = opts.nowMs ?? Date.now();
121
+ const harnessId = opts.harnessId ?? "";
122
+ const baselines = opts.baselines ?? [];
123
+ const current = buildUsageReport(db, { windowDays: 1, harnessId, nowMs, baselines });
124
+ const previous = buildUsageReport(db, { windowDays: 1, harnessId, nowMs: nowMs - DAY_MS, untilMs: current.sinceMs });
125
+ return {
126
+ generatedAtMs: nowMs,
127
+ sinceMs: current.sinceMs,
128
+ harnessId,
129
+ current: windowOf(current),
130
+ previous: windowOf(previous),
131
+ topModels: current.models.slice(0, TOP_MODELS).map((m) => ({ slug: m.key, spendUsd: m.spendUsd, share: m.share, dispatches: m.dispatches })),
132
+ tierChanges: countTierChanges(db, current.sinceMs, harnessId),
133
+ baseline: current.baselines[0] ?? null,
134
+ spikes: [...(opts.spikes ?? [])],
135
+ ollama: opts.ollama ?? null,
136
+ };
137
+ }
138
+
139
+ /** Whether the once-a-day auto summary is worth posting: something happened, or something is wrong. */
140
+ export function summaryHasNews(s: DailySummary): boolean {
141
+ return s.current.dispatches > 0 || s.spikes.length > 0;
142
+ }
143
+
144
+ const usd = (v: number): string => (v >= 100 ? `$${v.toFixed(0)}` : v >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(3)}`);
145
+ const pct = (v: number, estimated = false): string => `${estimated ? "~" : ""}${Math.round(v * 100)}%`;
146
+
147
+ function delta(current: number, previous: number): string {
148
+ if (previous <= 0) return current > 0 ? " (prev 24h: none)" : "";
149
+ const change = (current - previous) / previous;
150
+ const sign = change >= 0 ? "+" : "−";
151
+ return ` (prev 24h ${usd(previous)}, ${sign}${Math.round(Math.abs(change) * 100)}%)`;
152
+ }
153
+
154
+ /** Renders the summary as a few plain lines for the transcript. */
155
+ export function renderDailySummary(s: DailySummary): string {
156
+ const scope = s.harnessId === "" ? "all harnesses" : `harness ${s.harnessId}`;
157
+ const out: string[] = [`auto-model-router daily summary — last 24h (${scope})`];
158
+ const c = s.current;
159
+ if (c.dispatches === 0) {
160
+ out.push("no routed turns in the last 24h");
161
+ } else {
162
+ out.push(
163
+ `spend ${usd(c.spendUsd)}${delta(c.spendUsd, s.previous.spendUsd)} · ${c.dispatches} turns · ${c.conversations} conversations · ${usd(c.spendUsd / c.dispatches)}/turn`,
164
+ );
165
+ const moves = c.modelSwitches > 0 ? ` (${s.tierChanges.up} tier up, ${s.tierChanges.down} down)` : "";
166
+ out.push(`cache hit ${pct(c.cacheHitRate, c.cacheEstimated)} · ${c.escalations} escalations · ${c.errors} errors · ${c.modelSwitches} model switches${moves}`);
167
+ if (s.topModels.length > 0) {
168
+ out.push(`top models: ${s.topModels.map((m) => `${m.slug} ${usd(m.spendUsd)} (${pct(m.share)}, ${m.dispatches} turns)`).join(" · ")}`);
169
+ }
170
+ if (s.baseline !== null && s.baseline.usd > 0) {
171
+ const b = s.baseline;
172
+ out.push(b.savedShare >= 0 ? `saved ${pct(b.savedShare)} vs ${b.slug} (${usd(b.usd)} at list)` : `cost ${pct(-b.savedShare)} MORE than ${b.slug} (${usd(b.usd)} at list)`);
173
+ }
174
+ const extras: string[] = [];
175
+ if (c.digests > 0) extras.push(`${c.digests} digests for ${usd(c.digestSpendUsd)}`);
176
+ if (c.subagentSpendUsd > 0) extras.push(`subagents ${usd(c.subagentSpendUsd)}`);
177
+ if (extras.length > 0) out.push(extras.join(" · "));
178
+ }
179
+ if (s.spikes.length === 0) out.push("soft failures: no model spiking in the last hour");
180
+ else {
181
+ out.push(`soft failures SPIKING (${s.spikes.length}):`);
182
+ for (const sp of s.spikes) {
183
+ out.push(` ${sp.slug}: ${pct(sp.recentRate)} of ${sp.recentDispatches} failed in the last 1h (7d baseline ${pct(sp.baselineRate)} of ${sp.baselineDispatches})`);
184
+ }
185
+ }
186
+ const o = s.ollama;
187
+ if (o !== null) {
188
+ const runway = o.runwayDays === null ? "" : ` · ~${Math.round(o.runwayDays)} days of credits left`;
189
+ out.push(`ollama: ${o.plan === null ? "plan" : `${o.plan} plan`} $${o.usedUsd.toFixed(2)} of $${o.creditsUsd}${runway}`);
190
+ }
191
+ return out.join("\n");
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Once-a-day gate
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /** A summary posted less than this long ago is not due again. */
199
+ export const DAILY_SUMMARY_INTERVAL_MS = 20 * 3_600_000;
200
+
201
+ export interface KeyValueStore {
202
+ get(key: string): string | null;
203
+ set(key: string, value: string): void;
204
+ }
205
+
206
+ /** A tiny durable key/value store over the `router_kv` table. */
207
+ export function createKv(db: Database): KeyValueStore {
208
+ const getStmt = db.query("SELECT value FROM router_kv WHERE key = $key");
209
+ const setStmt = db.query("INSERT INTO router_kv (key, value, updated_at_ms) VALUES ($key, $value, $at) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at_ms = excluded.updated_at_ms");
210
+ return {
211
+ get(key) {
212
+ const row = getStmt.get({ $key: key }) as { value: string } | null;
213
+ return row === null ? null : row.value;
214
+ },
215
+ set(key, value) {
216
+ setStmt.run({ $key: key, $value: value, $at: Date.now() });
217
+ },
218
+ };
219
+ }
220
+
221
+ const shownKey = (harnessId: string): string => `daily_summary_shown:${harnessId}`;
222
+
223
+ /** True when no auto summary has been posted for this harness within the interval. */
224
+ export function summaryDue(kv: KeyValueStore, harnessId: string, nowMs = Date.now()): boolean {
225
+ const last = Number(kv.get(shownKey(harnessId)) ?? "0");
226
+ return !(Number.isFinite(last) && nowMs - last < DAILY_SUMMARY_INTERVAL_MS);
227
+ }
228
+
229
+ export function markSummaryShown(kv: KeyValueStore, harnessId: string, nowMs = Date.now()): void {
230
+ kv.set(shownKey(harnessId), String(nowMs));
231
+ }
package/src/cost/types.ts CHANGED
@@ -230,6 +230,25 @@ export interface EscalationCost {
230
230
  windowDays: number;
231
231
  }
232
232
 
233
+ /**
234
+ * A model whose recent failure rate (probe rejections OpenRouter counts as
235
+ * success, plus attributable transport errors) is well above its own
236
+ * baseline. Visibility only: the ledger data showed soft failures do not
237
+ * cluster tightly enough for a breaker to save money, so the router reports
238
+ * spikes (/health, /router status, the daily summary) rather than acting.
239
+ */
240
+ export interface SoftFailureSpike {
241
+ slug: string;
242
+ /** Dispatches and failures in the recent window. */
243
+ recentDispatches: number;
244
+ recentFailures: number;
245
+ recentRate: number;
246
+ /** The same, over the baseline window (recent window excluded). */
247
+ baselineDispatches: number;
248
+ baselineFailures: number;
249
+ baselineRate: number;
250
+ }
251
+
233
252
  export interface Ledger {
234
253
  record(entry: LedgerEntry): void;
235
254
  /** Total reported (or predicted, when reported is null) spend for a conversation. */
@@ -240,8 +259,12 @@ export interface Ledger {
240
259
  */
241
260
  spendSince(sinceMs: number, harnessId?: string): number;
242
261
  blendedRate(windowDays: number): BlendedRate | null;
243
- /** Per-model reliability over the ledger, optionally scoped to a harness. */
244
- trust(slug: string, harnessId?: string): ModelTrust | null;
262
+ /**
263
+ * Per-model reliability over the ledger, optionally scoped to a harness.
264
+ * `task` (with `filters.feedbackByTask`) counts only verdicts given on
265
+ * turns of that task type, plus verdicts on turns with no recorded task.
266
+ */
267
+ trust(slug: string, harnessId?: string, task?: string): ModelTrust | null;
245
268
  allTrust(): ModelTrust[];
246
269
  /**
247
270
  * Per-model responsiveness (mean TTFT + completion throughput), optionally
@@ -251,7 +274,7 @@ export interface Ledger {
251
274
  */
252
275
  latency(slug: string, harnessId?: string): ModelLatency | null;
253
276
  /** Batch trust and latency for one candidate set; one query per signal kind. Optional — callers can fall back to per-slug calls. */
254
- signals?(slugs: readonly string[], harnessId?: string): Map<string, LedgerSignals>;
277
+ signals?(slugs: readonly string[], harnessId?: string, task?: string): Map<string, LedgerSignals>;
255
278
  /**
256
279
  * What an escalated retry actually bills per prompt token, measured over
257
280
  * the last `windowDays` of attempt > 0 rows. Null until enough escalated
@@ -271,8 +294,17 @@ export interface Ledger {
271
294
  recentEntries(limit: number): LedgerEntry[];
272
295
  /** Spend since an instant on slugs with a prefix (`ollama/`), for provider-level reconciliation. Optional. */
273
296
  providerSpendSince?(slugPrefix: string, sinceMs: number): number;
297
+ /**
298
+ * Models whose soft-failure rate over the last `recentMs` is a spike against
299
+ * their own rate over the preceding `baselineMs`. Optional; visibility only.
300
+ */
301
+ softFailureSpikes?(nowMs?: number, recentMs?: number, baselineMs?: number): SoftFailureSpike[];
274
302
  /** Newest kept (non-wasted) entry for an omp session, for /router why and feedback. Optional so fakes need not implement it. */
275
303
  latestForSession?(ompSessionId: string): LedgerEntry | null;
276
304
  /** Newest entries for an omp session, newest first. Optional. */
277
305
  entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
306
+ /** Deletes rows older than `retentionDays` (0 ⇒ none); returns how many. Optional. */
307
+ prune?(retentionDays: number, nowMs?: number): number;
308
+ /** Marks one row wasted after the fact (a digest the agent went back on). Optional. */
309
+ markWasted?(id: string): void;
278
310
  }
@@ -263,7 +263,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
263
263
  const signals = args.signals;
264
264
  const trust =
265
265
  signals?.get(slug)?.trust ??
266
- ledger?.trust(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ??
266
+ ledger?.trust(slug, filters.trustScopedByHarness ? req.harnessId : undefined, filters.feedbackByTask ? task : undefined) ??
267
267
  null;
268
268
  if (!relaxTrust && trust !== null && trust.attempts >= filters.minTrustSamples && trust.successRate < filters.minTrust) {
269
269
  rejected.push({
@@ -10,7 +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
+ import { learnedRiskName, loadLearnedModel, predictRisk } from "./learned.ts";
14
14
  import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
15
15
  import type { Classification, Features, TaskType, Tier } from "./types.ts";
16
16
 
@@ -313,7 +313,7 @@ export async function classify(
313
313
  if (model !== null) {
314
314
  const risk = predictRisk(model, f);
315
315
  heuristic.learnedRisk = risk;
316
- heuristic.reasons.push(`learned: p(escalate)=${risk.toFixed(3)}`);
316
+ heuristic.reasons.push(`learned: p(${learnedRiskName(model)})=${risk.toFixed(3)}`);
317
317
  }
318
318
  }
319
319
  if (cc.ambiguityThreshold <= 0 || heuristic.confidence >= cc.ambiguityThreshold) return heuristic;
@@ -33,6 +33,7 @@ const BREADCRUMB_BYTES = 120;
33
33
  */
34
34
  export function compactedBytes(originalBytes: number, edit: CompactionEdit | undefined): number {
35
35
  if (edit === undefined) return originalBytes;
36
+ if (edit.digest !== undefined) return Math.min(originalBytes, Buffer.byteLength(edit.digest));
36
37
  const kept = edit.mode === "stub" ? BREADCRUMB_BYTES : edit.keepHead + edit.keepTail + BREADCRUMB_BYTES;
37
38
  return Math.min(originalBytes, kept);
38
39
  }
@@ -63,7 +64,7 @@ export function validatePlan(
63
64
  * for the resource a call operates on (a `path`, `id`, `query`, ...). Used to
64
65
  * detect when a later call supersedes an earlier read of the same resource.
65
66
  */
66
- function primaryArg(argsJson: string): string | null {
67
+ export function primaryArg(argsJson: string): string | null {
67
68
  try {
68
69
  const parsed: unknown = JSON.parse(argsJson);
69
70
  if (parsed !== null && typeof parsed === "object") {
@@ -77,8 +77,13 @@ export function learnedVector(f: Partial<Features>): number[] {
77
77
  ];
78
78
  }
79
79
 
80
+ /** What a learned model's positive class means. */
81
+ export type LearnedLabel = "escalation" | "feedback";
82
+
80
83
  export interface LearnedModel {
81
84
  version: number;
85
+ /** Positive class: the turn escalated (default), or the user judged it bad. */
86
+ label?: LearnedLabel;
82
87
  trainedAtMs: number;
83
88
  rows: number;
84
89
  positives: number;
@@ -93,7 +98,12 @@ export interface LearnedModel {
93
98
 
94
99
  const sigmoid = (z: number): number => 1 / (1 + Math.exp(-z));
95
100
 
96
- /** P(escalate) for one turn under a model. */
101
+ /** The positive-class name a decision reason should print for a model. */
102
+ export function learnedRiskName(model: LearnedModel): string {
103
+ return model.label === "feedback" ? "bad" : "escalate";
104
+ }
105
+
106
+ /** P(positive class) for one turn under a model: p(escalate), or p(bad) for a feedback-labelled model. */
97
107
  export function predictRisk(model: LearnedModel, f: Partial<Features>): number {
98
108
  const x = learnedVector(f);
99
109
  let z = model.bias;