auto-model-router 0.1.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 (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
@@ -0,0 +1,302 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { joinBenchmarks, normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
+ import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
5
+ import { loadConfig } from "../src/config/load.ts";
6
+ import { buildCandidates } from "../src/router/candidates.ts";
7
+ import { extractFeatures } from "../src/router/features.ts";
8
+ import { computeTierPlan, effectiveQualityFloor, tierPlanFor } from "../src/router/tier-plan.ts";
9
+ import { TIER_ORDER } from "../src/router/types.ts";
10
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
11
+
12
+ const BASE = loadConfig({});
13
+
14
+ /** Raw `/models`-shaped record with a controllable coding score and price. */
15
+ function raw(id: string, coding: number | null, inPerMtok: number): Record<string, unknown> {
16
+ const record: Record<string, unknown> = {
17
+ id,
18
+ canonical_slug: id,
19
+ name: id,
20
+ context_length: 200_000,
21
+ pricing: { prompt: String(inPerMtok / 1e6), completion: String((inPerMtok * 3) / 1e6) },
22
+ supported_parameters: ["tools"],
23
+ architecture: { input_modalities: ["text"], tokenizer: "GPT" },
24
+ created: 1_700_000_000,
25
+ };
26
+ if (coding !== null) {
27
+ record.benchmarks = { artificial_analysis: { coding_index: coding, intelligence_index: coding, agentic_index: coding } };
28
+ }
29
+ return record;
30
+ }
31
+
32
+ function models(specs: ReadonlyArray<[string, number | null, number]>): CatalogModel[] {
33
+ const out: CatalogModel[] = [];
34
+ for (const [id, coding, price] of specs) {
35
+ const m = normalizeCatalogModel(raw(id, coding, price));
36
+ if (m !== null) out.push(m);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function snapshot(list: CatalogModel[]): CatalogSnapshot {
42
+ return { models: list, fetchedAtMs: Date.now(), keyScoped: true };
43
+ }
44
+
45
+ describe("joinBenchmarks", () => {
46
+ test("copies benchmarks onto key-scoped records matched by id", () => {
47
+ const keyScoped = [{ id: "a/one" }, { id: "b/two" }];
48
+ const pub = [
49
+ { id: "a/one", benchmarks: { artificial_analysis: { coding_index: 70 } } },
50
+ { id: "c/three", benchmarks: { artificial_analysis: { coding_index: 10 } } },
51
+ ];
52
+ expect(joinBenchmarks(keyScoped, pub)).toBe(1);
53
+ expect(keyScoped[0]).toHaveProperty("benchmarks");
54
+ expect(keyScoped[1]).not.toHaveProperty("benchmarks");
55
+ });
56
+
57
+ test("falls back to canonical_slug", () => {
58
+ const keyScoped = [{ id: "vendor/model-preview", canonical_slug: "vendor/model" }];
59
+ const pub = [{ id: "vendor/model", benchmarks: { artificial_analysis: { coding_index: 55 } } }];
60
+ expect(joinBenchmarks(keyScoped, pub)).toBe(1);
61
+ });
62
+
63
+ test("strips a leading ~ from an alias id", () => {
64
+ const keyScoped = [{ id: "~vendor/model-latest" }];
65
+ const pub = [{ id: "vendor/model-latest", benchmarks: { artificial_analysis: { coding_index: 60 } } }];
66
+ expect(joinBenchmarks(keyScoped, pub)).toBe(1);
67
+ });
68
+
69
+ /** Reads the joined coding index off a raw record without narrowing games. */
70
+ function codingOf(record: unknown): number | undefined {
71
+ const rec = record as { benchmarks?: { artificial_analysis?: { coding_index?: number } } };
72
+ return rec.benchmarks?.artificial_analysis?.coding_index;
73
+ }
74
+
75
+ test("never overwrites benchmarks that are already present", () => {
76
+ const keyScoped: unknown[] = [{ id: "a/one", benchmarks: { artificial_analysis: { coding_index: 1 } } }];
77
+ const pub: unknown[] = [{ id: "a/one", benchmarks: { artificial_analysis: { coding_index: 99 } } }];
78
+ expect(joinBenchmarks(keyScoped, pub)).toBe(0);
79
+ expect(codingOf(keyScoped[0])).toBe(1);
80
+ });
81
+
82
+ test("a real id beats an alias target for the same key", () => {
83
+ const keyScoped: unknown[] = [{ id: "vendor/model" }];
84
+ const pub: unknown[] = [
85
+ { id: "other/model", canonical_slug: "vendor/model", benchmarks: { artificial_analysis: { coding_index: 10 } } },
86
+ { id: "vendor/model", benchmarks: { artificial_analysis: { coding_index: 80 } } },
87
+ ];
88
+ joinBenchmarks(keyScoped, pub);
89
+ expect(codingOf(keyScoped[0])).toBe(80);
90
+ });
91
+
92
+ test("tolerates junk records on both sides", () => {
93
+ expect(joinBenchmarks([null, 7, "x"], [null, { id: "a" }])).toBe(0);
94
+ });
95
+
96
+ test("normalizing a joined record yields a scored model", () => {
97
+ const keyScoped: unknown[] = [raw("a/one", null, 1)];
98
+ joinBenchmarks(keyScoped, [raw("a/one", 66, 1)]);
99
+ const model = normalizeCatalogModel(keyScoped[0]);
100
+ expect(model?.quality.coding).toBe(66);
101
+ });
102
+ });
103
+
104
+ describe("computeTierPlan", () => {
105
+ test("bands ascend across tiers", () => {
106
+ const plan = computeTierPlan(
107
+ models([
108
+ ["a/1", 10, 0.1],
109
+ ["a/2", 30, 0.1],
110
+ ["a/3", 50, 0.1],
111
+ ["a/4", 70, 0.1],
112
+ ]),
113
+ BASE,
114
+ );
115
+ const f = plan.floors.coding;
116
+ expect(f.trivial).toBe(10);
117
+ expect(f.simple).toBe(30);
118
+ expect(f.moderate).toBe(50);
119
+ expect(f.hard).toBe(70);
120
+ });
121
+
122
+ test("an all-unscored catalog yields zero floors, never an imputed score", () => {
123
+ const plan = computeTierPlan(
124
+ models([
125
+ ["a/1", null, 0.1],
126
+ ["a/2", null, 5],
127
+ ]),
128
+ BASE,
129
+ );
130
+ for (const tier of TIER_ORDER) expect(plan.floors.coding[tier]).toBe(0);
131
+ expect(plan.scoredCount.coding).toBe(0);
132
+ });
133
+
134
+ test("every tier floor is met by at least one available model", () => {
135
+ const list = models([
136
+ ["a/1", 12, 0.1],
137
+ ["a/2", 44, 0.2],
138
+ ["a/3", 61, 0.3],
139
+ ["a/4", 63, 0.4],
140
+ ["a/5", 77, 0.5],
141
+ ]);
142
+ const plan = computeTierPlan(list, BASE);
143
+ for (const tier of TIER_ORDER) {
144
+ const floor = plan.floors.coding[tier];
145
+ expect(list.some((m) => (m.quality.coding ?? -1) >= floor), `tier ${tier} floor ${floor}`).toBe(true);
146
+ }
147
+ });
148
+
149
+ test("excludes built-in denials from the ranking", () => {
150
+ // The batch entry is cheap and scored, but selection can never pick it,
151
+ // so it must not drag the bands down.
152
+ const plan = computeTierPlan(
153
+ models([
154
+ ["a/1:batch", 1, 0.1],
155
+ ["~a/latest", 2, 0.1],
156
+ ["a/2", 70, 0.1],
157
+ ]),
158
+ BASE,
159
+ );
160
+ expect(plan.scoredCount.coding).toBe(1);
161
+ expect(plan.floors.coding.trivial).toBe(70);
162
+ });
163
+
164
+ test("a single scored model puts that model in every tier", () => {
165
+ const plan = computeTierPlan(models([["a/1", 42, 0.1]]), BASE);
166
+ for (const tier of TIER_ORDER) expect(plan.floors.coding[tier]).toBe(42);
167
+ });
168
+
169
+ test("scores each axis independently", () => {
170
+ const plan = computeTierPlan(models([["a/1", 30, 0.1]]), BASE);
171
+ expect(plan.scoredCount.coding).toBe(1);
172
+ expect(plan.scoredCount.agentic).toBe(1);
173
+ expect(plan.scoredCount.intelligence).toBe(1);
174
+ });
175
+ });
176
+
177
+ describe("effectiveQualityFloor", () => {
178
+ const plan = computeTierPlan(
179
+ models([
180
+ ["a/1", 20, 0.1],
181
+ ["a/2", 40, 0.1],
182
+ ["a/3", 60, 0.1],
183
+ ["a/4", 80, 0.1],
184
+ ]),
185
+ BASE,
186
+ );
187
+
188
+ test("relaxes a floor the catalog cannot meet", () => {
189
+ expect(effectiveQualityFloor(95, "hard", "coding", plan)).toBe(80);
190
+ });
191
+
192
+ test("never tightens a floor the catalog exceeds", () => {
193
+ expect(effectiveQualityFloor(10, "hard", "coding", plan)).toBe(10);
194
+ });
195
+
196
+ test("is a no-op when configured equals adaptive", () => {
197
+ expect(effectiveQualityFloor(80, "hard", "coding", plan)).toBe(80);
198
+ });
199
+ });
200
+
201
+ describe("tierPlanFor", () => {
202
+ test("memoizes per snapshot object", () => {
203
+ const snap = snapshot(models([["a/1", 50, 0.1]]));
204
+ expect(tierPlanFor(snap, BASE)).toBe(tierPlanFor(snap, BASE));
205
+ });
206
+
207
+ test("a new snapshot recomputes, so a refresh tracks availability", () => {
208
+ const first = snapshot(models([["a/1", 50, 0.1]]));
209
+ const second = snapshot(models([["a/1", 50, 0.1], ["a/2", 90, 0.1]]));
210
+ expect(tierPlanFor(second, BASE)).not.toBe(tierPlanFor(first, BASE));
211
+ expect(tierPlanFor(second, BASE).floors.coding.hard).toBe(90);
212
+ });
213
+ });
214
+
215
+ describe("adaptive floors in candidate selection", () => {
216
+ const req = parseChatRequest(
217
+ {
218
+ model: "auto",
219
+ tools: [{ type: "function", function: { name: "read", description: "Read", parameters: { type: "object", properties: {} } } }],
220
+ messages: [{ role: "user", content: "refactor the auth module" }],
221
+ },
222
+ new Headers(),
223
+ );
224
+ const features = extractFeatures(req, 100);
225
+
226
+ // Every model scores far below the configured `hard` floor of 72.
227
+ const lowCatalog = snapshot(
228
+ models([
229
+ ["a/1", 20, 0.05],
230
+ ["a/2", 30, 0.06],
231
+ ["a/3", 40, 0.07],
232
+ ["a/4", 50, 0.08],
233
+ ]),
234
+ );
235
+
236
+ function build(cfg: typeof BASE) {
237
+ return buildCandidates({
238
+ req,
239
+ features,
240
+ tier: "hard",
241
+ task: "coding",
242
+ snapshot: lowCatalog,
243
+ ledger: null,
244
+ cfg,
245
+ expectedCompletionTokens: 512,
246
+ warmSlug: null,
247
+ });
248
+ }
249
+
250
+ test("hard is empty with adaptive floors off", () => {
251
+ const { candidates } = build({ ...BASE, adaptiveTierFloors: false });
252
+ expect(candidates).toHaveLength(0);
253
+ });
254
+
255
+ test("hard still selects the best available with adaptive floors on", () => {
256
+ const { candidates } = build({ ...BASE, adaptiveTierFloors: true });
257
+ expect(candidates.length).toBeGreaterThan(0);
258
+ // The top band is the best-scoring model, not the cheapest.
259
+ expect(candidates.some((c) => c.model.slug === "a/4")).toBe(true);
260
+ });
261
+
262
+ test("adaptive floors still order the tiers apart", () => {
263
+ const cfg = { ...BASE, adaptiveTierFloors: true };
264
+ const best = (tier: "trivial" | "hard"): number => {
265
+ const { candidates } = buildCandidates({
266
+ req,
267
+ features,
268
+ tier,
269
+ task: "coding",
270
+ snapshot: lowCatalog,
271
+ ledger: null,
272
+ cfg,
273
+ expectedCompletionTokens: 512,
274
+ warmSlug: null,
275
+ });
276
+ return Math.max(...candidates.map((c) => c.model.quality.coding ?? 0));
277
+ };
278
+ // `hard` must not admit a strictly worse best-model than `trivial`.
279
+ expect(best("hard")).toBeGreaterThanOrEqual(best("trivial"));
280
+ });
281
+
282
+ test("excludeSlugs removes a model from the candidate set", () => {
283
+ const cfg = { ...BASE, adaptiveTierFloors: true };
284
+ const all = build(cfg).candidates.map((c) => c.model.slug);
285
+ const target = all[0];
286
+ expect(target).toBeDefined();
287
+ const { candidates, rejected } = buildCandidates({
288
+ req,
289
+ features,
290
+ tier: "hard",
291
+ task: "coding",
292
+ snapshot: lowCatalog,
293
+ ledger: null,
294
+ cfg,
295
+ expectedCompletionTokens: 512,
296
+ warmSlug: null,
297
+ excludeSlugs: [target ?? ""],
298
+ });
299
+ expect(candidates.map((c) => c.model.slug)).not.toContain(target);
300
+ expect(rejected.some((r) => r.slug === target && r.reason === "failed_this_turn")).toBe(true);
301
+ });
302
+ });
@@ -0,0 +1,160 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parse as parseYaml } from "yaml";
4
+
5
+ import {
6
+ DEFAULT_ROUTER_URL,
7
+ newestId,
8
+ resolveRouterUrl,
9
+ selectToasts,
10
+ toToastText,
11
+ type ToastDecision,
12
+ } from "../omp-extension/toast-logic.ts";
13
+
14
+ describe("resolveRouterUrl", () => {
15
+ const resolve = (env: string | undefined, text: string | null): string =>
16
+ resolveRouterUrl(env, text, parseYaml);
17
+
18
+ test("the embed port file wins over AUTO_MODEL_ROUTER_PORT and the config", () => {
19
+ // The embedded router binds a free OS-assigned port and writes it to the
20
+ // port file; the toast must poll that actual address, not a stale config.
21
+ expect(resolveRouterUrl(undefined, "server:\n port: 8788\n", parseYaml, "8812", 45678)).toBe(
22
+ "http://127.0.0.1:45678",
23
+ );
24
+ });
25
+
26
+ test("AUTO_MODEL_ROUTER_URL still beats the embed port file", () => {
27
+ expect(resolveRouterUrl("http://host:9999", "server:\n port: 8788\n", parseYaml, "8812", 45678)).toBe(
28
+ "http://host:9999",
29
+ );
30
+ });
31
+
32
+ test("an embed port file of null falls back to AUTO_MODEL_ROUTER_PORT", () => {
33
+ expect(resolveRouterUrl(undefined, "server:\n port: 8788\n", parseYaml, "8812", null)).toBe("http://127.0.0.1:8812");
34
+ });
35
+
36
+ test("AUTO_MODEL_ROUTER_URL still beats AUTO_MODEL_ROUTER_PORT", () => {
37
+ expect(resolveRouterUrl("http://host:9999", "server:\n port: 8788\n", parseYaml, "8812")).toBe("http://host:9999");
38
+ });
39
+
40
+ test("an invalid AUTO_MODEL_ROUTER_PORT falls back to the config port", () => {
41
+ expect(resolveRouterUrl(undefined, "server:\n port: 8788\n", parseYaml, "notaport")).toBe("http://127.0.0.1:8788");
42
+ expect(resolveRouterUrl(undefined, "server:\n port: 8788\n", parseYaml, "70000")).toBe("http://127.0.0.1:8788");
43
+ });
44
+
45
+ test("AUTO_MODEL_ROUTER_PORT with no config uses loopback", () => {
46
+ expect(resolveRouterUrl(undefined, null, parseYaml, "8812")).toBe("http://127.0.0.1:8812");
47
+ });
48
+
49
+ test("reads host and port from the router's own config", () => {
50
+ // The bug this prevents: defaulting to 8788 polls whatever else owns that
51
+ // port once the router has been moved, and toasts silently never appear.
52
+ expect(resolve(undefined, "server:\n host: 127.0.0.1\n port: 8788\n")).toBe("http://127.0.0.1:8788");
53
+ });
54
+
55
+ test("a port-only config keeps the loopback default host", () => {
56
+ expect(resolve(undefined, "server:\n port: 8790\n")).toBe("http://127.0.0.1:8790");
57
+ });
58
+
59
+ test("a wildcard listen address becomes loopback", () => {
60
+ expect(resolve(undefined, "server:\n host: 0.0.0.0\n port: 8788\n")).toBe("http://127.0.0.1:8788");
61
+ expect(resolve(undefined, "server:\n host: '::'\n port: 8788\n")).toBe("http://127.0.0.1:8788");
62
+ });
63
+
64
+ test("falls back when there is no config, no server block, or junk", () => {
65
+ expect(resolve(undefined, null)).toBe(DEFAULT_ROUTER_URL);
66
+ expect(resolve(undefined, "")).toBe(DEFAULT_ROUTER_URL);
67
+ expect(resolve(undefined, "logLevel: debug\n")).toBe(DEFAULT_ROUTER_URL);
68
+ expect(resolve(undefined, "server: 5\n")).toBe(DEFAULT_ROUTER_URL);
69
+ });
70
+
71
+ test("ignores a non-integer or non-positive port", () => {
72
+ expect(resolve(undefined, "server:\n port: 0\n")).toBe(DEFAULT_ROUTER_URL);
73
+ expect(resolve(undefined, "server:\n port: notaport\n")).toBe(DEFAULT_ROUTER_URL);
74
+ });
75
+
76
+ test("an empty env override does not shadow the config", () => {
77
+ expect(resolve("", "server:\n port: 8788\n")).toBe("http://127.0.0.1:8788");
78
+ });
79
+ });
80
+
81
+ function dec(partial: Partial<ToastDecision>): ToastDecision {
82
+ return {
83
+ id: "d1",
84
+ slug: "meta/muse-glimmer-30b",
85
+ servedSlug: null,
86
+ tier: "trivial",
87
+ reportedUsd: 0.0000123,
88
+ wasted: false,
89
+ harnessId: "",
90
+ ...partial,
91
+ };
92
+ }
93
+
94
+ describe("selectToasts", () => {
95
+ test("toasts nothing on the first tick (lastSeenId null)", () => {
96
+ const entries = [dec({ id: "a" }), dec({ id: "b" })];
97
+ expect(selectToasts(entries, null)).toEqual([]);
98
+ });
99
+
100
+ test("toasts only entries newer than the last-seen id, oldest first", () => {
101
+ // newest-first order: d3 is newest, d1 oldest
102
+ const entries = [dec({ id: "d3", slug: "x/c" }), dec({ id: "d2", slug: "x/b" }), dec({ id: "d1", slug: "x/a" })];
103
+ const toasts = selectToasts(entries, "d1");
104
+ expect(toasts).toHaveLength(2);
105
+ // oldest→newest emission order
106
+ expect(toasts[0]?.model).toBe("x/b");
107
+ expect(toasts[1]?.model).toBe("x/c");
108
+ });
109
+
110
+ test("skips wasted (abandoned escalation) entries", () => {
111
+ const entries = [dec({ id: "d2", slug: "served", wasted: false }), dec({ id: "d1", wasted: true })];
112
+ // both newer than lastSeenId ""; only the non-wasted one toasts
113
+ expect(selectToasts(entries, "")).toHaveLength(1);
114
+ const withPrior = [dec({ id: "d3", slug: "real", wasted: false }), dec({ id: "d2", wasted: true }), dec({ id: "d1", slug: "prior" })];
115
+ const out = selectToasts(withPrior, "d1");
116
+ expect(out).toHaveLength(1);
117
+ expect(out[0]?.model).toBe("real");
118
+ });
119
+
120
+ test("empty input yields no toasts and null newest id", () => {
121
+ expect(selectToasts([], "x")).toEqual([]);
122
+ expect(newestId([])).toBeNull();
123
+ });
124
+
125
+ test("filters to the requesting harness when one is set", () => {
126
+ const entries = [
127
+ dec({ id: "d3", slug: "mine", harnessId: "harness-a" }),
128
+ dec({ id: "d2", slug: "other", harnessId: "harness-b" }),
129
+ dec({ id: "d1", slug: "prior", harnessId: "harness-a" }),
130
+ ];
131
+ // Only harness-a entries newer than d1 toast; harness-b is excluded.
132
+ const toasts = selectToasts(entries, "d1", "harness-a");
133
+ expect(toasts).toHaveLength(1);
134
+ expect(toasts[0]?.model).toBe("mine");
135
+ });
136
+
137
+ test("empty harness id toasts every harness", () => {
138
+ const entries = [
139
+ dec({ id: "d2", slug: "a", harnessId: "harness-a" }),
140
+ dec({ id: "d1", slug: "b", harnessId: "harness-b" }),
141
+ ];
142
+ expect(selectToasts(entries, "", "")).toHaveLength(2);
143
+ });
144
+ });
145
+
146
+ describe("toToastText", () => {
147
+ test("prefers servedSlug when present, else slug", () => {
148
+ expect(toToastText(dec({ slug: "s/one", servedSlug: "s/real" }))).toContain("s/real");
149
+ expect(toToastText(dec({ slug: "s/one", servedSlug: null }))).toContain("s/one");
150
+ });
151
+
152
+ test("includes cost when reported, omits otherwise", () => {
153
+ expect(toToastText(dec({ reportedUsd: 0.5 }))).toContain("$0.50000");
154
+ expect(toToastText(dec({ reportedUsd: null }))).not.toContain("$");
155
+ });
156
+
157
+ test("renders model [tier]", () => {
158
+ expect(toToastText(dec({ slug: "q/w", tier: "hard", reportedUsd: null }))).toBe("q/w [hard]");
159
+ });
160
+ });
@@ -0,0 +1,160 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { loadConfig } from "../src/config/load.ts";
4
+ import { createLedger } from "../src/cost/ledger.ts";
5
+ import { EMPTY_USAGE, type LedgerEntry } from "../src/cost/types.ts";
6
+ import { DEFAULT_BYTES_PER_TOKEN, estimatePromptTokens, estimateTokens } from "../src/tokens/estimate.ts";
7
+ import { openDb } from "../src/util/sqlite.ts";
8
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
9
+
10
+ const cfg = loadConfig({});
11
+
12
+ function entry(over: Partial<LedgerEntry>): LedgerEntry {
13
+ return {
14
+ id: crypto.randomUUID(),
15
+ createdAtMs: Date.now(),
16
+ conversationKey: "k",
17
+ sessionId: "omp-k",
18
+ turn: 1,
19
+ requestedModel: "auto",
20
+ harnessId: "",
21
+ slug: "openai/gpt-5-mini",
22
+ servedSlug: "openai/gpt-5-mini",
23
+ tier: "simple",
24
+ classificationSource: "heuristic",
25
+ reasons: [],
26
+ predictedUsd: 0.001,
27
+ reportedUsd: 0.001,
28
+ usage: EMPTY_USAGE,
29
+ attempt: 0,
30
+ escalationSignal: null,
31
+ latencyMs: 100,
32
+ ttftMs: 50,
33
+ finishReason: "stop",
34
+ wasted: false,
35
+ upstreamGenerationId: null,
36
+ error: null,
37
+ ...over,
38
+ };
39
+ }
40
+
41
+ describe("estimateTokens", () => {
42
+ test("uses the default ratio for an unknown tokenizer family", () => {
43
+ expect(estimateTokens(3600, "no-such-tokenizer", null)).toBe(Math.ceil(3600 / DEFAULT_BYTES_PER_TOKEN));
44
+ });
45
+
46
+ test("scales linearly with byte count and never goes negative", () => {
47
+ expect(estimateTokens(0, "gpt", null)).toBe(0);
48
+ const small = estimateTokens(1000, "gpt", null);
49
+ const large = estimateTokens(10_000, "gpt", null);
50
+ expect(large).toBeGreaterThan(small);
51
+ });
52
+
53
+ test("a code-dense family estimates more tokens for the same bytes", () => {
54
+ // BPE tokenizers emit more tokens per character on code than on prose,
55
+ // so a lower bytes-per-token ratio must yield a higher token count.
56
+ expect(estimateTokens(10_000, "deepseek", null)).toBeGreaterThan(estimateTokens(10_000, "gpt", null));
57
+ });
58
+
59
+ test("is case-insensitive about the tokenizer name", () => {
60
+ expect(estimateTokens(5000, "Claude", null)).toBe(estimateTokens(5000, "claude", null));
61
+ });
62
+ });
63
+
64
+ describe("ledger calibration", () => {
65
+ test("a calibrated ratio replaces the family default once enough samples land", () => {
66
+ const db = openDb(":memory:");
67
+ try {
68
+ const ledger = createLedger(db, cfg);
69
+ expect(ledger.tokenRatio("claude")).toBeNull();
70
+
71
+ const req = parseChatRequest(
72
+ { model: "auto", messages: [{ role: "user", content: "x".repeat(4000) }] },
73
+ new Headers(),
74
+ );
75
+
76
+ // The real prompt turned out to be far more token-dense than 3.6
77
+ // bytes/token; the ledger must converge on the measurement.
78
+ const observedTokens = Math.round(req.promptBytes / 2);
79
+ for (let i = 0; i < 30; i++) {
80
+ estimatePromptTokens(req, "claude", ledger);
81
+ ledger.record(
82
+ entry({
83
+ conversationKey: req.conversationKey,
84
+ usage: { ...EMPTY_USAGE, promptTokens: observedTokens, completionTokens: 10 },
85
+ }),
86
+ );
87
+ }
88
+
89
+ const ratio = ledger.tokenRatio("claude");
90
+ expect(ratio).not.toBeNull();
91
+ if (ratio === null) return;
92
+ expect(ratio).toBeCloseTo(2, 1);
93
+
94
+ // And the estimate now follows the measurement, not the default.
95
+ const calibrated = estimateTokens(4000, "claude", ledger);
96
+ expect(calibrated).toBeGreaterThan(estimateTokens(4000, "claude", null));
97
+ } finally {
98
+ db.close();
99
+ }
100
+ });
101
+
102
+ test("an uncalibrated family still falls back to its default", () => {
103
+ const db = openDb(":memory:");
104
+ try {
105
+ const ledger = createLedger(db, cfg);
106
+ expect(ledger.tokenRatio("gemini")).toBeNull();
107
+ expect(estimateTokens(3600, "gemini", ledger)).toBe(estimateTokens(3600, "gemini", null));
108
+ } finally {
109
+ db.close();
110
+ }
111
+ });
112
+ });
113
+
114
+ describe("estimatePromptTokens", () => {
115
+ test("counts tool schemas, not just message text", () => {
116
+ const bare = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers());
117
+ const withTools = parseChatRequest(
118
+ {
119
+ model: "auto",
120
+ messages: [{ role: "user", content: "hi" }],
121
+ tools: [
122
+ {
123
+ type: "function",
124
+ function: {
125
+ name: "bash",
126
+ description: "Run a shell command and return its output",
127
+ parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
128
+ },
129
+ },
130
+ ],
131
+ },
132
+ new Headers(),
133
+ );
134
+ expect(estimatePromptTokens(withTools, "gpt", null)).toBeGreaterThan(estimatePromptTokens(bare, "gpt", null));
135
+ });
136
+
137
+ test("charges a per-image allowance on top of text", () => {
138
+ const text = parseChatRequest(
139
+ { model: "auto", messages: [{ role: "user", content: [{ type: "text", text: "describe" }] }] },
140
+ new Headers(),
141
+ );
142
+ const withImage = parseChatRequest(
143
+ {
144
+ model: "auto",
145
+ messages: [
146
+ {
147
+ role: "user",
148
+ content: [
149
+ { type: "text", text: "describe" },
150
+ { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
151
+ ],
152
+ },
153
+ ],
154
+ },
155
+ new Headers(),
156
+ );
157
+ // An image costs far more than the handful of bytes its URL adds.
158
+ expect(estimatePromptTokens(withImage, "gpt", null)).toBeGreaterThan(estimatePromptTokens(text, "gpt", null) + 500);
159
+ });
160
+ });