auto-model-router 0.30.3 → 0.32.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 (112) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +32 -2
  3. package/omp-extension/router-configure.ts +9 -7
  4. package/package.json +1 -1
  5. package/src/cli/config-cmd.ts +8 -7
  6. package/src/cli/explain.ts +10 -5
  7. package/src/cli/export.ts +6 -5
  8. package/src/cli/models.ts +10 -7
  9. package/src/cli/report.ts +6 -1
  10. package/src/cli/stats.ts +7 -7
  11. package/src/config/load.ts +10 -1
  12. package/src/config/types.ts +10 -1
  13. package/src/context/bridge.ts +7 -7
  14. package/src/context/index.ts +3 -3
  15. package/src/context/store.ts +39 -56
  16. package/src/context/types.ts +7 -6
  17. package/src/cost/blended.ts +28 -7
  18. package/src/cost/feedback.ts +33 -37
  19. package/src/cost/ledger-sql.ts +547 -0
  20. package/src/cost/ledger.ts +30 -459
  21. package/src/cost/report.ts +171 -129
  22. package/src/cost/retention.ts +10 -10
  23. package/src/cost/summary.ts +15 -10
  24. package/src/cost/types.ts +43 -62
  25. package/src/cost/views.ts +79 -49
  26. package/src/eval/calibrate.ts +47 -12
  27. package/src/eval/run.ts +18 -2
  28. package/src/lib.ts +6 -2
  29. package/src/router/candidates.ts +7 -15
  30. package/src/router/classify.ts +6 -4
  31. package/src/router/index.ts +95 -9
  32. package/src/router/select.ts +38 -21
  33. package/src/router/state.ts +90 -102
  34. package/src/router/types.ts +11 -5
  35. package/src/server/advise.ts +6 -4
  36. package/src/server/compaction-digest.ts +1 -1
  37. package/src/server/digest.ts +9 -10
  38. package/src/server/http.ts +109 -46
  39. package/src/server/providers.ts +18 -4
  40. package/src/server/turn.ts +32 -9
  41. package/src/tokens/estimate.ts +16 -6
  42. package/src/upstream/ollama-usage.ts +21 -11
  43. package/src/util/schema.ts +201 -0
  44. package/src/util/sql.ts +246 -0
  45. package/src/wire/anthropic/messages.ts +3 -4
  46. package/src/wire/openai/request.ts +1 -0
  47. package/src/wire/types.ts +7 -0
  48. package/test/anthropic-wire.test.ts +9 -9
  49. package/test/benchmark-feeds.test.ts +7 -7
  50. package/test/cache-control.test.ts +7 -7
  51. package/test/cache-estimate.test.ts +5 -5
  52. package/test/catalog-view.test.ts +4 -4
  53. package/test/catalog.test.ts +11 -11
  54. package/test/classify.test.ts +24 -24
  55. package/test/compaction.test.ts +20 -20
  56. package/test/config-wizard.test.ts +32 -32
  57. package/test/config.test.ts +10 -10
  58. package/test/connect-harnesses.test.ts +11 -11
  59. package/test/context-bridge.test.ts +40 -30
  60. package/test/context-prune.test.ts +43 -36
  61. package/test/context-query.test.ts +8 -8
  62. package/test/controls.test.ts +54 -27
  63. package/test/cost.test.ts +12 -12
  64. package/test/digest.test.ts +55 -44
  65. package/test/embed-lifecycle.test.ts +5 -5
  66. package/test/embed-logic.test.ts +26 -26
  67. package/test/escalate.test.ts +17 -17
  68. package/test/eval.test.ts +73 -16
  69. package/test/executable.test.ts +6 -6
  70. package/test/exploration.test.ts +19 -20
  71. package/test/failover.test.ts +22 -21
  72. package/test/fakes.ts +105 -0
  73. package/test/features.test.ts +21 -21
  74. package/test/harness-requests.test.ts +3 -3
  75. package/test/harness-switch.test.ts +5 -5
  76. package/test/hold-exploration.test.ts +13 -13
  77. package/test/hot-reload.test.ts +5 -5
  78. package/test/learned.test.ts +5 -5
  79. package/test/ledger-sql.test.ts +342 -0
  80. package/test/mcp-entry.test.ts +5 -5
  81. package/test/migrations.test.ts +28 -22
  82. package/test/models-yml.test.ts +18 -18
  83. package/test/ollama.test.ts +40 -34
  84. package/test/omp-credentials.test.ts +16 -16
  85. package/test/policy.test.ts +3 -3
  86. package/test/reconfigure.test.ts +4 -4
  87. package/test/redaction.test.ts +41 -35
  88. package/test/remote.test.ts +12 -12
  89. package/test/report-logic.test.ts +8 -8
  90. package/test/report.test.ts +95 -87
  91. package/test/retention.test.ts +79 -66
  92. package/test/schema.test.ts +123 -0
  93. package/test/scope.test.ts +8 -8
  94. package/test/select.test.ts +216 -257
  95. package/test/skills.test.ts +3 -3
  96. package/test/sql-shim.test.ts +154 -0
  97. package/test/state.test.ts +43 -36
  98. package/test/summary.test.ts +38 -27
  99. package/test/tier-plan.test.ts +45 -62
  100. package/test/toast-logic.test.ts +31 -31
  101. package/test/tokens.test.ts +95 -80
  102. package/test/trust-attribution.test.ts +217 -187
  103. package/test/trust-window.test.ts +37 -32
  104. package/test/turn.test.ts +55 -23
  105. package/test/upstreams.test.ts +13 -13
  106. package/test/views.test.ts +81 -59
  107. package/test/wire-request.test.ts +17 -17
  108. package/test/wire-responses.test.ts +4 -4
  109. package/tools/agentdox-e2e.ts +5 -2
  110. package/tools/export-benchmarks.ts +5 -5
  111. package/tools/ledger-parity.ts +266 -0
  112. package/tools/replay.ts +16 -8
@@ -1,10 +1,11 @@
1
- import { mkdirSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
3
4
  import type { Server } from "bun";
4
5
  import { createProviders } from "./providers.ts";
5
6
  import { createBridgeFromConfig } from "../context/index.ts";
6
7
  import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
7
- import { createLedger } from "../cost/ledger.ts";
8
+ import { createSqlLedger } from "../cost/ledger-sql.ts";
8
9
  import { createRetentionRunner } from "../cost/retention.ts";
9
10
  import { redactionRulesFor } from "../config/redaction.ts";
10
11
  import { createSessionOverrides } from "./overrides.ts";
@@ -18,7 +19,7 @@ import { runEval, type Completer } from "../eval/run.ts";
18
19
  import type { ToolSpec } from "../eval/agentic.ts";
19
20
  import type { ToolCall } from "../upstream/types.ts";
20
21
  import type { QualityAxis } from "../config/types.ts";
21
- import { fitCalibration, pickAnchors, toLocalFeedScores, MIN_ANCHORS } from "../eval/calibrate.ts";
22
+ import { fitCalibration, hardRaw, pickAnchors, toLocalFeedScores, MIN_ANCHORS, PUBLISH_MIN_R } from "../eval/calibrate.ts";
22
23
  import { makeJudge } from "../eval/judge.ts";
23
24
  import { loadLocalScores, saveLocalScores } from "../catalog/benchmark-feeds.ts";
24
25
  import { advise } from "./advise.ts";
@@ -27,11 +28,11 @@ import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/rep
27
28
  import { decisionEntries, exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
28
29
  import { anthropicErrorResponse, countAnthropicTokens, createMessagesWire } from "../wire/anthropic/messages.ts";
29
30
  import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
30
- import type { Ledger, ModelTrust } from "../cost/types.ts";
31
+ import type { AsyncLedger, ModelTrust } from "../cost/types.ts";
31
32
  import { createRouter } from "../router/index.ts";
32
33
  import { createConversationStore } from "../router/state.ts";
33
34
  import { UpstreamError } from "../upstream/types.ts";
34
- import { apiKeySource, ollamaKeySource } from "../config/load.ts";
35
+ import { apiKeySource, ollamaKeySource, routerHome } from "../config/load.ts";
35
36
  import { ollamaMeter } from "../upstream/ollama-usage.ts";
36
37
  import { routerConfigPath } from "../cli/config-cmd.ts";
37
38
  import { applyConfigPatch, touched } from "../config/apply.ts";
@@ -40,6 +41,8 @@ import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
40
41
  import type { RouterConfig } from "../config/types.ts";
41
42
  import { createLogger } from "../util/log.ts";
42
43
  import { openDb } from "../util/sqlite.ts";
44
+ import { dialectOf, openSqlDb } from "../util/sql.ts";
45
+ import { migrateStore } from "../util/schema.ts";
43
46
  import { WireErrorException, renderErrorEnvelope } from "../wire/openai/errors.ts";
44
47
  import { renderModelList } from "../wire/openai/models.ts";
45
48
  import { parseChatRequest } from "../wire/openai/request.ts";
@@ -110,14 +113,14 @@ export interface RouterStats {
110
113
  * computed over a bounded tail of recent entries; the headline spend numbers
111
114
  * use `spendSince`, which is exact.
112
115
  */
113
- export function computeStats(ledger: Ledger, opts?: { windowDays?: number; nowMs?: number }): RouterStats {
116
+ export async function computeStats(ledger: AsyncLedger, opts?: { windowDays?: number; nowMs?: number }): Promise<RouterStats> {
114
117
  const nowMs = opts?.nowMs ?? Date.now();
115
118
  const windowDays = opts?.windowDays;
116
119
  const cutoffMs = windowDays === undefined ? 0 : nowMs - windowDays * 86_400_000;
117
120
 
118
121
  // 100k turns is operational eternity for a single-operator router; the cap
119
122
  // only bounds memory on this read, never what the ledger retains.
120
- const entries = ledger.recentEntries(100_000).filter((e) => e.createdAtMs >= cutoffMs);
123
+ const entries = (await ledger.recentEntries(100_000)).filter((e) => e.createdAtMs >= cutoffMs);
121
124
 
122
125
  const dayStart = new Date(nowMs);
123
126
  dayStart.setHours(0, 0, 0, 0);
@@ -157,16 +160,16 @@ export function computeStats(ledger: Ledger, opts?: { windowDays?: number; nowMs
157
160
  return {
158
161
  generatedAtMs: nowMs,
159
162
  windowDays: windowDays ?? null,
160
- spendTodayUsd: ledger.spendSince(dayStart.getTime()),
161
- spend7dUsd: ledger.spendSince(nowMs - 7 * 86_400_000),
162
- spendAllTimeUsd: ledger.spendSince(0),
163
+ spendTodayUsd: await ledger.spendSince(dayStart.getTime()),
164
+ spend7dUsd: await ledger.spendSince(nowMs - 7 * 86_400_000),
165
+ spendAllTimeUsd: await ledger.spendSince(0),
163
166
  windowSpendUsd,
164
167
  requests: entries.length,
165
168
  escalations,
166
169
  escalationRate: entries.length > 0 ? escalations / entries.length : 0,
167
170
  meanPredictionError: errorSamples > 0 ? errorSum / errorSamples : null,
168
171
  perModel: rows,
169
- trust: ledger.allTrust(),
172
+ trust: await ledger.allTrust(),
170
173
  };
171
174
  }
172
175
 
@@ -250,16 +253,47 @@ function clampDays(raw: string | null, dflt: number): number {
250
253
  export function startServer(cfg: RouterConfig): StartedServer {
251
254
  const log = createLogger(cfg.logLevel);
252
255
 
253
- if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
254
- const db = openDb(cfg.ledger.path);
255
- const ledger = createLedger(db, cfg);
256
- const providers = createProviders(cfg, db, log);
256
+ // `:memory:` names a store that cannot be shared: a second handle gets its
257
+ // OWN empty database, so the ledger views would query a table that does not
258
+ // exist there. Resolve it to a private file instead — still discarded, still
259
+ // isolated per server, but one store that both handles agree on.
260
+ const memoryStore = cfg.ledger.path === ":memory:" ? mkdtempSync(join(tmpdir(), "amr-mem-")) : null;
261
+ // The config is NOT rewritten: a caller that asked for `:memory:` still
262
+ // reads `:memory:` back, and `reconfigure` refuses a ledger-path change by
263
+ // comparing against what it was given.
264
+ const storePath = memoryStore === null ? cfg.ledger.path : join(memoryStore, "router.db");
265
+ // Two stores, chosen by what the data is for:
266
+ // - the SHARED store (`ledger.path`, SQLite file or Postgres URL) holds
267
+ // what a second replica must see the same copy of: the turn rows a cap
268
+ // counts, conversation routing memory, context blocks.
269
+ // - the LOCAL store is always a SQLite file and holds only caches — the
270
+ // catalog payload, benchmark feeds, the once-a-day summary marker.
271
+ // Sharing a cache buys contention and nothing else, and its one durable
272
+ // output (`local_scores`) belongs to the machine that measured it.
273
+ // On SQLite they are the same file, which is what every existing install
274
+ // already has.
275
+ const postgres = dialectOf(storePath) === "postgres";
276
+ const cachePath = postgres ? join(routerHome(), "cache.db") : storePath;
277
+ mkdirSync(dirname(cachePath), { recursive: true });
278
+ // `openDb` is the migration path for a SQLite file: nineteen versions, in
279
+ // order, on whatever an older release left behind.
280
+ const db = openDb(cachePath);
281
+ const sqlDb = openSqlDb(storePath);
282
+ // A Postgres store has no bootstrap of its own to run synchronously, so the
283
+ // shape is created on the way up and every entry point waits for it once.
284
+ // Resolved already on SQLite, where `openDb` just did it.
285
+ const storeReady = postgres ? migrateStore(sqlDb) : Promise.resolve();
286
+ // The ledger reads and writes through the engine-agnostic handle. `findModel`
287
+ // closes over the catalog built just below: a shared store has no catalog
288
+ // cache of its own to price a row from.
289
+ const ledger = createSqlLedger(sqlDb, cfg, { findModel: (slug: string) => catalog.find(slug) });
290
+ const providers = createProviders(cfg, db, sqlDb, log);
257
291
  const { upstream, catalog, ollama, ollamaServing, ollamaUsage, ollamaCostScale } = providers;
258
- const conversations = createConversationStore(db);
292
+ const conversations = createConversationStore(sqlDb);
259
293
  const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
260
- const context = createBridgeFromConfig(cfg, db);
294
+ const context = createBridgeFromConfig(cfg, sqlDb);
261
295
  const overrides = createSessionOverrides();
262
- const feedback = createFeedbackStore(db);
296
+ const feedback = createFeedbackStore(sqlDb);
263
297
  const kv = createKv(db);
264
298
  /**
265
299
  * Background local-benchmark runs. In memory and not persisted: a run is an explicit,
@@ -341,9 +375,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
341
375
  // run a whole-ledger delete more often than that. It reads the window live,
342
376
  // so a hot reload that lowers it applies on the next tick.
343
377
  const retention = createRetentionRunner({ ledger, retentionDays: () => cfg.ledger.retentionDays });
344
- const retain = (): void => {
378
+ const retain = async (): Promise<void> => {
379
+ await storeReady;
345
380
  try {
346
- const result = retention.maybeRun();
381
+ const result = await retention.maybeRun();
347
382
  if (result !== null && result.deleted > 0) {
348
383
  log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
349
384
  }
@@ -355,8 +390,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
355
390
  // One housekeeping timer for all three tables. `unref`'d so it never holds
356
391
  // the process open.
357
392
  const pruneTimer = setInterval(() => {
393
+ // Fire and forget, with both halves guarded: a housekeeping failure must
394
+ // never become an unhandled rejection that takes the process down.
395
+ void (async () => {
396
+ await storeReady;
358
397
  try {
359
- const dropped = conversations.prune(cfg.ledger.conversationTtlMs);
398
+ const dropped = await conversations.prune(cfg.ledger.conversationTtlMs);
360
399
  if (dropped > 0) log.debug("pruned stale conversations", { dropped });
361
400
  } catch (err) {
362
401
  log.warn("conversation prune failed", { error: err instanceof Error ? err.message : String(err) });
@@ -366,18 +405,21 @@ export function startServer(cfg: RouterConfig): StartedServer {
366
405
  // block of that age has no future reader. Nothing else reclaims these:
367
406
  // blocks are content-addressed and shared, so they accumulated for the
368
407
  // life of the install (measured: 220 rows / 2.7 MB, 68 unreferenced).
369
- const dropped = context.pruneBlocks(cfg.context.maxStalenessMs);
408
+ const dropped = await context.pruneBlocks(cfg.context.maxStalenessMs);
370
409
  if (dropped > 0) log.debug("pruned unreferenced context blocks", { dropped });
371
410
  } catch (err) {
372
411
  log.warn("context block prune failed", { error: err instanceof Error ? err.message : String(err) });
373
412
  }
374
- retain();
413
+ await retain();
414
+ })();
375
415
  }, 60_000);
376
416
  pruneTimer.unref();
377
417
 
378
418
  // Once shortly after boot, so a lowered window takes effect without waiting
379
419
  // out an hour; the runner's own floor governs everything after that.
380
- setTimeout(retain, 5_000).unref();
420
+ // `void`: a boot-time prune failure is logged inside `retain`, and an
421
+ // unhandled rejection here would take the process down.
422
+ setTimeout(() => void retain(), 5_000).unref();
381
423
 
382
424
  // Periodically refetch the (key-scoped) catalog in the background so
383
425
  // guardrail/preference changes are picked up without needing traffic and a
@@ -483,6 +525,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
483
525
  // SECONDS (max 255), so convert from the ms upstream timeout and cap.
484
526
  idleTimeout: Math.min(Math.ceil(cfg.openrouter.timeoutMs / 1000), 255),
485
527
  async fetch(req: Request): Promise<Response> {
528
+ // One await on the first request of a Postgres deployment, already
529
+ // resolved on SQLite: no route may read a table the bootstrap has
530
+ // not created yet.
531
+ await storeReady;
486
532
  // Reject requests whose Host header does not name a loopback address
487
533
  // when the server is bound to loopback. This blunts DNS rebinding: a
488
534
  // malicious page resolving a host to 127.0.0.1 sends its own domain as
@@ -523,17 +569,19 @@ export function startServer(cfg: RouterConfig): StartedServer {
523
569
  }
524
570
  if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
525
571
  try {
526
- return json({ input_tokens: countAnthropicTokens(await req.json(), cfg.anthropic.models, ledger) });
572
+ return json({
573
+ input_tokens: countAnthropicTokens(await req.json(), cfg.anthropic.models, await ledger.tokenRatio("anthropic")),
574
+ });
527
575
  } catch (err) {
528
576
  if (err instanceof WireErrorException) return anthropicErrorResponse(err.wireError);
529
577
  return anthropicErrorResponse({ status: 400, code: "invalid_json", message: err instanceof Error ? err.message : "request body is not valid JSON" });
530
578
  }
531
579
  }
532
580
  if (req.method === "GET" && url.pathname === "/v1/models") {
533
- return json(renderModelList(cfg, ledger.blendedRate(cfg.ledger.blendWindowDays)));
581
+ return json(renderModelList(cfg, await ledger.blendedRate(cfg.ledger.blendWindowDays)));
534
582
  }
535
583
  if (req.method === "GET" && url.pathname === "/v1/router/stats") {
536
- return json(computeStats(ledger));
584
+ return json(await computeStats(ledger));
537
585
  }
538
586
  if (req.method === "GET" && url.pathname === "/v1/router/catalog") {
539
587
  // The catalog as data, judged under `?policy=` (the X-Omp-Policy
@@ -576,15 +624,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
576
624
  if (!Number.isFinite(since)) return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "sinceMs required" });
577
625
  // `scope` narrows to one agentdox context scope: a project's own spend.
578
626
  const contextScope = url.searchParams.get("scope") ?? "";
579
- return json({ sinceMs: since, usd: spendUsdSince(db, since, harnessScopeParam(url.searchParams.get("harness")), contextScope), ...(contextScope === "" ? {} : { scope: contextScope }) });
627
+ return json({ sinceMs: since, usd: await spendUsdSince(sqlDb, since, harnessScopeParam(url.searchParams.get("harness")), contextScope), ...(contextScope === "" ? {} : { scope: contextScope }) });
580
628
  }
581
629
  if (req.method === "GET" && url.pathname === "/v1/router/feedback") {
582
630
  const days = clampDays(url.searchParams.get("days"), 30);
583
- return json({ days, ...feedbackView(db, Date.now() - days * 86_400_000, harnessScopeParam(url.searchParams.get("harness"))) });
631
+ return json({ days, ...await feedbackView(sqlDb, Date.now() - days * 86_400_000, harnessScopeParam(url.searchParams.get("harness"))) });
584
632
  }
585
633
  if (req.method === "GET" && url.pathname === "/v1/router/export") {
586
634
  const days = clampDays(url.searchParams.get("days"), 30);
587
- const rows = exportRows(db, Date.now() - days * 86_400_000, harnessScopeParam(url.searchParams.get("harness")));
635
+ const rows = await exportRows(sqlDb, Date.now() - days * 86_400_000, harnessScopeParam(url.searchParams.get("harness")));
588
636
  if (url.searchParams.get("format") === "json") return json({ days, rows });
589
637
  return new Response(exportCsv(rows), { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="auto-model-router-export-${new Date().toISOString().slice(0, 10)}.csv"` } });
590
638
  }
@@ -593,7 +641,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
593
641
  // optional harness scope (the X-Omp-Harness header value).
594
642
  const windowDays = clampDays(url.searchParams.get("days"), 7);
595
643
  const harnessId = url.searchParams.get("harness") ?? "";
596
- const report = buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) });
644
+ const report = await buildUsageReport(sqlDb, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) });
597
645
  // ?format=text: the rendered report for harnesses without a renderer of their own (the Hermes plugin).
598
646
  if (url.searchParams.get("format") === "text") return new Response(renderUsageReport(report), { headers: { "content-type": "text/plain; charset=utf-8" } });
599
647
  return json(report);
@@ -627,13 +675,13 @@ export function startServer(cfg: RouterConfig): StartedServer {
627
675
  if (auto && !cfg.report.dailySummary) return json({ due: false, reason: "report.dailySummary is off", summary: null });
628
676
  if (auto && !summaryDue(kv, harnessId)) return json({ due: false, reason: "posted in the last 20h", summary: null });
629
677
  const meter = ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd);
630
- const runway = ollamaRunway(meter, ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1);
678
+ const runway = ollamaRunway(meter, await ledger.providerSpendSince("ollama/", Date.now() - 7 * 86_400_000), ollamaUsage.calibration()?.factor ?? 1);
631
679
  const ollamaSummary: SummaryOllama | null =
632
680
  !cfg.ollama.enabled || meter === null ? null : { plan: meter.plan ?? null, usedUsd: meter.usedUsd, creditsUsd: meter.creditsUsd, runwayDays: runway?.days ?? null };
633
- const summary = buildDailySummary(db, {
681
+ const summary = await buildDailySummary(sqlDb, {
634
682
  harnessId,
635
683
  baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)),
636
- spikes: ledger.softFailureSpikes?.() ?? [],
684
+ spikes: await ledger.softFailureSpikes(),
637
685
  ollama: ollamaSummary,
638
686
  });
639
687
  if (auto && !summaryHasNews(summary)) return json({ due: false, reason: "nothing to report", summary: null });
@@ -651,7 +699,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
651
699
  const sinceRaw = Number.parseInt(url.searchParams.get("since") ?? "", 10);
652
700
  const daysRaw = url.searchParams.get("days");
653
701
  const sinceMs = Number.isFinite(sinceRaw) ? sinceRaw : daysRaw === null ? 0 : Date.now() - clampDays(daysRaw, 30) * 86_400_000;
654
- const entries = decisionEntries(db, {
702
+ const entries = await decisionEntries(sqlDb, {
655
703
  sinceMs,
656
704
  harness: harnessScopeParam(url.searchParams.get("harness")),
657
705
  limit,
@@ -717,7 +765,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
717
765
  // ledger file by design, so this route is the only way it can act
718
766
  // on its own retention policy — and the once-an-hour floor is the
719
767
  // runner's, not the caller's, so calling it in a loop is harmless.
720
- const result = retention.runNow();
768
+ const result = await retention.runNow();
721
769
  if (result.deleted > 0) log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
722
770
  return json({ ...result, retentionDays: cfg.ledger.retentionDays });
723
771
  }
@@ -812,12 +860,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
812
860
  });
813
861
  const target = results[0]!;
814
862
  const published = (s: string, axis: QualityAxis): number | undefined => models.find((m) => m.slug === s)?.quality[axis];
815
- const cal = fitCalibration(results.slice(1), published);
863
+ const cal = fitCalibration(results.slice(1), published, hardRaw);
816
864
  const authorOf = (s: string): string => models.find((m) => m.slug === s)?.author ?? "";
817
- const fresh = toLocalFeedScores([target], cal, authorOf);
818
- const shared = { slug, anchors, raw: target.axes, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, tookMs: Date.now() - started };
865
+ const fresh = toLocalFeedScores([target], cal, authorOf, hardRaw);
866
+ // The fit is reported, not just used: `r` and `n` say whether the numbers deserve
867
+ // belief, and an axis dropped for a weak fit should say so rather than vanish.
868
+ const fitDetail = Object.fromEntries(Object.entries(cal).map(([axis, f]) => [axis, { r: Number(f.r.toFixed(3)), n: f.n, published: f.r >= PUBLISH_MIN_R }]));
869
+ const shared = { slug, anchors, raw: target.axes, rawHard: target.axesHard, byComplexity: target.byComplexity, repeats: target.repeats, spread: target.spread, errors: target.errors, fit: fitDetail, publishMinR: PUBLISH_MIN_R, tookMs: Date.now() - started };
819
870
  if (fresh.length === 0) {
820
- benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: null, applied: false, reason: "no axis produced a usable fit; try more or better-spread anchors" } });
871
+ benchmarkJobs.set(jobId, { ...benchmarkJobs.get(jobId)!, state: "done", result: { ...shared, calibrated: null, applied: false, reason: `no axis produced a fit at r >= ${PUBLISH_MIN_R} on the hard band; try more or better-spread anchors` } });
821
872
  return;
822
873
  }
823
874
  // Merge, never replace: other models' measurements are not this run's to discard.
@@ -850,10 +901,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
850
901
  }
851
902
  const target =
852
903
  typeof body?.ledgerId === "string"
853
- ? (ledger.recentEntries(1_000).find((e) => e.id === body.ledgerId) ?? null)
854
- : (ledger.latestForSession?.(session) ?? null);
904
+ ? ((await ledger.recentEntries(1_000)).find((e) => e.id === body.ledgerId) ?? null)
905
+ : ((await ledger.latestForSession(session)) ?? null);
855
906
  if (target === null) return wireErrorResponse({ status: 404, code: "not_found", message: "no routed turn for that session yet" });
856
- const id = feedback.record({
907
+ const id = await feedback.record({
857
908
  ledgerId: target.id,
858
909
  ompSessionId: session,
859
910
  slug: target.servedSlug ?? target.slug,
@@ -901,12 +952,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
901
952
  meter: ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd),
902
953
  // Ledger vs meter, and how long the credits last at the recent burn.
903
954
  calibration: ollamaUsage.calibration(),
904
- runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1),
955
+ runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), await ledger.providerSpendSince("ollama/", Date.now() - 7 * 86_400_000), ollamaUsage.calibration()?.factor ?? 1),
905
956
  costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
906
957
  },
907
958
  // Models failing well above their own baseline in the last hour.
908
959
  // Visibility only: nothing routes around a spike.
909
- softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: ledger.softFailureSpikes?.() ?? [] },
960
+ softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: await ledger.softFailureSpikes() },
910
961
  catalog: snap === null
911
962
  ? null
912
963
  : {
@@ -997,7 +1048,19 @@ export function startServer(cfg: RouterConfig): StartedServer {
997
1048
  // Drain queued agentdox write-backs before the DB closes under them.
998
1049
  context.close();
999
1050
  await context.flush();
1051
+ // Both handles on the store, or Windows keeps the file locked and a
1052
+ // caller that deletes its temp directory gets EBUSY.
1053
+ await sqlDb.close();
1000
1054
  db.close();
1055
+ if (memoryStore !== null) {
1056
+ // A `:memory:` ledger promised nothing durable, so its stand-in file
1057
+ // goes with the server. Best-effort: Windows may still hold the WAL.
1058
+ try {
1059
+ rmSync(memoryStore, { recursive: true, force: true });
1060
+ } catch {
1061
+ /* the OS reclaims a temp directory soon enough */
1062
+ }
1063
+ }
1001
1064
  },
1002
1065
  };
1003
1066
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Database } from "bun:sqlite";
8
+ import { num, type SqlDb } from "../util/sql.ts";
8
9
  import { createCompositeCatalog } from "../catalog/composite.ts";
9
10
  import { createStaticCatalogSource } from "../catalog/static-catalog.ts";
10
11
  import { createAnthropicClient } from "../upstream/anthropic.ts";
@@ -15,7 +16,6 @@ import type { CatalogSnapshot, CatalogSource } from "../catalog/types.ts";
15
16
  import type { RouterConfig } from "../config/types.ts";
16
17
  import { createMultiUpstream } from "../upstream/multi.ts";
17
18
  import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
18
- import { createLedger } from "../cost/ledger.ts";
19
19
  import { setKnownUpstreamIds } from "../cost/report.ts";
20
20
  import { createOllamaUsageSource, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
21
21
  import { createOpenRouterClient } from "../upstream/openrouter.ts";
@@ -39,7 +39,13 @@ export interface Providers {
39
39
  namedServing(): string[];
40
40
  }
41
41
 
42
- export function createProviders(cfg: RouterConfig, db: Database, log: Logger = createLogger(cfg.logLevel)): Providers {
42
+ export function createProviders(
43
+ cfg: RouterConfig,
44
+ db: Database,
45
+ /** The engine-agnostic handle on the same store, for the calibration samples. */
46
+ sqlDb: SqlDb,
47
+ log: Logger = createLogger(cfg.logLevel),
48
+ ): Providers {
43
49
  const openrouter = createOpenRouterClient(cfg);
44
50
  const openrouterCatalog = createCatalog(cfg, openrouter, db);
45
51
  // Ollama Cloud is a second upstream ranked in the same catalog: `ollama/…`
@@ -51,7 +57,15 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
51
57
  const ollamaServing = (): boolean => cfg.ollama.enabled && ollama.available();
52
58
  // Plan usage lives on ollama.com whichever base URL dispatches; it needs the
53
59
  // key, so the daemon path without `/login ollama-cloud` keeps a static bias.
54
- const ledgerForCalibration = createLedger(db, cfg);
60
+ // Only the ledger's Ollama total is needed, so this reads through the shim
61
+ // rather than constructing a second full ledger.
62
+ const ollamaLedgerUsd = async (): Promise<number> => {
63
+ const row = await sqlDb.one<{ total: unknown }>(
64
+ "SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE COALESCE(served_slug, slug) LIKE $prefix",
65
+ { prefix: "ollama/%" },
66
+ );
67
+ return num(row?.total);
68
+ };
55
69
  const ollamaUsage = createOllamaUsageSource({
56
70
  apiKey: () => cfg.ollama.apiKey,
57
71
  pollMs: cfg.ollama.usagePollMs,
@@ -59,7 +73,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
59
73
  log,
60
74
  // Each poll records the meter beside the ledger's Ollama total, so the
61
75
  // estimate can be scaled to what ollama.com actually bills.
62
- calibration: { db, ledgerUsd: () => ledgerForCalibration.providerSpendSince?.("ollama/", 0) ?? 0, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
76
+ calibration: { db: sqlDb, ledgerUsd: ollamaLedgerUsd, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
63
77
  });
64
78
  // Named upstreams (OpenAI, Azure, Anthropic, vLLM…): a client per id, built when
65
79
  // first needed and kept — its breaker state must survive config reloads — while
@@ -14,7 +14,7 @@ import type { ContextBridge } from "../context/types.ts";
14
14
  import type { RouterConfig } from "../config/types.ts";
15
15
  import { estimateUnreportedCache } from "../cost/cache-estimate.ts";
16
16
  import { computeCost } from "../cost/forecast.ts";
17
- import { EMPTY_USAGE, type Ledger, type UsageCounts } from "../cost/types.ts";
17
+ import { EMPTY_USAGE, type AsyncLedger, type UsageCounts } from "../cost/types.ts";
18
18
  import { createProbe, type Probe } from "../router/escalate.ts";
19
19
  import { resolveHoldTurns } from "../router/explore.ts";
20
20
  import { adjustPendingEstimate } from "../tokens/estimate.ts";
@@ -65,7 +65,7 @@ export interface TurnDeps {
65
65
  config: RouterConfig;
66
66
  router: Router;
67
67
  upstream: UpstreamClient;
68
- ledger: Ledger;
68
+ ledger: AsyncLedger;
69
69
  conversations: ConversationStore;
70
70
  catalog: CatalogSource;
71
71
  /** agentdox bridge. The disabled bridge makes every call here a no-op. */
@@ -86,6 +86,17 @@ class SinkError extends Error {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * A deliberate refusal from `route()` — `BudgetExceededError` today — reports
91
+ * the status and code it chose. Anything else is an internal failure and gets
92
+ * a 500, so a new throw never silently becomes a 4xx.
93
+ */
94
+ function routerRefusal(err: unknown): { status: number; code: string } | null {
95
+ if (!(err instanceof Error) || !("status" in err) || !("code" in err)) return null;
96
+ const { status, code } = err as Error & { status: unknown; code: unknown };
97
+ return typeof status === "number" && typeof code === "string" ? { status, code } : null;
98
+ }
99
+
89
100
  /** Latest user text, used to bias agentdox relevance ranking and as the recorded turn. */
90
101
  function lastUserText(req: NormRequest): string {
91
102
  for (let i = req.messages.length - 1; i >= 0; i--) {
@@ -122,7 +133,7 @@ export async function runTurn(
122
133
  ): Promise<void> {
123
134
  const { config, router, upstream, ledger, conversations, context: bridge } = deps;
124
135
  const log = createLogger(config.logLevel);
125
- const state = conversations.load(req.conversationKey);
136
+ const state = await conversations.load(req.conversationKey);
126
137
  const turnNumber = state.turn + 1;
127
138
  // Digest quality signal: the calls the agent just made, matched against
128
139
  // recent digests of this session (a re-run of a digested read means the
@@ -131,7 +142,9 @@ export async function runTurn(
131
142
  for (let i = req.messages.length - 1; i >= 0; i--) {
132
143
  const m = req.messages[i];
133
144
  if (m === undefined || m.role !== "assistant") continue;
134
- if (m.toolCalls.length > 0) deps.digester.noteToolCalls(req.ompSessionId, m.toolCalls.map((c) => ({ name: c.name, argsJson: c.argsJson })));
145
+ if (m.toolCalls.length > 0) {
146
+ await deps.digester.noteToolCalls(req.ompSessionId, m.toolCalls.map((c) => ({ name: c.name, argsJson: c.argsJson })));
147
+ }
135
148
  break;
136
149
  }
137
150
  }
@@ -191,7 +204,17 @@ export async function runTurn(
191
204
  }
192
205
  decision = await router.route(req, opts);
193
206
  } catch (err) {
194
- await sink.error({ status: 500, code: "router_error", message: err instanceof Error ? err.message : String(err) });
207
+ // A refusal the router MEANS carries its own status and code — a
208
+ // budget cap in `reject` mode is a 402 `budget_exceeded`, not an
209
+ // internal failure. Reported as a 500 it reads to the caller (and
210
+ // to a team front door relaying it) as "the router broke", which
211
+ // sent a capped deployment looking for a crash.
212
+ const refusal = routerRefusal(err);
213
+ await sink.error({
214
+ status: refusal?.status ?? 500,
215
+ code: refusal?.code ?? "router_error",
216
+ message: err instanceof Error ? err.message : String(err),
217
+ });
195
218
  return;
196
219
  }
197
220
  }
@@ -314,7 +337,7 @@ export async function runTurn(
314
337
  generationId = await dispatch.generationId().catch(() => null);
315
338
  }
316
339
  const priceModel = deps.catalog.find(servedSlug ?? decision.slug);
317
- ledger.record({
340
+ await ledger.record({
318
341
  id: crypto.randomUUID(),
319
342
  createdAtMs: Date.now(),
320
343
  conversationKey: req.conversationKey,
@@ -364,7 +387,7 @@ export async function runTurn(
364
387
  // the committed path used to reach the state update below, so aborted
365
388
  // dispatches (30% of real spend on live data) stayed invisible to the
366
389
  // per-conversation budget guard.
367
- conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
390
+ await conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
368
391
  };
369
392
 
370
393
  // Same-tier failover: re-route with every failed slug excluded and accept
@@ -641,7 +664,7 @@ export async function runTurn(
641
664
  state.currentTier = decision.tier;
642
665
  // Escalations accumulate in SQL for the same reason spend does: `save`
643
666
  // below no longer writes this column, so a snapshot cannot clobber it.
644
- conversations.accrue(req.conversationKey, { escalations });
667
+ await conversations.accrue(req.conversationKey, { escalations });
645
668
  // Hysteresis window. Only re-arm when the served tier actually changed
646
669
  // (or this turn escalated). Re-arming on EVERY turn — even a trivial one
647
670
  // served by a held hard model — extends the lock forever: the classifier
@@ -672,7 +695,7 @@ export async function runTurn(
672
695
  state.cacheWarmAtMs = Date.now();
673
696
  }
674
697
  state.updatedAtMs = Date.now();
675
- conversations.save(state);
698
+ await conversations.save(state);
676
699
 
677
700
  // Record the settled turn into agentdox, attributed to the model that
678
701
  // actually served it. Queued and never awaited: the transcript is an
@@ -6,7 +6,6 @@
6
6
  * `token_calibration` per tokenizer family on measurement.
7
7
  */
8
8
 
9
- import type { Ledger } from "../cost/types.ts";
10
9
  import type { NormRequest } from "../wire/types.ts";
11
10
 
12
11
  /** Chars-per-token when nothing better is known. Conservative-ish for mixed prose+code. */
@@ -39,9 +38,20 @@ function familyKey(tokenizer: string): string {
39
38
  return tokenizer.trim().toLowerCase();
40
39
  }
41
40
 
42
- export function estimateTokens(bytes: number, tokenizer: string, ledger: Ledger | null): number {
43
- const ratio = ledger?.tokenRatio(tokenizer) ?? FAMILY_BYTES_PER_TOKEN[familyKey(tokenizer)] ?? DEFAULT_BYTES_PER_TOKEN;
44
- return Math.max(0, Math.ceil(bytes / ratio));
41
+ /**
42
+ * A measured bytes-per-token ratio for a tokenizer family, or null when the
43
+ * ledger has too few samples to have calibrated one.
44
+ *
45
+ * Passed as a VALUE rather than read from the ledger here: estimation runs
46
+ * inside synchronous code (the Anthropic token counter, candidate scoring), and
47
+ * the ledger may be a shared database that cannot be read synchronously. The
48
+ * caller fetches the one ratio it needs before estimating.
49
+ */
50
+ export type TokenRatio = number | null;
51
+
52
+ export function estimateTokens(bytes: number, tokenizer: string, ratio: TokenRatio): number {
53
+ const perToken = ratio ?? FAMILY_BYTES_PER_TOKEN[familyKey(tokenizer)] ?? DEFAULT_BYTES_PER_TOKEN;
54
+ return Math.max(0, Math.ceil(bytes / perToken));
45
55
  }
46
56
 
47
57
  /**
@@ -53,10 +63,10 @@ export function estimateTokens(bytes: number, tokenizer: string, ledger: Ledger
53
63
  const PENDING_CAP = 1024;
54
64
  const pendingEstimates = new Map<string, { tokenizer: string; bytes: number }>();
55
65
 
56
- export function estimatePromptTokens(req: NormRequest, tokenizer: string, ledger: Ledger | null): number {
66
+ export function estimatePromptTokens(req: NormRequest, tokenizer: string, ratio: TokenRatio): number {
57
67
  let images = 0;
58
68
  for (const message of req.messages) images += message.images;
59
- const tokens = estimateTokens(req.promptBytes, tokenizer, ledger) + images * IMAGE_TOKEN_ALLOWANCE;
69
+ const tokens = estimateTokens(req.promptBytes, tokenizer, ratio) + images * IMAGE_TOKEN_ALLOWANCE;
60
70
  if (pendingEstimates.size >= PENDING_CAP && !pendingEstimates.has(req.conversationKey)) {
61
71
  // Map iteration order is insertion order: drop the eldest.
62
72
  const eldest = pendingEstimates.keys().next();
@@ -24,7 +24,7 @@
24
24
  * percents) rather than 100%, which errs toward keeping the bias on.
25
25
  */
26
26
 
27
- import type { Database } from "bun:sqlite";
27
+ import { num, type SqlDb } from "../util/sql.ts";
28
28
  import type { Logger } from "../util/log.ts";
29
29
 
30
30
  export interface OllamaUsage {
@@ -158,9 +158,9 @@ export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () =>
158
158
 
159
159
  /** Where calibration samples come from and go: the ledger's Ollama total and the plan's credits. */
160
160
  export interface CalibrationDeps {
161
- db: Database;
161
+ db: SqlDb;
162
162
  /** The ledger's all-time Ollama spend, read at sampling time. */
163
- ledgerUsd(): number;
163
+ ledgerUsd(): Promise<number>;
164
164
  /** Configured credit override (0 = detect from the plan). */
165
165
  planCreditsOverrideUsd: number;
166
166
  }
@@ -172,17 +172,25 @@ export function createOllamaUsageSource(
172
172
  // the dashboard later, so the reader stays live and simply idles until it is.
173
173
  if (opts.pollMs <= 0) return NO_USAGE;
174
174
  const cal = opts.calibration;
175
- const insertSample = cal === undefined ? null : cal.db.query("INSERT OR REPLACE INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd) VALUES (?, ?, ?)");
176
- const readSamples = cal === undefined ? null : cal.db.query("SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= ? ORDER BY at_ms ASC");
177
175
  let calibrationMemo: OllamaCalibration | null = null;
178
- function sample(usage: OllamaUsage): void {
179
- if (cal === null || cal === undefined || insertSample === null || readSamples === null) return;
176
+ async function sample(usage: OllamaUsage): Promise<void> {
177
+ if (cal === null || cal === undefined) return;
180
178
  const credits = ollamaPlanCredits(usage, cal.planCreditsOverrideUsd);
181
179
  if (credits === null || usage.monthlyUsedFraction === null) return;
182
180
  try {
183
- insertSample.run(usage.fetchedAtMs, usage.monthlyUsedFraction * credits, cal.ledgerUsd());
184
- const rows = readSamples.all(usage.fetchedAtMs - 30 * 86_400_000) as { at_ms: number; meter_usd: number; ledger_usd: number }[];
185
- calibrationMemo = calibrationFrom(rows.map((r) => ({ atMs: r.at_ms, meterUsd: r.meter_usd, ledgerUsd: r.ledger_usd })));
181
+ const db = cal.db;
182
+ // `INSERT OR REPLACE` is SQLite-only; `ON CONFLICT` says the same thing
183
+ // on both engines, and the key is the sample instant.
184
+ await db.sql`INSERT INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd)
185
+ VALUES (${usage.fetchedAtMs}, ${usage.monthlyUsedFraction * credits}, ${await cal.ledgerUsd()})
186
+ ON CONFLICT (at_ms) DO UPDATE SET meter_usd = excluded.meter_usd, ledger_usd = excluded.ledger_usd`;
187
+ const rows = await db.query<{ at_ms: unknown; meter_usd: unknown; ledger_usd: unknown }>(
188
+ "SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= $since ORDER BY at_ms ASC",
189
+ { since: usage.fetchedAtMs - 30 * 86_400_000 },
190
+ );
191
+ calibrationMemo = calibrationFrom(
192
+ rows.map((r) => ({ atMs: num(r.at_ms), meterUsd: num(r.meter_usd), ledgerUsd: num(r.ledger_usd) })),
193
+ );
186
194
  } catch (err) {
187
195
  opts.log.debug("ollama calibration sample failed", { error: err instanceof Error ? err.message : String(err) });
188
196
  }
@@ -232,7 +240,9 @@ export function createOllamaUsageSource(
232
240
  if (parsed !== null) {
233
241
  current = { ...parsed, plan };
234
242
  warned = false;
235
- sample(current);
243
+ // Awaited: the next poll's calibration reads what this one wrote,
244
+ // and a shared store makes that a round trip rather than a call.
245
+ await sample(current);
236
246
  } else if (!warned) {
237
247
  warned = true;
238
248
  opts.log.warn("ollama usage payload had no recognisable fields; credit-aware bias stays on its last reading");