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
@@ -8,8 +8,10 @@
8
8
  * usage-priced figure the orchestrator computed, else the forecast.
9
9
  */
10
10
 
11
- import type { Database } from "bun:sqlite";
12
- import { createFeedbackStore, type FeedbackCounts } from "./feedback.ts";
11
+ // `num` here is the local thousands formatter for the rendered report, so the
12
+ // coercion helpers are aliased rather than renamed at 40 call sites.
13
+ import { num as asNum, numOrNull as asNumOrNull, type SqlDb } from "../util/sql.ts";
14
+ import type { FeedbackCounts } from "./feedback.ts";
13
15
 
14
16
  export interface ReportTotals {
15
17
  dispatches: number;
@@ -142,9 +144,20 @@ export interface AnatomyShare {
142
144
  }
143
145
 
144
146
  const USD = "COALESCE(reported_usd, predicted_usd)";
145
- const PT = "json_extract(usage, '$.promptTokens')";
146
- const CT = "json_extract(usage, '$.cachedTokens')";
147
- const COMP = "json_extract(usage, '$.completionTokens')";
147
+
148
+ /**
149
+ * The usage members this report sums, spelled for the engine in front of it.
150
+ * `json_extract` against `->>` is the whole difference, and getting the
151
+ * BOOLEAN one wrong does not fail — it silently misclassifies every row.
152
+ */
153
+ function fragments(db: SqlDb): { PT: string; CT: string; COMP: string; EST: string } {
154
+ return {
155
+ PT: db.jsonNum("usage", "promptTokens"),
156
+ CT: db.jsonNum("usage", "cachedTokens"),
157
+ COMP: db.jsonNum("usage", "completionTokens"),
158
+ EST: `${db.jsonBool("usage", "cachedEstimated")} = 1`,
159
+ };
160
+ }
148
161
  /** Named upstream ids the ledger's provider derivation knows; set by createProviders from the live config. */
149
162
  let knownUpstreamIds: () => readonly string[] = () => [];
150
163
  /** Ids, or a getter read live so a hot-reloaded list applies; an embedder (the team edition) calls this too, since the registry is per process. */
@@ -170,11 +183,12 @@ function providerCase(): string {
170
183
  return `CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ${knownUpstreamIds().map((id) => `WHEN slug LIKE '${id}/%' THEN '${id}'`).join(" ")} ELSE 'openrouter' END`;
171
184
  }
172
185
  const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
173
- const EST = "json_extract(usage, '$.cachedEstimated') = 1";
174
186
  /** Rows a forecast can be judged on: a reported cost, a prediction, clean and kept, not a side call. */
175
187
  const FORECASTABLE = "reported_usd > 0 AND predicted_usd IS NOT NULL AND wasted = 0 AND error IS NULL AND requested_model <> 'digest'";
176
188
 
177
- const ROW_SELECT = `
189
+ function rowSelect(db: SqlDb): string {
190
+ const { PT, CT, COMP, EST } = fragments(db);
191
+ return `
178
192
  COUNT(*) AS dispatches,
179
193
  COALESCE(SUM(${USD}), 0) AS spend,
180
194
  COALESCE(SUM(${PT}), 0) AS prompt_tokens,
@@ -186,35 +200,44 @@ const ROW_SELECT = `
186
200
  SUM(CASE WHEN ${STREAMED} AND latency_ms > ttft_ms AND ${COMP} > 0 THEN latency_ms - ttft_ms END) AS elapsed_ms,
187
201
  SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
188
202
  SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors`;
203
+ }
189
204
 
190
205
  interface RawRow {
191
206
  key: string;
192
- dispatches: number;
193
- spend: number;
194
- prompt_tokens: number;
195
- cached_tokens: number;
196
- estimated_rows: number | null;
197
- avg_prompt_tokens: number;
198
- ttft_ms: number | null;
199
- ctok_sum: number | null;
200
- elapsed_ms: number | null;
201
- escalations: number;
202
- errors: number;
207
+ dispatches: unknown;
208
+ spend: unknown;
209
+ prompt_tokens: unknown;
210
+ cached_tokens: unknown;
211
+ estimated_rows: unknown;
212
+ avg_prompt_tokens: unknown;
213
+ ttft_ms: unknown;
214
+ ctok_sum: unknown;
215
+ elapsed_ms: unknown;
216
+ escalations: unknown;
217
+ errors: unknown;
203
218
  }
204
219
 
205
- function toRow(r: RawRow, windowSpend: number): ReportRow {
220
+ function toRow(raw: RawRow, windowSpend: number): ReportRow {
221
+ // Every numeric field goes through `num`: Postgres returns COUNT(*) and
222
+ // BIGINT sums as strings, and arithmetic on those is wrong rather than loud.
223
+ const spend = asNum(raw.spend);
224
+ const promptTokens = asNum(raw.prompt_tokens);
225
+ const cachedTokens = asNum(raw.cached_tokens);
226
+ const ttftMs = asNumOrNull(raw.ttft_ms);
227
+ const elapsedMs = asNumOrNull(raw.elapsed_ms);
228
+ const ctokSum = asNumOrNull(raw.ctok_sum);
206
229
  return {
207
- key: r.key,
208
- dispatches: r.dispatches,
209
- spendUsd: r.spend,
210
- share: windowSpend > 0 ? r.spend / windowSpend : 0,
211
- cacheHitRate: r.prompt_tokens > 0 ? r.cached_tokens / r.prompt_tokens : 0,
212
- cacheEstimated: (r.estimated_rows ?? 0) > 0,
213
- avgPromptTokens: Math.round(r.avg_prompt_tokens),
214
- avgTtftMs: r.ttft_ms === null ? null : Math.round(r.ttft_ms),
215
- tokensPerSec: r.elapsed_ms !== null && r.elapsed_ms > 0 && r.ctok_sum !== null ? (r.ctok_sum * 1000) / r.elapsed_ms : null,
216
- escalations: r.escalations,
217
- errors: r.errors,
230
+ key: raw.key,
231
+ dispatches: asNum(raw.dispatches),
232
+ spendUsd: spend,
233
+ share: windowSpend > 0 ? spend / windowSpend : 0,
234
+ cacheHitRate: promptTokens > 0 ? cachedTokens / promptTokens : 0,
235
+ cacheEstimated: asNum(raw.estimated_rows) > 0,
236
+ avgPromptTokens: Math.round(asNum(raw.avg_prompt_tokens)),
237
+ avgTtftMs: ttftMs === null ? null : Math.round(ttftMs),
238
+ tokensPerSec: elapsedMs !== null && elapsedMs > 0 && ctokSum !== null ? (ctokSum * 1000) / elapsedMs : null,
239
+ escalations: asNum(raw.escalations),
240
+ errors: asNum(raw.errors),
218
241
  };
219
242
  }
220
243
 
@@ -228,15 +251,45 @@ export function harnessFilter(harnessId: string, param = "$harness"): { sql: str
228
251
  return { sql: [`harness_id IN (${ids.map((_, i) => `${param}${i}`).join(", ")})`], bind };
229
252
  }
230
253
 
254
+ /**
255
+ * Verdict counts per model, scoped to the judging harness.
256
+ *
257
+ * Read here rather than through `createFeedbackStore`, which still owns the
258
+ * WRITE path on a bun:sqlite handle: the report only ever counted, and a
259
+ * second engine would otherwise need the whole store ported to read two
260
+ * columns. A ledger nobody has judged has no table at all.
261
+ */
262
+ async function feedbackCounts(db: SqlDb, sinceMs: number, harnessId: string): Promise<Map<string, FeedbackCounts>> {
263
+ const out = new Map<string, FeedbackCounts>();
264
+ if (!(await db.tableExists("feedback"))) return out;
265
+ const hf = harnessFilter(harnessId, "$fh");
266
+ const where = ["f.created_at_ms >= $since", ...hf.sql.map((s) => s.replace(/^harness_id/, "l.harness_id"))].join(" AND ");
267
+ const rows = await db.query<{ slug: string; good: unknown; bad: unknown }>(
268
+ `SELECT f.slug,
269
+ COALESCE(SUM(CASE WHEN f.verdict = 'good' THEN 1 ELSE 0 END), 0) AS good,
270
+ COALESCE(SUM(CASE WHEN f.verdict = 'bad' THEN 1 ELSE 0 END), 0) AS bad
271
+ FROM feedback f LEFT JOIN ledger l ON l.id = f.ledger_id
272
+ WHERE ${where} GROUP BY f.slug`,
273
+ { $since: sinceMs, ...hf.bind },
274
+ );
275
+ for (const row of rows) out.set(row.slug, { good: asNum(row.good), bad: asNum(row.bad) });
276
+ return out;
277
+ }
278
+
231
279
  /**
232
280
  * Builds the report for the last `windowDays`. `harnessId` narrows to one
233
281
  * harness (the `X-Omp-Harness` header) or a comma-separated set of them (a
234
282
  * team edition group); empty means everything.
235
283
  */
236
- export function buildUsageReport(
237
- db: Database,
284
+ export async function buildUsageReport(
285
+ db: SqlDb,
238
286
  opts: { windowDays: number; harnessId?: string; nowMs?: number; baselines?: readonly BaselinePrice[]; /** Exclusive upper bound; default open-ended. */ untilMs?: number },
239
- ): UsageReport {
287
+ ): Promise<UsageReport> {
288
+ const { PT, CT, COMP, EST } = fragments(db);
289
+ const ROW_SELECT = rowSelect(db);
290
+ // A JSON BOOLEAN, like usage.cachedEstimated: the engines disagree on both
291
+ // the accessor and the value's type.
292
+ const subagent = `${db.jsonBool("features", "isSubagent")} = 1`;
240
293
  const nowMs = opts.nowMs ?? Date.now();
241
294
  const windowDays = Math.max(1, opts.windowDays);
242
295
  const sinceMs = nowMs - windowDays * 86_400_000;
@@ -246,17 +299,16 @@ export function buildUsageReport(
246
299
  const where = ["created_at_ms >= $since", ...(untilMs === undefined ? [] : ["created_at_ms < $until"]), ...hf.sql].join(" AND ");
247
300
  const bind = { $since: sinceMs, ...(untilMs === undefined ? {} : { $until: untilMs }), ...hf.bind };
248
301
 
249
- const t = db
250
- .query(
251
- `SELECT COUNT(*) AS dispatches,
302
+ const t = (await db.one<Record<string, unknown>>(
303
+ `SELECT COUNT(*) AS dispatches,
252
304
  COUNT(DISTINCT conversation_key) AS conversations,
253
305
  COALESCE(SUM(${USD}), 0) AS spend,
254
306
  COALESCE(SUM(${PT}), 0) AS prompt_tokens,
255
307
  COALESCE(SUM(${CT}), 0) AS cached_tokens,
256
308
  COALESCE(SUM(${COMP}), 0) AS completion_tokens,
257
309
  SUM(CASE WHEN ${EST} THEN 1 ELSE 0 END) AS estimated_rows,
258
- SUM(CASE WHEN json_extract(features, '$.isSubagent') = 1 THEN 1 ELSE 0 END) AS subagent_rows,
259
- COALESCE(SUM(CASE WHEN json_extract(features, '$.isSubagent') = 1 THEN ${USD} ELSE 0 END), 0) AS subagent_spend,
310
+ SUM(CASE WHEN ${subagent} THEN 1 ELSE 0 END) AS subagent_rows,
311
+ COALESCE(SUM(CASE WHEN ${subagent} THEN ${USD} ELSE 0 END), 0) AS subagent_spend,
260
312
  SUM(CASE WHEN requested_model = 'digest' THEN 1 ELSE 0 END) AS digests,
261
313
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${USD} ELSE 0 END), 0) AS digest_spend,
262
314
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${PT} ELSE 0 END), 0) AS digest_input,
@@ -267,42 +319,22 @@ export function buildUsageReport(
267
319
  COALESCE(SUM(CASE WHEN ${FORECASTABLE} THEN ABS(predicted_usd - reported_usd) / reported_usd END), 0) AS fc_err,
268
320
  SUM(CASE WHEN ${FORECASTABLE} AND predicted_usd > reported_usd THEN 1 ELSE 0 END) AS fc_over,
269
321
  SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
270
- SUM(CASE WHEN instr(reasons, 'failover:') > 0 THEN 1 ELSE 0 END) AS failovers,
322
+ SUM(CASE WHEN ${db.contains("reasons", "'failover:'")} THEN 1 ELSE 0 END) AS failovers,
271
323
  SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors,
272
324
  SUM(CASE WHEN error = 'request aborted' THEN 1 ELSE 0 END) AS aborted
273
325
  FROM ledger WHERE ${where}`,
274
- )
275
- .get(bind) as {
276
- dispatches: number;
277
- conversations: number;
278
- spend: number;
279
- prompt_tokens: number;
280
- cached_tokens: number;
281
- completion_tokens: number;
282
- estimated_rows: number | null;
283
- subagent_rows: number | null;
284
- subagent_spend: number;
285
- digests: number | null;
286
- digest_spend: number;
287
- digest_input: number;
288
- digest_reruns: number | null;
289
- redactions: number;
290
- redacted_rows: number | null;
291
- fc_n: number | null;
292
- fc_err: number;
293
- fc_over: number | null;
294
- escalations: number | null;
295
- failovers: number | null;
296
- errors: number | null;
297
- aborted: number | null;
326
+ bind,
327
+ )) as unknown as {
328
+ [key: string]: unknown;
298
329
  };
299
330
 
300
331
  // Model switches: consecutive non-wasted rows of one conversation on
301
332
  // different slugs. Computed in JS over a slim projection; the window is
302
333
  // bounded, and SQLite window functions would make the query less portable.
303
- const seq = db
304
- .query(`SELECT conversation_key AS ck, slug FROM ledger WHERE ${where} AND wasted = 0 ORDER BY conversation_key, created_at_ms`)
305
- .all(bind) as { ck: string; slug: string }[];
334
+ const seq = await db.query<{ ck: string; slug: string }>(
335
+ `SELECT conversation_key AS ck, slug FROM ledger WHERE ${where} AND wasted = 0 ORDER BY conversation_key, created_at_ms`,
336
+ bind,
337
+ );
306
338
  let switches = 0;
307
339
  for (let i = 1; i < seq.length; i++) {
308
340
  const a = seq[i - 1]!;
@@ -310,24 +342,31 @@ export function buildUsageReport(
310
342
  if (a.ck === b.ck && a.slug !== b.slug) switches++;
311
343
  }
312
344
 
313
- const windowSpend = t.spend;
345
+ const windowSpend = asNum(t.spend);
346
+ // GROUP BY repeats the expression rather than the alias: Postgres does not
347
+ // accept a select alias there.
314
348
  const providers = (
315
- db.query(`SELECT ${providerCase()} AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
349
+ await db.query<RawRow>(
350
+ `SELECT ${providerCase()} AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY ${providerCase()} ORDER BY spend DESC`,
351
+ bind,
352
+ )
316
353
  ).map((r) => toRow(r, windowSpend));
317
354
 
318
- const modelRows = db
319
- .query(`SELECT COALESCE(served_slug, slug) AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`)
320
- .all(bind) as RawRow[];
321
- const tierMix = db
322
- .query(`SELECT COALESCE(served_slug, slug) AS key, tier, COUNT(*) AS n FROM ledger WHERE ${where} GROUP BY key, tier`)
323
- .all(bind) as { key: string; tier: string; n: number }[];
355
+ const modelRows = await db.query<RawRow>(
356
+ `SELECT COALESCE(served_slug, slug) AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY COALESCE(served_slug, slug) ORDER BY spend DESC`,
357
+ bind,
358
+ );
359
+ const tierMix = await db.query<{ key: string; tier: string; n: unknown }>(
360
+ `SELECT COALESCE(served_slug, slug) AS key, tier, COUNT(*) AS n FROM ledger WHERE ${where} GROUP BY COALESCE(served_slug, slug), tier`,
361
+ bind,
362
+ );
324
363
  const mixByModel = new Map<string, Record<string, number>>();
325
364
  for (const m of tierMix) {
326
365
  const rec = mixByModel.get(m.key) ?? {};
327
- rec[m.tier] = m.n;
366
+ rec[m.tier] = asNum(m.n);
328
367
  mixByModel.set(m.key, rec);
329
368
  }
330
- const feedbackBySlug = createFeedbackStore(db).countsBySlug(sinceMs, harnessId);
369
+ const feedbackBySlug = await feedbackCounts(db, sinceMs, harnessId);
331
370
  const models: ModelRow[] = modelRows.map((r) => ({
332
371
  ...toRow(r, windowSpend),
333
372
  // `providerOfSlug`, not a two-way guess: a named upstream's namespace is a provider
@@ -339,41 +378,44 @@ export function buildUsageReport(
339
378
  }));
340
379
 
341
380
  const tiers = (
342
- db.query(`SELECT tier AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
381
+ await db.query<RawRow>(`SELECT tier AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY tier ORDER BY spend DESC`, bind)
343
382
  ).map((r) => toRow(r, windowSpend));
344
383
 
384
+ const dayExpr = db.utcDay("created_at_ms");
345
385
  const days = (
346
- db
347
- .query(
348
- `SELECT date(created_at_ms / 1000, 'unixepoch') AS day, COUNT(*) AS dispatches, COALESCE(SUM(${USD}), 0) AS spend,
386
+ await db.query<{ day: string; dispatches: unknown; spend: unknown; prompt_tokens: unknown; cached_tokens: unknown }>(
387
+ `SELECT ${dayExpr} AS day, COUNT(*) AS dispatches, COALESCE(SUM(${USD}), 0) AS spend,
349
388
  COALESCE(SUM(${PT}), 0) AS prompt_tokens, COALESCE(SUM(${CT}), 0) AS cached_tokens
350
- FROM ledger WHERE ${where} GROUP BY day ORDER BY day`,
351
- )
352
- .all(bind) as { day: string; dispatches: number; spend: number; prompt_tokens: number; cached_tokens: number }[]
353
- ).map((d) => ({
354
- day: d.day,
355
- dispatches: d.dispatches,
356
- spendUsd: d.spend,
357
- cacheHitRate: d.prompt_tokens > 0 ? d.cached_tokens / d.prompt_tokens : 0,
358
- }));
359
-
360
- const an = db
361
- .query(
362
- `SELECT COUNT(*) AS rows, AVG(json_extract(features, '$.anatomy.messages')) AS msgs,
363
- AVG(json_extract(features, '$.anatomy.systemBytes')) AS sys, AVG(json_extract(features, '$.anatomy.userBytes')) AS usr,
364
- AVG(json_extract(features, '$.anatomy.assistantBytes')) AS asst, AVG(json_extract(features, '$.anatomy.toolBytes')) AS tool,
365
- AVG(json_extract(features, '$.toolSchemaBytes')) AS schemas,
366
- AVG(json_extract(features, '$.anatomy.olderHalfBytes')) AS older, AVG(json_extract(features, '$.anatomy.staleToolBytes')) AS stale
367
- FROM ledger WHERE ${where} AND json_extract(features, '$.anatomy.messages') IS NOT NULL`,
389
+ FROM ledger WHERE ${where} GROUP BY ${dayExpr} ORDER BY 1`,
390
+ bind,
368
391
  )
369
- .get(bind) as { rows: number; msgs: number | null; sys: number | null; usr: number | null; asst: number | null; tool: number | null; schemas: number | null; older: number | null; stale: number | null };
392
+ ).map((d) => {
393
+ const promptTokens = asNum(d.prompt_tokens);
394
+ return {
395
+ day: d.day,
396
+ dispatches: asNum(d.dispatches),
397
+ spendUsd: asNum(d.spend),
398
+ cacheHitRate: promptTokens > 0 ? asNum(d.cached_tokens) / promptTokens : 0,
399
+ };
400
+ });
401
+
402
+ const anat = (key: string): string => db.jsonPathNum("features", ["anatomy", key]);
403
+ const an = (await db.one<Record<string, unknown>>(
404
+ `SELECT COUNT(*) AS rows, AVG(${anat("messages")}) AS msgs,
405
+ AVG(${anat("systemBytes")}) AS sys, AVG(${anat("userBytes")}) AS usr,
406
+ AVG(${anat("assistantBytes")}) AS asst, AVG(${anat("toolBytes")}) AS tool,
407
+ AVG(${db.jsonNum("features", "toolSchemaBytes")}) AS schemas,
408
+ AVG(${anat("olderHalfBytes")}) AS older, AVG(${anat("staleToolBytes")}) AS stale
409
+ FROM ledger WHERE ${where} AND ${anat("messages")} IS NOT NULL`,
410
+ bind,
411
+ )) ?? {};
370
412
  let anatomy: AnatomyShare | null = null;
371
- if (an.rows > 0) {
372
- const total = (an.sys ?? 0) + (an.usr ?? 0) + (an.asst ?? 0) + (an.tool ?? 0);
373
- const share = (v: number | null): number => (total > 0 ? (v ?? 0) / total : 0);
413
+ if (asNum(an.rows) > 0) {
414
+ const total = asNum(an.sys) + asNum(an.usr) + asNum(an.asst) + asNum(an.tool);
415
+ const share = (v: unknown): number => (total > 0 ? asNum(v) / total : 0);
374
416
  anatomy = {
375
- rows: an.rows,
376
- avgMessages: Math.round(an.msgs ?? 0),
417
+ rows: asNum(an.rows),
418
+ avgMessages: Math.round(asNum(an.msgs)),
377
419
  system: share(an.sys),
378
420
  user: share(an.usr),
379
421
  assistant: share(an.asst),
@@ -388,9 +430,9 @@ export function buildUsageReport(
388
430
  // price with the window's own cache hit rate (cached tokens read at the
389
431
  // baseline's cache rate, or full price when it publishes none).
390
432
  const baselines: BaselineRow[] = (opts.baselines ?? []).map((b) => {
391
- const fresh = Math.max(0, t.prompt_tokens - t.cached_tokens);
392
- const usd = fresh * b.prompt + t.cached_tokens * (b.cacheRead ?? b.prompt) + t.completion_tokens * b.completion;
393
- return { slug: b.slug, usd, savedShare: usd > 0 ? 1 - t.spend / usd : 0 };
433
+ const fresh = Math.max(0, asNum(t.prompt_tokens) - asNum(t.cached_tokens));
434
+ const usd = fresh * b.prompt + asNum(t.cached_tokens) * (b.cacheRead ?? b.prompt) + asNum(t.completion_tokens) * b.completion;
435
+ return { slug: b.slug, usd, savedShare: usd > 0 ? 1 - windowSpend / usd : 0 };
394
436
  });
395
437
 
396
438
  return {
@@ -399,29 +441,29 @@ export function buildUsageReport(
399
441
  sinceMs,
400
442
  harnessId,
401
443
  totals: {
402
- dispatches: t.dispatches,
403
- conversations: t.conversations,
404
- spendUsd: t.spend,
405
- cacheHitRate: t.prompt_tokens > 0 ? t.cached_tokens / t.prompt_tokens : 0,
406
- promptTokens: t.prompt_tokens,
407
- completionTokens: t.completion_tokens,
408
- escalations: t.escalations ?? 0,
409
- failovers: t.failovers ?? 0,
410
- errors: t.errors ?? 0,
411
- aborted: t.aborted ?? 0,
444
+ dispatches: asNum(t.dispatches),
445
+ conversations: asNum(t.conversations),
446
+ spendUsd: windowSpend,
447
+ cacheHitRate: asNum(t.prompt_tokens) > 0 ? asNum(t.cached_tokens) / asNum(t.prompt_tokens) : 0,
448
+ promptTokens: asNum(t.prompt_tokens),
449
+ completionTokens: asNum(t.completion_tokens),
450
+ escalations: asNum(t.escalations),
451
+ failovers: asNum(t.failovers),
452
+ errors: asNum(t.errors),
453
+ aborted: asNum(t.aborted),
412
454
  modelSwitches: switches,
413
- cacheEstimated: (t.estimated_rows ?? 0) > 0,
414
- subagentDispatches: t.subagent_rows ?? 0,
415
- subagentSpendUsd: t.subagent_spend,
416
- digests: t.digests ?? 0,
417
- digestSpendUsd: t.digest_spend,
418
- digestInputTokens: t.digest_input,
419
- digestReruns: t.digest_reruns ?? 0,
420
- redactions: t.redactions,
421
- redactedTurns: t.redacted_rows ?? 0,
422
- forecastSamples: t.fc_n ?? 0,
423
- forecastMeanError: (t.fc_n ?? 0) > 0 ? t.fc_err / (t.fc_n ?? 1) : 0,
424
- forecastOverShare: (t.fc_n ?? 0) > 0 ? (t.fc_over ?? 0) / (t.fc_n ?? 1) : 0,
455
+ cacheEstimated: asNum(t.estimated_rows) > 0,
456
+ subagentDispatches: asNum(t.subagent_rows),
457
+ subagentSpendUsd: asNum(t.subagent_spend),
458
+ digests: asNum(t.digests),
459
+ digestSpendUsd: asNum(t.digest_spend),
460
+ digestInputTokens: asNum(t.digest_input),
461
+ digestReruns: asNum(t.digest_reruns),
462
+ redactions: asNum(t.redactions),
463
+ redactedTurns: asNum(t.redacted_rows),
464
+ forecastSamples: asNum(t.fc_n),
465
+ forecastMeanError: asNum(t.fc_n) > 0 ? asNum(t.fc_err) / asNum(t.fc_n) : 0,
466
+ forecastOverShare: asNum(t.fc_n) > 0 ? asNum(t.fc_over) / asNum(t.fc_n) : 0,
425
467
  },
426
468
  providers,
427
469
  models,
@@ -12,16 +12,16 @@
12
12
  * than a detail of the caller.
13
13
  */
14
14
 
15
- import type { Ledger, PruneResult } from "./types.ts";
15
+ import type { AsyncLedger, PruneResult } from "./types.ts";
16
16
 
17
17
  /** Floor between two scheduled prunes. A retention window is measured in days; an hour is fine grain for it. */
18
18
  export const RETENTION_INTERVAL_MS = 3_600_000;
19
19
 
20
20
  export interface RetentionRunner {
21
21
  /** Prunes when the interval has elapsed since the last run; null when it was skipped. */
22
- maybeRun(nowMs?: number): PruneResult | null;
22
+ maybeRun(nowMs?: number): Promise<PruneResult | null>;
23
23
  /** Prunes regardless (the route), and satisfies the schedule for the next hour. */
24
- runNow(nowMs?: number): PruneResult;
24
+ runNow(nowMs?: number): Promise<PruneResult>;
25
25
  /** The configured window this runner would apply, for a caller that reports it. */
26
26
  retentionDays(): number | null;
27
27
  }
@@ -34,24 +34,24 @@ export interface RetentionRunner {
34
34
  * and so lowering it takes effect on the next tick rather than the next boot.
35
35
  */
36
36
  export function createRetentionRunner(opts: {
37
- ledger: Ledger;
37
+ ledger: AsyncLedger;
38
38
  retentionDays: () => number | null;
39
39
  intervalMs?: number;
40
40
  }): RetentionRunner {
41
41
  const intervalMs = opts.intervalMs ?? RETENTION_INTERVAL_MS;
42
42
  // Never run: the first call is always due, so a lowered window applies at boot.
43
43
  let lastRunMs: number | null = null;
44
- const run = (nowMs: number): PruneResult => {
44
+ const run = async (nowMs: number): Promise<PruneResult> => {
45
45
  lastRunMs = nowMs;
46
- return opts.ledger.prune?.(opts.retentionDays(), nowMs) ?? { deleted: 0, oldestKeptMs: null };
46
+ return await opts.ledger.prune(opts.retentionDays(), nowMs);
47
47
  };
48
48
  return {
49
- maybeRun(nowMs = Date.now()) {
49
+ async maybeRun(nowMs = Date.now()) {
50
50
  if (lastRunMs !== null && nowMs - lastRunMs < intervalMs) return null;
51
- return run(nowMs);
51
+ return await run(nowMs);
52
52
  },
53
- runNow(nowMs = Date.now()) {
54
- return run(nowMs);
53
+ async runNow(nowMs = Date.now()) {
54
+ return await run(nowMs);
55
55
  },
56
56
  retentionDays: opts.retentionDays,
57
57
  };
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { Database } from "bun:sqlite";
15
+ import type { SqlDb } from "../util/sql.ts";
15
16
  import { TIER_ORDER } from "../router/types.ts";
16
17
  import { buildUsageReport, harnessFilter, type BaselinePrice, type BaselineRow, type UsageReport } from "./report.ts";
17
18
  import type { SoftFailureSpike } from "./types.ts";
@@ -88,13 +89,15 @@ function windowOf(r: UsageReport): SummaryWindow {
88
89
  }
89
90
 
90
91
  /** Counts tier moves up and down between consecutive kept turns of each conversation since `sinceMs`. */
91
- export function countTierChanges(db: Database, sinceMs: number, harnessId: string): { up: number; down: number } {
92
+ export async function countTierChanges(db: SqlDb, sinceMs: number, harnessId: string): Promise<{ up: number; down: number }> {
92
93
  const hf = harnessFilter(harnessId);
93
94
  const where = ["created_at_ms >= $since", ...hf.sql].join(" AND ");
94
95
  const bind = { $since: sinceMs, ...hf.bind };
95
- const seq = db
96
- .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`)
97
- .all(bind) as { ck: string; tier: string }[];
96
+ const seq = await db.query<{ ck: string; tier: string }>(
97
+ `SELECT conversation_key AS ck, tier FROM ledger WHERE ${where} AND wasted = 0 AND requested_model <> 'digest'
98
+ ORDER BY conversation_key, created_at_ms`,
99
+ bind,
100
+ );
98
101
  let up = 0;
99
102
  let down = 0;
100
103
  for (let i = 1; i < seq.length; i++) {
@@ -110,8 +113,8 @@ export function countTierChanges(db: Database, sinceMs: number, harnessId: strin
110
113
  return { up, down };
111
114
  }
112
115
 
113
- export function buildDailySummary(
114
- db: Database,
116
+ export async function buildDailySummary(
117
+ db: SqlDb,
115
118
  opts: {
116
119
  harnessId?: string;
117
120
  nowMs?: number;
@@ -119,12 +122,14 @@ export function buildDailySummary(
119
122
  spikes?: readonly SoftFailureSpike[];
120
123
  ollama?: SummaryOllama | null;
121
124
  } = {},
122
- ): DailySummary {
125
+ ): Promise<DailySummary> {
123
126
  const nowMs = opts.nowMs ?? Date.now();
124
127
  const harnessId = opts.harnessId ?? "";
125
128
  const baselines = opts.baselines ?? [];
126
- const current = buildUsageReport(db, { windowDays: 1, harnessId, nowMs, baselines });
127
- const previous = buildUsageReport(db, { windowDays: 1, harnessId, nowMs: nowMs - DAY_MS, untilMs: current.sinceMs });
129
+ const current = await buildUsageReport(db, { windowDays: 1, harnessId, nowMs, baselines });
130
+ // The previous window ends where this one starts, so the two never
131
+ // double-count a turn on the boundary.
132
+ const previous = await buildUsageReport(db, { windowDays: 1, harnessId, nowMs: nowMs - DAY_MS, untilMs: current.sinceMs });
128
133
  return {
129
134
  generatedAtMs: nowMs,
130
135
  sinceMs: current.sinceMs,
@@ -132,7 +137,7 @@ export function buildDailySummary(
132
137
  current: windowOf(current),
133
138
  previous: windowOf(previous),
134
139
  topModels: current.models.slice(0, TOP_MODELS).map((m) => ({ slug: m.key, spendUsd: m.spendUsd, share: m.share, dispatches: m.dispatches })),
135
- tierChanges: countTierChanges(db, current.sinceMs, harnessId),
140
+ tierChanges: await countTierChanges(db, current.sinceMs, harnessId),
136
141
  baseline: current.baselines[0] ?? null,
137
142
  spikes: [...(opts.spikes ?? [])],
138
143
  ollama: opts.ollama ?? null,
package/src/cost/types.ts CHANGED
@@ -283,66 +283,47 @@ export interface SoftFailureSpike {
283
283
  baselineRate: number;
284
284
  }
285
285
 
286
- export interface Ledger {
287
- record(entry: LedgerEntry): void;
288
- /** Total reported (or predicted, when reported is null) spend for a conversation. */
289
- conversationSpend(conversationKey: string): number;
290
- /**
291
- * Total spend since a wall-clock instant. When `harnessId` is non-empty,
292
- * scoped to that harness only; empty ⇒ all harnesses (global).
293
- */
294
- spendSince(sinceMs: number, harnessId?: string): number;
295
- blendedRate(windowDays: number): BlendedRate | null;
296
- /**
297
- * Per-model reliability over the ledger, optionally scoped to a harness.
298
- * `task` (with `filters.feedbackByTask`) counts only verdicts given on
299
- * turns of that task type, plus verdicts on turns with no recorded task.
300
- */
301
- trust(slug: string, harnessId?: string, task?: string): ModelTrust | null;
302
- allTrust(): ModelTrust[];
303
- /**
304
- * Per-model responsiveness (mean TTFT + completion throughput), optionally
305
- * scoped to a harness. Null until `filters.latencyMinSamples` streamed samples
306
- * exist. TTFT isolates start latency from answer length; throughput captures
307
- * how fast the body streams once it starts.
308
- */
309
- latency(slug: string, harnessId?: string): ModelLatency | null;
310
- /** Batch trust and latency for one candidate set; one query per signal kind. Optional — callers can fall back to per-slug calls. */
311
- signals?(slugs: readonly string[], harnessId?: string, task?: string): Map<string, LedgerSignals>;
312
- /**
313
- * What an escalated retry actually bills per prompt token, measured over
314
- * the last `windowDays` of attempt > 0 rows. Null until enough escalated
315
- * attempts exist to measure. Optional so fakes need not implement it; the
316
- * escalation-cost term in candidate scoring is inert without it.
317
- */
318
- escalationCost?(windowDays: number): EscalationCost | null;
319
- /**
320
- * Observed cache hit rate when a warm cache was expected (see
321
- * ModelCacheReliability). Null until any sample exists. Optional so fakes
322
- * need not implement it; the stay/switch comparison assumes a reliable
323
- * cache without it.
324
- */
325
- cacheReliability?(slug: string): ModelCacheReliability | null;
326
- /** Observed chars-per-token ratio for a tokenizer family; null until calibrated. */
327
- tokenRatio(tokenizer: string): number | null;
328
- recentEntries(limit: number): LedgerEntry[];
329
- /** Spend since an instant on slugs with a prefix (`ollama/`), for provider-level reconciliation. Optional. */
330
- providerSpendSince?(slugPrefix: string, sinceMs: number): number;
331
- /**
332
- * Models whose soft-failure rate over the last `recentMs` is a spike against
333
- * their own rate over the preceding `baselineMs`. Optional; visibility only.
334
- */
335
- softFailureSpikes?(nowMs?: number, recentMs?: number, baselineMs?: number): SoftFailureSpike[];
336
- /** Newest kept (non-wasted) entry for an omp session, for /router why and feedback. Optional so fakes need not implement it. */
337
- latestForSession?(ompSessionId: string): LedgerEntry | null;
338
- /** Newest entries for an omp session, newest first. Optional. */
339
- entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
340
- /**
341
- * Deletes ledger rows past the retention window, and the feedback keyed to
342
- * them (`null` or 0 ⇒ nothing is deleted). Optional so fakes need not
343
- * implement it.
344
- */
345
- prune?(retentionDays: number | null, nowMs?: number): PruneResult;
346
- /** Marks one row wasted after the fact (a digest the agent went back on). Optional. */
347
- markWasted?(id: string): void;
286
+ /**
287
+ * The turn's ledger reads, asynchronously. A local SQLite ledger satisfies this
288
+ * trivially (its reads are already synchronous); a Postgres-backed one cannot
289
+ * be read from inside `select`, so this is the seam the router prefetches
290
+ * through. One implementation per storage engine, and the routing core never
291
+ * learns which it is talking to.
292
+ */
293
+ export interface LedgerReader {
294
+ signals(slugs: readonly string[], harnessId?: string, task?: string): Promise<Map<string, LedgerSignals>>;
295
+ /** Warm-cache hit rates for the slugs a turn may choose between. */
296
+ cacheReliability(slugs: readonly string[]): Promise<Map<string, ModelCacheReliability>>;
297
+ escalationCost(windowDays: number): Promise<EscalationCost | null>;
298
+ spendSince(sinceMs: number, harnessId?: string): Promise<number>;
299
+ }
300
+
301
+
302
+ /**
303
+ * The ledger. One interface, one implementation (`ledger-sql.ts`), either
304
+ * engine underneath.
305
+ *
306
+ * Every method is a promise because the store may be a shared database rather
307
+ * than a local file, and a Postgres read cannot be made synchronous. Nothing
308
+ * is optional: a caller made to guess which half of an interface it holds is
309
+ * how a signal silently goes missing, and test doubles get their omissions
310
+ * filled by `test/fakes.ts` instead. `LedgerReader` stays the narrow seam the
311
+ * turn path prefetches through; this is the full surface the server and the
312
+ * reports use.
313
+ */
314
+ export interface AsyncLedger extends LedgerReader {
315
+ record(entry: LedgerEntry): Promise<void>;
316
+ conversationSpend(conversationKey: string): Promise<number>;
317
+ blendedRate(windowDays: number): Promise<BlendedRate | null>;
318
+ trust(slug: string, harnessId?: string, task?: string): Promise<ModelTrust | null>;
319
+ allTrust(): Promise<ModelTrust[]>;
320
+ latency(slug: string, harnessId?: string): Promise<ModelLatency | null>;
321
+ tokenRatio(tokenizer: string): Promise<number | null>;
322
+ recentEntries(limit: number): Promise<LedgerEntry[]>;
323
+ providerSpendSince(slugPrefix: string, sinceMs: number): Promise<number>;
324
+ softFailureSpikes(nowMs?: number, recentMs?: number, baselineMs?: number): Promise<SoftFailureSpike[]>;
325
+ latestForSession(ompSessionId: string): Promise<LedgerEntry | null>;
326
+ entriesForSession(ompSessionId: string, limit: number): Promise<LedgerEntry[]>;
327
+ prune(retentionDays: number | null, nowMs?: number): Promise<PruneResult>;
328
+ markWasted(id: string): Promise<void>;
348
329
  }