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
@@ -9,7 +9,7 @@
9
9
  import type { CatalogSnapshot } from "../catalog/types.ts";
10
10
  import type { ProfileConfig, RouterConfig } from "../config/types.ts";
11
11
  import { forecast, priceAt } from "../cost/forecast.ts";
12
- import type { Ledger } from "../cost/types.ts";
12
+ import type { LedgerSignals, ModelCacheReliability } from "../cost/types.ts";
13
13
  import { explorationDraw } from "./explore.ts";
14
14
  import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
15
15
  import { compactedBytes, planCompaction, validatePlan, type CompactionResult } from "./compaction.ts";
@@ -35,7 +35,6 @@ export interface SelectArgs {
35
35
  profile: ProfileConfig;
36
36
  state: ConversationState;
37
37
  snapshot: CatalogSnapshot;
38
- ledger: Ledger | null;
39
38
  cfg: RouterConfig;
40
39
  nowMs: number;
41
40
  /**
@@ -49,6 +48,30 @@ export interface SelectArgs {
49
48
  * stay/switch comparison. Ignored when the catalog has no such model.
50
49
  */
51
50
  forceSlug?: string;
51
+ /** Ledger reads already performed for this turn; see TurnReads. */
52
+ reads?: TurnReads;
53
+ }
54
+
55
+ /**
56
+ * Every ledger read a turn needs, fetched BEFORE selection.
57
+ *
58
+ * `select` is synchronous on purpose — it is a pure ranking function, and
59
+ * `explain` depends on being able to run it without side effects. A ledger on
60
+ * Postgres cannot be read synchronously, so the reads move up to `route`,
61
+ * which is already async, and arrive here as data. Absent ⇒ read through the
62
+ * absent reads degrade to no signals rather than reaching for the store.
63
+ */
64
+ export interface TurnReads {
65
+ /** Trust and latency per candidate slug; one batch query per signal kind. */
66
+ signals?: Map<string, LedgerSignals>;
67
+ /** Observed warm-cache hit rate per slug. */
68
+ cacheReliability?: Map<string, ModelCacheReliability>;
69
+ /** What an escalated retry bills per prompt token; null ⇒ the term is inert. */
70
+ escalationUsdPerPromptToken?: number | null;
71
+ /** Spend since the start of the UTC month, scoped as the filters say. */
72
+ monthSpendUsd?: number;
73
+ /** Spend over the rolling 24h, scoped as the filters say. */
74
+ daySpendUsd?: number;
52
75
  }
53
76
 
54
77
  /**
@@ -67,7 +90,6 @@ export class BudgetExceededError extends Error {
67
90
  // Mid-range completion assumption for forecasts. Long generations amortize
68
91
  // into prompt-dominated cost anyway; precision here does not move rankings.
69
92
  const EXPECTED_COMPLETION_TOKENS = 1024;
70
- const DAY_MS = 86_400_000;
71
93
 
72
94
  /**
73
95
  * Authors known to accept replayed assistant reasoning over chat completions:
@@ -129,7 +151,7 @@ export function monthPace(nowMs: number, perMonthUsd: number, spentUsd: number):
129
151
  }
130
152
 
131
153
  export function select(args: SelectArgs): Decision {
132
- const { req, features, classification, profile, state, snapshot, ledger, cfg, nowMs } = args;
154
+ const { req, features, classification, profile, state, snapshot, cfg, nowMs } = args;
133
155
  const reasons: string[] = [];
134
156
  const minI = tierIdx(profile.minTier);
135
157
  const maxI = tierIdx(profile.maxTier);
@@ -351,29 +373,23 @@ export function select(args: SelectArgs): Decision {
351
373
  const warmSlug = cacheWarm ? state.cacheWarmSlug : null;
352
374
  // Expected cache hit for a model: its observed rate once enough warm-expected
353
375
  // samples exist (filters.cacheReliabilityMinSamples), else a reliable 1.
376
+ const reads = args.reads;
354
377
  const cacheHitExpectation = (slug: string): { rate: number; measured: boolean; samples: number } => {
355
378
  const min = cfg.filters.cacheReliabilityMinSamples;
356
- const rel = min > 0 ? (ledger?.cacheReliability?.(slug) ?? null) : null;
379
+ const rel = min > 0 ? (reads?.cacheReliability?.get(slug) ?? null) : null;
357
380
  if (rel === null || rel.samples < min) return { rate: 1, measured: false, samples: rel?.samples ?? 0 };
358
381
  return { rate: rel.hitRate, measured: true, samples: rel.samples };
359
382
  };
360
- // Pre-fetch trust/latency signals for all candidate slugs in one batch
361
- // query per signal kind, instead of per-model individual lookups.
362
- const candidateSignals =
363
- ledger !== null && snapshot.models.length > 0
364
- ? ledger.signals?.(
365
- snapshot.models.map((m) => m.slug),
366
- cfg.filters.trustScopedByHarness ? req.harnessId : undefined,
367
- cfg.filters.feedbackByTask ? classification.task : undefined,
368
- )
369
- : undefined;
383
+ // Trust and latency for every candidate, fetched by `route` before this ran.
384
+ // There is no fallback read here on purpose: selection is synchronous and the
385
+ // store may be a shared database, so a missing prefetch must degrade to
386
+ // "no signals" rather than silently reach for a handle it cannot await.
387
+ const candidateSignals = reads?.signals;
370
388
  // What an escalated retry has actually been billing per prompt token, for
371
389
  // the escalation-cost term in candidate scoring. Read once per turn; null
372
390
  // (term inert) when the weight is 0 or the ledger has too few samples.
373
391
  const escalationUsdPerPromptToken =
374
- cfg.filters.escalationCostWeight > 0
375
- ? (ledger?.escalationCost?.(cfg.ledger.blendWindowDays)?.usdPerPromptToken ?? null)
376
- : null;
392
+ cfg.filters.escalationCostWeight > 0 ? (reads?.escalationUsdPerPromptToken ?? null) : null;
377
393
  // A pinned slug is admitted the way a config pin is: into the tier's pin
378
394
  // list for this call only, so the quality floor cannot keep it out.
379
395
  const pinSlug = args.forceSlug !== undefined && snapshot.models.some((m) => m.slug === args.forceSlug) ? args.forceSlug : undefined;
@@ -386,7 +402,6 @@ export function select(args: SelectArgs): Decision {
386
402
  tier: t,
387
403
  task: classification.task,
388
404
  snapshot,
389
- ledger,
390
405
  cfg: buildCfg,
391
406
  expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
392
407
  warmSlug,
@@ -515,13 +530,15 @@ export function select(args: SelectArgs): Decision {
515
530
  // left, becomes a daily ceiling that tightens as the month runs ahead.
516
531
  let paceNote = "";
517
532
  if (budget.perMonthUsd !== undefined) {
518
- const pace = monthPace(nowMs, budget.perMonthUsd, ledger?.spendSince(monthStartMs(nowMs), req.harnessId) ?? 0);
533
+ const monthSpend = reads?.monthSpendUsd ?? 0;
534
+ const pace = monthPace(nowMs, budget.perMonthUsd, monthSpend);
519
535
  if (budget.perDayUsd === undefined || pace.dailyCapUsd < budget.perDayUsd) {
520
536
  budget.perDayUsd = pace.dailyCapUsd;
521
537
  paceNote = ` (month pacing: $${pace.spentUsd.toFixed(2)} of $${budget.perMonthUsd} spent, $${pace.dailyCapUsd.toFixed(2)}/day for ${pace.daysLeft} more days)`;
522
538
  }
523
539
  }
524
- const daySpend = budget.perDayUsd !== undefined ? (ledger?.spendSince(nowMs - DAY_MS, req.harnessId) ?? 0) : 0;
540
+ const daySpend =
541
+ budget.perDayUsd !== undefined ? (reads?.daySpendUsd ?? 0) : 0;
525
542
  const breach = (c: Candidate): string | null => {
526
543
  if (budget.perTurnUsd !== undefined && c.forecast.coldUsd > budget.perTurnUsd) {
527
544
  return `cold forecast $${c.forecast.coldUsd.toFixed(4)} > per-turn budget $${budget.perTurnUsd}`;
@@ -5,11 +5,11 @@
5
5
  * survive a restart: an omp session outlives this process, and forgetting
6
6
  * which model is warm would cold-start a paid prompt cache for no reason.
7
7
  *
8
- * The `conversations` table is created by `util/sqlite.ts`, the single
9
- * migration path.
8
+ * The `conversations` table is created by the store's migration
9
+ * (`util/schema.ts`, or `util/sqlite.ts` for a file that predates it).
10
10
  */
11
11
 
12
- import type { Database, Statement } from "bun:sqlite";
12
+ import { jsonParam, jsonValue, num, type SqlDb } from "../util/sql.ts";
13
13
 
14
14
  import type { CompactionEdit } from "../wire/types.ts";
15
15
  import type { ConversationState, ConversationStore, Tier } from "./types.ts";
@@ -19,147 +19,135 @@ import type { ConversationState, ConversationStore, Tier } from "./types.ts";
19
19
  interface Row {
20
20
  key: string;
21
21
  session_id: string;
22
- turn: number;
22
+ turn: unknown;
23
23
  current_slug: string | null;
24
24
  current_tier: string | null;
25
- sticky_until_turn: number;
26
- escalations: number;
27
- spent_usd: number;
28
- last_prompt_tokens: number;
25
+ sticky_until_turn: unknown;
26
+ escalations: unknown;
27
+ spent_usd: unknown;
28
+ last_prompt_tokens: unknown;
29
29
  cache_warm_slug: string | null;
30
- cache_warm_at_ms: number;
30
+ cache_warm_at_ms: unknown;
31
31
  context_version: string | null;
32
- context_fetched_at_ms: number;
33
- compaction_plan: string | null;
34
- compaction_plan_tokens: number;
32
+ context_fetched_at_ms: unknown;
33
+ /** JSON: text on sqlite, already parsed on postgres. */
34
+ compaction_plan: unknown;
35
+ compaction_plan_tokens: unknown;
35
36
  upgrade_deferred_tier: string | null;
36
- updated_at_ms: number;
37
+ updated_at_ms: unknown;
37
38
  }
38
39
 
39
40
  function toState(row: Row): ConversationState {
40
41
  return {
41
42
  key: row.key,
42
43
  sessionId: row.session_id,
43
- turn: row.turn,
44
+ // Counts and sums arrive as strings from Postgres, and every one of these
45
+ // feeds arithmetic — the sticky window, the budget guard, cache warmth.
46
+ turn: num(row.turn),
44
47
  currentSlug: row.current_slug,
45
48
  // Stored as free text; the column is only ever written from a Tier.
46
49
  currentTier: row.current_tier as Tier | null,
47
- stickyUntilTurn: row.sticky_until_turn,
48
- escalations: row.escalations,
49
- spentUsd: row.spent_usd,
50
- lastPromptTokens: row.last_prompt_tokens,
50
+ stickyUntilTurn: num(row.sticky_until_turn),
51
+ escalations: num(row.escalations),
52
+ spentUsd: num(row.spent_usd),
53
+ lastPromptTokens: num(row.last_prompt_tokens),
51
54
  cacheWarmSlug: row.cache_warm_slug,
52
- cacheWarmAtMs: row.cache_warm_at_ms,
55
+ cacheWarmAtMs: num(row.cache_warm_at_ms),
53
56
  contextVersion: row.context_version,
54
- contextFetchedAtMs: row.context_fetched_at_ms,
55
- compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
56
- compactionPlanTokens: row.compaction_plan_tokens,
57
+ contextFetchedAtMs: num(row.context_fetched_at_ms),
58
+ compactionPlan: jsonValue<CompactionEdit[]>(row.compaction_plan),
59
+ compactionPlanTokens: num(row.compaction_plan_tokens),
57
60
  upgradeDeferredTier: row.upgrade_deferred_tier as Tier | null,
58
- updatedAtMs: row.updated_at_ms,
61
+ updatedAtMs: num(row.updated_at_ms),
59
62
  };
60
63
  }
61
64
 
62
- export function createConversationStore(db: Database): ConversationStore {
63
- // Hoisted: this runs on every turn, twice when an escalation retries.
64
- const selectOne: Statement<Row, [string]> = db.query("SELECT * FROM conversations WHERE key = ?");
65
- const insertOne: Statement<unknown, [string, string, number]> = db.query(
66
- "INSERT INTO conversations (key, session_id, updated_at_ms) VALUES (?, ?, ?)",
67
- );
68
- // `spent_usd` and `escalations` are ABSENT from this statement on purpose.
69
- // They accumulate through `accrueOne` below, so writing a turn-start snapshot
70
- // back here would erase whatever a billed-but-uncommitted dispatch added.
71
- // The schema defaults both to 0, so the INSERT arm still works.
72
- const upsert = db.query(`
73
- INSERT INTO conversations (
74
- key, session_id, turn, current_slug, current_tier, sticky_until_turn,
75
- last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
76
- context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens, upgrade_deferred_tier, updated_at_ms
77
- ) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
78
- $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
79
- $contextVersion, $contextFetchedAtMs, $compactionPlan, $compactionPlanTokens, $upgradeDeferredTier, $updatedAtMs)
80
- ON CONFLICT(key) DO UPDATE SET
81
- session_id = excluded.session_id,
82
- turn = excluded.turn,
83
- current_slug = excluded.current_slug,
84
- current_tier = excluded.current_tier,
85
- sticky_until_turn = excluded.sticky_until_turn,
86
- last_prompt_tokens = excluded.last_prompt_tokens,
87
- cache_warm_slug = excluded.cache_warm_slug,
88
- cache_warm_at_ms = excluded.cache_warm_at_ms,
89
- context_version = excluded.context_version,
90
- context_fetched_at_ms = excluded.context_fetched_at_ms,
91
- compaction_plan = excluded.compaction_plan,
92
- compaction_plan_tokens = excluded.compaction_plan_tokens,
93
- upgrade_deferred_tier = excluded.upgrade_deferred_tier,
94
- updated_at_ms = excluded.updated_at_ms
95
- `);
96
- // Read-modify-write in JS lost money: an aborted or failed dispatch is still
97
- // billed by the upstream, but it returns before the commit path, so the next
98
- // dispatch loaded a stale total and overwrote it. Measured on live data:
99
- // 152 aborted dispatches billing $0.9985 — 30% of all spend — never reached
100
- // `spent_usd`, leaving the per-conversation budget guard blind to it.
101
- // Accumulating in SQL is correct regardless of who raced whom.
102
- const accrueOne = db.query(`
103
- UPDATE conversations
104
- SET spent_usd = spent_usd + $spentUsd,
105
- escalations = escalations + $escalations,
106
- updated_at_ms = $updatedAtMs
107
- WHERE key = $key
108
- `);
109
- const deleteStale: Statement<unknown, [number]> = db.query("DELETE FROM conversations WHERE updated_at_ms < ?");
65
+ export function createConversationStore(db: SqlDb): ConversationStore {
66
+ const { sql } = db;
67
+ const selectOne = async (key: string): Promise<Row | null> =>
68
+ await db.one<Row>("SELECT * FROM conversations WHERE key = $key", { key });
110
69
 
111
70
  return {
112
- get(key) {
113
- const row = selectOne.get(key);
71
+ async get(key) {
72
+ const row = await selectOne(key);
114
73
  return row === null ? null : toState(row);
115
74
  },
116
75
 
117
- load(key) {
118
- const existing = selectOne.get(key);
76
+ async load(key) {
77
+ const existing = await selectOne(key);
119
78
  if (existing !== null) return toState(existing);
120
79
  // Session id is derived, not random, so it stays stable if this row is
121
80
  // ever pruned and the same conversation continues afterwards.
122
81
  const sessionId = `omp-${key}`;
123
- insertOne.run(key, sessionId, Date.now());
124
- const inserted = selectOne.get(key);
82
+ // Two replicas can reach this at once for the same conversation; the
83
+ // loser must read the winner's row rather than fail the turn.
84
+ await sql`INSERT INTO conversations (key, session_id, updated_at_ms) VALUES (${key}, ${sessionId}, ${Date.now()})
85
+ ON CONFLICT (key) DO NOTHING`;
86
+ const inserted = await selectOne(key);
125
87
  if (inserted === null) throw new Error(`conversation row vanished immediately after insert: ${key}`);
126
88
  return toState(inserted);
127
89
  },
128
90
 
129
- save(state) {
130
- // bun:sqlite matches named parameters by their literal `$name` key;
131
- // bare keys bind nothing at all and every column silently lands NULL.
132
- // No $spentUsd / $escalations here see the statement above.
133
- upsert.run({
134
- $key: state.key,
135
- $sessionId: state.sessionId,
136
- $turn: state.turn,
137
- $currentSlug: state.currentSlug,
138
- $currentTier: state.currentTier,
139
- $stickyUntilTurn: state.stickyUntilTurn,
140
- $lastPromptTokens: state.lastPromptTokens,
141
- $compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
142
- $compactionPlanTokens: state.compactionPlanTokens ?? 0,
143
- $upgradeDeferredTier: state.upgradeDeferredTier ?? null,
144
- $cacheWarmSlug: state.cacheWarmSlug,
145
- $cacheWarmAtMs: state.cacheWarmAtMs,
146
- $contextVersion: state.contextVersion,
147
- $contextFetchedAtMs: state.contextFetchedAtMs,
148
- $updatedAtMs: Date.now(),
149
- });
91
+ async save(state) {
92
+ // `spent_usd` and `escalations` are ABSENT on purpose: they accumulate
93
+ // through `accrue`, so writing a turn-start snapshot back here would
94
+ // erase whatever a billed-but-uncommitted dispatch added. The schema
95
+ // defaults both to 0, so the INSERT arm still works.
96
+ await sql`
97
+ INSERT INTO conversations (
98
+ key, session_id, turn, current_slug, current_tier, sticky_until_turn,
99
+ last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
100
+ context_version, context_fetched_at_ms, compaction_plan, compaction_plan_tokens,
101
+ upgrade_deferred_tier, updated_at_ms
102
+ ) VALUES (
103
+ ${state.key}, ${state.sessionId}, ${state.turn}, ${state.currentSlug}, ${state.currentTier},
104
+ ${state.stickyUntilTurn}, ${state.lastPromptTokens}, ${state.cacheWarmSlug}, ${state.cacheWarmAtMs},
105
+ ${state.contextVersion}, ${state.contextFetchedAtMs}, ${jsonParam(db, state.compactionPlan)},
106
+ ${state.compactionPlanTokens ?? 0}, ${state.upgradeDeferredTier ?? null}, ${Date.now()}
107
+ )
108
+ ON CONFLICT (key) DO UPDATE SET
109
+ session_id = excluded.session_id,
110
+ turn = excluded.turn,
111
+ current_slug = excluded.current_slug,
112
+ current_tier = excluded.current_tier,
113
+ sticky_until_turn = excluded.sticky_until_turn,
114
+ last_prompt_tokens = excluded.last_prompt_tokens,
115
+ cache_warm_slug = excluded.cache_warm_slug,
116
+ cache_warm_at_ms = excluded.cache_warm_at_ms,
117
+ context_version = excluded.context_version,
118
+ context_fetched_at_ms = excluded.context_fetched_at_ms,
119
+ compaction_plan = excluded.compaction_plan,
120
+ compaction_plan_tokens = excluded.compaction_plan_tokens,
121
+ upgrade_deferred_tier = excluded.upgrade_deferred_tier,
122
+ updated_at_ms = excluded.updated_at_ms`;
150
123
  },
151
124
 
152
- accrue(key, delta) {
125
+ async accrue(key, delta) {
153
126
  const spentUsd = delta.spentUsd ?? 0;
154
127
  const escalations = delta.escalations ?? 0;
155
128
  // Nothing to add: skip the write rather than bump updated_at_ms and
156
129
  // keep a dead conversation alive against `prune`.
157
130
  if (spentUsd === 0 && escalations === 0) return;
158
- accrueOne.run({ $key: key, $spentUsd: spentUsd, $escalations: escalations, $updatedAtMs: Date.now() });
131
+ // Read-modify-write in JS lost money: an aborted or failed dispatch is
132
+ // still billed by the upstream, but it returns before the commit path,
133
+ // so the next dispatch loaded a stale total and overwrote it. Measured
134
+ // on live data: 152 aborted dispatches billing $0.9985 — 30% of all
135
+ // spend — never reached `spent_usd`. Accumulating in SQL is correct
136
+ // regardless of who raced whom, and with a shared store the racers can
137
+ // now be different processes.
138
+ await sql`
139
+ UPDATE conversations
140
+ SET spent_usd = spent_usd + ${spentUsd},
141
+ escalations = escalations + ${escalations},
142
+ updated_at_ms = ${Date.now()}
143
+ WHERE key = ${key}`;
159
144
  },
160
145
 
161
- prune(maxAgeMs) {
162
- return deleteStale.run(Date.now() - maxAgeMs).changes;
146
+ async prune(maxAgeMs) {
147
+ const deleted = (await sql`DELETE FROM conversations WHERE updated_at_ms < ${Date.now() - maxAgeMs} RETURNING key`) as {
148
+ key: string;
149
+ }[];
150
+ return deleted.length;
163
151
  },
164
152
  };
165
153
  }
@@ -226,16 +226,22 @@ export interface ConversationState {
226
226
  updatedAtMs: number;
227
227
  }
228
228
 
229
+ /**
230
+ * Every method is asynchronous because the store may be a shared database
231
+ * rather than a local file: replicas that cannot see each other's conversation
232
+ * state re-cold-start prompt caches for turns they did not serve, which at an
233
+ * observed 93% cache hit rate is a cost regression, not a latency one.
234
+ */
229
235
  export interface ConversationStore {
230
- get(key: string): ConversationState | null;
236
+ get(key: string): Promise<ConversationState | null>;
231
237
  /** Loads existing state or creates a fresh record. */
232
- load(key: string): ConversationState;
238
+ load(key: string): Promise<ConversationState>;
233
239
  /**
234
240
  * Persists the latest-wins fields. Deliberately does NOT write `spentUsd` or
235
241
  * `escalations` — those accumulate via `accrue`, and writing back a snapshot
236
242
  * here would clobber what a concurrent or already-billed dispatch added.
237
243
  */
238
- save(state: ConversationState): void;
244
+ save(state: ConversationState): Promise<void>;
239
245
  /**
240
246
  * Adds to the persisted counters, atomically in SQL.
241
247
  *
@@ -244,9 +250,9 @@ export interface ConversationStore {
244
250
  * reach the per-conversation budget guard anyway. Requires `load` to have
245
251
  * created the row.
246
252
  */
247
- accrue(key: string, delta: { spentUsd?: number; escalations?: number }): void;
253
+ accrue(key: string, delta: { spentUsd?: number; escalations?: number }): Promise<void>;
248
254
  /** Drops records untouched for longer than `maxAgeMs`. */
249
- prune(maxAgeMs: number): number;
255
+ prune(maxAgeMs: number): Promise<number>;
250
256
  }
251
257
 
252
258
  /** Guarded-probe configuration for one dispatch. */
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { RouterConfig } from "../config/types.ts";
19
- import type { Ledger } from "../cost/types.ts";
19
+ import type { AsyncLedger } from "../cost/types.ts";
20
20
  import { scoreHeuristic } from "../router/classify.ts";
21
21
  import { extractFeatures } from "../router/features.ts";
22
22
  import { estimateTokens } from "../tokens/estimate.ts";
@@ -69,11 +69,13 @@ function requestOf(req: AdviseRequest): NormRequest {
69
69
  }
70
70
 
71
71
  /** Classifies a prompt the way the first turn of a conversation would be, without dispatching anything. */
72
- export function advise(cfg: RouterConfig, ledger: Ledger | null, req: AdviseRequest): Advice {
72
+ export async function advise(cfg: RouterConfig, ledger: AsyncLedger | null, req: AdviseRequest): Promise<Advice> {
73
73
  const norm = requestOf(req);
74
- const features = extractFeatures(norm, estimateTokens(norm.promptBytes, "unknown", ledger));
74
+ // Advice is a hint for a client that has not dispatched yet: the default
75
+ // family ratio is enough, and it keeps this off the store entirely.
76
+ const features = extractFeatures(norm, estimateTokens(norm.promptBytes, "unknown", null));
75
77
  const cls = scoreHeuristic(features, cfg);
76
- const last = req.ompSessionId === "" ? null : (ledger?.latestForSession?.(req.ompSessionId)?.tier ?? null);
78
+ const last = req.ompSessionId === "" ? null : ((await ledger?.latestForSession(req.ompSessionId))?.tier ?? null);
77
79
  return {
78
80
  tier: cls.tier,
79
81
  task: cls.task,
@@ -24,7 +24,7 @@ import type { DigestRequest, DigestResult } from "./digest.ts";
24
24
  export interface CompactionDigester {
25
25
  digest(req: DigestRequest): Promise<DigestResult>;
26
26
  /** See Digester.noteToolCalls; optional so a fake need not implement it. */
27
- noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
27
+ noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): Promise<number>;
28
28
  }
29
29
 
30
30
  export interface DigestCompactionArgs {
@@ -21,7 +21,7 @@
21
21
  import type { CatalogModel, CatalogSource } from "../catalog/types.ts";
22
22
  import type { DigestConfig, RouterConfig } from "../config/types.ts";
23
23
  import { computeCost, forecast } from "../cost/forecast.ts";
24
- import type { Ledger, LedgerEntry } from "../cost/types.ts";
24
+ import type { AsyncLedger, LedgerEntry } from "../cost/types.ts";
25
25
  import { buildCandidates } from "../router/candidates.ts";
26
26
  import { primaryArg } from "../router/compaction.ts";
27
27
  import { extractFeatures } from "../router/features.ts";
@@ -57,7 +57,7 @@ export type DigestResult =
57
57
  export interface DigesterDeps {
58
58
  cfg: RouterConfig;
59
59
  catalog: CatalogSource;
60
- ledger: Ledger;
60
+ ledger: AsyncLedger;
61
61
  upstream: UpstreamClient;
62
62
  log: Logger;
63
63
  }
@@ -144,7 +144,7 @@ export interface Digester {
144
144
  * back for the full output; that digest's ledger row is marked wasted and
145
145
  * the report shows the re-run rate. Returns how many were marked.
146
146
  */
147
- noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
147
+ noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): Promise<number>;
148
148
  }
149
149
 
150
150
  export function createDigester(deps: DigesterDeps): Digester {
@@ -163,7 +163,6 @@ export function createDigester(deps: DigesterDeps): Digester {
163
163
  tier: cfg.digest.tier,
164
164
  task: "documentation",
165
165
  snapshot,
166
- ledger,
167
166
  cfg,
168
167
  expectedCompletionTokens: cfg.digest.maxOutputTokens,
169
168
  warmSlug: null,
@@ -179,14 +178,14 @@ export function createDigester(deps: DigesterDeps): Digester {
179
178
  async digest(req) {
180
179
  const inputBytes = Buffer.byteLength(req.content);
181
180
  const source = req.source ?? "tool_result";
182
- const currentTier = req.tier ?? ledger.latestForSession?.(req.ompSessionId)?.tier ?? null;
181
+ const currentTier = req.tier ?? (await ledger.latestForSession(req.ompSessionId))?.tier ?? null;
183
182
  const gate = source === "compaction" ? { ...cfg.digest, enabled: cfg.compaction.digestToolResults } : cfg.digest;
184
183
  const applies = digestApplies(gate, req.toolName, inputBytes, false, currentTier);
185
184
  if (!applies.ok) return { digested: false, reason: applies.reason };
186
185
 
187
186
  const promptText = `Task: ${req.query === "" ? "(unknown)" : req.query}\nTool: ${req.toolName} ${JSON.stringify(req.input)}\n--- output ---\n${req.content}`;
188
187
  const synthetic = syntheticRequest(req, promptText);
189
- const promptTokens = estimateTokens(synthetic.promptBytes, "unknown", ledger);
188
+ const promptTokens = estimateTokens(synthetic.promptBytes, "unknown", null);
190
189
  const model = await pickModel(synthetic, promptTokens);
191
190
  if (model === null) return { digested: false, reason: "no digest model available" };
192
191
  const est = forecast(model, { promptTokens, completionTokens: cfg.digest.maxOutputTokens, cacheHitRate: 0, images: 0 });
@@ -222,7 +221,7 @@ export function createDigester(deps: DigesterDeps): Digester {
222
221
  clearTimeout(timer);
223
222
  }
224
223
  const ms = Date.now() - startedAt;
225
- const completionTokens = estimateTokens(Buffer.byteLength(text), model.tokenizer, ledger);
224
+ const completionTokens = estimateTokens(Buffer.byteLength(text), model.tokenizer, null);
226
225
  const usage = { promptTokens, cachedTokens: 0, cacheWriteTokens: 0, completionTokens, reasoningTokens: 0, images: 0 };
227
226
  const usd = costUsd ?? computeCost(model, usage).total;
228
227
 
@@ -264,7 +263,7 @@ export function createDigester(deps: DigesterDeps): Digester {
264
263
  priceModel: model,
265
264
  };
266
265
  try {
267
- ledger.record(entry);
266
+ await ledger.record(entry);
268
267
  } catch (err) {
269
268
  log.debug("digest ledger record failed", { error: err instanceof Error ? err.message : String(err) });
270
269
  }
@@ -285,7 +284,7 @@ export function createDigester(deps: DigesterDeps): Digester {
285
284
  ms,
286
285
  };
287
286
  },
288
- noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
287
+ async noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
289
288
  const list = recent.get(ompSessionId);
290
289
  if (list === undefined || list.length === 0) return 0;
291
290
  let marked = 0;
@@ -303,7 +302,7 @@ export function createDigester(deps: DigesterDeps): Digester {
303
302
  d.rerun = true;
304
303
  marked++;
305
304
  try {
306
- ledger.markWasted?.(d.ledgerId);
305
+ await ledger.markWasted(d.ledgerId);
307
306
  } catch (err) {
308
307
  log.debug("digest re-run mark failed", { error: err instanceof Error ? err.message : String(err) });
309
308
  }