auto-model-router 0.31.0 → 0.33.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 (116) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +61 -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/lib.ts +6 -2
  27. package/src/router/candidates.ts +16 -15
  28. package/src/router/classify.ts +6 -4
  29. package/src/router/index.ts +95 -9
  30. package/src/router/select.ts +38 -21
  31. package/src/router/state.ts +90 -102
  32. package/src/router/types.ts +13 -5
  33. package/src/server/advise.ts +6 -4
  34. package/src/server/compaction-digest.ts +1 -1
  35. package/src/server/digest.ts +9 -10
  36. package/src/server/http.ts +107 -41
  37. package/src/server/providers.ts +18 -4
  38. package/src/server/turn.ts +35 -10
  39. package/src/tokens/estimate.ts +16 -6
  40. package/src/upstream/anthropic.ts +5 -4
  41. package/src/upstream/compat.ts +13 -7
  42. package/src/upstream/ollama-usage.ts +21 -11
  43. package/src/upstream/ollama.ts +4 -3
  44. package/src/upstream/openrouter.ts +4 -3
  45. package/src/upstream/types.ts +7 -0
  46. package/src/util/schema.ts +201 -0
  47. package/src/util/sql.ts +246 -0
  48. package/src/wire/anthropic/messages.ts +3 -4
  49. package/src/wire/openai/request.ts +30 -0
  50. package/src/wire/types.ts +22 -0
  51. package/test/anthropic-wire.test.ts +9 -9
  52. package/test/benchmark-feeds.test.ts +7 -7
  53. package/test/cache-control.test.ts +7 -7
  54. package/test/cache-estimate.test.ts +5 -5
  55. package/test/catalog-view.test.ts +4 -4
  56. package/test/catalog.test.ts +11 -11
  57. package/test/classify.test.ts +24 -24
  58. package/test/compaction.test.ts +20 -20
  59. package/test/config-wizard.test.ts +32 -32
  60. package/test/config.test.ts +10 -10
  61. package/test/connect-harnesses.test.ts +11 -11
  62. package/test/context-bridge.test.ts +40 -30
  63. package/test/context-prune.test.ts +43 -36
  64. package/test/context-query.test.ts +8 -8
  65. package/test/controls.test.ts +54 -27
  66. package/test/cost.test.ts +12 -12
  67. package/test/digest.test.ts +55 -44
  68. package/test/embed-lifecycle.test.ts +5 -5
  69. package/test/embed-logic.test.ts +26 -26
  70. package/test/escalate.test.ts +17 -17
  71. package/test/eval.test.ts +13 -13
  72. package/test/executable.test.ts +6 -6
  73. package/test/exploration.test.ts +19 -20
  74. package/test/failover.test.ts +63 -21
  75. package/test/fakes.ts +105 -0
  76. package/test/features.test.ts +21 -21
  77. package/test/harness-requests.test.ts +3 -3
  78. package/test/harness-switch.test.ts +5 -5
  79. package/test/hold-exploration.test.ts +13 -13
  80. package/test/hot-reload.test.ts +5 -5
  81. package/test/learned.test.ts +5 -5
  82. package/test/ledger-sql.test.ts +342 -0
  83. package/test/mcp-entry.test.ts +5 -5
  84. package/test/migrations.test.ts +28 -22
  85. package/test/models-yml.test.ts +18 -18
  86. package/test/ollama.test.ts +40 -34
  87. package/test/omp-credentials.test.ts +16 -16
  88. package/test/policy.test.ts +3 -3
  89. package/test/reconfigure.test.ts +4 -4
  90. package/test/redaction.test.ts +41 -35
  91. package/test/remote.test.ts +12 -12
  92. package/test/report-logic.test.ts +8 -8
  93. package/test/report.test.ts +95 -87
  94. package/test/retention.test.ts +79 -66
  95. package/test/schema.test.ts +123 -0
  96. package/test/scope.test.ts +8 -8
  97. package/test/select.test.ts +216 -257
  98. package/test/skills.test.ts +3 -3
  99. package/test/sql-shim.test.ts +154 -0
  100. package/test/state.test.ts +43 -36
  101. package/test/summary.test.ts +38 -27
  102. package/test/tier-plan.test.ts +45 -62
  103. package/test/toast-logic.test.ts +31 -31
  104. package/test/tokens.test.ts +95 -80
  105. package/test/trust-attribution.test.ts +217 -187
  106. package/test/trust-window.test.ts +37 -32
  107. package/test/turn.test.ts +55 -23
  108. package/test/upstream-keys.test.ts +240 -0
  109. package/test/upstreams.test.ts +13 -13
  110. package/test/views.test.ts +81 -59
  111. package/test/wire-request.test.ts +17 -17
  112. package/test/wire-responses.test.ts +4 -4
  113. package/tools/agentdox-e2e.ts +5 -2
  114. package/tools/export-benchmarks.ts +5 -5
  115. package/tools/ledger-parity.ts +266 -0
  116. 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";
@@ -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
  }
@@ -853,10 +901,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
853
901
  }
854
902
  const target =
855
903
  typeof body?.ledgerId === "string"
856
- ? (ledger.recentEntries(1_000).find((e) => e.id === body.ledgerId) ?? null)
857
- : (ledger.latestForSession?.(session) ?? null);
904
+ ? ((await ledger.recentEntries(1_000)).find((e) => e.id === body.ledgerId) ?? null)
905
+ : ((await ledger.latestForSession(session)) ?? null);
858
906
  if (target === null) return wireErrorResponse({ status: 404, code: "not_found", message: "no routed turn for that session yet" });
859
- const id = feedback.record({
907
+ const id = await feedback.record({
860
908
  ledgerId: target.id,
861
909
  ompSessionId: session,
862
910
  slug: target.servedSlug ?? target.slug,
@@ -870,6 +918,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
870
918
  const snap = catalog.peek();
871
919
  return json({
872
920
  status: "ok",
921
+ // What a front door may rely on, by name rather than by version: a
922
+ // team edition that sends per-turn credentials to a router without
923
+ // `upstream-keys` would have them ignored, and its tenants served on
924
+ // the deployment's own credential — a silent cross-charge. A name it
925
+ // can check turns that into a refusal it can explain.
926
+ features: ["upstream-keys"],
873
927
  apiKeyConfigured: cfg.openrouter.apiKey !== "",
874
928
  // Which upstreams turns can actually be served from: OpenRouter needs
875
929
  // its key; Ollama needs to be on and out of cooldown.
@@ -904,12 +958,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
904
958
  meter: ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd),
905
959
  // Ledger vs meter, and how long the credits last at the recent burn.
906
960
  calibration: ollamaUsage.calibration(),
907
- runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1),
961
+ runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), await ledger.providerSpendSince("ollama/", Date.now() - 7 * 86_400_000), ollamaUsage.calibration()?.factor ?? 1),
908
962
  costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
909
963
  },
910
964
  // Models failing well above their own baseline in the last hour.
911
965
  // Visibility only: nothing routes around a spike.
912
- softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: ledger.softFailureSpikes?.() ?? [] },
966
+ softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: await ledger.softFailureSpikes() },
913
967
  catalog: snap === null
914
968
  ? null
915
969
  : {
@@ -1000,7 +1054,19 @@ export function startServer(cfg: RouterConfig): StartedServer {
1000
1054
  // Drain queued agentdox write-backs before the DB closes under them.
1001
1055
  context.close();
1002
1056
  await context.flush();
1057
+ // Both handles on the store, or Windows keeps the file locked and a
1058
+ // caller that deletes its temp directory gets EBUSY.
1059
+ await sqlDb.close();
1003
1060
  db.close();
1061
+ if (memoryStore !== null) {
1062
+ // A `:memory:` ledger promised nothing durable, so its stand-in file
1063
+ // goes with the server. Best-effort: Windows may still hold the WAL.
1064
+ try {
1065
+ rmSync(memoryStore, { recursive: true, force: true });
1066
+ } catch {
1067
+ /* the OS reclaims a temp directory soon enough */
1068
+ }
1069
+ }
1004
1070
  },
1005
1071
  };
1006
1072
  }
@@ -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
@@ -449,7 +472,9 @@ export async function runTurn(
449
472
  let streamEnded = false;
450
473
 
451
474
  try {
452
- dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal });
475
+ // Inside the attempt loop, so a retry, a same-tier failover and a tier
476
+ // escalation all dispatch with the same per-turn credentials.
477
+ dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal, ...(req.upstreamKeys === undefined ? {} : { upstreamKeys: req.upstreamKeys }) });
453
478
  } catch (err) {
454
479
  streamError = err;
455
480
  }
@@ -641,7 +666,7 @@ export async function runTurn(
641
666
  state.currentTier = decision.tier;
642
667
  // Escalations accumulate in SQL for the same reason spend does: `save`
643
668
  // below no longer writes this column, so a snapshot cannot clobber it.
644
- conversations.accrue(req.conversationKey, { escalations });
669
+ await conversations.accrue(req.conversationKey, { escalations });
645
670
  // Hysteresis window. Only re-arm when the served tier actually changed
646
671
  // (or this turn escalated). Re-arming on EVERY turn — even a trivial one
647
672
  // served by a held hard model — extends the lock forever: the classifier
@@ -672,7 +697,7 @@ export async function runTurn(
672
697
  state.cacheWarmAtMs = Date.now();
673
698
  }
674
699
  state.updatedAtMs = Date.now();
675
- conversations.save(state);
700
+ await conversations.save(state);
676
701
 
677
702
  // Record the settled turn into agentdox, attributed to the model that
678
703
  // 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();
@@ -410,13 +410,13 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
410
410
  if (caller && timeout) return AbortSignal.any([caller, timeout]);
411
411
  return caller ?? timeout;
412
412
  }
413
- async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined): Promise<Response> {
413
+ async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined, apiKey: string = e.apiKey): Promise<Response> {
414
414
  const headers: Record<string, string> = { "content-type": "application/json", "anthropic-version": ANTHROPIC_VERSION, ...e.headers };
415
415
  if (e.auth === "oauth-bearer") {
416
416
  // A Claude Pro/Max subscription token: Bearer auth at the first-party API, with the OAuth beta. No per-token cost is reported.
417
- headers["authorization"] = `Bearer ${e.apiKey}`;
417
+ headers["authorization"] = `Bearer ${apiKey}`;
418
418
  headers["anthropic-beta"] = e.headers["anthropic-beta"] ?? "oauth-2025-04-20,claude-code-20250219";
419
- } else if (e.apiKey !== "") headers["x-api-key"] = e.apiKey;
419
+ } else if (apiKey !== "") headers["x-api-key"] = apiKey;
420
420
  try {
421
421
  return await fetchImpl(`${e.baseUrl.replace(/\/+$/, "")}/v1/messages`, { method: "POST", headers, body: JSON.stringify(body), signal: composeSignal(e, signal) });
422
422
  } catch (err) {
@@ -444,7 +444,8 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
444
444
  async dispatch(opts: DispatchOptions): Promise<Dispatch> {
445
445
  const e = entry();
446
446
  const { rendered, servedSlug } = render(e, { ...opts.body, stream: true });
447
- const res = await post(e, rendered, opts.signal);
447
+ // A per-turn credential for this upstream wins for this dispatch only; the shared entry is never touched.
448
+ const res = await post(e, rendered, opts.signal, opts.upstreamKeys?.[id] ?? e.apiKey);
448
449
  if (!res.ok) throw await httpError(res);
449
450
  if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
450
451
  const translator = createAnthropicTranslator(servedSlug);
@@ -113,15 +113,20 @@ function transportError(id: string, err: unknown): UpstreamError {
113
113
  return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
114
114
  }
115
115
 
116
- /** The chat-completions URL and auth for an entry: Azure names the deployment in the path and keys with `api-key`. */
117
- export function compatEndpoint(entry: UpstreamEntry, modelId: string): { url: string; headers: Record<string, string> } {
116
+ /**
117
+ * The chat-completions URL and auth for an entry: Azure names the deployment in the path and keys with `api-key`.
118
+ *
119
+ * `apiKey` overrides the entry's own credential for one dispatch (a per-turn
120
+ * key from the front door). The entry itself is never written to.
121
+ */
122
+ export function compatEndpoint(entry: UpstreamEntry, modelId: string, apiKey: string = entry.apiKey): { url: string; headers: Record<string, string> } {
118
123
  const base = entry.baseUrl.replace(/\/+$/, "");
119
124
  const headers: Record<string, string> = { "content-type": "application/json", ...entry.headers };
120
125
  if (entry.kind === "azure") {
121
- if (entry.apiKey !== "") headers["api-key"] = entry.apiKey;
126
+ if (apiKey !== "") headers["api-key"] = apiKey;
122
127
  return { url: `${base}/openai/deployments/${encodeURIComponent(modelId)}/chat/completions?api-version=${encodeURIComponent(entry.apiVersion)}`, headers };
123
128
  }
124
- if (entry.apiKey !== "") headers.authorization = `Bearer ${entry.apiKey}`;
129
+ if (apiKey !== "") headers.authorization = `Bearer ${apiKey}`;
125
130
  return { url: `${base}/chat/completions`, headers };
126
131
  }
127
132
 
@@ -195,8 +200,8 @@ export function createCompatClient(cfg: RouterConfig, id: string, fetchImpl: Fet
195
200
  return err;
196
201
  }
197
202
 
198
- async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined): Promise<Response> {
199
- const { url, headers } = compatEndpoint(e, typeof body.model === "string" ? body.model : "");
203
+ async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined, apiKey: string = e.apiKey): Promise<Response> {
204
+ const { url, headers } = compatEndpoint(e, typeof body.model === "string" ? body.model : "", apiKey);
200
205
  try {
201
206
  return await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: composeSignal(e, signal) });
202
207
  } catch (err) {
@@ -212,7 +217,8 @@ export function createCompatClient(cfg: RouterConfig, id: string, fetchImpl: Fet
212
217
 
213
218
  async dispatch(opts: DispatchOptions): Promise<Dispatch> {
214
219
  const e = entry();
215
- const res = await post(e, toCompatBody(id, { ...opts.body, stream: true }), opts.signal);
220
+ // A per-turn credential for this upstream wins for this dispatch only; the shared entry is never touched.
221
+ const res = await post(e, toCompatBody(id, { ...opts.body, stream: true }), opts.signal, opts.upstreamKeys?.[id] ?? e.apiKey);
216
222
  if (!res.ok) throw await httpError(res);
217
223
  if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
218
224
  const parsed = parseSse(res.body, (msg, fields) => log.warn(msg, fields));