auto-model-router 0.1.3 → 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 (54) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +127 -46
  3. package/bun.lock +606 -0
  4. package/omp-extension/router-embed.ts +14 -6
  5. package/omp-extension/router-toast.ts +6 -1
  6. package/omp-extension/toast-logic.ts +7 -0
  7. package/package.json +2 -1
  8. package/research/analyze-ledger.ts +173 -0
  9. package/research/apply-cost-tuning.ts +73 -0
  10. package/research/cost-analysis.ts +150 -0
  11. package/research/feed-check.ts +64 -0
  12. package/research/model-recommendations.ts +86 -0
  13. package/research/project-yield.ts +96 -0
  14. package/research/run-eval.ts +133 -0
  15. package/research/status.ts +55 -0
  16. package/research/tier-fill.ts +109 -0
  17. package/research/tier-map.ts +123 -0
  18. package/src/catalog/benchmark-feeds.ts +397 -0
  19. package/src/catalog/openrouter-catalog.ts +30 -0
  20. package/src/config/defaults.ts +30 -0
  21. package/src/config/load.ts +2 -0
  22. package/src/config/schema.ts +34 -0
  23. package/src/config/types.ts +106 -0
  24. package/src/cost/ledger.ts +27 -3
  25. package/src/cost/types.ts +30 -0
  26. package/src/eval/calibrate.ts +131 -0
  27. package/src/eval/grade.ts +115 -0
  28. package/src/eval/judge.ts +71 -0
  29. package/src/eval/run.ts +126 -0
  30. package/src/eval/tasks.ts +272 -0
  31. package/src/index.ts +0 -1
  32. package/src/router/candidates.ts +13 -6
  33. package/src/router/explore.ts +59 -0
  34. package/src/router/select.ts +54 -4
  35. package/src/router/tier-plan.ts +57 -1
  36. package/src/router/types.ts +13 -0
  37. package/src/server/turn.ts +10 -2
  38. package/src/util/sqlite.ts +79 -1
  39. package/src/wire/openai/request.ts +5 -0
  40. package/src/wire/types.ts +7 -0
  41. package/test/benchmark-feeds.test.ts +222 -0
  42. package/test/escalate.test.ts +1 -0
  43. package/test/eval.test.ts +184 -0
  44. package/test/exploration.test.ts +251 -0
  45. package/test/failover.test.ts +5 -0
  46. package/test/hold-exploration.test.ts +124 -0
  47. package/test/tier-plan.test.ts +55 -1
  48. package/test/toast-logic.test.ts +32 -0
  49. package/test/tokens.test.ts +8 -0
  50. package/test/trust-attribution.test.ts +110 -2
  51. package/test/turn.test.ts +46 -0
  52. package/test/wire-request.test.ts +11 -0
  53. package/tools/smoke.ts +2 -0
  54. package/tools/sync-marketplace-version.ts +60 -0
@@ -0,0 +1,184 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
+ import { applyFeedScores, loadLocalScores, saveLocalScores, type FeedScore } from "../src/catalog/benchmark-feeds.ts";
5
+ import { answerScore, extractJson, isRefusalOrEmpty, jsonField, tokenCoverage } from "../src/eval/grade.ts";
6
+ import { applyFit, fitAxis, fitCalibration, toLocalFeedScores, MIN_ANCHORS } from "../src/eval/calibrate.ts";
7
+ import { runEval, type EvalResult } from "../src/eval/run.ts";
8
+ import { makeJudge, parseScore } from "../src/eval/judge.ts";
9
+ import type { EvalTask, JudgedTask } from "../src/eval/tasks.ts";
10
+ import { openDb } from "../src/util/sqlite.ts";
11
+
12
+ describe("grade helpers", () => {
13
+ test("answerScore matches whole reply, last line, or a standalone token", () => {
14
+ expect(answerScore("9.9", "9.9")).toBe(1);
15
+ expect(answerScore("The answer is 9.9", "9.9")).toBe(1);
16
+ expect(answerScore("reasoning...\n9.9", "9.9")).toBe(1);
17
+ expect(answerScore("19.99", "9.9")).toBe(0); // not a substring match
18
+ expect(answerScore("", "9.9")).toBe(0);
19
+ });
20
+ test("tokenCoverage is the fraction of tokens present", () => {
21
+ expect(tokenCoverage("return a + b;", ["a + b"])).toBe(1);
22
+ expect(tokenCoverage("n * 2", ["n", "*", "2"])).toBe(1);
23
+ expect(tokenCoverage("n plus two", ["n", "*", "2"])).toBeCloseTo(1 / 3);
24
+ });
25
+ test("extractJson tolerates fences and prose; jsonField reads a key", () => {
26
+ expect(extractJson('here: {"answer": 8} ok')).toEqual({ answer: 8 });
27
+ expect(extractJson("```json\n[2,3,5]\n```")).toEqual([2, 3, 5]);
28
+ expect(extractJson("no json here")).toBeUndefined();
29
+ expect(jsonField({ tool: "read_file" }, "tool")).toBe("read_file");
30
+ expect(jsonField([1, 2], "tool")).toBeUndefined();
31
+ });
32
+ test("isRefusalOrEmpty flags empties and refusals", () => {
33
+ expect(isRefusalOrEmpty("")).toBe(true);
34
+ expect(isRefusalOrEmpty("I cannot help with that")).toBe(true);
35
+ expect(isRefusalOrEmpty("sure, here")).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe("calibration", () => {
40
+ test("fitAxis is OLS, needs MIN_ANCHORS points and some spread", () => {
41
+ const fit = fitAxis([
42
+ { raw: 0.2, aa: 40 },
43
+ { raw: 0.5, aa: 60 },
44
+ { raw: 0.8, aa: 80 },
45
+ ]);
46
+ expect(fit).not.toBeNull();
47
+ expect(fit!.slope).toBeCloseTo(66.67, 1);
48
+ expect(fit!.r).toBeCloseTo(1, 5);
49
+ expect(applyFit(fit!, 0.5)).toBeCloseTo(60, 5);
50
+ expect(applyFit(fit!, 5)).toBe(100); // clamped
51
+ expect(fitAxis([{ raw: 0.2, aa: 40 }, { raw: 0.5, aa: 60 }])).toBeNull(); // < MIN_ANCHORS
52
+ expect(fitAxis([{ raw: 0.5, aa: 40 }, { raw: 0.5, aa: 60 }, { raw: 0.5, aa: 80 }])).toBeNull(); // no spread
53
+ // Negative correlation (suite ranks models opposite to AA) is refused.
54
+ expect(fitAxis([{ raw: 0.8, aa: 40 }, { raw: 0.5, aa: 60 }, { raw: 0.2, aa: 80 }])).toBeNull();
55
+ });
56
+
57
+ test("fitCalibration + toLocalFeedScores place a target on the AA scale", () => {
58
+ expect(MIN_ANCHORS).toBe(3);
59
+ const anchors: EvalResult[] = [
60
+ { slug: "a/one", axes: { coding: { sum: 0.2, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
61
+ { slug: "a/two", axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
62
+ { slug: "a/three", axes: { coding: { sum: 0.8, n: 1 }, intelligence: { sum: 0, n: 0 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
63
+ ];
64
+ const aaOf: Record<string, number> = { "a/one": 40, "a/two": 60, "a/three": 80 };
65
+ const cal = fitCalibration(anchors, (slug, axis) => (axis === "coding" ? aaOf[slug] : undefined));
66
+ expect(cal.coding).toBeDefined();
67
+ expect(cal.intelligence).toBeUndefined(); // no anchor data on that axis
68
+
69
+ const targets: EvalResult[] = [
70
+ { slug: "z/gap", axes: { coding: { sum: 0.5, n: 1 }, intelligence: { sum: 0.9, n: 1 }, agentic: { sum: 0, n: 0 } }, errors: 0 },
71
+ ];
72
+ const local = toLocalFeedScores(targets, cal, (s) => s.slice(0, s.indexOf("/")));
73
+ expect(local).toHaveLength(1);
74
+ expect(local[0]).toMatchObject({ key: "gap", creator: "z", source: "local" });
75
+ expect(local[0]!.coding).toBeCloseTo(60, 5); // calibrated from raw 0.5
76
+ expect(local[0]!.intelligence).toBeUndefined(); // axis had no fit, so not emitted
77
+ });
78
+ });
79
+
80
+ describe("runEval", () => {
81
+ test("aggregates grades into per-axis means", async () => {
82
+ const tasks: EvalTask[] = [
83
+ { id: "c1", axis: "coding", user: "x", grade: (o) => (o === "good" ? 1 : 0) },
84
+ { id: "c2", axis: "coding", user: "y", grade: () => 0.5 },
85
+ { id: "a1", axis: "agentic", user: "z", grade: (o) => (o === "good" ? 1 : 0) },
86
+ ];
87
+ const results = await runEval({ slugs: ["good", "bad"], tasks, complete: async (slug) => slug });
88
+ const good = results.find((r) => r.slug === "good")!;
89
+ expect(good.axes.coding.sum).toBe(1.5); // 1 + 0.5
90
+ expect(good.axes.coding.n).toBe(2);
91
+ expect(good.axes.agentic.sum).toBe(1);
92
+ const bad = results.find((r) => r.slug === "bad")!;
93
+ expect(bad.axes.coding.sum).toBe(0.5); // 0 + 0.5
94
+ expect(bad.axes.agentic.sum).toBe(0);
95
+ });
96
+
97
+ test("a throwing completion is excluded, not scored 0", async () => {
98
+ const tasks: EvalTask[] = [{ id: "a", axis: "coding", user: "x", grade: () => 1 }];
99
+ const results = await runEval({
100
+ slugs: ["m"],
101
+ tasks,
102
+ complete: async () => {
103
+ throw new Error("boom");
104
+ },
105
+ });
106
+ expect(results[0]!.axes.coding.n).toBe(0); // no observation
107
+ expect(results[0]!.errors).toBe(1);
108
+ });
109
+ });
110
+
111
+ describe("local source integration", () => {
112
+ function raw(id: string): Record<string, unknown> {
113
+ return {
114
+ id,
115
+ canonical_slug: id,
116
+ name: id,
117
+ context_length: 131072,
118
+ pricing: { prompt: "0.0000003", completion: "0.0000011" },
119
+ supported_parameters: ["tools"],
120
+ architecture: { input_modalities: ["text"], tokenizer: "Other" },
121
+ created: 1_700_000_000,
122
+ };
123
+ }
124
+
125
+ test("local fills only where no stronger source has the axis", () => {
126
+ const catalog = [raw("z-ai/glm-5.3-flash")];
127
+ const feeds: FeedScore[] = [
128
+ { key: "glm-5-3-flash", creator: "z-ai", source: "artificial_analysis", coding: 61 },
129
+ { key: "glm-5-3-flash", creator: "z-ai", source: "local", coding: 20, intelligence: 55 },
130
+ ];
131
+ const result = applyFeedScores(catalog, feeds);
132
+ const q = normalizeCatalogModel(catalog[0])?.quality;
133
+ expect(q?.coding).toBe(61); // AA wins over local
134
+ expect(q?.intelligence).toBe(55); // local fills the axis nobody else had
135
+ expect(result.sources.local).toBe(1);
136
+ expect(result.sources.artificial_analysis).toBe(1);
137
+ });
138
+
139
+ test("saveLocalScores / loadLocalScores round-trip", () => {
140
+ const db = openDb(":memory:");
141
+ const scores: FeedScore[] = [{ key: "muse-glimmer-30b", creator: "meta", source: "local", coding: 42, agentic: 39 }];
142
+ saveLocalScores(db, scores, 123);
143
+ expect(loadLocalScores(db)).toEqual(scores);
144
+ db.close();
145
+ });
146
+ });
147
+
148
+ describe("llm judge", () => {
149
+ test("parseScore takes the last standalone 0-10 and scales to 0-1", () => {
150
+ expect(parseScore("8")).toBeCloseTo(0.8, 5);
151
+ expect(parseScore("Score: 10/10")).toBeCloseTo(1, 5);
152
+ expect(parseScore("I count 3 issues, so 7")).toBeCloseTo(0.7, 5); // last wins
153
+ expect(parseScore("no number here")).toBeNull();
154
+ });
155
+
156
+ test("makeJudge parses a score, and returns null on a thrown completion", async () => {
157
+ const task: JudgedTask = { id: "j", axis: "coding", user: "do a thing" };
158
+ const good = makeJudge(async () => "the answer earns 8", "judge/model");
159
+ expect(await good(task, "some answer")).toBeCloseTo(0.8, 5);
160
+ const bad = makeJudge(async () => {
161
+ throw new Error("judge down");
162
+ }, "judge/model");
163
+ expect(await bad(task, "some answer")).toBeNull();
164
+ });
165
+
166
+ test("runEval folds judged scores into the axis mean, and drops unscorable ones", async () => {
167
+ const judged: JudgedTask[] = [
168
+ { id: "j1", axis: "coding", user: "a" },
169
+ { id: "j2", axis: "coding", user: "b" },
170
+ ];
171
+ // j1 scores 0.6; j2 is unscorable (null) → excluded as an error.
172
+ const judge = async (t: JudgedTask) => (t.id === "j1" ? 0.6 : null);
173
+ const results = await runEval({ slugs: ["m"], tasks: [], judged, judge, complete: async () => "ans" });
174
+ expect(results[0]!.axes.coding).toEqual({ sum: 0.6, n: 1 });
175
+ expect(results[0]!.errors).toBe(1);
176
+ });
177
+
178
+ test("judged tasks are skipped entirely when no judge is supplied", async () => {
179
+ const judged: JudgedTask[] = [{ id: "j1", axis: "coding", user: "a" }];
180
+ const results = await runEval({ slugs: ["m"], tasks: [], judged, complete: async () => "ans" });
181
+ expect(results[0]!.axes.coding).toEqual({ sum: 0, n: 0 });
182
+ expect(results[0]!.errors).toBe(0);
183
+ });
184
+ });
@@ -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
  }
@@ -79,6 +82,7 @@ function mkReq(): NormRequest {
79
82
  protocol: "openai-chat",
80
83
  conversationKey: "conv-test",
81
84
  harnessId: "",
85
+ ompSessionId: "",
82
86
  requestedModel: "auto",
83
87
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
84
88
  tools: [],
@@ -140,6 +144,7 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
140
144
  considered: [],
141
145
  rejected: [],
142
146
  reasons: ["test decision"],
147
+ explored: null,
143
148
  budgetDowngraded: false,
144
149
  };
145
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
+ });