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,319 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
+
5
+ const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json()) as { data: unknown[] };
6
+ const RAW = FIXTURE.data;
7
+
8
+ function rawFor(slug: string): unknown {
9
+ const found = RAW.find((m) => typeof m === "object" && m !== null && "id" in m && m.id === slug);
10
+ if (found === undefined) throw new Error(`fixture is missing ${slug}`);
11
+ return found;
12
+ }
13
+
14
+ describe("normalizeCatalogModel", () => {
15
+ test("survives every record in the real catalog without throwing", () => {
16
+ let normalized = 0;
17
+ for (const raw of RAW) {
18
+ const model = normalizeCatalogModel(raw);
19
+ if (model !== null) normalized++;
20
+ }
21
+ // Most of the catalog must be usable, or a filter is misfiring.
22
+ expect(normalized).toBeGreaterThan(RAW.length * 0.9);
23
+ });
24
+
25
+ test("every normalized model has strictly positive prompt and completion prices", () => {
26
+ for (const raw of RAW) {
27
+ const model = normalizeCatalogModel(raw);
28
+ if (model === null) continue;
29
+ expect(model.price.prompt).toBeGreaterThanOrEqual(0);
30
+ expect(model.price.completion).toBeGreaterThanOrEqual(0);
31
+ expect(Number.isFinite(model.price.prompt)).toBe(true);
32
+ expect(Number.isFinite(model.price.completion)).toBe(true);
33
+ }
34
+ });
35
+
36
+ test("rejects the openrouter meta-routers, whose -1 pricing means unknown", () => {
37
+ // Routing to another router is both out of scope and uncostable: a -1
38
+ // price would otherwise be read as free and win every tier outright.
39
+ for (const slug of ["openrouter/auto", "openrouter/pareto-code", "openrouter/fusion"]) {
40
+ expect(normalizeCatalogModel(rawFor(slug))).toBeNull();
41
+ }
42
+ });
43
+
44
+ test("does not mistake unknown (-1) pricing for free pricing", () => {
45
+ const free = normalizeCatalogModel(rawFor("openai/gpt-oss-20b:free"));
46
+ expect(free).not.toBeNull();
47
+ expect(free?.isFree).toBe(true);
48
+ // The -1 models never normalize at all, so they can never be marked free.
49
+ expect(normalizeCatalogModel(rawFor("openrouter/auto"))).toBeNull();
50
+ });
51
+
52
+ test("preserves published quality scores and never imputes missing ones", () => {
53
+ const scoredInFixture = RAW.filter(
54
+ (m) =>
55
+ typeof m === "object" &&
56
+ m !== null &&
57
+ "benchmarks" in m &&
58
+ typeof m.benchmarks === "object" &&
59
+ m.benchmarks !== null &&
60
+ "artificial_analysis" in m.benchmarks,
61
+ ).length;
62
+
63
+ let scored = 0;
64
+ let unscored = 0;
65
+ for (const raw of RAW) {
66
+ const model = normalizeCatalogModel(raw);
67
+ if (model === null) continue;
68
+ const q = model.quality;
69
+ if (q.coding !== undefined || q.agentic !== undefined || q.intelligence !== undefined) scored++;
70
+ else unscored++;
71
+ }
72
+ // Coverage is genuinely partial; that fact drives the quality-floor rule.
73
+ expect(scored).toBeLessThanOrEqual(scoredInFixture);
74
+ expect(unscored).toBeGreaterThan(0);
75
+ });
76
+
77
+ test("reads long-context override tiers, sorted ascending", () => {
78
+ const sonnet = normalizeCatalogModel(rawFor("anthropic/claude-sonnet-4.5"));
79
+ expect(sonnet).not.toBeNull();
80
+ expect(sonnet?.priceTiers.length).toBeGreaterThan(0);
81
+ const tiers = sonnet?.priceTiers ?? [];
82
+ for (let i = 1; i < tiers.length; i++) {
83
+ const prev = tiers[i - 1];
84
+ const cur = tiers[i];
85
+ if (prev === undefined || cur === undefined) continue;
86
+ expect(cur.minPromptTokens).toBeGreaterThan(prev.minPromptTokens);
87
+ }
88
+ // Anthropic doubles above 200k; the override must be dearer than base.
89
+ const first = tiers[0];
90
+ expect(first?.minPromptTokens).toBe(200000);
91
+ expect(first?.price.prompt).toBeGreaterThan(sonnet?.price.prompt ?? 0);
92
+ });
93
+
94
+ test("an override tier inherits components it does not restate", () => {
95
+ for (const raw of RAW) {
96
+ const model = normalizeCatalogModel(raw);
97
+ if (model === null || model.priceTiers.length === 0) continue;
98
+ for (const tier of model.priceTiers) {
99
+ // Completion is always meaningful, whether restated or inherited.
100
+ expect(Number.isFinite(tier.price.completion)).toBe(true);
101
+ expect(tier.price.completion).toBeGreaterThan(0);
102
+ }
103
+ }
104
+ });
105
+
106
+ test("strips the floating-alias marker from the author segment", () => {
107
+ const alias = normalizeCatalogModel(rawFor("~x-ai/grok-latest"));
108
+ expect(alias).not.toBeNull();
109
+ expect(alias?.author).toBe("x-ai");
110
+ // The slug itself is retained verbatim so the deny rule can still see it.
111
+ expect(alias?.slug.startsWith("~")).toBe(true);
112
+ });
113
+
114
+ test("derives capability flags from supported_parameters", () => {
115
+ const sonnet = normalizeCatalogModel(rawFor("anthropic/claude-sonnet-4.5"));
116
+ expect(sonnet?.supportsTools).toBe(true);
117
+ expect(sonnet?.supportsToolChoice).toBe(true);
118
+ expect(sonnet?.supportsReasoning).toBe(true);
119
+ expect(sonnet?.reasoningMandatory).toBe(false);
120
+
121
+ const toolless = normalizeCatalogModel(rawFor("tencent/hy-mt2-1.8b"));
122
+ expect(toolless?.supportsTools).toBe(false);
123
+ });
124
+
125
+ test("rejects records missing the fields routing depends on", () => {
126
+ expect(normalizeCatalogModel({})).toBeNull();
127
+ expect(normalizeCatalogModel(null)).toBeNull();
128
+ expect(normalizeCatalogModel({ id: "x/y" })).toBeNull();
129
+ expect(normalizeCatalogModel({ id: "x/y", pricing: { prompt: "0.1" } })).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe("createCatalog key-scoped availability", () => {
134
+ test("prefers fetchModelsForUser when an API key is configured", async () => {
135
+ const { createCatalog } = await import("../src/catalog/openrouter-catalog.ts");
136
+ const { openDb } = await import("../src/util/sqlite.ts");
137
+ const { DEFAULT_CONFIG } = await import("../src/config/defaults.ts");
138
+
139
+ let userCalls = 0;
140
+ let publicCalls = 0;
141
+ const upstream: any = {
142
+ dispatch: () => Promise.reject(new Error("unused")),
143
+ complete: () => Promise.reject(new Error("unused")),
144
+ fetchModels: async () => {
145
+ publicCalls++;
146
+ return RAW;
147
+ },
148
+ fetchModelsForUser: async () => {
149
+ userCalls++;
150
+ return [rawFor("anthropic/claude-sonnet-4.5")];
151
+ },
152
+ };
153
+
154
+ const cfg = { ...DEFAULT_CONFIG, openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "sk-or-test" } };
155
+ const db = openDb(":memory:");
156
+ const catalog = createCatalog(cfg, upstream, db);
157
+
158
+ const snapshot = await catalog.get();
159
+ expect(userCalls).toBe(1);
160
+ // The public catalog is now also fetched, but ONLY to join AA benchmark
161
+ // scores on: `/models/user` omits them, and an unscored catalog leaves
162
+ // every tier above `trivial` permanently empty.
163
+ expect(publicCalls).toBe(1);
164
+ expect(snapshot.keyScoped).toBe(true);
165
+ // Availability still comes solely from the key-scoped list: none of the
166
+ // public models may leak into a key-scoped snapshot.
167
+ expect(snapshot.models.length).toBe(1);
168
+ expect(snapshot.models[0]?.slug).toBe("anthropic/claude-sonnet-4.5");
169
+ db.close();
170
+ });
171
+
172
+ test("uses public fetchModels when no API key is configured", async () => {
173
+ const { createCatalog } = await import("../src/catalog/openrouter-catalog.ts");
174
+ const { openDb } = await import("../src/util/sqlite.ts");
175
+ const { DEFAULT_CONFIG } = await import("../src/config/defaults.ts");
176
+
177
+ let userCalls = 0;
178
+ let publicCalls = 0;
179
+ const upstream: any = {
180
+ dispatch: () => Promise.reject(new Error("unused")),
181
+ complete: () => Promise.reject(new Error("unused")),
182
+ fetchModels: async () => {
183
+ publicCalls++;
184
+ return RAW;
185
+ },
186
+ fetchModelsForUser: async () => {
187
+ userCalls++;
188
+ return [];
189
+ },
190
+ };
191
+
192
+ const cfg = { ...DEFAULT_CONFIG, openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "" } };
193
+ const db = openDb(":memory:");
194
+ const catalog = createCatalog(cfg, upstream, db);
195
+
196
+ const snapshot = await catalog.get();
197
+ expect(userCalls).toBe(0);
198
+ expect(publicCalls).toBe(1);
199
+ expect(snapshot.keyScoped).toBe(false);
200
+ expect(snapshot.models.length).toBeGreaterThan(50);
201
+ db.close();
202
+ });
203
+
204
+ test("re-throws 401/403 authorization failures rather than falling back to un-scoped catalog", async () => {
205
+ const { createCatalog } = await import("../src/catalog/openrouter-catalog.ts");
206
+ const { openDb } = await import("../src/util/sqlite.ts");
207
+ const { DEFAULT_CONFIG } = await import("../src/config/defaults.ts");
208
+ const { UpstreamError } = await import("../src/upstream/types.ts");
209
+
210
+ const upstream: any = {
211
+ dispatch: () => Promise.reject(new Error("unused")),
212
+ complete: () => Promise.reject(new Error("unused")),
213
+ fetchModels: async () => RAW,
214
+ fetchModelsForUser: async () => {
215
+ throw new UpstreamError("auth", 401, "Unauthorized", false);
216
+ },
217
+ };
218
+
219
+ const cfg = { ...DEFAULT_CONFIG, openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "sk-or-invalid" } };
220
+ const db = openDb(":memory:");
221
+ const catalog = createCatalog(cfg, upstream, db);
222
+
223
+ await expect(catalog.get()).rejects.toThrow("Unauthorized");
224
+ db.close();
225
+ });
226
+
227
+ test("falls back to public catalog on transient (500) key-scoped fetch failure", async () => {
228
+ const { createCatalog } = await import("../src/catalog/openrouter-catalog.ts");
229
+ const { openDb } = await import("../src/util/sqlite.ts");
230
+ const { DEFAULT_CONFIG } = await import("../src/config/defaults.ts");
231
+ const { UpstreamError } = await import("../src/upstream/types.ts");
232
+
233
+ const upstream: any = {
234
+ dispatch: () => Promise.reject(new Error("unused")),
235
+ complete: () => Promise.reject(new Error("unused")),
236
+ fetchModels: async () => [rawFor("anthropic/claude-sonnet-4.5")],
237
+ fetchModelsForUser: async () => {
238
+ throw new UpstreamError("upstream_error", 500, "Internal Server Error", true);
239
+ },
240
+ };
241
+
242
+ const cfg = { ...DEFAULT_CONFIG, openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "sk-or-test" } };
243
+ const db = openDb(":memory:");
244
+ const catalog = createCatalog(cfg, upstream, db);
245
+
246
+ const snapshot = await catalog.get();
247
+ expect(snapshot.models.length).toBe(1);
248
+ expect(snapshot.keyScoped).toBe(false);
249
+ db.close();
250
+ });
251
+
252
+ test("keeps the previous snapshot when a key-scoped refresh yields no usable models", async () => {
253
+ const { createCatalog } = await import("../src/catalog/openrouter-catalog.ts");
254
+ const { openDb } = await import("../src/util/sqlite.ts");
255
+ const { DEFAULT_CONFIG } = await import("../src/config/defaults.ts");
256
+
257
+ // First fetch returns a real model; the second returns an empty list.
258
+ let calls = 0;
259
+ const upstream: any = {
260
+ dispatch: () => Promise.reject(new Error("unused")),
261
+ complete: () => Promise.reject(new Error("unused")),
262
+ fetchModels: async () => [rawFor("anthropic/claude-sonnet-4.5")],
263
+ fetchModelsForUser: async () => {
264
+ calls++;
265
+ return calls === 1 ? [rawFor("anthropic/claude-sonnet-4.5")] : [];
266
+ },
267
+ };
268
+
269
+ const cfg = { ...DEFAULT_CONFIG, openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "sk-or-test" } };
270
+ const db = openDb(":memory:");
271
+ const catalog = createCatalog(cfg, upstream, db);
272
+
273
+ const first = await catalog.get();
274
+ expect(first.models.length).toBe(1);
275
+
276
+ // Force a refresh that returns empty; the stale snapshot must survive.
277
+ const second = await catalog.refresh();
278
+ expect(second.models.length).toBe(1);
279
+ expect(second.models[0]?.slug).toBe("anthropic/claude-sonnet-4.5");
280
+ db.close();
281
+ });
282
+
283
+ test("does not persist the public fallback over a key-scoped snapshot", async () => {
284
+ const { createCatalog } = await import("../src/catalog/openrouter-catalog.ts");
285
+ const { openDb } = await import("../src/util/sqlite.ts");
286
+ const { DEFAULT_CONFIG } = await import("../src/config/defaults.ts");
287
+ const { UpstreamError } = await import("../src/upstream/types.ts");
288
+
289
+ // Key-scoped succeeds once, then fails transiently; public returns a
290
+ // DIFFERENT model. The public payload must not overwrite the key-scoped
291
+ // cache on disk.
292
+ let userCalls = 0;
293
+ const upstream: any = {
294
+ dispatch: () => Promise.reject(new Error("unused")),
295
+ complete: () => Promise.reject(new Error("unused")),
296
+ fetchModels: async () => [rawFor("openai/gpt-oss-20b")],
297
+ fetchModelsForUser: async () => {
298
+ userCalls++;
299
+ if (userCalls === 1) return [rawFor("anthropic/claude-sonnet-4.5")];
300
+ throw new UpstreamError("upstream_error", 500, "Internal Server Error", true);
301
+ },
302
+ };
303
+
304
+ const cfg = { ...DEFAULT_CONFIG, openrouter: { ...DEFAULT_CONFIG.openrouter, apiKey: "sk-or-test" } };
305
+ const db = openDb(":memory:");
306
+ const catalog = createCatalog(cfg, upstream, db);
307
+
308
+ await catalog.get(); // key-scoped, persisted
309
+ await catalog.refresh(); // falls back to public in-memory, must NOT persist
310
+
311
+ // A fresh catalog over the same DB hydrates from disk: it must still be
312
+ // the key-scoped model, not the public fallback.
313
+ const catalog2 = createCatalog(cfg, upstream, db);
314
+ const hydrated = catalog2.peek();
315
+ expect(hydrated?.models[0]?.slug).toBe("anthropic/claude-sonnet-4.5");
316
+ expect(hydrated?.keyScoped).toBe(true);
317
+ db.close();
318
+ });
319
+ });
@@ -0,0 +1,269 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { loadConfig } from "../src/config/load.ts";
4
+ import type { RouterConfig } from "../src/config/types.ts";
5
+ import { classify, classifyTask, pickQualityAxis, scoreHeuristic } from "../src/router/classify.ts";
6
+ import { extractFeatures } from "../src/router/features.ts";
7
+ import { TIER_ORDER, type Features, type Tier } from "../src/router/types.ts";
8
+ import type { Dispatch, DispatchOptions, UpstreamClient } from "../src/upstream/types.ts";
9
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
10
+ import type { NormRequest } from "../src/wire/types.ts";
11
+
12
+ const BASE = loadConfig({});
13
+
14
+ function cfgWith(over: Partial<RouterConfig>): RouterConfig {
15
+ return { ...BASE, ...over };
16
+ }
17
+
18
+ const TOOLS = [
19
+ {
20
+ type: "function",
21
+ function: {
22
+ name: "read",
23
+ description: "Read a file",
24
+ parameters: { type: "object", properties: { path: { type: "string" } } },
25
+ },
26
+ },
27
+ ];
28
+
29
+ function req(messages: unknown[], tools: unknown[] | undefined = TOOLS): NormRequest {
30
+ const body: Record<string, unknown> = { model: "auto", messages };
31
+ if (tools !== undefined) body.tools = tools;
32
+ return parseChatRequest(body, new Headers());
33
+ }
34
+
35
+ function featuresFor(messages: unknown[], tools?: unknown[] | undefined): Features {
36
+ const r = req(messages, tools === undefined ? TOOLS : tools);
37
+ return extractFeatures(r, 5000);
38
+ }
39
+
40
+ const SYSTEM = { role: "system", content: "You are a coding agent." };
41
+
42
+ /** Upstream double that fails loudly if the adjudicator is consulted. */
43
+ function forbiddenUpstream(): UpstreamClient {
44
+ return {
45
+ dispatch(_opts: DispatchOptions): Promise<Dispatch> {
46
+ throw new Error("dispatch must not be called during classification");
47
+ },
48
+ complete(): Promise<{ text: string; costUsd: number | null }> {
49
+ throw new Error("adjudicator must not be called");
50
+ },
51
+ fetchModels(): Promise<unknown[]> {
52
+ return Promise.resolve([]);
53
+ },
54
+ fetchModelsForUser(): Promise<unknown[]> {
55
+ return Promise.resolve([]);
56
+ },
57
+ };
58
+ }
59
+
60
+ function scriptedUpstream(behaviour: () => Promise<{ text: string; costUsd: number | null }>): UpstreamClient {
61
+ return {
62
+ dispatch(_opts: DispatchOptions): Promise<Dispatch> {
63
+ throw new Error("dispatch must not be called during classification");
64
+ },
65
+ complete: behaviour,
66
+ fetchModels(): Promise<unknown[]> {
67
+ return Promise.resolve([]);
68
+ },
69
+ fetchModelsForUser(): Promise<unknown[]> {
70
+ return Promise.resolve([]);
71
+ },
72
+ };
73
+ }
74
+
75
+ const tierIdx = (t: Tier): number => TIER_ORDER.indexOf(t);
76
+ describe("scoreHeuristic", () => {
77
+ test("a mechanical tool-result continuation scores cheaper than a fresh architecture question", () => {
78
+ // The single most valuable signal in agent traffic: most turns are
79
+ // post-tool-result continuations, and they do not need a frontier model.
80
+ const continuation = scoreHeuristic(
81
+ featuresFor([
82
+ SYSTEM,
83
+ { role: "user", content: "check the version" },
84
+ {
85
+ role: "assistant",
86
+ content: null,
87
+ tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: '{"path":"p.json"}' } }],
88
+ },
89
+ { role: "tool", tool_call_id: "c1", content: '{"version":"1.0.0"}' },
90
+ ]),
91
+ BASE,
92
+ );
93
+ const architecture = scoreHeuristic(
94
+ featuresFor([
95
+ SYSTEM,
96
+ {
97
+ role: "user",
98
+ content:
99
+ "Find the root cause of this deadlock, explain the race between the queue drain and shutdown, and redesign the architecture to remove the invariant violation.",
100
+ },
101
+ ]),
102
+ BASE,
103
+ );
104
+ expect(continuation.score).toBeLessThan(architecture.score);
105
+ expect(tierIdx(continuation.tier)).toBeLessThan(tierIdx(architecture.tier));
106
+ });
107
+
108
+ test("a failing tool result raises the tier above a clean one", () => {
109
+ const clean = scoreHeuristic(
110
+ featuresFor([
111
+ SYSTEM,
112
+ { role: "user", content: "build" },
113
+ {
114
+ role: "assistant",
115
+ content: null,
116
+ tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }],
117
+ },
118
+ { role: "tool", tool_call_id: "c1", content: "ok, build succeeded" },
119
+ ]),
120
+ BASE,
121
+ );
122
+ const failed = scoreHeuristic(
123
+ featuresFor([
124
+ SYSTEM,
125
+ { role: "user", content: "build" },
126
+ {
127
+ role: "assistant",
128
+ content: null,
129
+ tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }],
130
+ },
131
+ { role: "tool", tool_call_id: "c1", content: "make: *** [all] Error 2" },
132
+ ]),
133
+ BASE,
134
+ );
135
+ expect(failed.score).toBeGreaterThan(clean.score);
136
+ });
137
+
138
+ test("a requested high reasoning effort raises the score", () => {
139
+ const plain = scoreHeuristic(featuresFor([SYSTEM, { role: "user", content: "tidy this up" }]), BASE);
140
+ const thinking = scoreHeuristic(
141
+ extractFeatures(
142
+ parseChatRequest(
143
+ { model: "auto", tools: TOOLS, reasoning_effort: "high", messages: [SYSTEM, { role: "user", content: "tidy this up" }] },
144
+ new Headers(),
145
+ ),
146
+ 5000,
147
+ ),
148
+ BASE,
149
+ );
150
+ expect(thinking.score).toBeGreaterThan(plain.score);
151
+ });
152
+
153
+ test("always produces a bounded score, a real tier, and its reasoning", () => {
154
+ const c = scoreHeuristic(featuresFor([SYSTEM, { role: "user", content: "hello" }]), BASE);
155
+ expect(c.score).toBeGreaterThanOrEqual(0);
156
+ expect(c.score).toBeLessThanOrEqual(1);
157
+ expect(TIER_ORDER).toContain(c.tier);
158
+ expect(c.source).toBe("heuristic");
159
+ expect(c.reasons.length).toBeGreaterThan(0);
160
+ expect(c.confidence).toBeGreaterThanOrEqual(0);
161
+ expect(c.confidence).toBeLessThanOrEqual(1);
162
+ });
163
+ });
164
+
165
+ describe("pickQualityAxis", () => {
166
+ test("tools imply the coding axis, plain chat the chat axis", () => {
167
+ expect(pickQualityAxis(featuresFor([SYSTEM, { role: "user", content: "fix it" }]), BASE)).toBe(BASE.classifier.toolAxis);
168
+ expect(pickQualityAxis(featuresFor([SYSTEM, { role: "user", content: "hello" }], []), BASE)).toBe(
169
+ BASE.classifier.chatAxis,
170
+ );
171
+ });
172
+
173
+ test("a deep tool loop switches to the agentic axis", () => {
174
+ const deep = featuresFor([
175
+ SYSTEM,
176
+ { role: "user", content: "go" },
177
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: '{"path":"a"}' } }] },
178
+ { role: "tool", tool_call_id: "c1", content: "a" },
179
+ { role: "assistant", content: null, tool_calls: [{ id: "c2", type: "function", function: { name: "read", arguments: '{"path":"b"}' } }] },
180
+ { role: "tool", tool_call_id: "c2", content: "b" },
181
+ { role: "assistant", content: null, tool_calls: [{ id: "c3", type: "function", function: { name: "read", arguments: '{"path":"c"}' } }] },
182
+ { role: "tool", tool_call_id: "c3", content: "c" },
183
+ { role: "assistant", content: null, tool_calls: [{ id: "c4", type: "function", function: { name: "read", arguments: '{"path":"d"}' } }] },
184
+ { role: "tool", tool_call_id: "c4", content: "d" },
185
+ ]);
186
+ expect(deep.toolLoopDepth).toBeGreaterThanOrEqual(BASE.classifier.agenticLoopDepth);
187
+ expect(pickQualityAxis(deep, BASE)).toBe("agentic");
188
+ });
189
+ });
190
+
191
+ describe("classify", () => {
192
+ const messages = [SYSTEM, { role: "user", content: "tidy the retry helper a bit" }];
193
+
194
+ test("never consults the adjudicator when the ambiguity threshold is zero", async () => {
195
+ const cfg = cfgWith({ classifier: { ...BASE.classifier, ambiguityThreshold: 0 } });
196
+ const r = req(messages);
197
+ const result = await classify(r, extractFeatures(r, 5000), cfg, {
198
+ upstream: forbiddenUpstream(),
199
+ ledger: null,
200
+ catalog: null,
201
+ });
202
+ expect(result.source).toBe("heuristic");
203
+ });
204
+
205
+ test("falls back to the heuristic when the adjudicator returns garbage", async () => {
206
+ // Always-ambiguous, so the adjudicator is definitely consulted.
207
+ const cfg = cfgWith({ classifier: { ...BASE.classifier, ambiguityThreshold: 1.1 } });
208
+ const r = req(messages);
209
+ const f = extractFeatures(r, 5000);
210
+ const expected = scoreHeuristic(f, cfg);
211
+ const result = await classify(r, f, cfg, {
212
+ upstream: scriptedUpstream(() => Promise.resolve({ text: "definitely not a tier", costUsd: 0 })),
213
+ ledger: null,
214
+ catalog: null,
215
+ });
216
+ expect(result.tier).toBe(expected.tier);
217
+ });
218
+
219
+ test("falls back to the heuristic when the adjudicator throws", async () => {
220
+ const cfg = cfgWith({ classifier: { ...BASE.classifier, ambiguityThreshold: 1.1 } });
221
+ const r = req(messages);
222
+ const f = extractFeatures(r, 5000);
223
+ const expected = scoreHeuristic(f, cfg);
224
+ const result = await classify(r, f, cfg, {
225
+ upstream: scriptedUpstream(() => Promise.reject(new Error("upstream exploded"))),
226
+ ledger: null,
227
+ catalog: null,
228
+ });
229
+ expect(result.tier).toBe(expected.tier);
230
+ expect(result.reasons.some((x) => x.toLowerCase().includes("adjudicat"))).toBe(true);
231
+ });
232
+
233
+ test("adopts a valid adjudicator verdict", async () => {
234
+ const cfg = cfgWith({ classifier: { ...BASE.classifier, ambiguityThreshold: 1.1 } });
235
+ const r = req(messages);
236
+ const f = extractFeatures(r, 5000);
237
+ const result = await classify(r, f, cfg, {
238
+ upstream: scriptedUpstream(() => Promise.resolve({ text: "hard", costUsd: 0.00001 })),
239
+ ledger: null,
240
+ catalog: null,
241
+ });
242
+ expect(result.tier).toBe("hard");
243
+ expect(result.source).toBe("llm");
244
+ });
245
+ });
246
+
247
+ describe("classifyTask", () => {
248
+ test("image input is a vision task", () => {
249
+ const f = featuresFor([SYSTEM, { role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,xxx" } }] }], []);
250
+ expect(classifyTask(f)).toBe("vision");
251
+ });
252
+
253
+ test("code blocks and diffs are coding tasks", () => {
254
+ expect(classifyTask(featuresFor([SYSTEM, { role: "user", content: "```ts\nconst x = 1;\n```" }], []))).toBe("coding");
255
+ expect(classifyTask(featuresFor([SYSTEM, { role: "user", content: "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+new" }], []))).toBe("coding");
256
+ });
257
+
258
+ test("tools offered is a coding task", () => {
259
+ expect(classifyTask(featuresFor([SYSTEM, { role: "user", content: "read the file" }], TOOLS))).toBe("coding");
260
+ });
261
+
262
+ test("bare chat with no tools or code is a chat task", () => {
263
+ expect(classifyTask(featuresFor([SYSTEM, { role: "user", content: "hello, how are you?" }], []))).toBe("chat");
264
+ });
265
+
266
+ test("design/architecture prose is a documentation task", () => {
267
+ expect(classifyTask(featuresFor([SYSTEM, { role: "user", content: "explain the architecture of the system" }], []))).toBe("documentation");
268
+ });
269
+ });