auto-model-router 0.31.0 → 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 (110) 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/lib.ts +6 -2
  27. package/src/router/candidates.ts +7 -15
  28. package/src/router/classify.ts +6 -4
  29. package/src/router/index.ts +95 -9
  30. package/src/router/select.ts +38 -21
  31. package/src/router/state.ts +90 -102
  32. package/src/router/types.ts +11 -5
  33. package/src/server/advise.ts +6 -4
  34. package/src/server/compaction-digest.ts +1 -1
  35. package/src/server/digest.ts +9 -10
  36. package/src/server/http.ts +101 -41
  37. package/src/server/providers.ts +18 -4
  38. package/src/server/turn.ts +32 -9
  39. package/src/tokens/estimate.ts +16 -6
  40. package/src/upstream/ollama-usage.ts +21 -11
  41. package/src/util/schema.ts +201 -0
  42. package/src/util/sql.ts +246 -0
  43. package/src/wire/anthropic/messages.ts +3 -4
  44. package/src/wire/openai/request.ts +1 -0
  45. package/src/wire/types.ts +7 -0
  46. package/test/anthropic-wire.test.ts +9 -9
  47. package/test/benchmark-feeds.test.ts +7 -7
  48. package/test/cache-control.test.ts +7 -7
  49. package/test/cache-estimate.test.ts +5 -5
  50. package/test/catalog-view.test.ts +4 -4
  51. package/test/catalog.test.ts +11 -11
  52. package/test/classify.test.ts +24 -24
  53. package/test/compaction.test.ts +20 -20
  54. package/test/config-wizard.test.ts +32 -32
  55. package/test/config.test.ts +10 -10
  56. package/test/connect-harnesses.test.ts +11 -11
  57. package/test/context-bridge.test.ts +40 -30
  58. package/test/context-prune.test.ts +43 -36
  59. package/test/context-query.test.ts +8 -8
  60. package/test/controls.test.ts +54 -27
  61. package/test/cost.test.ts +12 -12
  62. package/test/digest.test.ts +55 -44
  63. package/test/embed-lifecycle.test.ts +5 -5
  64. package/test/embed-logic.test.ts +26 -26
  65. package/test/escalate.test.ts +17 -17
  66. package/test/eval.test.ts +13 -13
  67. package/test/executable.test.ts +6 -6
  68. package/test/exploration.test.ts +19 -20
  69. package/test/failover.test.ts +22 -21
  70. package/test/fakes.ts +105 -0
  71. package/test/features.test.ts +21 -21
  72. package/test/harness-requests.test.ts +3 -3
  73. package/test/harness-switch.test.ts +5 -5
  74. package/test/hold-exploration.test.ts +13 -13
  75. package/test/hot-reload.test.ts +5 -5
  76. package/test/learned.test.ts +5 -5
  77. package/test/ledger-sql.test.ts +342 -0
  78. package/test/mcp-entry.test.ts +5 -5
  79. package/test/migrations.test.ts +28 -22
  80. package/test/models-yml.test.ts +18 -18
  81. package/test/ollama.test.ts +40 -34
  82. package/test/omp-credentials.test.ts +16 -16
  83. package/test/policy.test.ts +3 -3
  84. package/test/reconfigure.test.ts +4 -4
  85. package/test/redaction.test.ts +41 -35
  86. package/test/remote.test.ts +12 -12
  87. package/test/report-logic.test.ts +8 -8
  88. package/test/report.test.ts +95 -87
  89. package/test/retention.test.ts +79 -66
  90. package/test/schema.test.ts +123 -0
  91. package/test/scope.test.ts +8 -8
  92. package/test/select.test.ts +216 -257
  93. package/test/skills.test.ts +3 -3
  94. package/test/sql-shim.test.ts +154 -0
  95. package/test/state.test.ts +43 -36
  96. package/test/summary.test.ts +38 -27
  97. package/test/tier-plan.test.ts +45 -62
  98. package/test/toast-logic.test.ts +31 -31
  99. package/test/tokens.test.ts +95 -80
  100. package/test/trust-attribution.test.ts +217 -187
  101. package/test/trust-window.test.ts +37 -32
  102. package/test/turn.test.ts +55 -23
  103. package/test/upstreams.test.ts +13 -13
  104. package/test/views.test.ts +81 -59
  105. package/test/wire-request.test.ts +17 -17
  106. package/test/wire-responses.test.ts +4 -4
  107. package/tools/agentdox-e2e.ts +5 -2
  108. package/tools/export-benchmarks.ts +5 -5
  109. package/tools/ledger-parity.ts +266 -0
  110. package/tools/replay.ts +16 -8
package/test/cost.test.ts CHANGED
@@ -23,12 +23,12 @@ const SONNET = model("anthropic/claude-sonnet-4.5"); // publishes cache prices +
23
23
  const ALL = FIXTURE.data.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
24
24
 
25
25
  describe("priceAt", () => {
26
- test("returns the base price below every override threshold", () => {
26
+ test("returns the base price below every override threshold", async () => {
27
27
  expect(priceAt(SONNET, 1000).prompt).toBe(SONNET.price.prompt);
28
28
  expect(priceAt(SONNET, 199_999).prompt).toBe(SONNET.price.prompt);
29
29
  });
30
30
 
31
- test("crosses into the long-context tier at the threshold", () => {
31
+ test("crosses into the long-context tier at the threshold", async () => {
32
32
  const tier = SONNET.priceTiers[0];
33
33
  expect(tier).toBeDefined();
34
34
  if (tier === undefined) return;
@@ -36,7 +36,7 @@ describe("priceAt", () => {
36
36
  expect(priceAt(SONNET, tier.minPromptTokens + 1).prompt).toBeGreaterThan(SONNET.price.prompt);
37
37
  });
38
38
 
39
- test("a long conversation is dearer per token than a short one", () => {
39
+ test("a long conversation is dearer per token than a short one", async () => {
40
40
  // The whole reason override tiers are modelled: ignoring them
41
41
  // underestimates long-session cost by roughly half.
42
42
  const short = computeCost(SONNET, usage({ promptTokens: 50_000, completionTokens: 1000 }));
@@ -48,7 +48,7 @@ describe("priceAt", () => {
48
48
  });
49
49
 
50
50
  describe("computeCost", () => {
51
- test("components sum to the reported total", () => {
51
+ test("components sum to the reported total", async () => {
52
52
  const b = computeCost(
53
53
  SONNET,
54
54
  usage({ promptTokens: 10_000, cachedTokens: 6000, cacheWriteTokens: 1000, completionTokens: 500, reasoningTokens: 200, images: 2 }),
@@ -57,7 +57,7 @@ describe("computeCost", () => {
57
57
  expect(sum).toBeCloseTo(b.total, 12);
58
58
  });
59
59
 
60
- test("prompt_tokens already includes cached tokens, so they are not billed twice", () => {
60
+ test("prompt_tokens already includes cached tokens, so they are not billed twice", async () => {
61
61
  // 10k prompt of which 10k cached must cost far less than 10k fresh,
62
62
  // and must not be billed as 20k.
63
63
  const allFresh = computeCost(SONNET, usage({ promptTokens: 10_000 }));
@@ -70,7 +70,7 @@ describe("computeCost", () => {
70
70
  expect(allCached.cacheRead).toBeCloseTo(10_000 * cacheRead, 12);
71
71
  });
72
72
 
73
- test("cache reads are cheaper than fresh prompt tokens wherever published", () => {
73
+ test("cache reads are cheaper than fresh prompt tokens wherever published", async () => {
74
74
  let checked = 0;
75
75
  for (const m of ALL) {
76
76
  const read = m.price.cacheRead;
@@ -83,7 +83,7 @@ describe("computeCost", () => {
83
83
  expect(checked).toBeGreaterThan(0);
84
84
  });
85
85
 
86
- test("reasoning tokens are a subset of completion tokens and never double-billed", () => {
86
+ test("reasoning tokens are a subset of completion tokens and never double-billed", async () => {
87
87
  const withReasoning = computeCost(SONNET, usage({ completionTokens: 1000, reasoningTokens: 400 }));
88
88
  const withoutReasoning = computeCost(SONNET, usage({ completionTokens: 1000 }));
89
89
  // Sonnet publishes no separate reasoning rate, so 1000 completion tokens
@@ -94,14 +94,14 @@ describe("computeCost", () => {
94
94
  expect(withReasoning.total).toBeLessThan(inflated.total);
95
95
  });
96
96
 
97
- test("zero usage costs nothing beyond any flat per-request fee", () => {
97
+ test("zero usage costs nothing beyond any flat per-request fee", async () => {
98
98
  const b = computeCost(SONNET, EMPTY_USAGE);
99
99
  expect(b.total).toBe(b.request);
100
100
  });
101
101
  });
102
102
 
103
103
  describe("forecast", () => {
104
- test("cold is never cheaper than expected, for every model in the catalog", () => {
104
+ test("cold is never cheaper than expected, for every model in the catalog", async () => {
105
105
  // A budget guard checks the cold number, so this ordering is load-bearing:
106
106
  // several models publish a cache-write rate BELOW their prompt rate, so
107
107
  // the honest worst case is the max of "no cache" and "full cache write".
@@ -113,14 +113,14 @@ describe("forecast", () => {
113
113
  }
114
114
  });
115
115
 
116
- test("a higher assumed cache hit rate lowers the expected cost", () => {
116
+ test("a higher assumed cache hit rate lowers the expected cost", async () => {
117
117
  const cold = forecast(SONNET, { promptTokens: 50_000, completionTokens: 500, cacheHitRate: 0, images: 0 });
118
118
  const warm = forecast(SONNET, { promptTokens: 50_000, completionTokens: 500, cacheHitRate: 0.9, images: 0 });
119
119
  expect(warm.expectedUsd).toBeLessThan(cold.expectedUsd);
120
120
  expect(warm.assumedCacheHitRate).toBeCloseTo(0.9, 12);
121
121
  });
122
122
 
123
- test("records the assumptions it was given", () => {
123
+ test("records the assumptions it was given", async () => {
124
124
  const f = forecast(SONNET, { promptTokens: 1234, completionTokens: 567, cacheHitRate: 0.25, images: 3 });
125
125
  expect(f.slug).toBe(SONNET.slug);
126
126
  expect(f.assumedPromptTokens).toBe(1234);
@@ -128,7 +128,7 @@ describe("forecast", () => {
128
128
  expect(f.expectedUsd).toBeGreaterThan(0);
129
129
  });
130
130
 
131
- test("a cheap model forecasts below an expensive one for identical work", () => {
131
+ test("a cheap model forecasts below an expensive one for identical work", async () => {
132
132
  const cheap = model("openai/gpt-5-nano");
133
133
  const dear = model("openai/gpt-5-pro");
134
134
  const args = { promptTokens: 20_000, completionTokens: 1000, cacheHitRate: 0, images: 0 };
@@ -1,15 +1,20 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { AsyncLedger } from "../src/cost/types.ts";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { migrateStore } from "../src/util/schema.ts";
7
+ import { openSqlDb } from "../src/util/sql.ts";
2
8
 
3
9
  import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
10
  import type { CatalogModel, CatalogSnapshot, CatalogSource } from "../src/catalog/types.ts";
5
11
  import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
6
12
  import type { RouterConfig } from "../src/config/types.ts";
7
- import { createLedger } from "../src/cost/ledger.ts";
13
+ import { createSqlLedger } from "../src/cost/ledger-sql.ts";
8
14
  import type { LedgerEntry } from "../src/cost/types.ts";
9
15
  import { createDigester, digestApplies, digestMarker } from "../src/server/digest.ts";
10
16
  import type { UpstreamClient } from "../src/upstream/types.ts";
11
17
  import { createLogger } from "../src/util/log.ts";
12
- import { openDb } from "../src/util/sqlite.ts";
13
18
  import { digestToast, parsePolicy, shouldSend, textOf } from "../omp-extension/digest-logic.ts";
14
19
 
15
20
  /**
@@ -37,7 +42,7 @@ function cfgWith(over: Partial<RouterConfig["digest"]> = {}): RouterConfig {
37
42
  return cfg;
38
43
  }
39
44
 
40
- function seedSession(ledger: ReturnType<typeof createLedger>, tier: string): void {
45
+ async function seedSession(ledger: AsyncLedger, tier: string): Promise<void> {
41
46
  const e: LedgerEntry = {
42
47
  id: crypto.randomUUID(),
43
48
  createdAtMs: Date.now(),
@@ -72,7 +77,7 @@ function seedSession(ledger: ReturnType<typeof createLedger>, tier: string): voi
72
77
  error: null,
73
78
  promptTokensSaved: 0,
74
79
  };
75
- ledger.record(e);
80
+ await ledger.record(e);
76
81
  }
77
82
 
78
83
  function fakeUpstream(reply: (body: Record<string, unknown>) => string, costUsd: number | null = 0.0004): { upstream: UpstreamClient; calls: Record<string, unknown>[] } {
@@ -95,7 +100,7 @@ const BIG = Array.from({ length: 400 }, (_, i) => `${i + 1}: export const value$
95
100
 
96
101
  describe("digestApplies", () => {
97
102
  const d = { ...DEFAULT_CONFIG.digest, enabled: true, minBytes: 100, maxBytes: 1000 };
98
- test("gates on switch, error, tool, size and session tier", () => {
103
+ test("gates on switch, error, tool, size and session tier", async () => {
99
104
  expect(digestApplies({ ...d, enabled: false }, "read", 500, false, "hard").ok).toBe(false);
100
105
  expect(digestApplies(d, "read", 500, true, "hard").ok).toBe(false);
101
106
  expect(digestApplies(d, "edit", 500, false, "hard").ok).toBe(false);
@@ -111,9 +116,10 @@ describe("digestApplies", () => {
111
116
  describe("createDigester", () => {
112
117
  test("condenses a large read for a hard-tier session on a cheap model and records a ledger row", async () => {
113
118
  const cfg = cfgWith();
114
- const db = openDb(":memory:");
115
- const ledger = createLedger(db, cfg);
116
- seedSession(ledger, "hard");
119
+ const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
120
+ await migrateStore(db);
121
+ const ledger = createSqlLedger(db, cfg, { findModel: () => null });
122
+ await seedSession(ledger, "hard");
117
123
  const { upstream, calls } = fakeUpstream(() => "Omitted 380 trivial constants.\n1: export const value0 = 0;\n...");
118
124
  const d = createDigester({ cfg, catalog, ledger, upstream, log });
119
125
  const r = await d.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: { path: "src/values.ts" }, content: BIG, query: "find value0" });
@@ -129,44 +135,47 @@ describe("createDigester", () => {
129
135
  expect(catalog.find(call.model as string)?.price.prompt).toBeLessThanOrEqual(cfg.tiers.simple.maxInputPerMtok! / 1e6);
130
136
  expect(JSON.stringify(call.messages)).toContain("Task: find value0");
131
137
  // A ledger row under requestedModel "digest" with the served model and its cost.
132
- const rows = ledger.recentEntries(10).filter((e) => e.requestedModel === "digest");
138
+ const rows = (await ledger.recentEntries(10)).filter((e) => e.requestedModel === "digest");
133
139
  expect(rows).toHaveLength(1);
134
140
  expect(rows[0]!.slug).toBe(call.model as string);
135
141
  expect(rows[0]!.reportedUsd).toBeCloseTo(0.0004, 6);
136
142
  expect(rows[0]!.ompSessionId).toBe("omp-1");
137
- db.close();
143
+ await db.close();
138
144
  });
139
145
 
140
146
  test("declines below the session tier, over the cost guard, when the model fails, or when nothing shrinks", async () => {
141
- const db = openDb(":memory:");
147
+ const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
148
+ await migrateStore(db);
142
149
  const cfg = cfgWith();
143
- const ledger = createLedger(db, cfg);
144
- seedSession(ledger, "simple");
150
+ const ledger = createSqlLedger(db, cfg, { findModel: () => null });
151
+ await seedSession(ledger, "simple");
145
152
  const cheap = createDigester({ cfg, catalog, ledger, upstream: fakeUpstream(() => "short").upstream, log });
146
153
  expect(await cheap.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: expect.stringContaining("below digest.fromTier") });
147
154
 
148
- const db2 = openDb(":memory:");
155
+ const db2 = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
156
+ await migrateStore(db2);
149
157
  const strict = cfgWith({ maxCostUsd: 0 });
150
- const ledger2 = createLedger(db2, strict);
151
- seedSession(ledger2, "hard");
158
+ const ledger2 = createSqlLedger(db2, strict, { findModel: () => null });
159
+ await seedSession(ledger2, "hard");
152
160
  expect(await createDigester({ cfg: strict, catalog, ledger: ledger2, upstream: fakeUpstream(() => "x").upstream, log }).digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: expect.stringContaining("exceeds digest.maxCostUsd") });
153
161
 
154
162
  const failing: UpstreamClient = { ...fakeUpstream(() => "x").upstream, complete: () => Promise.reject(new Error("boom")) };
155
163
  expect(await createDigester({ cfg, catalog, ledger: ledger2, upstream: failing, log }).digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: "digest model failed: boom" });
156
164
  // The failed attempt is still a ledger row, with the error.
157
- expect(ledger2.recentEntries(5).find((e) => e.requestedModel === "digest")?.error).toBe("boom");
165
+ expect((await ledger2.recentEntries(5)).find((e) => e.requestedModel === "digest")?.error).toBe("boom");
158
166
 
159
167
  const same = createDigester({ cfg, catalog, ledger: ledger2, upstream: fakeUpstream(() => BIG).upstream, log });
160
168
  expect(await same.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: {}, content: BIG, query: "" })).toMatchObject({ digested: false, reason: "digest did not shrink the output" });
161
- db.close();
169
+ await db.close();
162
170
  db2.close();
163
171
  });
164
172
 
165
173
  test("a compaction-sourced digest is gated on compaction.digestToolResults and judges the given tier", async () => {
166
174
  const cfg = cfgWith({ enabled: false });
167
175
  cfg.compaction.digestToolResults = true;
168
- const db = openDb(":memory:");
169
- const ledger = createLedger(db, cfg);
176
+ const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
177
+ await migrateStore(db);
178
+ const ledger = createSqlLedger(db, cfg, { findModel: () => null });
170
179
  // No session rows at all: the tier comes from the request.
171
180
  const { upstream, calls } = fakeUpstream(() => "Condensed.");
172
181
  const d = createDigester({ cfg, catalog, ledger, upstream, log });
@@ -178,63 +187,65 @@ describe("createDigester", () => {
178
187
  const r = await d.digest({ ...base, tier: "hard", source: "compaction" });
179
188
  expect(r.digested).toBe(true);
180
189
  expect(calls).toHaveLength(1);
181
- expect(ledger.recentEntries(5).find((e) => e.requestedModel === "digest")?.reasons[0]).toContain("digest (compaction)");
190
+ expect((await ledger.recentEntries(5)).find((e) => e.requestedModel === "digest")?.reasons[0]).toContain("digest (compaction)");
182
191
  cfg.compaction.digestToolResults = false;
183
192
  expect(await d.digest({ ...base, tier: "hard", source: "compaction" })).toMatchObject({ digested: false });
184
- db.close();
193
+ await db.close();
185
194
  });
186
195
 
187
196
  test("a later call of the same tool with the same primary argument marks the digest wasted", async () => {
188
197
  const cfg = cfgWith();
189
- const db = openDb(":memory:");
190
- const ledger = createLedger(db, cfg);
191
- seedSession(ledger, "hard");
198
+ const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
199
+ await migrateStore(db);
200
+ const ledger = createSqlLedger(db, cfg, { findModel: () => null });
201
+ await seedSession(ledger, "hard");
192
202
  const dg = createDigester({ cfg, catalog, ledger, upstream: fakeUpstream(() => "Condensed.").upstream, log });
193
203
  const r = await dg.digest({ ompSessionId: "omp-1", harnessId: "", toolName: "read", input: { path: "src/a.ts", offset: 1 }, content: BIG, query: "" });
194
204
  expect(r.digested).toBe(true);
195
- const row = () => ledger.recentEntries(10).find((e) => e.requestedModel === "digest")!;
196
- expect(row().wasted).toBe(false);
205
+ const row = async (): Promise<LedgerEntry> => (await ledger.recentEntries(10)).find((e) => e.requestedModel === "digest")!;
206
+ expect((await row()).wasted).toBe(false);
197
207
  // A different file, a different tool, another session: no match.
198
- expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/b.ts"}' }, { name: "grep", argsJson: '{"pattern":"src/a.ts"}' }])).toBe(0);
199
- expect(dg.noteToolCalls("omp-2", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
200
- expect(row().wasted).toBe(false);
208
+ expect(await dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/b.ts"}' }, { name: "grep", argsJson: '{"pattern":"src/a.ts"}' }])).toBe(0);
209
+ expect(await dg.noteToolCalls("omp-2", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
210
+ expect((await row()).wasted).toBe(false);
201
211
  // The next request carries the call that PRODUCED the digest in its last assistant message: not a re-run.
202
- expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts","offset":1}' }])).toBe(0);
203
- expect(row().wasted).toBe(false);
212
+ expect(await dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts","offset":1}' }])).toBe(0);
213
+ expect((await row()).wasted).toBe(false);
204
214
  // The same read again (case-insensitive tool name, any other args): the agent wanted the full output.
205
- expect(dg.noteToolCalls("omp-1", [{ name: "Read", argsJson: '{"path":"src/a.ts","limit":50}' }])).toBe(1);
206
- expect(row().wasted).toBe(true);
215
+ expect(await dg.noteToolCalls("omp-1", [{ name: "Read", argsJson: '{"path":"src/a.ts","limit":50}' }])).toBe(1);
216
+ expect((await row()).wasted).toBe(true);
207
217
  // Marked once; a third read does not count again.
208
- expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
209
- db.close();
218
+ expect(await dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
219
+ await db.close();
210
220
  });
211
221
 
212
222
  test("a pinned digest model is used as-is", async () => {
213
223
  const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
214
224
  const cfg = cfgWith({ model: pinned });
215
- const db = openDb(":memory:");
216
- const ledger = createLedger(db, cfg);
217
- seedSession(ledger, "hard");
225
+ const db = openSqlDb(join(tmpdir(), `t-digest.test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`));
226
+ await migrateStore(db);
227
+ const ledger = createSqlLedger(db, cfg, { findModel: () => null });
228
+ await seedSession(ledger, "hard");
218
229
  const { upstream, calls } = fakeUpstream(() => "digest");
219
230
  await createDigester({ cfg, catalog, ledger, upstream, log }).digest({ ompSessionId: "omp-1", harnessId: "", toolName: "grep", input: { pattern: "x" }, content: BIG, query: "" });
220
231
  expect(calls[0]?.model as string).toBe(pinned);
221
- db.close();
232
+ await db.close();
222
233
  });
223
234
  });
224
235
 
225
236
  describe("digest marker and extension logic", () => {
226
- test("the marker names the tool, sizes, model and how to get the full output", () => {
237
+ test("the marker names the tool, sizes, model and how to get the full output", async () => {
227
238
  expect(digestMarker("grep", { pattern: "retry" }, "z-ai/glm-5.3-flash", 48_000, 3_000)).toBe(
228
239
  '[digest: grep output 48,000 bytes → 3,000 chars by z-ai/glm-5.3-flash. Full output: re-run grep {"pattern":"retry"}]',
229
240
  );
230
241
  });
231
242
 
232
- test("textOf joins text parts and flags images", () => {
243
+ test("textOf joins text parts and flags images", async () => {
233
244
  expect(textOf([{ type: "text", text: "a" }, { type: "text", text: "b" }])).toEqual({ text: "a\nb", hasImage: false });
234
245
  expect(textOf([{ type: "image" }, { type: "text", text: "a" }])).toEqual({ text: "a", hasImage: true });
235
246
  });
236
247
 
237
- test("shouldSend applies the client-side gate; parsePolicy is defensive", () => {
248
+ test("shouldSend applies the client-side gate; parsePolicy is defensive", async () => {
238
249
  const p = parsePolicy({ enabled: true, minBytes: 10, maxBytes: 100, tools: ["Read", "grep"], fromTier: "hard" });
239
250
  expect(p.tools).toEqual(["read", "grep"]);
240
251
  expect(shouldSend(p, "read", false, "x".repeat(50), false)).toBe(true);
@@ -248,7 +259,7 @@ describe("digest marker and extension logic", () => {
248
259
  expect(parsePolicy({ enabled: true }).minBytes).toBe(12_000);
249
260
  });
250
261
 
251
- test("digestToast is one readable line", () => {
262
+ test("digestToast is one readable line", async () => {
252
263
  expect(digestToast("read", 48 * 1024, 3 * 1024, "ollama/glm-5.3-flash", 0.00042)).toBe("digested read 48KB → 3KB via glm-5.3-flash ($0.0004)");
253
264
  });
254
265
  });
@@ -124,7 +124,7 @@ afterAll(async () => {
124
124
  });
125
125
 
126
126
  describe("embedded router: port selection", () => {
127
- test("adopts the port models.yml advertises, so omp's pre-resolved handle is valid", () => {
127
+ test("adopts the port models.yml advertises, so omp's pre-resolved handle is valid", async () => {
128
128
  expect(portOfLatestRegistration()).toBe(advertised);
129
129
  });
130
130
 
@@ -132,13 +132,13 @@ describe("embedded router: port selection", () => {
132
132
  expect(await alive(advertised)).toBe(true);
133
133
  });
134
134
 
135
- test("does NOT write its port into models.yml", () => {
135
+ test("does NOT write its port into models.yml", async () => {
136
136
  // Persisting an ephemeral port makes it authoritative for the NEXT
137
137
  // session's startup resolution, which is where the dead handle came from.
138
138
  expect(readFileSync(modelsYmlPath, "utf8")).toBe(modelsYmlBefore);
139
139
  });
140
140
 
141
- test("publishes the port for subagents and the toast", () => {
141
+ test("publishes the port for subagents and the toast", async () => {
142
142
  const portFile = join(home, "embed.port");
143
143
  expect(existsSync(portFile)).toBe(true);
144
144
  expect(readFileSync(portFile, "utf8").trim()).toBe(String(advertised));
@@ -146,7 +146,7 @@ describe("embedded router: port selection", () => {
146
146
  });
147
147
 
148
148
  describe("embedded router: lifetime", () => {
149
- test("registers NO session_shutdown teardown", () => {
149
+ test("registers NO session_shutdown teardown", async () => {
150
150
  // omp fires that from a throwaway host during provider refresh, so a
151
151
  // teardown there kills a router the live session is still using.
152
152
  expect(handlers.get("session_shutdown") ?? []).toHaveLength(0);
@@ -165,7 +165,7 @@ describe("embedded router: lifetime", () => {
165
165
  expect(await alive(advertised)).toBe(true);
166
166
  });
167
167
 
168
- test("each session still gets its own registration, so per-session tagging survives reuse", () => {
168
+ test("each session still gets its own registration, so per-session tagging survives reuse", async () => {
169
169
  expect(registrations.length).toBeGreaterThanOrEqual(2);
170
170
  });
171
171
  });
@@ -18,17 +18,17 @@ import {
18
18
  } from "../omp-extension/embed-logic.ts";
19
19
 
20
20
  describe("resolveEmbedPort", () => {
21
- test("returns 0 (let the OS assign a free port) when nothing is configured", () => {
21
+ test("returns 0 (let the OS assign a free port) when nothing is configured", async () => {
22
22
  expect(resolveEmbedPort(undefined)).toBe(0);
23
23
  expect(resolveEmbedPort("")).toBe(0);
24
24
  });
25
25
 
26
- test("uses an explicit valid env port verbatim", () => {
26
+ test("uses an explicit valid env port verbatim", async () => {
27
27
  expect(resolveEmbedPort("8812")).toBe(8812);
28
28
  expect(resolveEmbedPort("0")).toBe(0);
29
29
  });
30
30
 
31
- test("falls back to 0 on junk or out-of-range values", () => {
31
+ test("falls back to 0 on junk or out-of-range values", async () => {
32
32
  expect(resolveEmbedPort("notaport")).toBe(0);
33
33
  expect(resolveEmbedPort("-1")).toBe(0);
34
34
  expect(resolveEmbedPort("70000")).toBe(0);
@@ -37,20 +37,20 @@ describe("resolveEmbedPort", () => {
37
37
  // A stable port is what keeps omp's PRE-extension model resolution correct:
38
38
  // it reads models.yml before this extension can bind and rewrite it, so an
39
39
  // ephemeral port leaves that block naming the previous session's dead port.
40
- test("uses the configured server.port when no env override is set", () => {
40
+ test("uses the configured server.port when no env override is set", async () => {
41
41
  expect(resolveEmbedPort(undefined, 8788)).toBe(8788);
42
42
  expect(resolveEmbedPort("", 8788)).toBe(8788);
43
43
  });
44
44
 
45
- test("the env var wins over the configured port", () => {
45
+ test("the env var wins over the configured port", async () => {
46
46
  expect(resolveEmbedPort("8812", 8788)).toBe(8812);
47
47
  });
48
48
 
49
- test("an explicit env 0 wins, so an ephemeral port stays requestable", () => {
49
+ test("an explicit env 0 wins, so an ephemeral port stays requestable", async () => {
50
50
  expect(resolveEmbedPort("0", 8788)).toBe(0);
51
51
  });
52
52
 
53
- test("ignores a nonsense configured port rather than binding it", () => {
53
+ test("ignores a nonsense configured port rather than binding it", async () => {
54
54
  expect(resolveEmbedPort(undefined, 0)).toBe(0);
55
55
  expect(resolveEmbedPort(undefined, -5)).toBe(0);
56
56
  expect(resolveEmbedPort(undefined, 70_000)).toBe(0);
@@ -73,16 +73,16 @@ describe("modelsYmlPort", () => {
73
73
  name: Auto (auto-model-router)
74
74
  `;
75
75
 
76
- test("reads the advertised port out of a real block", () => {
76
+ test("reads the advertised port out of a real block", async () => {
77
77
  expect(modelsYmlPort(REAL)).toBe(58724);
78
78
  });
79
79
 
80
- test("returns null when our provider block is absent", () => {
80
+ test("returns null when our provider block is absent", async () => {
81
81
  expect(modelsYmlPort("providers:\n openrouter:\n baseUrl: https://openrouter.ai/api/v1\n")).toBeNull();
82
82
  expect(modelsYmlPort("")).toBeNull();
83
83
  });
84
84
 
85
- test("is not fooled by another provider's baseUrl appearing first", () => {
85
+ test("is not fooled by another provider's baseUrl appearing first", async () => {
86
86
  const mixed = `providers:
87
87
  llama.cpp:
88
88
  baseUrl: http://127.0.0.1:8080/v1
@@ -92,7 +92,7 @@ describe("modelsYmlPort", () => {
92
92
  expect(modelsYmlPort(mixed)).toBe(8788);
93
93
  });
94
94
 
95
- test("returns null when the block carries no parseable url", () => {
95
+ test("returns null when the block carries no parseable url", async () => {
96
96
  expect(modelsYmlPort("providers:\n auto-model-router:\n api: openai-completions\n")).toBeNull();
97
97
  });
98
98
  });
@@ -107,14 +107,14 @@ describe("embed port file", () => {
107
107
  if (dir) rmSync(dir, { recursive: true, force: true });
108
108
  });
109
109
 
110
- test("round-trips the bound port", () => {
110
+ test("round-trips the bound port", async () => {
111
111
  const p = embedPortPath(dir);
112
112
  expect(p).toBe(join(dir, EMBED_PORT_FILE));
113
113
  writeEmbedPort(p, 45678);
114
114
  expect(readEmbedPort(p)).toBe(45678);
115
115
  });
116
116
 
117
- test("returns null for a missing or malformed file", () => {
117
+ test("returns null for a missing or malformed file", async () => {
118
118
  expect(readEmbedPort(embedPortPath(join(dir, "absent")))).toBeNull();
119
119
  writeEmbedPort(embedPortPath(dir), -5);
120
120
  expect(readEmbedPort(embedPortPath(dir))).toBeNull();
@@ -135,7 +135,7 @@ describe("buildProviderConfig", () => {
135
135
  ledger: { fallbackBlend: { inputPerMtok: 0.2, outputPerMtok: 0.8 } },
136
136
  };
137
137
 
138
- test("builds a provider config against the actual bound port", () => {
138
+ test("builds a provider config against the actual bound port", async () => {
139
139
  const c: EmbedConfig = buildProviderConfig(45678, base);
140
140
  expect(c.baseUrl).toBe("http://127.0.0.1:45678/v1");
141
141
  expect(c.port).toBe(45678);
@@ -145,41 +145,41 @@ describe("buildProviderConfig", () => {
145
145
  expect(c.models[0]).toMatchObject({ id: "auto", contextWindow: 400_000, maxTokens: 32_000 });
146
146
  });
147
147
 
148
- test("converts cost to USD-per-million-token and applies cache multipliers", () => {
148
+ test("converts cost to USD-per-million-token and applies cache multipliers", async () => {
149
149
  const c: EmbedConfig = buildProviderConfig(45678, base);
150
150
  // input 0.2, output 0.8, cacheRead = 0.2*0.1 = 0.02, cacheWrite = 0.2*1.25 = 0.25
151
151
  expect(c.models[0]!.cost).toEqual({ input: 0.2, output: 0.8, cacheRead: 0.02, cacheWrite: 0.25 });
152
152
  });
153
153
 
154
- test("normalizes a wildcard listen host to loopback", () => {
154
+ test("normalizes a wildcard listen host to loopback", async () => {
155
155
  const c: EmbedConfig = buildProviderConfig(45678, { ...base, server: { host: "0.0.0.0" } });
156
156
  expect(c.baseUrl).toBe("http://127.0.0.1:45678/v1");
157
157
  });
158
158
 
159
- test("carries the harness id through when configured", () => {
159
+ test("carries the harness id through when configured", async () => {
160
160
  const c: EmbedConfig = buildProviderConfig(45678, { ...base, server: { host: "127.0.0.1", harnessId: "prod-a" } });
161
161
  expect(c.harnessId).toBe("prod-a");
162
162
  });
163
163
  });
164
164
 
165
165
  describe("embed constants", () => {
166
- test("provider id and dummy key stay stable", () => {
166
+ test("provider id and dummy key stay stable", async () => {
167
167
  expect(EMBED_PROVIDER_ID).toBe("auto-model-router");
168
168
  });
169
- test("port file name is stable", () => {
169
+ test("port file name is stable", async () => {
170
170
  expect(EMBED_PORT_FILE).toBe("embed.port");
171
171
  });
172
172
  });
173
173
 
174
174
  describe("agentdox scope", () => {
175
- test("derives a slug from the workspace basename", () => {
175
+ test("derives a slug from the workspace basename", async () => {
176
176
  expect(deriveAgentdoxScope("E:/projects/Ashlands/Ashlands")).toBe("ashlands");
177
177
  expect(deriveAgentdoxScope("/home/drew/omp-router")).toBe("omp-router");
178
178
  expect(deriveAgentdoxScope("E:\\projects\\My Game\\")).toBe("my-game");
179
179
  expect(deriveAgentdoxScope("")).toBe("");
180
180
  });
181
181
 
182
- test("the workspace derivation wins over the scope-agnostic defaultScope", () => {
182
+ test("the workspace derivation wins over the scope-agnostic defaultScope", async () => {
183
183
  // Regression: one router install serves every project on the machine, so a
184
184
  // global `defaultScope` overriding the derivation made an ashlands session
185
185
  // ship `X-Agentdox-Scope: omp-router` — wrong context injected, turns
@@ -198,7 +198,7 @@ describe("agentdox scope", () => {
198
198
  expect(fallback.agentdoxScope).toBe("pinned");
199
199
  });
200
200
 
201
- test("no scope header when the bridge is off", () => {
201
+ test("no scope header when the bridge is off", async () => {
202
202
  const cfg = {
203
203
  server: { host: "127.0.0.1" },
204
204
  profiles: [],
@@ -219,7 +219,7 @@ describe("workspace origin", () => {
219
219
  rmSync(root, { recursive: true, force: true });
220
220
  });
221
221
 
222
- test("a plain repository: the remote from .git/config, also from a subdirectory", () => {
222
+ test("a plain repository: the remote from .git/config, also from a subdirectory", async () => {
223
223
  const repo = join(root, "repo");
224
224
  mkdirSync(join(repo, ".git"), { recursive: true });
225
225
  writeFileSync(join(repo, ".git", "config"), CONFIG);
@@ -230,7 +230,7 @@ describe("workspace origin", () => {
230
230
  expect(deriveAgentdoxScope(repo)).toBe("repo");
231
231
  });
232
232
 
233
- test("a worktree: .git is a file naming the git dir, whose shared config is one hop further", () => {
233
+ test("a worktree: .git is a file naming the git dir, whose shared config is one hop further", async () => {
234
234
  // The main checkout holds the config; the worktree's own dir only points at it.
235
235
  const main = join(root, "main");
236
236
  mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true });
@@ -249,7 +249,7 @@ describe("workspace origin", () => {
249
249
  expect(deriveWorkspaceOrigin(join(sub, "lib"))).toBe("github.com/org/lib");
250
250
  });
251
251
 
252
- test("no remote, a local remote, no repository, or nothing at all: no fingerprint, never a throw", () => {
252
+ test("no remote, a local remote, no repository, or nothing at all: no fingerprint, never a throw", async () => {
253
253
  const bare = join(root, "bare");
254
254
  mkdirSync(join(bare, ".git"), { recursive: true });
255
255
  writeFileSync(join(bare, ".git", "config"), "[core]\n\tbare = false\n");
@@ -278,7 +278,7 @@ describe("workspace origin", () => {
278
278
  expect(deriveWorkspaceOrigin("/x/repo", () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n')).toBe("");
279
279
  });
280
280
 
281
- test("buildProviderConfig carries the origin beside the scope, only where the scope goes", () => {
281
+ test("buildProviderConfig carries the origin beside the scope, only where the scope goes", async () => {
282
282
  const base = {
283
283
  server: { host: "127.0.0.1" },
284
284
  profiles: [],