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,126 @@
1
+ /**
2
+ * Runs the eval suite against models via a text completer, aggregating each
3
+ * model's grades into a raw mean per axis. IO is injected as `Completer` so the
4
+ * runner is unit-testable with a canned model and never touches the network in
5
+ * tests. A completion that throws is folded in as an empty reply (grade 0) — a
6
+ * model that errors on a turn genuinely failed it.
7
+ */
8
+
9
+ import type { QualityAxis } from "../config/types.ts";
10
+ import type { Judge } from "./judge.ts";
11
+ import { EVAL_TASKS, JUDGED_TASKS, type EvalTask, type JudgedTask } from "./tasks.ts";
12
+
13
+ export interface ChatMessage {
14
+ role: "system" | "user" | "assistant";
15
+ content: string;
16
+ }
17
+
18
+ export type Completer = (slug: string, messages: ChatMessage[]) => Promise<string>;
19
+
20
+ export interface AxisScore {
21
+ /** Sum of grades over this axis's tasks. */
22
+ sum: number;
23
+ /** Task count on this axis. */
24
+ n: number;
25
+ }
26
+
27
+ export interface EvalResult {
28
+ slug: string;
29
+ axes: Record<QualityAxis, AxisScore>;
30
+ /** Tasks whose completion threw (dispatch failure). Excluded from `axes`. */
31
+ errors: number;
32
+ }
33
+
34
+ function messagesFor(task: { system?: string; user: string }): ChatMessage[] {
35
+ const msgs: ChatMessage[] = [];
36
+ if (task.system !== undefined) msgs.push({ role: "system", content: task.system });
37
+ msgs.push({ role: "user", content: task.user });
38
+ return msgs;
39
+ }
40
+
41
+ export interface RunEvalArgs {
42
+ slugs: readonly string[];
43
+ complete: Completer;
44
+ /** Objective, deterministically-graded tasks. Defaults to the built-in suite. */
45
+ tasks?: readonly EvalTask[];
46
+ /** Open-ended tasks scored by `judge`. Ignored unless `judge` is supplied. */
47
+ judged?: readonly JudgedTask[];
48
+ /** LLM judge for the open-ended tasks. Absent ⇒ judged tasks are skipped entirely. */
49
+ judge?: Judge;
50
+ /** How many models to score at once. Default 4; bounded so upstream is not flooded. */
51
+ concurrency?: number;
52
+ /** Called as each model finishes, for progress logging. */
53
+ onProgress?: (result: EvalResult, done: number, total: number) => void;
54
+ }
55
+
56
+ async function scoreModel(slug: string, args: RunEvalArgs): Promise<EvalResult> {
57
+ const tasks = args.tasks ?? EVAL_TASKS;
58
+ const judge = args.judge;
59
+ const judged = judge !== undefined ? (args.judged ?? JUDGED_TASKS) : [];
60
+ const axes: Record<QualityAxis, AxisScore> = {
61
+ coding: { sum: 0, n: 0 },
62
+ intelligence: { sum: 0, n: 0 },
63
+ agentic: { sum: 0, n: 0 },
64
+ };
65
+ let errors = 0;
66
+ type Outcome = { axis: QualityAxis; grade: number; ok: boolean };
67
+ // A THROW (or an unscorable judge reply) means the turn produced no usable
68
+ // observation — NOT a score of 0, which would poison an anchor whose model is
69
+ // merely unavailable. Such turns are tallied as errors and excluded.
70
+ const objective: Outcome[] = await Promise.all(
71
+ tasks.map(async (task) => {
72
+ try {
73
+ const text = await args.complete(slug, messagesFor(task));
74
+ return { axis: task.axis, grade: task.grade(text), ok: true };
75
+ } catch {
76
+ return { axis: task.axis, grade: 0, ok: false };
77
+ }
78
+ }),
79
+ );
80
+ const judgedOutcomes: Outcome[] =
81
+ judge === undefined
82
+ ? []
83
+ : await Promise.all(
84
+ judged.map(async (task) => {
85
+ try {
86
+ const answer = await args.complete(slug, messagesFor(task));
87
+ const score = await judge(task, answer);
88
+ return score === null ? { axis: task.axis, grade: 0, ok: false } : { axis: task.axis, grade: score, ok: true };
89
+ } catch {
90
+ return { axis: task.axis, grade: 0, ok: false };
91
+ }
92
+ }),
93
+ );
94
+ for (const o of [...objective, ...judgedOutcomes]) {
95
+ if (!o.ok) {
96
+ errors += 1;
97
+ continue;
98
+ }
99
+ axes[o.axis].sum += o.grade;
100
+ axes[o.axis].n += 1;
101
+ }
102
+ return { slug, axes, errors };
103
+ }
104
+
105
+ export async function runEval(args: RunEvalArgs): Promise<EvalResult[]> {
106
+ const total = args.slugs.length;
107
+ const concurrency = Math.max(1, args.concurrency ?? 4);
108
+ const results: EvalResult[] = new Array(total);
109
+ let next = 0;
110
+ let done = 0;
111
+ // A fixed pool of workers pulls the next model index until the list is drained,
112
+ // so at most `concurrency` models are in flight at once.
113
+ const worker = async (): Promise<void> => {
114
+ for (;;) {
115
+ const i = next++;
116
+ if (i >= total) return;
117
+ const slug = args.slugs[i]!;
118
+ const r = await scoreModel(slug, args);
119
+ results[i] = r;
120
+ done += 1;
121
+ args.onProgress?.(r, done, total);
122
+ }
123
+ };
124
+ await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker));
125
+ return results;
126
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The curated eval suite: small, representative coding-agent turns with fully
3
+ * deterministic graders. Not ledger replay — the ledger stores no prompts — but
4
+ * an on-distribution stand-in: instruction-following, structured output, tool
5
+ * selection, and checkable answers, the things a tier gate actually cares about.
6
+ *
7
+ * Each task is tagged with the quality AXIS it exercises, so a model's raw score
8
+ * on an axis is the mean grade over that axis's tasks. Add tasks freely; the
9
+ * runner and calibration are agnostic to the count.
10
+ */
11
+
12
+ import type { QualityAxis } from "../config/types.ts";
13
+ import { answerScore, extractJson, jsonField, multiAnswerCoverage, tokenCoverage } from "./grade.ts";
14
+
15
+ export interface EvalTask {
16
+ id: string;
17
+ axis: QualityAxis;
18
+ system?: string;
19
+ user: string;
20
+ /** Deterministic proxy grade in [0, 1]. */
21
+ grade: (output: string) => number;
22
+ }
23
+
24
+ /** An open-ended task with no deterministic grader; scored 0-1 by an LLM judge. */
25
+ export interface JudgedTask {
26
+ id: string;
27
+ axis: QualityAxis;
28
+ system?: string;
29
+ user: string;
30
+ /** Optional strong answer given to the judge as a reference. */
31
+ reference?: string;
32
+ }
33
+
34
+ const JSON_ONLY = "Reply with ONLY the requested content and no prose, code fences, or explanation.";
35
+
36
+ export const EVAL_TASKS: readonly EvalTask[] = [
37
+ // ---- coding: does it produce correct, well-formed code/answers ----
38
+ {
39
+ id: "coding/sum-fn",
40
+ axis: "coding",
41
+ system: JSON_ONLY,
42
+ user: "Write a TypeScript function `sum(a: number, b: number): number` that returns their sum.",
43
+ grade: (o) => tokenCoverage(o, ["function sum", "a + b"]) === 1 ? 1 : tokenCoverage(o, ["sum", "a + b"]),
44
+ },
45
+ {
46
+ id: "coding/sort-output",
47
+ axis: "coding",
48
+ system: JSON_ONLY,
49
+ user: "What is the result of `[3, 1, 2].sort((a, b) => a - b)`? Reply with only the array.",
50
+ grade: (o) => answerScore(o, "[1, 2, 3]") === 1 ? 1 : answerScore(o, "[1,2,3]"),
51
+ },
52
+ {
53
+ id: "coding/map-double",
54
+ axis: "coding",
55
+ system: JSON_ONLY,
56
+ user: "Complete this to double each element: `const doubled = nums.map(n => ___)`. Reply with only the lambda body that replaces ___.",
57
+ grade: (o) => tokenCoverage(o, ["n", "*", "2"]),
58
+ },
59
+ {
60
+ id: "coding/primes-json",
61
+ axis: "coding",
62
+ system: JSON_ONLY,
63
+ user: "Reply with only a JSON array of the first five prime numbers.",
64
+ grade: (o) => {
65
+ const j = extractJson(o);
66
+ return Array.isArray(j) && JSON.stringify(j) === JSON.stringify([2, 3, 5, 7, 11]) ? 1 : 0;
67
+ },
68
+ },
69
+
70
+ // ---- intelligence: reasoning + constraint following ----
71
+ {
72
+ id: "intel/decimal-compare",
73
+ axis: "intelligence",
74
+ system: JSON_ONLY,
75
+ user: "Which is larger, 9.11 or 9.9? Reply with just the number.",
76
+ grade: (o) => answerScore(o, "9.9"),
77
+ },
78
+ {
79
+ id: "intel/syllogism",
80
+ axis: "intelligence",
81
+ system: JSON_ONLY,
82
+ user: "If all Bloops are Razzies and all Razzies are Lazzies, are all Bloops Lazzies? Reply yes or no.",
83
+ grade: (o) => answerScore(o, "yes"),
84
+ },
85
+ {
86
+ id: "intel/arith-json",
87
+ axis: "intelligence",
88
+ system: JSON_ONLY,
89
+ user: 'Reply with only a JSON object {"answer": n} where n = 2 + 2 * 3.',
90
+ grade: (o) => (jsonField(extractJson(o), "answer") === 8 ? 1 : 0),
91
+ },
92
+ {
93
+ id: "intel/bat-ball",
94
+ axis: "intelligence",
95
+ system: JSON_ONLY,
96
+ user: "A bat and a ball cost $1.10 together. The bat costs $1.00 more than the ball. How many cents does the ball cost? Reply with just the number.",
97
+ grade: (o) => answerScore(o, "5"),
98
+ },
99
+
100
+ // ---- agentic: tool selection + structured output (as text, since complete() returns text) ----
101
+ {
102
+ id: "agentic/single-tool",
103
+ axis: "agentic",
104
+ system: JSON_ONLY,
105
+ user: 'Tools: read_file(path). To read src/main.ts, emit only the JSON call: {"tool": <name>, "args": {"path": <path>}}.',
106
+ grade: (o) => {
107
+ const j = extractJson(o);
108
+ return jsonField(j, "tool") === "read_file" && jsonField(jsonField(j, "args"), "path") === "src/main.ts" ? 1 : 0;
109
+ },
110
+ },
111
+ {
112
+ id: "agentic/tool-choice",
113
+ axis: "agentic",
114
+ system: JSON_ONLY,
115
+ user: 'Tools: read_file(path), write_file(path, content). To save the text "hello" into out.txt, emit only the JSON call {"tool": <name>, "args": {...}}.',
116
+ grade: (o) => {
117
+ const j = extractJson(o);
118
+ if (jsonField(j, "tool") !== "write_file") return 0;
119
+ const args = jsonField(j, "args");
120
+ const pathOk = jsonField(args, "path") === "out.txt";
121
+ const content = jsonField(args, "content");
122
+ const contentOk = typeof content === "string" && content.includes("hello");
123
+ return pathOk && contentOk ? 1 : 0;
124
+ },
125
+ },
126
+ {
127
+ id: "agentic/tool-sequence",
128
+ axis: "agentic",
129
+ system: JSON_ONLY,
130
+ user: 'Tools: list_dir(path), read_file(path). Emit only a JSON array of the two calls, in order: first list the "src" directory, then read "src/a.ts". Each element is {"tool": <name>, "args": {"path": <path>}}.',
131
+ grade: (o) => {
132
+ const j = extractJson(o);
133
+ if (!Array.isArray(j) || j.length !== 2) return 0;
134
+ const first = jsonField(j[0], "tool") === "list_dir" && jsonField(jsonField(j[0], "args"), "path") === "src";
135
+ const second = jsonField(j[1], "tool") === "read_file" && jsonField(jsonField(j[1], "args"), "path") === "src/a.ts";
136
+ return first && second ? 1 : 0;
137
+ },
138
+ },
139
+ {
140
+ id: "agentic/exact-token",
141
+ axis: "agentic",
142
+ system: JSON_ONLY,
143
+ user: "Reply with EXACTLY the word ACK and nothing else.",
144
+ grade: (o) => answerScore(o, "ack"),
145
+ },
146
+
147
+ // ---- harder / partial-credit: spread models by HOW MANY parts they get right ----
148
+ {
149
+ id: "coding/trace-multi",
150
+ axis: "coding",
151
+ system: JSON_ONLY,
152
+ user: "Give the result of each expression, one per line, in order:\n(1) [1,2,3,4].reduce((a,b)=>a+b,0)\n(2) 'hello'.length\n(3) [5,3,8].sort()[0]\n(4) Object.keys({a:1,b:2}).length\n(5) parseInt('0x1F',16)",
153
+ grade: (o) => multiAnswerCoverage(o, ["10", "5", "3", "2", "31"]),
154
+ },
155
+ {
156
+ id: "coding/float-eq",
157
+ axis: "coding",
158
+ system: JSON_ONLY,
159
+ user: "In JavaScript, what does `0.1 + 0.2 === 0.3` evaluate to? Reply true or false.",
160
+ grade: (o) => answerScore(o, "false"),
161
+ },
162
+ {
163
+ id: "coding/regex-exec",
164
+ axis: "coding",
165
+ system: JSON_ONLY,
166
+ user: "What does `/\\d+/.exec('ab12cd34')[0]` return? Reply with only the value.",
167
+ grade: (o) => answerScore(o, "12"),
168
+ },
169
+ {
170
+ id: "intel/multi-arith",
171
+ axis: "intelligence",
172
+ system: JSON_ONLY,
173
+ user: "Answer each, one per line, in order:\n(1) 17 * 23\n(2) 100 - 37\n(3) 2^10\n(4) the 13th prime number\n(5) GCD(48, 36)",
174
+ grade: (o) => multiAnswerCoverage(o, ["391", "63", "1024", "41", "12"]),
175
+ },
176
+ {
177
+ id: "intel/units",
178
+ axis: "intelligence",
179
+ system: JSON_ONLY,
180
+ user: "How many minutes are in 3.5 hours? Reply with just the number.",
181
+ grade: (o) => answerScore(o, "210"),
182
+ },
183
+ {
184
+ id: "intel/order",
185
+ axis: "intelligence",
186
+ system: JSON_ONLY,
187
+ user: "Tom is older than Jane. Jane is older than Sue. Who is the youngest? Reply with just the name.",
188
+ grade: (o) => answerScore(o, "sue"),
189
+ },
190
+ {
191
+ id: "agentic/arg-synth",
192
+ axis: "agentic",
193
+ system: JSON_ONLY,
194
+ user: 'Tool: search(query, limit). Emit only the JSON call to search for "router config" limited to 5 results: {"tool": <name>, "args": {"query": <q>, "limit": <n>}}.',
195
+ grade: (o) => {
196
+ const j = extractJson(o);
197
+ const args = jsonField(j, "args");
198
+ const q = jsonField(args, "query");
199
+ return jsonField(j, "tool") === "search" && typeof q === "string" && q.toLowerCase().includes("router config") && jsonField(args, "limit") === 5 ? 1 : 0;
200
+ },
201
+ },
202
+ {
203
+ id: "agentic/conditional",
204
+ axis: "agentic",
205
+ system: JSON_ONLY,
206
+ user: 'Tools: read_file(path), create_file(path). If a file "x" exists, read it; otherwise create it. Assume it does NOT exist. Emit only the JSON call {"tool": <name>, "args": {"path": <path>}}.',
207
+ grade: (o) => {
208
+ const j = extractJson(o);
209
+ return jsonField(j, "tool") === "create_file" && jsonField(jsonField(j, "args"), "path") === "x" ? 1 : 0;
210
+ },
211
+ },
212
+ {
213
+ id: "agentic/three-step",
214
+ axis: "agentic",
215
+ system: JSON_ONLY,
216
+ user: 'Tools: list_dir(path), read_file(path), write_file(path, content). Emit only a JSON array of three calls, in order: (1) list "src", (2) read "src/config.ts", (3) write "note.txt" with content "done". Each element is {"tool": <name>, "args": {...}}.',
217
+ grade: (o) => {
218
+ const j = extractJson(o);
219
+ if (!Array.isArray(j)) return 0;
220
+ const want = [
221
+ { tool: "list_dir", key: "path", val: "src" },
222
+ { tool: "read_file", key: "path", val: "src/config.ts" },
223
+ { tool: "write_file", key: "path", val: "note.txt" },
224
+ ];
225
+ let hit = 0;
226
+ for (let i = 0; i < want.length; i++) {
227
+ const step = j[i];
228
+ const w = want[i]!;
229
+ if (jsonField(step, "tool") === w.tool && jsonField(jsonField(step, "args"), w.key) === w.val) hit += 1;
230
+ }
231
+ return hit / want.length;
232
+ },
233
+ },
234
+ ];
235
+
236
+ /**
237
+ * Open-ended tasks graded by an LLM judge. Deliberately harder and answer-free,
238
+ * so quality (not just pass/fail) varies and the judge can separate models the
239
+ * objective proxies pin at the ceiling.
240
+ */
241
+ export const JUDGED_TASKS: readonly JudgedTask[] = [
242
+ {
243
+ id: "coding/debounce",
244
+ axis: "coding",
245
+ user: "Implement `debounce(fn, ms)` in TypeScript that delays calls, so only the last call within a quiet window runs. Preserve `this` and the latest arguments, and type it generically. Explain any edge case you handle.",
246
+ },
247
+ {
248
+ id: "coding/bugfix-explain",
249
+ axis: "coding",
250
+ user: "This function is wrong:\n\nfunction median(xs) {\n xs.sort();\n const m = xs.length / 2;\n return xs[m];\n}\n\nRewrite it correctly for an array of numbers and explain every bug you fixed.",
251
+ },
252
+ {
253
+ id: "intel/concurrency-tradeoff",
254
+ axis: "intelligence",
255
+ user: "Explain the tradeoff between optimistic and pessimistic concurrency control. Give a concrete workload where each is the right choice, and say why.",
256
+ },
257
+ {
258
+ id: "intel/latency-diagnosis",
259
+ axis: "intelligence",
260
+ user: "A web service's p99 latency spiked 10x while p50 stayed flat. List the three most likely causes and, for each, one concrete measurement that would confirm or rule it out.",
261
+ },
262
+ {
263
+ id: "agentic/debug-plan",
264
+ axis: "agentic",
265
+ user: "You must fix a failing test in a repo you have never seen, with tools read_file, search, edit_file, run. Describe the exact sequence of tool actions you would take BEFORE editing anything, and why each step precedes the next.",
266
+ },
267
+ {
268
+ id: "agentic/locate-error",
269
+ axis: "agentic",
270
+ user: "Given only list_dir, read_file, and run, plan the concrete steps to locate the source of a runtime error 'TypeError: undefined is not a function' in an unfamiliar JS project. Be specific about what you run and what you look for at each step.",
271
+ },
272
+ ];
package/src/index.ts CHANGED
@@ -33,7 +33,6 @@ Global options:
33
33
 
34
34
  serve --port <n> --host <addr> --log <level>
35
35
  stats --days <n> --json
36
- stats --days <n> --json
37
36
  models --tier <trivial|simple|moderate|hard> --limit <n> --json
38
37
  explain --file <request.json> --json (reads stdin when --file is absent)
39
38
  config --print --write --path <models.yml> --config <router-config.yml>
@@ -9,7 +9,7 @@ import type { QualityAxis, RouterConfig } from "../config/types.ts";
9
9
  import { forecast, priceAt } from "../cost/forecast.ts";
10
10
  import type { Ledger } from "../cost/types.ts";
11
11
  import type { NormRequest } from "../wire/types.ts";
12
- import { effectiveQualityFloor, tierPlanFor } from "./tier-plan.ts";
12
+ import { effectivePriceCeiling, effectiveQualityFloor, tierPlanFor } from "./tier-plan.ts";
13
13
  import type { Candidate, Features, Rejection, TaskType, Tier } from "./types.ts";
14
14
 
15
15
  export interface BuildCandidatesArgs {
@@ -92,10 +92,17 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
92
92
  // - the TASK floor is a capability requirement (vision needs a model that
93
93
  // can actually see), so adaptive relaxation must never lower it.
94
94
  const taskFloor = taskCfg.minQuality ?? 0;
95
- const adaptiveTierFloor = cfg.adaptiveTierFloors
96
- ? effectiveQualityFloor(tierCfg.minQuality, tier, effectiveAxis, tierPlanFor(snapshot, cfg))
97
- : tierCfg.minQuality;
95
+ const plan = cfg.adaptiveTierFloors || cfg.adaptivePriceCeilings ? tierPlanFor(snapshot, cfg) : null;
96
+ const adaptiveTierFloor =
97
+ cfg.adaptiveTierFloors && plan !== null
98
+ ? effectiveQualityFloor(tierCfg.minQuality, tier, effectiveAxis, plan)
99
+ : tierCfg.minQuality;
98
100
  const qualityFloor = Math.max(taskFloor, adaptiveTierFloor);
101
+ // Input-price ceiling: catalog-derived band when adaptive, else the fixed config.
102
+ const priceCeiling =
103
+ cfg.adaptivePriceCeilings && plan !== null
104
+ ? effectivePriceCeiling(tierCfg.maxInputPerMtok, tier, plan, true)
105
+ : tierCfg.maxInputPerMtok;
99
106
  const taskPins = taskCfg.prefer ?? [];
100
107
  let images = 0;
101
108
  if (req.hasImages) for (const m of req.messages) images += m.images;
@@ -171,11 +178,11 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
171
178
  // push a model over the ceiling exactly when conversations get long.
172
179
  // Catalog prices are per-token; ceilings are per million tokens.
173
180
  const price = priceAt(model, Math.max(1, features.promptTokens));
174
- if (!relaxPrice && tierCfg.maxInputPerMtok !== undefined && price.prompt * 1e6 > tierCfg.maxInputPerMtok) {
181
+ if (!relaxPrice && priceCeiling !== undefined && price.prompt * 1e6 > priceCeiling) {
175
182
  rejected.push({
176
183
  slug,
177
184
  reason: "over_price_ceiling",
178
- detail: `input $${(price.prompt * 1e6).toFixed(2)}/Mtok > ceiling $${tierCfg.maxInputPerMtok}`,
185
+ detail: `input $${(price.prompt * 1e6).toFixed(2)}/Mtok > ceiling $${priceCeiling.toFixed(2)}`,
179
186
  });
180
187
  continue;
181
188
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Exploration primitives.
3
+ *
4
+ * Two experiments share one rule: the draw must be a pure function of the
5
+ * turn's identity, never `Math.random()`. Every stage of this pipeline is
6
+ * deterministic so `auto-model-router explain` can replay a past decision
7
+ * offline, and a random draw would silently break that. Hashing also keeps a
8
+ * draw stable across the failover retries of a single turn.
9
+ */
10
+
11
+ import type { RouterConfig } from "../config/types.ts";
12
+ import { sha256Hex } from "../util/hash.ts";
13
+
14
+ /** Deterministic uniform draw in [0,1) from an arbitrary seed string. */
15
+ export function explorationDraw(seed: string): number {
16
+ // 8 hex chars = 32 bits of the digest, divided by 2^32.
17
+ return Number.parseInt(sha256Hex(seed).slice(0, 8), 16) / 0x1_0000_0000;
18
+ }
19
+
20
+ /** The hold length in force for a conversation, and whether it was drawn. */
21
+ export interface HoldChoice {
22
+ turns: number;
23
+ /**
24
+ * The randomised arm this conversation was assigned, or null when hold
25
+ * exploration is off. Recorded on every turn of the conversation, not only
26
+ * the ones the hold actually affects, so the comparison between arms is a
27
+ * clean intention-to-treat one.
28
+ */
29
+ arm: number | null;
30
+ }
31
+
32
+ /**
33
+ * Resolves how long to hold a tier after this turn.
34
+ *
35
+ * Only the POST-ESCALATION hold is randomised. That is the one that governs
36
+ * expensive spend -- a turn escalates once and the hold then bills the next
37
+ * several turns at the escalated tier -- and leaving the ordinary hold alone
38
+ * keeps the experiment narrow enough to read.
39
+ *
40
+ * The arm is drawn from the conversation key alone, so it is constant for the
41
+ * life of a conversation. A length that changed mid-hold would measure
42
+ * nothing.
43
+ */
44
+ export function resolveHoldTurns(cfg: RouterConfig, conversationKey: string, escalated: boolean): HoldChoice {
45
+ const hx = cfg.exploration.holdTurns;
46
+ const exploring = cfg.exploration.enabled && hx.enabled && hx.values.length > 0;
47
+
48
+ const arm = exploring ? drawArm(hx.values, conversationKey) : null;
49
+ if (!escalated) return { turns: cfg.hysteresis.holdTurns, arm };
50
+ return { turns: arm ?? cfg.hysteresis.holdTurnsAfterEscalation, arm };
51
+ }
52
+
53
+ function drawArm(values: readonly number[], conversationKey: string): number | null {
54
+ const draw = explorationDraw(`hold:${conversationKey}`);
55
+ // Math.min guards the draw === 1 boundary, which the division cannot
56
+ // produce today but would if the bit width ever changed.
57
+ const idx = Math.min(Math.floor(draw * values.length), values.length - 1);
58
+ return values[idx] ?? null;
59
+ }
@@ -10,6 +10,7 @@ import type { CatalogSnapshot } from "../catalog/types.ts";
10
10
  import type { ProfileConfig, RouterConfig } from "../config/types.ts";
11
11
  import { priceAt } from "../cost/forecast.ts";
12
12
  import type { Ledger } from "../cost/types.ts";
13
+ import { explorationDraw } from "./explore.ts";
13
14
  import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
14
15
  import { planCacheBreakpoints } from "./cache-control.ts";
15
16
  import { buildCandidates } from "./candidates.ts";
@@ -19,6 +20,7 @@ import {
19
20
  type Classification,
20
21
  type ConversationState,
21
22
  type Decision,
23
+ type Exploration,
22
24
  type Features,
23
25
  type ProbePlan,
24
26
  type Rejection,
@@ -141,14 +143,61 @@ export function select(args: SelectArgs): Decision {
141
143
  }
142
144
  }
143
145
 
146
+ // Whether a usable warm prompt cache exists right now. Shared by
147
+ // exploration (2c) and candidate building (3) so both agree on the term.
148
+ const cacheWarm = state.cacheWarmSlug !== null && nowMs - state.cacheWarmAtMs <= cfg.hysteresis.cacheWarmTtlMs;
149
+
150
+ // 2c. Epsilon-greedy exploration: on a small deterministic fraction of
151
+ // turns, route one tier BELOW the tier we would otherwise use, so the
152
+ // ledger witnesses whether the cheaper tier would have sufficed.
153
+ //
154
+ // Escalation is what makes this safe rather than reckless: if the
155
+ // cheaper tier flounders, the probe rejects the attempt and the turn
156
+ // escalates, so a bad draw costs one wasted cheap attempt rather than
157
+ // a failed turn.
158
+ //
159
+ // Sticky turns are explored only once their cache has gone cold. The
160
+ // hold exists to protect a warm cache, so exploring while one is live
161
+ // would destroy precisely what the hold is for; once it has expired
162
+ // the objection lapses. This matters more than it sounds: most
163
+ // expensive turns reach their tier by hold rather than by
164
+ // classification, so excluding held turns confines exploration to the
165
+ // cheapest boundary in the system.
166
+ //
167
+ // Still skipped on forced escalations (the probe already proved the
168
+ // cheaper tier failed) and on failover retries, where a second
169
+ // confound is not wanted.
170
+ //
171
+ // This deliberately bypasses maxDowngradePerTurn by one tier: that
172
+ // limiter guards against a noisy classification, not against a probe
173
+ // that is sampled on purpose and escalates when it is wrong.
174
+ let explored: Exploration | null = null;
175
+ const ex = cfg.exploration;
176
+ const stickyAllows =
177
+ cls.source !== "sticky" ||
178
+ ex.stickyPolicy === "always" ||
179
+ (ex.stickyPolicy === "cold-cache" && !cacheWarm);
180
+ const tierRate = ex.rates[effective] ?? 0;
181
+ if (
182
+ ex.enabled &&
183
+ tierRate > 0 &&
184
+ stickyAllows &&
185
+ classification.source !== "escalation" &&
186
+ (args.excludeSlugs === undefined || args.excludeSlugs.length === 0)
187
+ ) {
188
+ const target = tierAt(Math.max(tierIdx(effective) - 1, minI));
189
+ if (target !== null && target !== effective && explorationDraw(`explore:${req.conversationKey}:${state.turn}`) < tierRate) {
190
+ const held = cls.source === "sticky" ? `, held tier (${cacheWarm ? "warm" : "cold"} cache)` : "";
191
+ reasons.push(`exploration: deliberately routing ${effective} → ${target} (rate ${tierRate}${held})`);
192
+ explored = { from: effective, to: target };
193
+ effective = target;
194
+ }
195
+ }
144
196
  // 3. Candidates for the effective tier; widen one tier upward, then
145
197
  // downward, and only fail when the whole profile envelope is exhausted.
146
198
  // The task type selects the quality axis and capability filters; the tier
147
199
  // still bounds cost (task selects, tier budgets).
148
- const warmSlug =
149
- state.cacheWarmSlug !== null && nowMs - state.cacheWarmAtMs <= cfg.hysteresis.cacheWarmTtlMs
150
- ? state.cacheWarmSlug
151
- : null;
200
+ const warmSlug = cacheWarm ? state.cacheWarmSlug : null;
152
201
  const build = (t: Tier, relaxLevel = 0): { candidates: Candidate[]; rejected: Rejection[] } =>
153
202
  buildCandidates({
154
203
  req,
@@ -360,6 +409,7 @@ export function select(args: SelectArgs): Decision {
360
409
  considered: candidates,
361
410
  rejected: resolved.rejected,
362
411
  reasons,
412
+ explored,
363
413
  budgetDowngraded,
364
414
  };
365
415
  }