auto-model-router 0.1.4 → 0.2.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 (45) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +43 -55
  3. package/bun.lock +606 -0
  4. package/package.json +2 -1
  5. package/research/analyze-ledger.ts +173 -0
  6. package/research/apply-cost-tuning.ts +73 -0
  7. package/research/cost-analysis.ts +150 -0
  8. package/research/feed-check.ts +64 -0
  9. package/research/model-recommendations.ts +86 -0
  10. package/research/project-yield.ts +96 -0
  11. package/research/run-eval.ts +133 -0
  12. package/research/status.ts +55 -0
  13. package/research/tier-fill.ts +109 -0
  14. package/research/tier-map.ts +123 -0
  15. package/src/catalog/benchmark-feeds.ts +397 -0
  16. package/src/catalog/openrouter-catalog.ts +30 -0
  17. package/src/config/defaults.ts +30 -0
  18. package/src/config/load.ts +2 -0
  19. package/src/config/schema.ts +34 -0
  20. package/src/config/types.ts +106 -0
  21. package/src/cost/ledger.ts +23 -2
  22. package/src/cost/types.ts +24 -0
  23. package/src/eval/calibrate.ts +131 -0
  24. package/src/eval/grade.ts +115 -0
  25. package/src/eval/judge.ts +71 -0
  26. package/src/eval/run.ts +126 -0
  27. package/src/eval/tasks.ts +272 -0
  28. package/src/router/candidates.ts +13 -6
  29. package/src/router/explore.ts +59 -0
  30. package/src/router/select.ts +54 -4
  31. package/src/router/tier-plan.ts +57 -1
  32. package/src/router/types.ts +13 -0
  33. package/src/server/turn.ts +9 -2
  34. package/src/util/sqlite.ts +68 -1
  35. package/test/benchmark-feeds.test.ts +222 -0
  36. package/test/eval.test.ts +184 -0
  37. package/test/exploration.test.ts +251 -0
  38. package/test/failover.test.ts +4 -0
  39. package/test/hold-exploration.test.ts +124 -0
  40. package/test/tier-plan.test.ts +55 -1
  41. package/test/tokens.test.ts +7 -0
  42. package/test/trust-attribution.test.ts +96 -2
  43. package/test/turn.test.ts +45 -0
  44. package/tools/smoke.ts +2 -0
  45. package/tools/sync-marketplace-version.ts +60 -0
@@ -0,0 +1,251 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { 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 type { ExplorationConfig, ProfileConfig, RouterConfig } from "../src/config/types.ts";
7
+ import { scoreHeuristic } from "../src/router/classify.ts";
8
+ import { extractFeatures } from "../src/router/features.ts";
9
+ import { select } from "../src/router/select.ts";
10
+ import type { ClassificationSource, ConversationState, Tier } from "../src/router/types.ts";
11
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
12
+ import type { NormRequest } from "../src/wire/types.ts";
13
+
14
+ const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json()) as { data: unknown[] };
15
+ const MODELS: CatalogModel[] = FIXTURE.data.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
16
+ const SNAPSHOT: CatalogSnapshot = { models: MODELS, fetchedAtMs: Date.now() };
17
+
18
+ const BASE = loadConfig({});
19
+ const PROFILE: ProfileConfig = {
20
+ id: "auto",
21
+ name: "Auto",
22
+ minTier: "trivial",
23
+ maxTier: "hard",
24
+ contextWindow: 400_000,
25
+ maxTokens: 32_000,
26
+ };
27
+ const NOW = Date.now();
28
+
29
+ function request(userText: string): NormRequest {
30
+ return parseChatRequest(
31
+ {
32
+ model: "auto",
33
+ messages: [
34
+ { role: "system", content: "You are a coding agent." },
35
+ { role: "user", content: userText },
36
+ ],
37
+ },
38
+ new Headers(),
39
+ );
40
+ }
41
+
42
+ function state(over: Partial<ConversationState> = {}): ConversationState {
43
+ return {
44
+ key: "abc123",
45
+ sessionId: "omp-abc123",
46
+ turn: 1,
47
+ currentSlug: null,
48
+ currentTier: null,
49
+ stickyUntilTurn: 0,
50
+ escalations: 0,
51
+ spentUsd: 0,
52
+ lastPromptTokens: 0,
53
+ cacheWarmSlug: null,
54
+ cacheWarmAtMs: 0,
55
+ updatedAtMs: NOW,
56
+ ...over,
57
+ };
58
+ }
59
+
60
+ /** A hysteresis hold at the given tier, with the prompt cache warm or expired. */
61
+ function heldState(held: Tier, cache: "warm" | "cold"): ConversationState {
62
+ return state({
63
+ turn: 1,
64
+ stickyUntilTurn: 5,
65
+ currentTier: held,
66
+ currentSlug: "vendor/model",
67
+ cacheWarmSlug: cache === "warm" ? "vendor/model" : null,
68
+ cacheWarmAtMs: cache === "warm" ? NOW : 0,
69
+ });
70
+ }
71
+
72
+ function withExploration(over: Partial<ExplorationConfig>): RouterConfig {
73
+ return { ...BASE, exploration: { ...BASE.exploration, ...over } };
74
+ }
75
+
76
+ function run(opts: {
77
+ userText?: string;
78
+ cfg?: RouterConfig;
79
+ st?: ConversationState;
80
+ tier?: Tier;
81
+ source?: ClassificationSource;
82
+ profile?: ProfileConfig;
83
+ excludeSlugs?: readonly string[];
84
+ }) {
85
+ const cfg = opts.cfg ?? BASE;
86
+ const req = request(opts.userText ?? "tidy the retry helper");
87
+ const features = extractFeatures(req, 4000);
88
+ const heuristic = scoreHeuristic(features, cfg);
89
+ const classification = {
90
+ ...heuristic,
91
+ ...(opts.tier === undefined ? {} : { tier: opts.tier }),
92
+ ...(opts.source === undefined ? {} : { source: opts.source }),
93
+ };
94
+ return select({
95
+ req,
96
+ features,
97
+ classification,
98
+ profile: opts.profile ?? PROFILE,
99
+ state: opts.st ?? state(),
100
+ snapshot: SNAPSHOT,
101
+ ledger: null,
102
+ cfg,
103
+ nowMs: NOW,
104
+ ...(opts.excludeSlugs === undefined ? {} : { excludeSlugs: opts.excludeSlugs }),
105
+ });
106
+ }
107
+
108
+ const ALWAYS = { simple: 1, moderate: 1, hard: 1 };
109
+
110
+ describe("exploration is opt-in", () => {
111
+ test("never fires under the shipped defaults", () => {
112
+ expect(BASE.exploration.enabled).toBe(false);
113
+ for (const tier of ["simple", "moderate", "hard"] as Tier[]) {
114
+ expect(run({ tier }).explored).toBeNull();
115
+ }
116
+ });
117
+
118
+ test("enabled with no rates configured still never fires", () => {
119
+ const cfg = withExploration({ enabled: true, rates: {} });
120
+ for (const tier of ["simple", "moderate", "hard"] as Tier[]) {
121
+ expect(run({ tier, cfg }).explored).toBeNull();
122
+ }
123
+ });
124
+ });
125
+
126
+ describe("per-tier rates", () => {
127
+ test("each tier is governed by its own rate, not one global one", () => {
128
+ const cfg = withExploration({ enabled: true, rates: { simple: 0, moderate: 0, hard: 1 } });
129
+ expect(run({ tier: "simple", cfg }).explored).toBeNull();
130
+ expect(run({ tier: "moderate", cfg }).explored).toBeNull();
131
+ expect(run({ tier: "hard", cfg }).explored).toEqual({ from: "hard", to: "moderate" });
132
+ });
133
+
134
+ test("a tier absent from the rates map is never explored", () => {
135
+ const cfg = withExploration({ enabled: true, rates: { hard: 1 } });
136
+ expect(run({ tier: "moderate", cfg }).explored).toBeNull();
137
+ expect(run({ tier: "hard", cfg }).explored).not.toBeNull();
138
+ });
139
+
140
+ test("the shipped defaults weight expensive tiers far above cheap ones", () => {
141
+ const r = BASE.exploration.rates;
142
+ expect(r.hard ?? 0).toBeGreaterThan(r.simple ?? 0);
143
+ expect(r.moderate ?? 0).toBeGreaterThan(r.simple ?? 0);
144
+ });
145
+ });
146
+
147
+ describe("exploration drops exactly one tier", () => {
148
+ const cfg = withExploration({ enabled: true, rates: ALWAYS });
149
+
150
+ test("moderate explores down to simple", () => {
151
+ expect(run({ tier: "moderate", cfg }).explored).toEqual({ from: "moderate", to: "simple" });
152
+ });
153
+
154
+ test("hard explores down to moderate, never further", () => {
155
+ expect(run({ tier: "hard", cfg }).explored).toEqual({ from: "hard", to: "moderate" });
156
+ });
157
+
158
+ test("trivial is the floor and cannot be explored below", () => {
159
+ expect(run({ tier: "trivial", cfg }).explored).toBeNull();
160
+ });
161
+
162
+ test("the decision trail says so out loud", () => {
163
+ expect(run({ tier: "moderate", cfg }).reasons.some((r) => r.startsWith("exploration:"))).toBe(true);
164
+ });
165
+ });
166
+
167
+ describe("hysteresis holds are explored only once the cache is cold", () => {
168
+ const cfg = withExploration({ enabled: true, rates: ALWAYS, stickyPolicy: "cold-cache" });
169
+
170
+ test("a held tier with a WARM cache is left alone", () => {
171
+ expect(run({ tier: "simple", cfg, st: heldState("hard", "warm") }).explored).toBeNull();
172
+ });
173
+
174
+ test("a held tier with a COLD cache is explorable", () => {
175
+ // This is the population that carries most of the spend: turns that
176
+ // reach hard by hold rather than by classification.
177
+ expect(run({ tier: "simple", cfg, st: heldState("hard", "cold") }).explored).toEqual({
178
+ from: "hard",
179
+ to: "moderate",
180
+ });
181
+ });
182
+
183
+ test("the reason names the hold and the cache state, for later analysis", () => {
184
+ const d = run({ tier: "simple", cfg, st: heldState("hard", "cold") });
185
+ expect(d.reasons.some((r) => r.includes("held tier (cold cache)"))).toBe(true);
186
+ });
187
+
188
+ test("stickyPolicy never leaves holds alone entirely", () => {
189
+ const off = withExploration({ enabled: true, rates: ALWAYS, stickyPolicy: "never" });
190
+ expect(run({ tier: "simple", cfg: off, st: heldState("hard", "cold") }).explored).toBeNull();
191
+ expect(run({ tier: "simple", cfg: off, st: heldState("hard", "warm") }).explored).toBeNull();
192
+ });
193
+
194
+ test("stickyPolicy always reaches held turns even with a live cache", () => {
195
+ // The only setting that samples the population carrying most of the
196
+ // spend, at the price of a forfeited cache read.
197
+ const always = withExploration({ enabled: true, rates: ALWAYS, stickyPolicy: "always" });
198
+ expect(run({ tier: "simple", cfg: always, st: heldState("hard", "warm") }).explored).toEqual({
199
+ from: "hard",
200
+ to: "moderate",
201
+ });
202
+ expect(
203
+ run({ tier: "simple", cfg: always, st: heldState("hard", "warm") }).reasons.some((r) =>
204
+ r.includes("held tier (warm cache)"),
205
+ ),
206
+ ).toBe(true);
207
+ });
208
+ });
209
+
210
+ describe("exploration respects the remaining guards", () => {
211
+ const cfg = withExploration({ enabled: true, rates: ALWAYS });
212
+
213
+ test("never routes below the profile floor", () => {
214
+ const floored: ProfileConfig = { ...PROFILE, minTier: "moderate" };
215
+ expect(run({ tier: "moderate", cfg, profile: floored }).explored).toBeNull();
216
+ });
217
+
218
+ test("skips forced escalations, which already proved the cheap tier failed", () => {
219
+ expect(run({ tier: "moderate", cfg, source: "escalation" }).explored).toBeNull();
220
+ });
221
+
222
+ test("skips failover retries so a second confound is not introduced", () => {
223
+ expect(run({ tier: "moderate", cfg, excludeSlugs: ["vendor/broken"] }).explored).toBeNull();
224
+ });
225
+ });
226
+
227
+ describe("exploration is deterministic", () => {
228
+ const cfg = withExploration({ enabled: true, rates: { simple: 0.5, moderate: 0.5, hard: 0.5 } });
229
+
230
+ test("the same turn always draws the same way, so explain can replay it", () => {
231
+ for (const text of ["alpha task", "beta task", "gamma task"]) {
232
+ const first = run({ userText: text, tier: "moderate", cfg });
233
+ for (let i = 0; i < 5; i++) {
234
+ expect(run({ userText: text, tier: "moderate", cfg }).explored).toEqual(first.explored);
235
+ }
236
+ }
237
+ });
238
+
239
+ test("the draw honours the configured rate across many turns", () => {
240
+ const cfg25 = withExploration({ enabled: true, rates: { moderate: 0.25 } });
241
+ let explored = 0;
242
+ const N = 400;
243
+ for (let i = 0; i < N; i++) {
244
+ if (run({ userText: "distinct task " + i, tier: "moderate", cfg: cfg25 }).explored !== null) explored++;
245
+ }
246
+ // Deterministic hash, so this cannot flake; the band is wide enough that
247
+ // only a genuinely biased draw would fail it.
248
+ expect(explored).toBeGreaterThan(N * 0.25 - 40);
249
+ expect(explored).toBeLessThan(N * 0.25 + 40);
250
+ });
251
+ });
@@ -29,6 +29,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
29
29
  return {
30
30
  server: { host: "127.0.0.1", port: 8787 },
31
31
  openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
32
+ benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
32
33
  tiers: {
33
34
  trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
34
35
  simple: { minQuality: 40, maxInputPerMtok: 1.5, qualityExponent: 0, pin: [] },
@@ -65,11 +66,13 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
65
66
  ...escalation,
66
67
  },
67
68
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
69
+ exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
68
70
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024 },
69
71
  budget: { onExceeded: "downgrade" },
70
72
  profiles: [],
71
73
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
72
74
  adaptiveTierFloors: true,
75
+ adaptivePriceCeilings: false,
73
76
  logLevel: "silent",
74
77
  };
75
78
  }
@@ -141,6 +144,7 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
141
144
  considered: [],
142
145
  rejected: [],
143
146
  reasons: ["test decision"],
147
+ explored: null,
144
148
  budgetDowngraded: false,
145
149
  };
146
150
  }
@@ -0,0 +1,124 @@
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 { explorationDraw, resolveHoldTurns } from "../src/router/explore.ts";
6
+
7
+ const BASE = loadConfig({});
8
+
9
+ function withHold(over: { enabled?: boolean; values?: number[] }, enabled = true): RouterConfig {
10
+ return {
11
+ ...BASE,
12
+ exploration: {
13
+ ...BASE.exploration,
14
+ enabled,
15
+ holdTurns: { enabled: over.enabled ?? true, values: over.values ?? [2, 3, 4] },
16
+ },
17
+ };
18
+ }
19
+
20
+ describe("hold exploration is opt-in", () => {
21
+ test("shipped defaults leave the hold alone", () => {
22
+ expect(BASE.exploration.holdTurns.enabled).toBe(false);
23
+ const got = resolveHoldTurns(BASE, "conv-1", true);
24
+ expect(got.turns).toBe(BASE.hysteresis.holdTurnsAfterEscalation);
25
+ expect(got.arm).toBeNull();
26
+ });
27
+
28
+ test("hold exploration stays off while exploration as a whole is off", () => {
29
+ const cfg = withHold({ values: [1] }, false);
30
+ const got = resolveHoldTurns(cfg, "conv-1", true);
31
+ expect(got.turns).toBe(BASE.hysteresis.holdTurnsAfterEscalation);
32
+ expect(got.arm).toBeNull();
33
+ });
34
+
35
+ test("an explicitly disabled hold experiment is inert", () => {
36
+ const cfg = withHold({ enabled: false, values: [1] });
37
+ expect(resolveHoldTurns(cfg, "conv-1", true).arm).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe("only the post-escalation hold is randomised", () => {
42
+ const cfg = withHold({ values: [1] });
43
+
44
+ test("an escalated turn uses the drawn arm", () => {
45
+ expect(resolveHoldTurns(cfg, "conv-1", true).turns).toBe(1);
46
+ });
47
+
48
+ test("an ordinary turn keeps the configured hold", () => {
49
+ // The experiment targets the hold that governs expensive spend; leaving
50
+ // the ordinary hold fixed keeps the comparison narrow enough to read.
51
+ expect(resolveHoldTurns(cfg, "conv-1", false).turns).toBe(BASE.hysteresis.holdTurns);
52
+ });
53
+
54
+ test("but the arm is still recorded on non-escalated turns", () => {
55
+ // Intention-to-treat: arms are compared on whole-conversation cost, so
56
+ // every turn of an assigned conversation has to carry its arm.
57
+ expect(resolveHoldTurns(cfg, "conv-1", false).arm).toBe(1);
58
+ });
59
+ });
60
+
61
+ describe("assignment is per conversation", () => {
62
+ const cfg = withHold({ values: [2, 3, 4] });
63
+
64
+ test("the same conversation always draws the same arm", () => {
65
+ const first = resolveHoldTurns(cfg, "conv-stable", true).arm;
66
+ for (let i = 0; i < 20; i++) {
67
+ expect(resolveHoldTurns(cfg, "conv-stable", true).arm).toBe(first);
68
+ }
69
+ });
70
+
71
+ test("the arm never changes mid-hold, whatever the escalation state", () => {
72
+ const a: number | null = resolveHoldTurns(cfg, "conv-x", true).arm;
73
+ const b = resolveHoldTurns(cfg, "conv-x", false).arm;
74
+ expect(a).toEqual(b);
75
+ });
76
+
77
+ test("every drawn arm comes from the configured set", () => {
78
+ for (let i = 0; i < 200; i++) {
79
+ const arm = resolveHoldTurns(cfg, `conv-${i}`, true).arm;
80
+ expect(arm).not.toBeNull();
81
+ expect([2, 3, 4]).toContain(arm ?? -1);
82
+ }
83
+ });
84
+
85
+ test("arms are spread across conversations rather than collapsing to one", () => {
86
+ const counts = new Map<number, number>();
87
+ const N = 600;
88
+ for (let i = 0; i < N; i++) {
89
+ const arm = resolveHoldTurns(cfg, `spread-${i}`, true).arm ?? -1;
90
+ counts.set(arm, (counts.get(arm) ?? 0) + 1);
91
+ }
92
+ expect(counts.size).toBe(3);
93
+ // Deterministic hash, so this cannot flake. Each of 3 arms should land
94
+ // near N/3; the band is wide enough that only real bias would fail.
95
+ for (const [, n] of counts) {
96
+ expect(n).toBeGreaterThan(N / 3 - 60);
97
+ expect(n).toBeLessThan(N / 3 + 60);
98
+ }
99
+ });
100
+
101
+ test("a single-value set assigns everyone the same arm", () => {
102
+ const one = withHold({ values: [3] });
103
+ for (let i = 0; i < 20; i++) {
104
+ expect(resolveHoldTurns(one, `conv-${i}`, true).turns).toBe(3);
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("the draw itself", () => {
110
+ test("is uniform in [0,1) and stable for a seed", () => {
111
+ expect(explorationDraw("seed-a")).toBe(explorationDraw("seed-a"));
112
+ expect(explorationDraw("seed-a")).not.toBe(explorationDraw("seed-b"));
113
+ for (const seed of ["a", "b", "c", "d", "e"]) {
114
+ const d = explorationDraw(seed);
115
+ expect(d).toBeGreaterThanOrEqual(0);
116
+ expect(d).toBeLessThan(1);
117
+ }
118
+ });
119
+
120
+ test("the tier draw and the hold draw are independent seeds", () => {
121
+ // Sharing a seed would correlate the two experiments and confound both.
122
+ expect(explorationDraw("hold:conv-1")).not.toBe(explorationDraw("explore:conv-1:1"));
123
+ });
124
+ });
@@ -5,7 +5,7 @@ import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
5
5
  import { loadConfig } from "../src/config/load.ts";
6
6
  import { buildCandidates } from "../src/router/candidates.ts";
7
7
  import { extractFeatures } from "../src/router/features.ts";
8
- import { computeTierPlan, effectiveQualityFloor, tierPlanFor } from "../src/router/tier-plan.ts";
8
+ import { computeTierPlan, effectivePriceCeiling, effectiveQualityFloor, tierPlanFor } from "../src/router/tier-plan.ts";
9
9
  import { TIER_ORDER } from "../src/router/types.ts";
10
10
  import { parseChatRequest } from "../src/wire/openai/request.ts";
11
11
 
@@ -300,3 +300,57 @@ describe("adaptive floors in candidate selection", () => {
300
300
  expect(rejected.some((r) => r.slug === target && r.reason === "failed_this_turn")).toBe(true);
301
301
  });
302
302
  });
303
+
304
+ describe("adaptive price ceilings", () => {
305
+ const priced = models([
306
+ ["a/1", 80, 1],
307
+ ["a/2", 80, 2],
308
+ ["a/3", 80, 3],
309
+ ["a/4", 80, 4],
310
+ ]);
311
+ const req = parseChatRequest(
312
+ {
313
+ model: "auto",
314
+ tools: [{ type: "function", function: { name: "read", description: "Read", parameters: { type: "object", properties: {} } } }],
315
+ messages: [{ role: "user", content: "refactor the auth module" }],
316
+ },
317
+ new Headers(),
318
+ );
319
+ const features = extractFeatures(req, 100);
320
+
321
+ test("computeTierPlan derives per-tier price bands from the catalog", () => {
322
+ const plan = computeTierPlan(priced, BASE);
323
+ expect(plan.priceCeilings).toEqual({ trivial: 1, simple: 2, moderate: 3, hard: 4 });
324
+ });
325
+
326
+ test("effectivePriceCeiling: band when on, tighter of config/band, config when off", () => {
327
+ const plan = computeTierPlan(priced, BASE);
328
+ expect(effectivePriceCeiling(undefined, "moderate", plan, true)).toBe(3); // band
329
+ expect(effectivePriceCeiling(2, "moderate", plan, true)).toBe(2); // config tightens
330
+ expect(effectivePriceCeiling(10, "moderate", plan, true)).toBe(3); // band tightens
331
+ expect(effectivePriceCeiling(2, "moderate", plan, false)).toBe(2); // off ⇒ config
332
+ expect(effectivePriceCeiling(undefined, "hard", plan, false)).toBeUndefined();
333
+ });
334
+
335
+ test("a model above the adaptive band is dropped in candidate selection", () => {
336
+ const snap = snapshot(priced);
337
+ const run = (adaptivePriceCeilings: boolean) =>
338
+ buildCandidates({
339
+ req,
340
+ features,
341
+ tier: "moderate",
342
+ task: "coding",
343
+ snapshot: snap,
344
+ ledger: null,
345
+ cfg: { ...BASE, adaptivePriceCeilings },
346
+ expectedCompletionTokens: 512,
347
+ warmSlug: null,
348
+ });
349
+ // Off: the fixed moderate ceiling ($4) admits a/4 at $4.
350
+ expect(run(false).candidates.map((c) => c.model.slug)).toContain("a/4");
351
+ // On: the catalog band tightens moderate to $3, so a/4 is over-ceiling.
352
+ const on = run(true);
353
+ expect(on.candidates.map((c) => c.model.slug)).not.toContain("a/4");
354
+ expect(on.rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
355
+ });
356
+ });
@@ -24,6 +24,13 @@ function entry(over: Partial<LedgerEntry>): LedgerEntry {
24
24
  tier: "simple",
25
25
  classificationSource: "heuristic",
26
26
  reasons: [],
27
+ features: null,
28
+ score: null,
29
+ confidence: null,
30
+ task: null,
31
+ classifierReasons: null,
32
+ exploredFrom: null,
33
+ holdArm: null,
27
34
  predictedUsd: 0.001,
28
35
  reportedUsd: 0.001,
29
36
  usage: EMPTY_USAGE,
@@ -22,6 +22,13 @@ function entry(over: Partial<LedgerEntry>): LedgerEntry {
22
22
  tier: "simple",
23
23
  classificationSource: "heuristic",
24
24
  reasons: [],
25
+ features: null,
26
+ score: null,
27
+ confidence: null,
28
+ task: null,
29
+ classifierReasons: null,
30
+ exploredFrom: null,
31
+ holdArm: null,
25
32
  predictedUsd: 0.001,
26
33
  reportedUsd: 0.001,
27
34
  usage: EMPTY_USAGE,
@@ -164,11 +171,11 @@ describe("v4 migration", () => {
164
171
  }
165
172
  });
166
173
 
167
- test("schema is at user_version 5", () => {
174
+ test("schema is at user_version 10", () => {
168
175
  const db = openDb(":memory:");
169
176
  try {
170
177
  const row = db.query("PRAGMA user_version").get() as { user_version: number };
171
- expect(row.user_version).toBe(5);
178
+ expect(row.user_version).toBe(10);
172
179
  } finally {
173
180
  db.close();
174
181
  }
@@ -187,3 +194,90 @@ describe("v4 migration", () => {
187
194
  }
188
195
  });
189
196
  });
197
+
198
+ describe("v6 classifier instrumentation", () => {
199
+ const FEATURES = {
200
+ promptTokens: 1234,
201
+ isToolResultContinuation: true,
202
+ toolLoopDepth: 3,
203
+ complexityKeywords: ["race", "debug"],
204
+ };
205
+
206
+ test("round-trips the feature vector and classifier outputs", () => {
207
+ const db = openDb(":memory:");
208
+ try {
209
+ const ledger = createLedger(db, cfg);
210
+ ledger.record(
211
+ entry({
212
+ features: FEATURES,
213
+ score: 0.42,
214
+ confidence: 0.75,
215
+ task: "coding",
216
+ classifierReasons: ["-0.28 tool-result continuation"],
217
+ }),
218
+ );
219
+
220
+ const got = ledger.recentEntries(1)[0];
221
+ expect(got?.features).toEqual(FEATURES);
222
+ expect(got?.score).toBe(0.42);
223
+ expect(got?.confidence).toBe(0.75);
224
+ expect(got?.task).toBe("coding");
225
+ expect(got?.classifierReasons).toEqual(["-0.28 tool-result continuation"]);
226
+ } finally {
227
+ db.close();
228
+ }
229
+ });
230
+
231
+ test("an uninstrumented row reads back as null, not as invented data", () => {
232
+ const db = openDb(":memory:");
233
+ try {
234
+ const ledger = createLedger(db, cfg);
235
+ ledger.record(entry({}));
236
+ const got = ledger.recentEntries(1)[0];
237
+ expect(got?.features).toBeNull();
238
+ expect(got?.score).toBeNull();
239
+ expect(got?.confidence).toBeNull();
240
+ expect(got?.task).toBeNull();
241
+ expect(got?.classifierReasons).toBeNull();
242
+ } finally {
243
+ db.close();
244
+ }
245
+ });
246
+
247
+ test("records which tier exploration dropped from, and NULL otherwise", () => {
248
+ const db = openDb(":memory:");
249
+ try {
250
+ const ledger = createLedger(db, cfg);
251
+ ledger.record(entry({ tier: "simple", exploredFrom: "moderate" }));
252
+ ledger.record(entry({ tier: "moderate" }));
253
+
254
+ const got = ledger.recentEntries(10);
255
+ expect(got.map((e) => e.exploredFrom).sort()).toEqual(["moderate", null] as unknown as string[]);
256
+
257
+ // The counterfactual query this whole column exists to make possible:
258
+ // of the turns we deliberately under-routed, how many had to escalate?
259
+ const counted = db
260
+ .query("SELECT COUNT(*) n FROM ledger WHERE explored_from IS NOT NULL")
261
+ .get() as { n: number };
262
+ expect(counted.n).toBe(1);
263
+ } finally {
264
+ db.close();
265
+ }
266
+ });
267
+ test("features land in the column as queryable JSON", () => {
268
+ const db = openDb(":memory:");
269
+ try {
270
+ const ledger = createLedger(db, cfg);
271
+ ledger.record(entry({ features: FEATURES, score: 0.9, confidence: 0.1, task: "vision" }));
272
+ // SQLite json_extract proves the blob is real JSON, not a stringified object.
273
+ const row = db
274
+ .query("SELECT json_extract(features, '$.toolLoopDepth') AS depth, score, task FROM ledger")
275
+ .get() as { depth: number; score: number; task: string };
276
+ expect(row.depth).toBe(3);
277
+ expect(row.score).toBe(0.9);
278
+ expect(row.task).toBe("vision");
279
+ } finally {
280
+ db.close();
281
+ }
282
+ });
283
+ });
package/test/turn.test.ts CHANGED
@@ -29,6 +29,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
29
29
  return {
30
30
  server: { host: "127.0.0.1", port: 8787 },
31
31
  openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
32
+ benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
32
33
  tiers: {
33
34
  trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
34
35
  simple: { minQuality: 40, maxInputPerMtok: 1.5, qualityExponent: 0, pin: [] },
@@ -65,11 +66,13 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
65
66
  ...escalation,
66
67
  },
67
68
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
69
+ exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
68
70
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024 },
69
71
  budget: { onExceeded: "downgrade" },
70
72
  profiles: [],
71
73
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
72
74
  adaptiveTierFloors: true,
75
+ adaptivePriceCeilings: false,
73
76
  logLevel: "silent",
74
77
  };
75
78
  }
@@ -141,6 +144,7 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
141
144
  considered: [],
142
145
  rejected: [],
143
146
  reasons: ["test decision"],
147
+ explored: null,
144
148
  budgetDowngraded: false,
145
149
  };
146
150
  }
@@ -497,3 +501,44 @@ describe("runTurn", () => {
497
501
  expect(errors).toHaveLength(0);
498
502
  });
499
503
  });
504
+
505
+ describe("exploration reaches the ledger", () => {
506
+ test("an explored turn records the tier it was dropped from", async () => {
507
+ const explored = { ...mkDecision("simple", "cheap/model"), explored: { from: "moderate" as Tier, to: "simple" as Tier } };
508
+ const { router } = mkRouter([explored]);
509
+ const { upstream } = mkUpstream([
510
+ {
511
+ kind: "chunks",
512
+ chunks: [startChunk("cheap/model"), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: 10, completionTokens: 2 }, 0.0001)],
513
+ },
514
+ ]);
515
+ const { ledger, entries } = mkLedger();
516
+ const { store } = mkConversations();
517
+ const { sink } = mkSink();
518
+
519
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
520
+
521
+ expect(entries).toHaveLength(1);
522
+ // The counterfactual pair: what the classifier wanted, and what actually ran.
523
+ expect(entries[0]?.exploredFrom).toBe("moderate");
524
+ expect(entries[0]?.tier).toBe("simple");
525
+ });
526
+
527
+ test("a normally routed turn leaves it null", async () => {
528
+ const { router } = mkRouter([mkDecision("simple", "cheap/model")]);
529
+ const { upstream } = mkUpstream([
530
+ {
531
+ kind: "chunks",
532
+ chunks: [startChunk("cheap/model"), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: 10, completionTokens: 2 }, 0.0001)],
533
+ },
534
+ ]);
535
+ const { ledger, entries } = mkLedger();
536
+ const { store } = mkConversations();
537
+ const { sink } = mkSink();
538
+
539
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
540
+
541
+ expect(entries).toHaveLength(1);
542
+ expect(entries[0]?.exploredFrom).toBeNull();
543
+ });
544
+ });
package/tools/smoke.ts CHANGED
@@ -160,6 +160,8 @@ cfg.ledger.path = join(home, "router.db");
160
160
  cfg.logLevel = "warn";
161
161
  // The adjudicator would call the mock and obscure which tier the heuristic chose.
162
162
  cfg.classifier.ambiguityThreshold = 0;
163
+ // External benchmark feeds hit the real internet; keep the smoke test hermetic.
164
+ cfg.benchmarks.enabled = false;
163
165
 
164
166
  const app = startServer(cfg);
165
167
  const base = `http://127.0.0.1:${app.server.port}`;