auto-model-router 0.7.0 → 0.7.2

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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.7.0",
10
+ "version": "0.7.2",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.7.0",
17
+ "version": "0.7.2",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -907,6 +907,7 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
907
907
  | `minTrustSamples` | `12` | Attempts before trust is enforced. |
908
908
  | `trustScopedByHarness` | `false` | `true` = each harness reads only its own trust rows. |
909
909
  | `contextHeadroom` | `1.25` | Fraction of context kept free (a model must fit prompt × this). |
910
+ | `reasoningCompletionFloor` | `512` | Smallest completion budget a **reasoning** model is dispatched with. A reasoning model spends the budget thinking before it answers, so a caller's tight cap (omp asks ~12 tokens for a conversation title) returns nothing: measured on `ollama/gpt-oss:20b`, which hit the cap having produced no content and cost a dead dispatch plus a failover. Raised only for models that reason, never above the model's own ceiling, and a cap is an upper bound so a direct answer still stops early. 0 disables. |
910
911
  | `latencyWeight` | `0` | How hard to penalise slow models in scoring (soft multiplier on effective cost). `0` disables it. |
911
912
  | `latencyMinSamples` | `20` | Streamed samples before latency is judged against a model. |
912
913
  | `cacheReliabilityMinSamples` | `10` | Warm-expected samples before a model's observed cache hit rate discounts its "stay warm" price in the stay/switch comparison. A model whose cache misses when it should be warm (measured: 5-6% on glm/gemini, 11% on ling, 50% on nex) is kept less eagerly. `0` assumes every cache is reliable. |
@@ -1296,10 +1297,21 @@ adds the Codex provider and the Aider settings, and prints (or with `--profile`
1296
1297
  the environment lines for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
1297
1298
  shows the changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
1298
1299
 
1300
+ `connect` also writes omp's `models.yml` (a managed block, other providers untouched, the
1301
+ previous file backed up). That entry is what makes `auto-model-router/auto` resolvable at
1302
+ **startup**: omp builds the main model's handle before extensions load, so without it only
1303
+ the late-resolved roles (`smol`, `tiny`) reach the router and the main turns fall back to
1304
+ whatever else is authenticated. A local router deliberately gets no such entry — its port
1305
+ is ephemeral, so a persisted one names a dead socket next launch — but a remote's URL and
1306
+ key are stable. **The file then holds the member key: treat it as a secret.**
1307
+
1299
1308
  In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
1300
1309
  remote router serves every repo on the machine with that repo's shared context. The remote
1301
1310
  decides what to do with it: a team edition that pins a scope on the member's group
1302
- overrides it, and one that pins none follows the workspace.
1311
+ overrides it, and one that pins none follows the workspace. That header rides on the roles
1312
+ the extensions register; the **main** model's handle comes from `models.yml`, which is
1313
+ machine-wide, so it carries a scope only if you pass `--scope <slug>` to `connect` — right
1314
+ for a single-project machine, wrong for one with several repos.
1303
1315
 
1304
1316
  ## Multiple coding harnesses, one router
1305
1317
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -189,6 +189,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
189
189
  { path: "filters.trustScopedByHarness", label: "Scope trust per harness", kind: "boolean" },
190
190
  { path: "filters.trustWindowDays", label: "Trust window", kind: "number", min: 0, hint: "days, 0=all time" },
191
191
  { path: "filters.contextHeadroom", label: "Context headroom", kind: "number", min: 1 },
192
+ { path: "filters.reasoningCompletionFloor", label: "Reasoning completion floor", kind: "number", min: 0 },
192
193
  { path: "filters.latencyWeight", label: "Latency weight", kind: "number", min: 0, hint: "0=ignore speed" },
193
194
  { path: "filters.latencyReferenceMs", label: "Latency reference TTFT", kind: "number", min: 1, hint: "ms" },
194
195
  { path: "filters.latencyReferenceTokensPerSec", label: "Latency reference speed", kind: "number", min: 1, hint: "tok/s" },
@@ -4,7 +4,12 @@
4
4
  * `<router home>/remote.json` (the omp extensions then run in remote mode and
5
5
  * never bind a local router), and configures every harness it finds:
6
6
  *
7
- * omp the four extensions are added to ~/.omp/agent/config.yml
7
+ * omp the four extensions are added to ~/.omp/agent/config.yml, and
8
+ * the remote is written into ~/.omp/agent/models.yml so
9
+ * `auto-model-router/auto` resolves at STARTUP — omp builds the
10
+ * main model's handle before extensions load, so without that
11
+ * entry only the late-resolved roles (smol, tiny) reach the
12
+ * router and the main turns fall back to another provider
8
13
  * Hermes the provider plugin and the native plugin are copied into
9
14
  * $HERMES_HOME/plugins and .env points them at the remote
10
15
  * Codex ~/.codex/config.toml gains the auto-model-router provider
@@ -37,6 +42,10 @@ export interface ConnectOptions {
37
42
  home: string;
38
43
  /** Where this package lives (the extensions are referenced from here). */
39
44
  packageDir: string;
45
+ /** Cost figures omp shows for the remote's virtual models, USD per million tokens. */
46
+ blend?: { inputPerMtok: number; outputPerMtok: number };
47
+ /** Adds `X-Agentdox-Scope` to omp's models.yml entry. Machine-wide: only for a single-project machine. */
48
+ agentdoxScope?: string;
40
49
  platform: string;
41
50
  pathHas: (bin: string) => boolean;
42
51
  }
@@ -103,6 +112,90 @@ http_headers = { "X-Omp-Harness" = "codex" }
103
112
  `;
104
113
  }
105
114
 
115
+ /** The models a remote router advertises, and what omp should believe they cost. */
116
+ const REMOTE_MODEL_ROWS: readonly { id: string; name: string }[] = [
117
+ { id: "auto", name: "Auto (auto-model-router)" },
118
+ { id: "auto-cheap", name: "Auto Cheap (auto-model-router)" },
119
+ { id: "auto-max", name: "Auto Max (auto-model-router)" },
120
+ ];
121
+
122
+ const MODELS_YML_BEGIN = " # BEGIN auto-model-router (remote)";
123
+ const MODELS_YML_END = " # END auto-model-router (remote)";
124
+
125
+ /**
126
+ * omp's `models.yml` entry for a remote router.
127
+ *
128
+ * A LOCAL router deliberately never writes this file: its port is ephemeral, so
129
+ * a persisted entry names a dead socket on the next launch. A remote router has
130
+ * neither problem — the URL and the key are stable — and the entry is what makes
131
+ * omp's main model resolvable at startup, before extensions load.
132
+ *
133
+ * `scope` adds `X-Agentdox-Scope` to every request through this provider. It is
134
+ * off by default on purpose: the file is machine-wide, so a scope here would
135
+ * label turns from every workspace with one project. The extensions still send
136
+ * the workspace's own scope on the roles that resolve after they load.
137
+ */
138
+ export function renderRemoteModelsYml(url: string, key: string, blend: { inputPerMtok: number; outputPerMtok: number }, scope = ""): string {
139
+ const round = (v: number): number => Math.round(v * 1e4) / 1e4;
140
+ const cost = {
141
+ input: round(blend.inputPerMtok),
142
+ output: round(blend.outputPerMtok),
143
+ cacheRead: round(blend.inputPerMtok * 0.1),
144
+ cacheWrite: round(blend.inputPerMtok * 1.25),
145
+ };
146
+ const lines = [
147
+ MODELS_YML_BEGIN,
148
+ " # Managed by `auto-model-router connect`. Remove this block to stop routing omp through the remote.",
149
+ " auto-model-router:",
150
+ ` baseUrl: ${url.replace(/\/+$/, "")}/v1`,
151
+ " api: openai-completions",
152
+ ` apiKey: ${key}`,
153
+ ];
154
+ if (scope !== "") lines.push(" headers:", ` X-Agentdox-Scope: ${scope}`);
155
+ lines.push(" models:");
156
+ for (const m of REMOTE_MODEL_ROWS) {
157
+ lines.push(
158
+ ` - id: ${m.id}`,
159
+ ` name: ${m.name}`,
160
+ " contextWindow: 200000",
161
+ " maxTokens: 32000",
162
+ " input: [text, image]",
163
+ ` cost: { input: ${cost.input}, output: ${cost.output}, cacheRead: ${cost.cacheRead}, cacheWrite: ${cost.cacheWrite} }`,
164
+ );
165
+ }
166
+ lines.push(MODELS_YML_END);
167
+ return lines.join("\n");
168
+ }
169
+
170
+ /**
171
+ * Merges the remote block into an existing `models.yml`, replacing a previous
172
+ * one and leaving every other provider alone. Returns the new file text.
173
+ */
174
+ export function mergeModelsYml(before: string, blockText: string): string {
175
+ const eol = before.includes("\r\n") ? "\r\n" : "\n";
176
+ const body = before.replace(/^\uFEFF/, "");
177
+ const block = blockText.split("\n").join(eol);
178
+ const begin = body.indexOf(MODELS_YML_BEGIN);
179
+ if (begin >= 0) {
180
+ const endIdx = body.indexOf(MODELS_YML_END, begin);
181
+ const end = endIdx < 0 ? body.length : endIdx + MODELS_YML_END.length;
182
+ return `${body.slice(0, begin)}${block}${body.slice(end)}`;
183
+ }
184
+ // A provider entry for the same id from an earlier local install would shadow
185
+ // ours; the caller reports it rather than editing a block it does not own.
186
+ if (body.trim() === "") return `providers:${eol}${block}${eol}`;
187
+ if (/^providers:\s*$/m.test(body)) {
188
+ return body.replace(/^providers:\s*$/m, (m) => `${m}${eol}${block}`);
189
+ }
190
+ return `${body.replace(/\s*$/, "")}${eol}providers:${eol}${block}${eol}`;
191
+ }
192
+
193
+ /** True when the file already defines our provider outside a block we manage. */
194
+ export function hasForeignRouterProvider(text: string): boolean {
195
+ if (text.includes(MODELS_YML_BEGIN)) return false;
196
+ return /^\s{2,}auto-model-router:\s*$/m.test(text);
197
+ }
198
+
106
199
  export function connectRemote(o: ConnectOptions): ConnectReport {
107
200
  const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [] };
108
201
  const write = (path: string, content: string): void => {
@@ -124,7 +217,22 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
124
217
  const before = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
125
218
  const after = addExtensions(before, ext);
126
219
  if (after !== before) write(cfgPath, after);
127
- report.configured.push(`omp (${cfgPath}; pick auto-model-router/auto as the model)`);
220
+ // models.yml: what makes the MAIN model resolvable, since omp builds that
221
+ // handle at startup, before the extensions register anything.
222
+ const modelsPath = join(agentDir, "models.yml");
223
+ const modelsBefore = existsSync(modelsPath) ? readFileSync(modelsPath, "utf8") : "";
224
+ if (hasForeignRouterProvider(modelsBefore)) {
225
+ report.notes.push(`${modelsPath} already defines an auto-model-router provider by hand; left alone — remove it to let connect manage the remote entry`);
226
+ report.configured.push(`omp (${cfgPath}; extensions only)`);
227
+ } else {
228
+ const modelsAfter = mergeModelsYml(modelsBefore, renderRemoteModelsYml(o.url, o.key, o.blend ?? { inputPerMtok: 1.1, outputPerMtok: 4.4 }, o.agentdoxScope ?? ""));
229
+ if (modelsAfter !== modelsBefore) {
230
+ // Never overwrite another provider's work without a way back.
231
+ if (modelsBefore !== "" && !o.dryRun) writeFileSync(`${modelsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, modelsBefore, "utf8");
232
+ write(modelsPath, modelsAfter);
233
+ }
234
+ report.configured.push(`omp (${cfgPath} + ${modelsPath}; auto-model-router/auto is ready to pick)`);
235
+ }
128
236
  } else report.skipped.push("omp (no ~/.omp/agent)");
129
237
 
130
238
  // 3. Hermes
@@ -183,6 +291,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
183
291
  report.notes.push(`environment appended to ${rc}; open a new shell or source it`);
184
292
  }
185
293
  } else report.notes.push("add the environment lines to your shell profile, or re-run with --profile");
294
+ report.notes.push("omp's models.yml now carries the member key; treat that file as a secret");
186
295
  return report;
187
296
  }
188
297
 
@@ -205,7 +314,10 @@ export async function connectCommand(args: CliArgs): Promise<void> {
205
314
  }
206
315
  // HOME wins when set (Git Bash, WSL, CI) so a caller can redirect every write; the OS profile otherwise.
207
316
  const home = process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir();
208
- const report = connectRemote({ url, key, userId, name, profile: args.flags.has("profile"), dryRun: args.flags.has("dry-run"), only, env: process.env, home, packageDir, platform: process.platform, pathHas });
317
+ // A single-project machine can label every request; a machine with several
318
+ // repos should leave it off and let the extensions send the workspace's own.
319
+ const agentdoxScope = flagString(args, "scope") ?? "";
320
+ const report = connectRemote({ url, key, userId, name, profile: args.flags.has("profile"), dryRun: args.flags.has("dry-run"), only, env: process.env, home, packageDir, platform: process.platform, pathHas, agentdoxScope });
209
321
  console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
210
322
  for (const c of report.configured) console.log(` configured ${c}`);
211
323
  for (const s of report.skipped) console.log(` skipped ${s}`);
@@ -114,6 +114,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
114
114
  // opt-in after a replay run prices it.
115
115
  trustWindowDays: 0,
116
116
  contextHeadroom: 1.25,
117
+ // Enough for a short chain of thought plus a brief answer. Measured against
118
+ // the failure it fixes: gpt-oss:20b and nemotron-3-nano returned empty at a
119
+ // 12-token cap, then failed over.
120
+ reasoningCompletionFloor: 512,
117
121
  // Latency scoring is off by default (weight 0): opt in after establishing a
118
122
  // baseline. Expected total wait (TTFT + expected completion / throughput)
119
123
  // above the references inflates a model's effective cost.
@@ -95,6 +95,7 @@ const filters = z.strictObject({
95
95
  trustScopedByHarness: z.boolean().optional(),
96
96
  trustWindowDays: z.number().nonnegative().optional(),
97
97
  contextHeadroom: z.number().positive().optional(),
98
+ reasoningCompletionFloor: z.number().int().nonnegative().optional(),
98
99
  latencyWeight: z.number().nonnegative().optional(),
99
100
  latencyReferenceMs: z.number().positive().optional(),
100
101
  latencyReferenceTokensPerSec: z.number().positive().optional(),
@@ -221,6 +221,21 @@ export interface FilterConfig {
221
221
  includeFree: boolean;
222
222
  /** Require `supported_parameters` to include `tools` whenever the request offers tools. */
223
223
  requireToolSupport: boolean;
224
+ /**
225
+ * Smallest completion budget a REASONING model is dispatched with, tokens.
226
+ *
227
+ * A reasoning model spends the budget thinking before it answers, so a
228
+ * caller's tight cap returns nothing at all: omp asks for ~12 tokens for a
229
+ * conversation title, and `ollama/gpt-oss:20b` hit the cap having produced
230
+ * no content, which cost a dead dispatch and a failover to another model.
231
+ * When the chosen model reasons and the caller asked for less than this, the
232
+ * dispatch is raised to this floor (never above the model's own completion
233
+ * ceiling). Models that answer directly keep the caller's cap.
234
+ *
235
+ * A cap is an upper bound, not a target — a model that answers in ten tokens
236
+ * still stops at ten. 0 disables the floor.
237
+ */
238
+ reasoningCompletionFloor: number;
224
239
  /** Drop models whose ledger success rate is below this, once `minTrustSamples` is met. */
225
240
  minTrust: number;
226
241
  /**
package/src/index.ts CHANGED
@@ -26,7 +26,7 @@ Usage: auto-model-router <command> [options]
26
26
  stats Show routed spend, per-model share, and escalation rates
27
27
  report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
28
28
  export One row per day, harness and model as CSV (--json for rows)
29
- connect Point this machine at a remote router (--url, --key; --profile persists the environment)
29
+ connect Point this machine at a remote router (--url, --key; --scope labels a single-project machine; --profile persists the environment)
30
30
  models Show what each complexity tier would consider, and why
31
31
  explain Route a saved request without dispatching it, and explain the decision
32
32
  config Interactive wizard over the router's own config.yml
@@ -608,6 +608,18 @@ export function select(args: SelectArgs): Decision {
608
608
  // The ceiling is a hard limit anyway; passing it explicitly also caps runaway completions.
609
609
  maxTokens = maxTokens === undefined ? ceiling : Math.min(maxTokens, ceiling);
610
610
  }
611
+ // A reasoning model spends the budget thinking before it answers, so a caller's
612
+ // tight cap returns nothing and the turn fails over having paid for the dispatch.
613
+ // Raise it to the floor for those models only; the ceiling still wins.
614
+ const floor = cfg.filters.reasoningCompletionFloor;
615
+ const thinksBeforeAnswering = chosen.model.reasoningMandatory || (chosen.model.supportsReasoning && reasoning !== "off");
616
+ if (floor > 0 && thinksBeforeAnswering && maxTokens !== undefined && maxTokens < floor) {
617
+ const raised = ceiling === undefined ? floor : Math.min(floor, ceiling);
618
+ if (raised > maxTokens) {
619
+ reasons.push(`completion budget raised ${maxTokens} → ${raised}: ${chosen.model.slug} reasons before it answers`);
620
+ maxTokens = raised;
621
+ }
622
+ }
611
623
  const stripAssistantReasoning = !(chosen.model.supportsReasoning && REASONING_REPLAY_AUTHORS[chosen.model.author] === true);
612
624
 
613
625
  // The recorded forecast is the EXPECTED price of this dispatch, not the
@@ -93,13 +93,21 @@ function lastUserText(req: NormRequest): string {
93
93
  return "";
94
94
  }
95
95
 
96
- /** Session title: the conversation's opening ask, truncated. */
96
+ /**
97
+ * Session title: the conversation's opening ask, truncated.
98
+ *
99
+ * Read through `userContent`, the same filter the recorded user message uses:
100
+ * a harness wraps the first message in reminders (omp opens with a
101
+ * `<system-reminder>` naming the date and cwd), and titling a session with that
102
+ * wrapper both reads as noise in agentdox and is re-injected into later context
103
+ * assembly, which lists session titles.
104
+ */
97
105
  function sessionTitle(req: NormRequest): string {
98
106
  for (const m of req.messages) {
99
- if (m.role === "user" && m.text.trim() !== "") {
100
- const t = m.text.trim().replace(/\s+/g, " ");
101
- return t.length > 80 ? `${t.slice(0, 79)}…` : t;
102
- }
107
+ if (m.role !== "user") continue;
108
+ const t = userContent(m.text);
109
+ if (t === "") continue;
110
+ return t.length > 80 ? `${t.slice(0, 79)}…` : t;
103
111
  }
104
112
  return `omp ${req.conversationKey.slice(0, 8)}`;
105
113
  }
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
46
46
  data: { axis: "intelligence", minQuality: 0 },
47
47
  chat: { axis: "intelligence", minQuality: 0 },
48
48
  },
49
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
50
50
  classifier: {
51
51
  ambiguityThreshold: 0,
52
52
  model: "test/adjudicator", learnedModelPath: "",
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
 
6
6
  import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
7
7
  import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
8
+ import { hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
8
9
 
9
10
  /**
10
11
  * Remote mode: remote.json puts the omp extensions on a router elsewhere,
@@ -102,3 +103,47 @@ describe("connect", () => {
102
103
  rmSync(h2, { recursive: true, force: true });
103
104
  });
104
105
  });
106
+
107
+ describe("omp models.yml for a remote router", () => {
108
+ const BLEND = { inputPerMtok: 1.1, outputPerMtok: 4.4 };
109
+ const NL = String.fromCharCode(10);
110
+ const yaml = (...lines: string[]): string => lines.join(NL) + NL;
111
+
112
+ test("the block names the remote, the key and the three virtual models; a scope is opt-in", () => {
113
+ const block = renderRemoteModelsYml("https://team.example/", "amrt_k", BLEND);
114
+ expect(block).toContain("baseUrl: https://team.example/v1");
115
+ expect(block).toContain("apiKey: amrt_k");
116
+ expect(block).toContain("- id: auto");
117
+ expect(block).toContain("- id: auto-cheap");
118
+ expect(block).toContain("- id: auto-max");
119
+ expect(block).toContain("cost: { input: 1.1, output: 4.4, cacheRead: 0.11, cacheWrite: 1.375 }");
120
+ // Machine-wide file: no scope unless the caller asks for one.
121
+ expect(block).not.toContain("X-Agentdox-Scope");
122
+ expect(renderRemoteModelsYml("https://team.example", "k", BLEND, "omp-router")).toContain("X-Agentdox-Scope: omp-router");
123
+ });
124
+
125
+ test("merging keeps other providers, replaces our own block, and is idempotent", () => {
126
+ const block = renderRemoteModelsYml("https://team.example", "k1", BLEND);
127
+ const empty = mergeModelsYml("", block);
128
+ expect(empty.startsWith("providers:")).toBe(true);
129
+ expect(mergeModelsYml(empty, block)).toBe(empty);
130
+
131
+ const existing = yaml("providers:", " openai:", " apiKey: sk-x");
132
+ const merged = mergeModelsYml(existing, block);
133
+ expect(merged).toContain("openai:");
134
+ expect(merged).toContain("baseUrl: https://team.example/v1");
135
+
136
+ // A later connect with a new key replaces the block in place, not a second copy.
137
+ const rekeyed = mergeModelsYml(merged, renderRemoteModelsYml("https://team.example", "k2", BLEND));
138
+ expect(rekeyed).toContain("apiKey: k2");
139
+ expect(rekeyed).not.toContain("apiKey: k1");
140
+ expect(rekeyed.match(/auto-model-router:/g)).toHaveLength(1);
141
+ expect(rekeyed).toContain("openai:");
142
+ });
143
+
144
+ test("a hand-written provider of the same name is left alone", () => {
145
+ expect(hasForeignRouterProvider(yaml("providers:", " auto-model-router:", " baseUrl: http://127.0.0.1:1/v1"))).toBe(true);
146
+ expect(hasForeignRouterProvider(mergeModelsYml("", renderRemoteModelsYml("https://t", "k", BLEND)))).toBe(false);
147
+ expect(hasForeignRouterProvider(yaml("providers:", " openai: {}"))).toBe(false);
148
+ });
149
+ });
@@ -82,9 +82,11 @@ function run(opts: {
82
82
  tier?: Tier;
83
83
  ledger?: Ledger | null;
84
84
  harnessId?: string;
85
+ maxTokens?: number;
85
86
  }) {
86
87
  const cfg = opts.cfg ?? BASE;
87
- const req = request(opts.userText ?? "tidy the retry helper");
88
+ const base = request(opts.userText ?? "tidy the retry helper");
89
+ const req = opts.maxTokens === undefined ? base : { ...base, maxTokens: opts.maxTokens };
88
90
  const features = extractFeatures(req, opts.promptTokens ?? 4000);
89
91
  const heuristic = scoreHeuristic(features, cfg);
90
92
  const classification = opts.tier === undefined ? heuristic : { ...heuristic, tier: opts.tier };
@@ -353,6 +355,31 @@ describe("decision shape", () => {
353
355
  }
354
356
  });
355
357
 
358
+ test("a reasoning model gets the completion floor; a direct one keeps the caller's cap", () => {
359
+ const usable = (m: (typeof MODELS)[number]): boolean => m.supportsTools && m.contextLength >= 32_000 && (m.maxCompletionTokens ?? 100_000) >= 4096;
360
+ const thinker = MODELS.find((m) => usable(m) && m.supportsReasoning);
361
+ const direct = MODELS.find((m) => usable(m) && !m.supportsReasoning && !m.reasoningMandatory);
362
+ expect(thinker).toBeDefined();
363
+ expect(direct).toBeDefined();
364
+ const withFloor = (slug: string, floor: number): RouterConfig => ({ ...BASE, filters: { ...BASE.filters, allow: [slug], reasoningCompletionFloor: floor } });
365
+
366
+ // omp asks for a dozen tokens for a title; a reasoning model would spend them thinking
367
+ // and return nothing, so the dispatch is raised.
368
+ const raised = run({ tier: "trivial", cfg: withFloor(thinker!.slug, 512), maxTokens: 12 });
369
+ expect(raised.slug).toBe(thinker!.slug);
370
+ expect(raised.maxTokens).toBe(512);
371
+ expect(raised.reasons.some((r) => r.includes("reasons before it answers"))).toBe(true);
372
+
373
+ // A model that answers directly is untouched: its cap is the caller's.
374
+ const kept = run({ tier: "trivial", cfg: withFloor(direct!.slug, 512), maxTokens: 12 });
375
+ expect(kept.slug).toBe(direct!.slug);
376
+ expect(kept.maxTokens).toBe(12);
377
+
378
+ // The floor never raises past what the caller already asked for, and 0 disables it.
379
+ expect(run({ tier: "trivial", cfg: withFloor(thinker!.slug, 512), maxTokens: 4000 }).maxTokens).toBe(4000);
380
+ expect(run({ tier: "trivial", cfg: withFloor(thinker!.slug, 0), maxTokens: 12 }).maxTokens).toBe(12);
381
+ });
382
+
356
383
  test("plans a probe for cheap tiers and leaves the top tier unprobed", () => {
357
384
  expect(run({ tier: "trivial" }).probe.enabled).toBe(true);
358
385
  // Nothing above `hard` to escalate into, so probing it would only add latency.
package/test/turn.test.ts CHANGED
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
46
46
  data: { axis: "intelligence", minQuality: 0 },
47
47
  chat: { axis: "intelligence", minQuality: 0 },
48
48
  },
49
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, reasoningCompletionFloor: 0, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
50
50
  classifier: {
51
51
  ambiguityThreshold: 0,
52
52
  model: "test/adjudicator", learnedModelPath: "",