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
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.30.3",
10
+ "version": "0.32.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.30.3",
17
+ "version": "0.32.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -888,7 +888,9 @@ disk and back up the previous file to a timestamped `.bak`.
888
888
  ### Configuration file location
889
889
 
890
890
  - Router config: `$AUTO_MODEL_ROUTER_HOME/config.yml` (default `~/.auto-model-router/config.yml`).
891
- - Ledger DB: `$AUTO_MODEL_ROUTER_HOME/router.db` (SQLite, WAL).
891
+ - Ledger DB: `$AUTO_MODEL_ROUTER_HOME/router.db` (SQLite, WAL), or a
892
+ `postgres://` URL in `ledger.path` when two replicas must share one store —
893
+ see [Sharing the store](#sharing-the-store).
892
894
 
893
895
  ### Environment variables
894
896
 
@@ -1158,13 +1160,41 @@ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
1158
1160
 
1159
1161
  | Key | Default | Meaning |
1160
1162
  | --- | --- | --- |
1161
- | `path` | `$AUTO_MODEL_ROUTER_HOME/router.db` | SQLite ledger path. |
1163
+ | `path` | `$AUTO_MODEL_ROUTER_HOME/router.db` | SQLite ledger path, or a `postgres://` URL. See [Sharing the store](#sharing-the-store). |
1162
1164
  | `blendWindowDays` | `7` | Window for the blended cost rate. |
1163
1165
  | `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
1164
1166
  | `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
1165
1167
  | `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
1166
1168
  | `retentionDays` | `null` | Delete ledger rows — and the feedback keyed to them — older than this many days, checked at most hourly; `null` (the default) and `0` keep everything. The ledger grows about 2.5 MB a day under steady use. See [Data governance](#data-governance). |
1167
1169
 
1170
+ #### Sharing the store
1171
+
1172
+ `ledger.path` accepts a `postgres://` URL as well as a SQLite path. One
1173
+ implementation serves both (`src/cost/ledger-sql.ts` over the dialect shim in
1174
+ `src/util/sql.ts`), and `tools/ledger-parity.ts` compares every signal and
1175
+ every report engine-against-engine on real rows.
1176
+
1177
+ What moves to the shared store is what a second replica must see one copy of:
1178
+
1179
+ - the turn rows a budget cap is counted from,
1180
+ - conversation routing memory (held tier, warm prompt-cache model, spend),
1181
+ - the agentdox context blocks.
1182
+
1183
+ What stays local is the cache layer — the catalog payload, the benchmark
1184
+ feeds, the local eval scores, the once-a-day summary marker. With a Postgres
1185
+ ledger those live in `$AUTO_MODEL_ROUTER_HOME/cache.db`. A cache shared
1186
+ between replicas buys contention and nothing else, and `local_scores` belongs
1187
+ to the machine that measured it.
1188
+
1189
+ Two replicas on one Postgres were verified end to end: a turn served by one
1190
+ replica leaves the next turn of that conversation on the same warm model when
1191
+ it lands on the other (`cache: keeping warm …`), and a replica that has served
1192
+ nothing refuses with `402 budget_exceeded` once the shared spend is past its
1193
+ cap — where the same cap against an empty store serves.
1194
+
1195
+ A SQLite deployment is unchanged: the file is still migrated in place through
1196
+ the nineteen shipped versions, and both halves live in the one file.
1197
+
1168
1198
  ### `redaction` — keep configured strings out of every request
1169
1199
 
1170
1200
  | Key | Default | Meaning |
@@ -51,7 +51,7 @@ import { loadConfig } from "../src/config/load.ts";
51
51
  import type { RouterConfig } from "../src/config/types.ts";
52
52
  import { buildUsageReport, renderUsageReport, type UsageReport } from "../src/cost/report.ts";
53
53
  import { buildDailySummary, renderDailySummary } from "../src/cost/summary.ts";
54
- import { openDb } from "../src/util/sqlite.ts";
54
+ import { openSqlDb } from "../src/util/sql.ts";
55
55
 
56
56
  import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
57
57
 
@@ -180,11 +180,13 @@ async function loadReport(req: ReportRequest): Promise<UsageReport> {
180
180
  if (!existsSync(cfg.ledger.path)) {
181
181
  throw new Error(`router unreachable (${err instanceof Error ? err.message : String(err)}) and no ledger at ${cfg.ledger.path}`);
182
182
  }
183
- const db = openDb(cfg.ledger.path);
183
+ // Fallback read when the router is unreachable: straight at the ledger,
184
+ // through the handle that works whichever engine holds it.
185
+ const db = openSqlDb(cfg.ledger.path);
184
186
  try {
185
- return buildUsageReport(db, req);
187
+ return await buildUsageReport(db, req);
186
188
  } finally {
187
- db.close();
189
+ await db.close();
188
190
  }
189
191
  }
190
192
  }
@@ -343,11 +345,11 @@ async function summary(pi: ExtensionAPI, ctx: ExtensionContext, argText: string)
343
345
  ctx.ui.notify(`router unreachable at ${routerBaseUrl()} and no ledger at ${cfg.ledger.path}`, "error");
344
346
  return;
345
347
  }
346
- const db = openDb(cfg.ledger.path);
348
+ const db = openSqlDb(cfg.ledger.path);
347
349
  try {
348
- post(pi, `${renderDailySummary(buildDailySummary(db, { harnessId }))}\n(router unreachable: read from the ledger; spikes and the Ollama meter need the router)`);
350
+ post(pi, `${renderDailySummary(await buildDailySummary(db, { harnessId }))}\n(router unreachable: read from the ledger; spikes and the Ollama meter need the router)`);
349
351
  } finally {
350
- db.close();
352
+ await db.close();
351
353
  }
352
354
  }
353
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.30.3",
3
+ "version": "0.32.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -24,9 +24,9 @@ import { parse as parseYaml, stringify } from "yaml";
24
24
  import { loadConfig, resolveTilde } from "../config/load.ts";
25
25
  import { configInputSchema } from "../config/schema.ts";
26
26
  import type { RouterConfig } from "../config/types.ts";
27
- import { createLedger } from "../cost/ledger.ts";
27
+ import { createSqlLedger } from "../cost/ledger-sql.ts";
28
+ import { dialectOf, openSqlDb } from "../util/sql.ts";
28
29
  import type { BlendedRate } from "../cost/types.ts";
29
- import { openDb } from "../util/sqlite.ts";
30
30
  import { configOpts, flagString, type CliArgs } from "./args.ts";
31
31
  import {
32
32
  mergeConfigPartial,
@@ -330,14 +330,15 @@ export function ompModelsPath(): string {
330
330
  export async function configCommand(args: CliArgs): Promise<void> {
331
331
  const cfg = loadConfig(configOpts(args));
332
332
 
333
- // Reading a blend must not create a database just by asking.
333
+ // Reading a blend must not create a store just by asking. A Postgres URL is
334
+ // always there to be asked; a file has to exist first.
334
335
  let blend: BlendedRate | null = null;
335
- if (existsSync(cfg.ledger.path)) {
336
- const db = openDb(cfg.ledger.path);
336
+ if (dialectOf(cfg.ledger.path) === "postgres" || existsSync(cfg.ledger.path)) {
337
+ const db = openSqlDb(cfg.ledger.path);
337
338
  try {
338
- blend = createLedger(db, cfg).blendedRate(cfg.ledger.blendWindowDays);
339
+ blend = await createSqlLedger(db, cfg, { findModel: () => null }).blendedRate(cfg.ledger.blendWindowDays);
339
340
  } finally {
340
- db.close();
341
+ await db.close();
341
342
  }
342
343
  }
343
344
 
@@ -12,11 +12,12 @@ import { existsSync } from "node:fs";
12
12
 
13
13
  import { createProviders } from "../server/providers.ts";
14
14
  import { loadConfig } from "../config/load.ts";
15
- import { createLedger } from "../cost/ledger.ts";
15
+ import { createSqlLedger } from "../cost/ledger-sql.ts";
16
16
  import { createRouter } from "../router/index.ts";
17
17
  import { createConversationStore } from "../router/state.ts";
18
18
  import type { Candidate, Decision, Features, Rejection } from "../router/types.ts";
19
19
  import { openDb } from "../util/sqlite.ts";
20
+ import { openSqlDb } from "../util/sql.ts";
20
21
  import { parseChatRequest } from "../wire/openai/request.ts";
21
22
  import { configOpts, flagString, type CliArgs } from "./args.ts";
22
23
 
@@ -130,10 +131,13 @@ export async function explainCommand(args: CliArgs): Promise<void> {
130
131
  const req = parseChatRequest(body, new Headers());
131
132
 
132
133
  const db = openDb(cfg.ledger.path);
134
+ // The conversation store reads through the engine-agnostic handle; the
135
+ // catalog and the ledger still use the bun:sqlite one. Same store.
136
+ const sdb = openSqlDb(cfg.ledger.path);
133
137
  try {
134
- const ledger = createLedger(db, cfg);
135
- const { upstream, catalog } = createProviders(cfg, db);
136
- const conversations = createConversationStore(db);
138
+ const ledger = createSqlLedger(sdb, cfg, { findModel: (slug: string) => catalog.find(slug) });
139
+ const { upstream, catalog } = createProviders(cfg, db, sdb);
140
+ const conversations = createConversationStore(sdb);
137
141
  const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
138
142
 
139
143
  const decision = await router.route(req, { attempt: 0 });
@@ -143,7 +147,7 @@ export async function explainCommand(args: CliArgs): Promise<void> {
143
147
  return;
144
148
  }
145
149
 
146
- const state = conversations.get(req.conversationKey);
150
+ const state = await conversations.get(req.conversationKey);
147
151
  console.log(`request: ${req.messages.length} messages, ${req.tools.length} tools, model "${req.requestedModel}"`);
148
152
  console.log(`conversation: ${req.conversationKey} (turn ${state?.turn ?? 0}, prior model ${state?.currentSlug ?? "none"})`);
149
153
  console.log(
@@ -161,6 +165,7 @@ export async function explainCommand(args: CliArgs): Promise<void> {
161
165
  renderDecision(decision);
162
166
  console.log("\n(no completion was dispatched; nothing was billed)");
163
167
  } finally {
168
+ await sdb.close();
164
169
  db.close();
165
170
  }
166
171
  }
package/src/cli/export.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync } from "node:fs";
8
- import { Database } from "bun:sqlite";
8
+ import { openSqlDb } from "../util/sql.ts";
9
9
  import { loadConfig } from "../config/load.ts";
10
10
  import { exportCsv, exportRows } from "../cost/views.ts";
11
11
  import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
@@ -18,12 +18,13 @@ export async function exportCommand(args: CliArgs): Promise<void> {
18
18
  process.stdout.write(args.flags.has("json") ? "[]\n" : exportCsv([]));
19
19
  return;
20
20
  }
21
- // Read-only: an export must never create or migrate the ledger.
22
- const db = new Database(cfg.ledger.path, { readonly: true });
21
+ // The shim opens the ledger wherever it lives; an export must never create
22
+ // or migrate it, so nothing here calls migrateLedger.
23
+ const db = openSqlDb(cfg.ledger.path);
23
24
  try {
24
- const rows = exportRows(db, Date.now() - days * 86_400_000, harness === "" ? null : harness.split(",").map((s) => s.trim()).filter((s) => s !== ""));
25
+ const rows = await exportRows(db, Date.now() - days * 86_400_000, harness === "" ? null : harness.split(",").map((s) => s.trim()).filter((s) => s !== ""));
25
26
  process.stdout.write(args.flags.has("json") ? `${JSON.stringify(rows, null, 2)}\n` : exportCsv(rows));
26
27
  } finally {
27
- db.close();
28
+ await db.close();
28
29
  }
29
30
  }
package/src/cli/models.ts CHANGED
@@ -7,21 +7,19 @@
7
7
  * command's whole value is that it cannot lie about what would be chosen.
8
8
  */
9
9
 
10
- import { existsSync } from "node:fs";
11
10
  import type { Database } from "bun:sqlite";
12
11
 
13
12
  import { createProviders } from "../server/providers.ts";
14
13
  import { effectiveQualityFloor, tierPlanFor } from "../router/tier-plan.ts";
15
14
  import { loadConfig } from "../config/load.ts";
16
15
  import type { QualityAxis, RouterConfig } from "../config/types.ts";
17
- import { createLedger } from "../cost/ledger.ts";
18
- import type { Ledger } from "../cost/types.ts";
19
16
  import { buildCandidates } from "../router/candidates.ts";
20
17
  import { classifyTask } from "../router/classify.ts";
21
18
  import { extractFeatures } from "../router/features.ts";
22
19
  import { TIER_ORDER, type Candidate, type Rejection, type Tier } from "../router/types.ts";
23
20
  import { estimatePromptTokens } from "../tokens/estimate.ts";
24
21
  import { openDb } from "../util/sqlite.ts";
22
+ import { openSqlDb } from "../util/sql.ts";
25
23
  import { parseChatRequest } from "../wire/openai/request.ts";
26
24
  import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
27
25
 
@@ -153,13 +151,18 @@ export async function modelsCommand(args: CliArgs): Promise<void> {
153
151
 
154
152
  // Reuse the on-disk catalog cache when present so a survey costs no network.
155
153
  const db: Database = openDb(cfg.ledger.path);
156
- const ledger: Ledger | null = existsSync(cfg.ledger.path) ? createLedger(db, cfg) : null;
154
+ // The engine-agnostic handle exists only to satisfy the providers' calibration
155
+ // store; a survey reads the catalog and nothing else.
156
+ const sqlDb = openSqlDb(cfg.ledger.path);
157
+ // A measured token ratio would change the estimate by a few percent and cost a
158
+ // read per survey; the family default is what a cold router uses anyway.
159
+ const ledgerRatio = null;
157
160
  try {
158
- const { catalog } = createProviders(cfg, db);
161
+ const { catalog } = createProviders(cfg, db, sqlDb);
159
162
  const snapshot = await catalog.get();
160
163
 
161
164
  const req = syntheticRequest();
162
- const promptTokens = estimatePromptTokens(req, "gpt", ledger);
165
+ const promptTokens = estimatePromptTokens(req, "gpt", ledgerRatio);
163
166
  const features = extractFeatures(req, promptTokens);
164
167
 
165
168
  const reports: TierReport[] = tiers.map((tier) => {
@@ -172,7 +175,6 @@ export async function modelsCommand(args: CliArgs): Promise<void> {
172
175
  tier,
173
176
  task,
174
177
  snapshot,
175
- ledger,
176
178
  cfg,
177
179
  expectedCompletionTokens: EXPECTED_COMPLETION_TOKENS,
178
180
  warmSlug: null,
@@ -233,6 +235,7 @@ export async function modelsCommand(args: CliArgs): Promise<void> {
233
235
  console.log(`survey request: ${promptTokens} estimated prompt tokens, ${req.tools.length} tools offered`);
234
236
  for (const report of reports) renderTier(report, limit);
235
237
  } finally {
238
+ await sqlDb.close();
236
239
  db.close();
237
240
  }
238
241
  }
package/src/cli/report.ts CHANGED
@@ -10,6 +10,7 @@ import { loadConfig } from "../config/load.ts";
10
10
  import { createCatalog } from "../catalog/openrouter-catalog.ts";
11
11
  import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
12
12
  import { openDb } from "../util/sqlite.ts";
13
+ import { openSqlDb } from "../util/sql.ts";
13
14
  import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
14
15
 
15
16
  export async function reportCommand(args: CliArgs): Promise<void> {
@@ -28,15 +29,19 @@ export async function reportCommand(args: CliArgs): Promise<void> {
28
29
  }
29
30
 
30
31
  const db = openDb(cfg.ledger.path);
32
+ // The catalog cache is read through the bun:sqlite handle; the report reads
33
+ // through the engine-agnostic one. Same store, two readers.
34
+ const sdb = openSqlDb(cfg.ledger.path);
31
35
  try {
32
36
  // Baseline prices from the cached catalog: no network for a report.
33
37
  const dead = { dispatch: () => Promise.reject(new Error("offline")), complete: () => Promise.reject(new Error("offline")), fetchModels: () => Promise.reject(new Error("offline")), fetchModelsForUser: () => Promise.reject(new Error("offline")) };
34
38
  const snapshot = createCatalog(cfg, dead, db).peek();
35
39
  const baselines = baselinePrices(cfg.report.baselines, (s) => snapshot?.models.find((m) => m.slug === s));
36
- const report = buildUsageReport(db, { windowDays: days, harnessId, baselines });
40
+ const report = await buildUsageReport(sdb, { windowDays: days, harnessId, baselines });
37
41
  if (args.flags.has("json")) console.log(JSON.stringify(report, null, 2));
38
42
  else console.log(renderUsageReport(report));
39
43
  } finally {
44
+ await sdb.close();
40
45
  db.close();
41
46
  }
42
47
  }
package/src/cli/stats.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
- import type { Database } from "bun:sqlite";
3
2
  import { loadConfig } from "../config/load.ts";
4
- import { createLedger } from "../cost/ledger.ts";
3
+ import { createSqlLedger } from "../cost/ledger-sql.ts";
5
4
  import { computeStats, type RouterStats } from "../server/http.ts";
6
- import { openDb } from "../util/sqlite.ts";
5
+ import { openSqlDb, type SqlDb } from "../util/sql.ts";
7
6
  import { configOpts, flagInt, type CliArgs } from "./args.ts";
8
7
 
9
8
  function usd(v: number): string {
@@ -38,11 +37,12 @@ export async function statsCommand(args: CliArgs): Promise<void> {
38
37
  const cfg = loadConfig(configOpts(args));
39
38
 
40
39
  // A stats query must not create the ledger file just by looking.
41
- let db: Database | null = null;
40
+ let db: SqlDb | null = null;
42
41
  let stats: RouterStats;
43
42
  if (existsSync(cfg.ledger.path)) {
44
- db = openDb(cfg.ledger.path);
45
- stats = computeStats(createLedger(db, cfg), { windowDays: days });
43
+ db = openSqlDb(cfg.ledger.path);
44
+ // No catalog here, so rows cannot be re-priced; stats only read.
45
+ stats = await computeStats(createSqlLedger(db, cfg, { findModel: () => null }), { windowDays: days });
46
46
  } else {
47
47
  stats = {
48
48
  generatedAtMs: Date.now(),
@@ -64,6 +64,6 @@ export async function statsCommand(args: CliArgs): Promise<void> {
64
64
  if (args.flags.has("json")) console.log(JSON.stringify(stats, null, 2));
65
65
  else renderStats(stats);
66
66
  } finally {
67
- db?.close();
67
+ await db?.close();
68
68
  }
69
69
  }
@@ -17,6 +17,15 @@ export function resolveTilde(p: string): string {
17
17
  return p;
18
18
  }
19
19
 
20
+ /**
21
+ * The router's own directory: config, the local cache database, and the
22
+ * default ledger file. One resolver so a caller that needs a sibling file
23
+ * lands in the same place `loadConfig` reads from.
24
+ */
25
+ export function routerHome(): string {
26
+ return resolveTilde(process.env.AUTO_MODEL_ROUTER_HOME ?? "~/.auto-model-router");
27
+ }
28
+
20
29
  function isPlainObject(v: unknown): v is Record<string, unknown> {
21
30
  return typeof v === "object" && v !== null && !Array.isArray(v);
22
31
  }
@@ -68,7 +77,7 @@ export type DeepPartial<T> = T extends readonly unknown[] | Date | RegExp
68
77
  * fail at dispatch time.
69
78
  */
70
79
  export function loadConfig(opts?: { path?: string; overrides?: DeepPartial<RouterConfig> }): RouterConfig {
71
- const home = resolveTilde(process.env.AUTO_MODEL_ROUTER_HOME ?? "~/.auto-model-router");
80
+ const home = routerHome();
72
81
 
73
82
  // Config file, when present.
74
83
  const filePath = opts?.path !== undefined
@@ -719,7 +719,16 @@ export interface ProfileConfig {
719
719
  }
720
720
 
721
721
  export interface LedgerConfig {
722
- /** SQLite path. Defaults to `$AUTO_MODEL_ROUTER_HOME/router.db`. */
722
+ /**
723
+ * Where the ledger lives: a SQLite path (default
724
+ * `$AUTO_MODEL_ROUTER_HOME/router.db`), or a `postgres://` URL for a store
725
+ * two replicas share. The shared store holds what correctness depends on
726
+ * being one copy — the turn rows a cap is counted from, conversation
727
+ * routing memory, and the context blocks. The local caches (catalog
728
+ * payloads, benchmark feeds, the summary marker) stay in a SQLite file
729
+ * beside the config either way: a cache is a per-process convenience, and
730
+ * sharing one would only add contention.
731
+ */
723
732
  path: string;
724
733
  /** Window for the blended rate published to omp, days. */
725
734
  blendWindowDays: number;
@@ -119,7 +119,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
119
119
  async resolve(input) {
120
120
  if (input.scope === "") return null;
121
121
 
122
- const pinned = input.pinnedVersion === null ? null : store.get(input.pinnedVersion);
122
+ const pinned = input.pinnedVersion === null ? null : await store.get(input.pinnedVersion);
123
123
  if (!shouldRefresh(input, pinned) && pinned !== null) {
124
124
  // Carry the conversation's own pin time forward, so the TTL keeps
125
125
  // counting from its last real refresh rather than resetting to
@@ -163,7 +163,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
163
163
  const version = sha256Hex(block).slice(0, 32);
164
164
  const pin: ContextPin = { version, block, fetchedAtMs: Date.now() };
165
165
  try {
166
- store.put(input.scope, pin);
166
+ await store.put(input.scope, pin);
167
167
  } catch (err) {
168
168
  log.debug("context block persist failed", { error: err instanceof Error ? err.message : String(err) });
169
169
  }
@@ -215,11 +215,11 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
215
215
  queued++;
216
216
  queue = queue
217
217
  .then(async () => {
218
- let sessionId = store.sessionFor(rec.conversationKey);
218
+ let sessionId = await store.sessionFor(rec.conversationKey);
219
219
  if (sessionId === null) {
220
220
  sessionId = await client.createSession(rec.scope, rec.title);
221
221
  if (sessionId === null) return;
222
- store.bindSession(rec.conversationKey, rec.scope, sessionId);
222
+ await store.bindSession(rec.conversationKey, rec.scope, sessionId);
223
223
  }
224
224
  // Model attribution rides on refs, which agentdox already carries
225
225
  // per message. This is what makes the transcript newly useful:
@@ -245,8 +245,8 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
245
245
  await queue;
246
246
  },
247
247
 
248
- pruneBlocks(maxAgeMs: number) {
249
- return store.prune(maxAgeMs);
248
+ async pruneBlocks(maxAgeMs: number) {
249
+ return await store.prune(maxAgeMs);
250
250
  },
251
251
 
252
252
  close() {
@@ -263,7 +263,7 @@ export function createDisabledBridge(): ContextBridge {
263
263
  resolve: async () => null,
264
264
  recordTurn: () => {},
265
265
  flush: async () => {},
266
- pruneBlocks: () => 0,
266
+ pruneBlocks: async () => 0,
267
267
  close: () => {},
268
268
  };
269
269
  }
@@ -4,7 +4,7 @@
4
4
  * reconfigurable, so those settings can change while the router runs.
5
5
  */
6
6
 
7
- import type { Database } from "bun:sqlite";
7
+ import type { SqlDb } from "../util/sql.ts";
8
8
 
9
9
  import type { RouterConfig } from "../config/types.ts";
10
10
  import { createLogger } from "../util/log.ts";
@@ -35,7 +35,7 @@ export interface ReloadableContextBridge extends ContextBridge {
35
35
  * restart. The block store is the database, not the bridge, so a rebuild keeps
36
36
  * every pinned block and session binding.
37
37
  */
38
- export function createBridgeFromConfig(cfg: RouterConfig, db: Database): ReloadableContextBridge {
38
+ export function createBridgeFromConfig(cfg: RouterConfig, db: SqlDb): ReloadableContextBridge {
39
39
  let inner = buildBridge(cfg, db);
40
40
  return {
41
41
  get enabled() {
@@ -58,7 +58,7 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Reloada
58
58
  };
59
59
  }
60
60
 
61
- function buildBridge(cfg: RouterConfig, db: Database): ContextBridge {
61
+ function buildBridge(cfg: RouterConfig, db: SqlDb): ContextBridge {
62
62
  const c = cfg.context;
63
63
  if (!c.enabled || c.baseUrl === "" || c.token === "") return createDisabledBridge();
64
64
  const log = createLogger(cfg.logLevel);
@@ -7,85 +7,68 @@
7
7
  * a conversation was already using — OpenRouter's prompt cache outlives our
8
8
  * process, and re-fetching would needlessly change the prefix.
9
9
  *
10
- * Tables are created by `util/sqlite.ts`, the single migration path.
10
+ * Tables are created by the store's migration (`util/schema.ts`).
11
11
  */
12
12
 
13
- import type { Database, Statement } from "bun:sqlite";
13
+ import { num, type SqlDb } from "../util/sql.ts";
14
14
 
15
15
  import type { ContextBlockStore, ContextPin } from "./types.ts";
16
16
 
17
17
  interface BlockRow {
18
18
  version: string;
19
19
  block: string;
20
- fetched_at_ms: number;
20
+ fetched_at_ms: unknown;
21
21
  }
22
22
 
23
- interface SessionRow {
24
- session_id: string;
25
- }
26
23
 
27
- export function createContextStore(db: Database): ContextBlockStore {
28
- // Hoisted: these run on the turn hot path.
29
- const selectBlock: Statement<BlockRow, [string]> = db.query(
30
- "SELECT version, block, fetched_at_ms FROM context_blocks WHERE version = ?",
31
- );
32
- const insertBlock = db.query(`
33
- INSERT INTO context_blocks (version, scope, block, fetched_at_ms)
34
- VALUES ($version, $scope, $block, $fetchedAtMs)
35
- ON CONFLICT(version) DO UPDATE SET fetched_at_ms = excluded.fetched_at_ms
36
- `);
37
- const selectSession: Statement<SessionRow, [string]> = db.query(
38
- "SELECT session_id FROM agentdox_sessions WHERE conversation_key = ?",
39
- );
40
- const insertSession = db.query(`
41
- INSERT INTO agentdox_sessions (conversation_key, scope, session_id, created_at_ms)
42
- VALUES ($key, $scope, $sessionId, $createdAtMs)
43
- ON CONFLICT(conversation_key) DO UPDATE SET session_id = excluded.session_id
44
- `);
45
- // Age alone is the wrong test: a block older than the staleness TTL may still
46
- // be PINNED by a live conversation, and deleting it forces that conversation
47
- // to refetch and re-inject different bytes — a prompt-cache miss caused by
48
- // housekeeping. Blocks are content-addressed and shared, so the safe set is
49
- // "old AND referenced by no conversation".
50
- const deleteStale: Statement<unknown, [number]> = db.query(`
51
- DELETE FROM context_blocks
52
- WHERE fetched_at_ms < ?
53
- AND version NOT IN (SELECT context_version FROM conversations WHERE context_version IS NOT NULL)
54
- `);
24
+ export function createContextStore(db: SqlDb): ContextBlockStore {
25
+ const { sql } = db;
55
26
 
56
27
  return {
57
- get(version) {
58
- const row = selectBlock.get(version);
28
+ async get(version) {
29
+ const row = await db.one<BlockRow>(
30
+ "SELECT version, block, fetched_at_ms FROM context_blocks WHERE version = $version",
31
+ { version },
32
+ );
59
33
  if (row === null) return null;
60
- return { version: row.version, block: row.block, fetchedAtMs: row.fetched_at_ms };
34
+ return { version: row.version, block: row.block, fetchedAtMs: num(row.fetched_at_ms) };
61
35
  },
62
36
 
63
- put(scope, pin: ContextPin) {
64
- insertBlock.run({
65
- $version: pin.version,
66
- $scope: scope,
67
- $block: pin.block,
68
- $fetchedAtMs: pin.fetchedAtMs,
69
- });
37
+ async put(scope, pin: ContextPin) {
38
+ await sql`
39
+ INSERT INTO context_blocks (version, scope, block, fetched_at_ms)
40
+ VALUES (${pin.version}, ${scope}, ${pin.block}, ${pin.fetchedAtMs})
41
+ ON CONFLICT (version) DO UPDATE SET fetched_at_ms = excluded.fetched_at_ms`;
70
42
  },
71
43
 
72
- sessionFor(conversationKey) {
73
- const row = selectSession.get(conversationKey);
44
+ async sessionFor(conversationKey) {
45
+ const row = await db.one<{ session_id: string }>(
46
+ "SELECT session_id FROM agentdox_sessions WHERE conversation_key = $key",
47
+ { key: conversationKey },
48
+ );
74
49
  return row === null ? null : row.session_id;
75
50
  },
76
51
 
77
- bindSession(conversationKey, scope, sessionId) {
78
- insertSession.run({
79
- $key: conversationKey,
80
- $scope: scope,
81
- $sessionId: sessionId,
82
- $createdAtMs: Date.now(),
83
- });
52
+ async bindSession(conversationKey, scope, sessionId) {
53
+ await sql`
54
+ INSERT INTO agentdox_sessions (conversation_key, scope, session_id, created_at_ms)
55
+ VALUES (${conversationKey}, ${scope}, ${sessionId}, ${Date.now()})
56
+ ON CONFLICT (conversation_key) DO UPDATE SET session_id = excluded.session_id`;
84
57
  },
85
58
 
86
- prune(maxAgeMs) {
87
- const res = deleteStale.run(Date.now() - maxAgeMs) as unknown as { changes?: number };
88
- return res.changes ?? 0;
59
+ async prune(maxAgeMs) {
60
+ // Age alone is the wrong test: a block older than the staleness TTL may
61
+ // still be PINNED by a live conversation, and deleting it forces that
62
+ // conversation to refetch and re-inject different bytes — a prompt-cache
63
+ // miss caused by housekeeping. Blocks are content-addressed and shared,
64
+ // so the safe set is "old AND referenced by no conversation". With a
65
+ // shared store that now means no conversation on ANY replica.
66
+ const deleted = (await sql`
67
+ DELETE FROM context_blocks
68
+ WHERE fetched_at_ms < ${Date.now() - maxAgeMs}
69
+ AND version NOT IN (SELECT context_version FROM conversations WHERE context_version IS NOT NULL)
70
+ RETURNING version`) as { version: string }[];
71
+ return deleted.length;
89
72
  },
90
73
  };
91
74
  }
@@ -110,22 +110,23 @@ export interface ContextBridge {
110
110
  * content-addressed and shared, so nothing else reclaims them — without this
111
111
  * the table grows for the life of the install.
112
112
  */
113
- pruneBlocks(maxAgeMs: number): number;
113
+ pruneBlocks(maxAgeMs: number): Promise<number>;
114
114
  close(): void;
115
115
  }
116
116
 
117
117
  /** Content-addressed store of fetched blocks, so a restart keeps a warm prefix. */
118
+ /** Asynchronous throughout: the store may be a shared database, not a file. */
118
119
  export interface ContextBlockStore {
119
- get(version: string): ContextPin | null;
120
- put(scope: string, pin: ContextPin): void;
120
+ get(version: string): Promise<ContextPin | null>;
121
+ put(scope: string, pin: ContextPin): Promise<void>;
121
122
  /** agentdox session id previously opened for a conversation. */
122
- sessionFor(conversationKey: string): string | null;
123
- bindSession(conversationKey: string, scope: string, sessionId: string): void;
123
+ sessionFor(conversationKey: string): Promise<string | null>;
124
+ bindSession(conversationKey: string, scope: string, sessionId: string): Promise<void>;
124
125
  /**
125
126
  * Drops blocks older than `maxAgeMs` that NO conversation still pins.
126
127
  * Returns the number removed. Referenced blocks are kept regardless of age:
127
128
  * deleting one would force a live conversation to refetch and re-inject
128
129
  * different bytes, turning housekeeping into a prompt-cache miss.
129
130
  */
130
- prune(maxAgeMs: number): number;
131
+ prune(maxAgeMs: number): Promise<number>;
131
132
  }