auto-model-router 0.2.15 → 0.2.20

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.2.15",
10
+ "version": "0.2.20",
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.2.15",
17
+ "version": "0.2.20",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -362,12 +362,11 @@ auto-model-router config
362
362
 
363
363
  Same fields, prompted on the terminal. Also:
364
364
 
365
- - `auto-model-router config --print` — prints the `models.yml` provider block.
366
- - `auto-model-router config --write` — merges that block into omp's `models.yml`.
365
+ - `auto-model-router config --print` — prints the OpenAI-compatible provider block ready to paste into `models.yml` or your harness config.
366
+ - `auto-model-router config --write` — merges that block into omp's `models.yml` automatically.
367
367
 
368
368
  Both write paths validate the merged file against the schema before touching
369
369
  disk and back up the previous file to a timestamped `.bak`.
370
-
371
370
  ### Configuration file location
372
371
 
373
372
  - Router config: `$AUTO_MODEL_ROUTER_HOME/config.yml` (default `~/.auto-model-router/config.yml`).
@@ -689,10 +688,17 @@ to a cheaper tier than an architecture question in the same conversation; a
689
688
  malformed tool call is escalated to a stronger model without the client ever
690
689
  seeing the failure; and the abandoned attempt is booked as wasted spend.
691
690
 
692
- `auto-model-router explain --file request.json` routes a saved request and prints the
693
- feature vector, classification reasoning, ranked candidates with forecasts, and
694
- every rejection with its cause without dispatching a completion.
691
+ ### Diagnostic CLI: `explain`
692
+
693
+ `auto-model-router explain --file request.json` routes a saved request offline and prints the
694
+ complete decision trace without dispatching a completion:
695
+
696
+ - **Features:** token counts, toolLoopDepth, code fence markers, image presence.
697
+ - **Classification:** chosen tier, confidence, rule hits, complexity reasoning.
698
+ - **Candidates:** ranked models with price forecasts, latency penalties, quality scores.
699
+ - **Rejections:** every filtered model and the exact constraint that excluded it (`over_price_ceiling`, `below_quality_floor`, `untrusted`, `context_length`).
695
700
 
701
+ Use it to debug unexpected tier selections or to see why a model was excluded in seconds.
696
702
  ---
697
703
 
698
704
  ## Where quality scores come from
@@ -0,0 +1,346 @@
1
+ # Routing benchmark findings — 2026-08-29
2
+
3
+ Measured feedback from an external head-to-head benchmark of `auto-model-router`
4
+ against Claude Opus 5. Harness, tasks and raw runs live in
5
+ `E:/projects/auto-router-marketing/bench/`.
6
+
7
+ Everything below is measured, not inferred, except where explicitly flagged. Two
8
+ of my own earlier conclusions were wrong and are corrected in place.
9
+
10
+ ---
11
+
12
+ ## TL;DR
13
+
14
+ 1. **The router is 20–32× cheaper at equal solve rate.** On 10 easy tasks both
15
+ arms solved 20/20; on a 7-rung difficulty ladder both solved 5/7.
16
+ 2. **It never escalates.** Across 86 routed turns on the ladder — including the
17
+ rungs it *failed* — there were zero escalation signals, zero retry
18
+ dispatches, and only two models ever served: `glm-5.3-flash` and
19
+ `gemini-3.7-flash`.
20
+ 3. **Root cause is two independent gates, and both must move.** The price
21
+ ceiling hard-excludes expensive models below `hard`; and the ranking
22
+ arithmetic makes quality unable to outweigh price at any sane exponent.
23
+ 4. **Escalation does work once both gates are opened** — verified end to end,
24
+ with `claude-opus-5` serving 3 turns of a task the cheap-only config failed.
25
+ 5. **The standalone `serve` process died 4 times inside one normally-completing
26
+ run.** The crash is real and mid-run; the *trigger* is unidentified — my first
27
+ explanation (client disconnect mid-stream) does not reproduce. Details in §7.
28
+
29
+ ---
30
+
31
+ ## 1. What was measured, and how
32
+
33
+ - **Harness**: omp in `-p --mode=json` print mode. Every turn's tokens,
34
+ duration, TTFT and tool calls are read off omp's own event stream, so both
35
+ arms are measured by the same instrument.
36
+ - **Router arm**: the standalone `auto-model-router serve` endpoint registered
37
+ as a plain OpenAI-compatible provider — the documented non-omp path (README
38
+ § "Standalone alternative"). Nothing pinned, nothing stubbed; the router
39
+ routes freely. Costs come from its own ledger `reported_usd`.
40
+ - **Opus arm**: omp's Anthropic provider at list price.
41
+ - **Isolation**: both arms run under a copied `PI_CODING_AGENT_DIR` with MCP and
42
+ the router's omp extensions stripped, so the tool surface is identical.
43
+ `AUTO_MODEL_ROUTER_HOME` points at a bench home seeded from the live DB, so the
44
+ router keeps its warm catalog, trust history and calibration.
45
+ - **Grading**: hidden test files copied in only *after* omp exits. Every task is
46
+ verified to fail an untouched workspace and to pass a reference solution
47
+ before any run (`bench/validate-ladder.ts`).
48
+
49
+ > **A note on print mode.** The embedded extension only registers the
50
+ > `auto-model-router` provider inside an interactive session — `ctx.hasUI` is
51
+ > false in `-p`, so it takes the subagent path and looks for an existing
52
+ > `embed.port`. In print mode with no interactive session running, the provider
53
+ > is absent from the model registry entirely and `--model auto-model-router/auto`
54
+ > fails with "Model not found". Worth documenting, or worth having the subagent
55
+ > path fall back to binding its own server.
56
+
57
+ ---
58
+
59
+ ## 2. Cost result
60
+
61
+ | Suite | Router | Opus 5 | Ratio |
62
+ |---|---|---|---|
63
+ | 10 easy tasks × 2 trials | 20/20 · $0.3577 | 20/20 · $11.4040 | 31.9× |
64
+ | 7-rung ladder × 1 trial | 5/7 · $0.3026 | 5/7 · $6.2506 | 20.7× |
65
+
66
+ Turn counts are comparable (184 vs 217 on the easy suite; 86 vs 91 on the
67
+ ladder), so the saving is not bought with extra turns. Median TTFT is the
68
+ router's one clear regression: **6564 ms vs 1473 ms**, a 4.5× penalty paid on
69
+ classification and dispatch before anything streams.
70
+
71
+ The multiple is not stable — it moved from 26.4× to 40.8× between two identical
72
+ trials, driven by which task Opus happened to thrash on. "Well over an order of
73
+ magnitude" is defensible; a precise figure is not.
74
+
75
+ ---
76
+
77
+ ## 3. The router never escalates
78
+
79
+ The ladder was built specifically to force an escalation decision: seven rungs
80
+ ending in npm semver range semantics and a minimal-diff with a specified
81
+ tie-break.
82
+
83
+ | Rung | Task | Router | Opus 5 | Escalated? |
84
+ |---|---|---|---|---|
85
+ | 1 | in-range | PASS | PASS | no |
86
+ | 2 | round-half-even | PASS | PASS | no |
87
+ | 3 | csv-document | PASS | PASS | no |
88
+ | 4 | sliding-limiter | PASS | **FAIL** | no |
89
+ | 5 | savepoints | **FAIL** | PASS | **no** |
90
+ | 6 | semver-ranges | timeout | timeout | no |
91
+ | 7 | minimal-diff | PASS | PASS | no |
92
+
93
+ Rung 6 timed out on both arms at the 10-minute cap; it is not evidence about
94
+ capability either way.
95
+
96
+ **The router assigned its `hard` tier on six of the seven rungs**, so the
97
+ classifier is not blind — the tier simply never converts into a more capable
98
+ model. On rung 5 it failed while continuing to dispatch to `glm-5.3-flash`.
99
+
100
+ ---
101
+
102
+ ## 4. Root cause: two gates, both hard
103
+
104
+ ### Gate 1 — the price ceiling is absolute
105
+
106
+ `explain` on a deep-loop hard task, shipped config:
107
+
108
+ ```
109
+ excluded:
110
+ over_price_ceiling 1 e.g. anthropic/claude-opus-5
111
+ decision:
112
+ model z-ai/glm-5.3-flash
113
+ tier moderate
114
+ ```
115
+
116
+ `moderate.maxInputPerMtok` is 4.0 and Opus 5 is $5.00/MTok, so it is excluded
117
+ before ranking ever runs. Sweeping `qualityExponent` at the shipped ceiling:
118
+
119
+ | qualityExponent | winner | Opus 5 |
120
+ |---|---|---|
121
+ | 3 | `deepseek-v4-flash-0731` | price-excluded |
122
+ | 100 | `gpt-5.6-sol` | price-excluded |
123
+ | 500 | `gpt-5.6-sol` | price-excluded |
124
+
125
+ **No exponent, however large, can select Opus 5 at `moderate`.** The ceiling is
126
+ a hard gate, not a weighting. Only `hard` has no ceiling — and reaching `hard`
127
+ requires the classifier to say so.
128
+
129
+ ### Gate 2 — the ranking cannot express "pay for quality"
130
+
131
+ `score = (quality/100) ^ qualityExponent / effectiveUsd` (`candidates.ts:266`).
132
+
133
+ With the ceiling lifted to $12/MTok so every model is eligible, the measured
134
+ winner by exponent:
135
+
136
+ | qualityExponent | winner |
137
+ |---|---|
138
+ | 1 – 30 | `glm-5.3-flash` / `deepseek-v4-flash` |
139
+ | 45 – 60 | `gemini-3.7-flash` |
140
+ | 100 | `gpt-5.6-sol` |
141
+ | **140+** | **`claude-opus-5`** |
142
+
143
+ Shipped values are 1 (`moderate`) and 3 (`hard`). Opus 5 needs roughly **140** —
144
+ two orders of magnitude higher.
145
+
146
+ The reason is structural: **quality scores occupy a narrow band (69–78 on the
147
+ coding axis) while prices span ~250× ($0.02 to $5.00).** Dividing a bounded
148
+ numerator by an unbounded denominator means price wins unless the exponent is
149
+ enormous. Raising the exponent is a numerically fragile lever — at 140,
150
+ `0.715^140 ≈ 1e-20` — and it distorts every other tier at the same time.
151
+
152
+ **Suggestion.** The exponent is the wrong shape of knob for this. Options worth
153
+ considering, roughly in order of how much they change:
154
+
155
+ - **Normalise quality within the candidate set** (percentile or z-score) before
156
+ exponentiating, so the spread is comparable to the price spread instead of
157
+ being compressed into 69–78.
158
+ - **Make the top tier a capability floor rather than a cost ranking** — at
159
+ `hard`, pick the highest-quality model within an absolute per-turn budget,
160
+ rather than the best quality-per-dollar.
161
+ - **A per-tier price *floor***, the mirror of the existing ceiling, so the top
162
+ tier cannot resolve to a bargain model.
163
+
164
+ ### An earlier explanation of mine was wrong
165
+
166
+ I first attributed this to the coding axis with fixed floors and computed
167
+ "exponent ≈ 49". Two errors: the axis actually in play varies (a short prompt
168
+ resolved on `intelligence`, 45–63, not `coding`), and the denominator is the
169
+ trust- and latency-adjusted *forecast turn cost*, not price per MTok. The
170
+ corrected figure is ~140, and the ceiling gate matters more than the exponent.
171
+
172
+ ---
173
+
174
+ ## 5. Classification is structural, not semantic
175
+
176
+ The classifier scores difficulty from conversational features — prompt tokens,
177
+ turn depth, tool-loop depth — not from what the task actually demands.
178
+
179
+ A single-turn request to implement a nested-transaction store with savepoints
180
+ classified **`simple`** (score 0.304), because `promptTokens 80, turnDepth 1`.
181
+ A 62-message deep tool loop on the same task classified **`moderate`**, with the
182
+ reasons `+0.08 conversation depth 32`, `+0.03 1 tools offered`.
183
+
184
+ This is coherent — context size is a real cost driver, and cheap models genuinely
185
+ handle long mechanical loops. But it means **`hard` tracks conversational depth,
186
+ not difficulty**, so a genuinely hard problem stated briefly will never reach the
187
+ tier that has no price ceiling. That is the mechanism behind finding 3.
188
+
189
+ ---
190
+
191
+ ## 6. Escalation works once both gates are opened
192
+
193
+ Overlay: `moderate.maxInputPerMtok: 12.0`, `qualityExponent: 140` on
194
+ `moderate` and `hard`. Re-running rung 5:
195
+
196
+ ```
197
+ L5-savepoints router PASS 19 turns $0.3338 347.8s
198
+ models: glm-5.3-flash×16 claude-opus-5×3
199
+ ```
200
+
201
+ The router escalated to Opus 5 for 3 of 19 turns and solved the task, at $0.33 —
202
+ **2.5× cheaper than the pure-Opus run ($0.8203)** which also solved it.
203
+
204
+ That is the shape the product presumably wants: cheap for the mechanical turns,
205
+ frontier for the few that need it. It is reachable today only by config that is
206
+ well outside the shipped defaults.
207
+
208
+ *Honest caveat:* rung 5 also passed once under default config on a re-run, so
209
+ this single run does **not** prove escalation caused the pass. What it proves is
210
+ that escalation *fires* — `claude-opus-5` served 3 turns, from the ledger.
211
+
212
+ ---
213
+
214
+ ## 7. The `serve` process dies intermittently — trigger unknown
215
+
216
+ **Corrected.** An earlier version of this document asserted that a client
217
+ disconnecting mid-stream crashes the router. That claim was challenged, I tried
218
+ to reproduce it, and **I could not.** What follows separates what is evidenced
219
+ from what is not.
220
+
221
+ ### What is established
222
+
223
+ `ladder-1` ran to completion (exit 0, `aborted: false`) and recorded
224
+ **4 router restarts** across 3 different cells (`L3-csv-document` ×1,
225
+ `L5-savepoints` ×2, `L6-semver-ranges` ×1). These were mid-run deaths detected by
226
+ the harness health check, which restarted the process each time — visible in
227
+ `_router-home/serve.log` as a crash trace immediately followed by a fresh
228
+ `auto-model-router listening on ...` line.
229
+
230
+ This is **not** the per-session embedded router shutting down with its omp
231
+ session. The benchmark never uses the embedded path: it spawns a standalone
232
+ `auto-model-router serve` process that outlives every individual omp cell, and no
233
+ external kill was issued during the run.
234
+
235
+ The process exits on an uncaught exception, with Bun's crash banner:
236
+
237
+ ```
238
+ TypeError: Invalid state: Controller is already closed
239
+ code: "ERR_INVALID_STATE"
240
+ at send (src/wire/openai/sink.ts:29:28)
241
+ at error (src/wire/openai/sink.ts:58:4)
242
+ at (src/server/http.ts:261:33)
243
+ ```
244
+
245
+ It is intermittent: `esc-2` ran clean with zero crashes.
246
+
247
+ ### What is NOT established
248
+
249
+ I assumed the trigger was a client vanishing mid-stream. Direct attempt to
250
+ reproduce, against a standalone `serve` with a real streaming completion aborted
251
+ at three different points:
252
+
253
+ | abort point | bytes streamed before abort | server survived? |
254
+ |---|---|---|
255
+ | before first chunk | 0 | yes |
256
+ | mid-stream | 96,118 | yes |
257
+ | later mid-stream | 201,342 | yes |
258
+
259
+ **A plain mid-stream client disconnect does not crash it.** So the trigger is
260
+ something narrower that I have not isolated. Reading the trace, the crash needs
261
+ *two* things to coincide: the controller already closed, **and** `runTurn`
262
+ subsequently rejecting so that `sink.error()` runs. A simple disconnect
263
+ apparently does not produce that pairing — possibly the turn completes through
264
+ `finish()` instead, which sets `closed` and makes `send()` return early.
265
+
266
+ Plausible remaining candidates, none verified: an upstream error arriving after
267
+ the client has gone; a mid-stream escalation aborting the first dispatch; a
268
+ failover path. Reproducing it probably needs fault injection at the upstream
269
+ rather than at the client.
270
+
271
+ ### The latent defect is real regardless of trigger
272
+
273
+ Independent of what fires it, `http.ts:260` cannot do the job its comment claims:
274
+
275
+ ```ts
276
+ // "this catch is the last line of defence so a rejected turn can never wedge the response"
277
+ return Promise.resolve(sink.error(toWireError(err))).catch(() => {});
278
+ ```
279
+
280
+ `sink.error()` is synchronous. It is evaluated *before* `Promise.resolve` wraps
281
+ anything, so a synchronous throw from it propagates out of the `.catch()`
282
+ handler and escapes the chain entirely. Whatever causes `send()` to throw, this
283
+ line will not contain it — and a throw inside a `.catch()` handler becomes an
284
+ unhandled rejection, which is what ends the process.
285
+
286
+ Two small changes make the failure survivable without needing to know the
287
+ trigger:
288
+
289
+ ```ts
290
+ // src/wire/openai/sink.ts — a dead stream is not an exceptional condition
291
+ const send = (bytes: Uint8Array): void => {
292
+ if (closed) return;
293
+ try {
294
+ controller?.enqueue(bytes);
295
+ } catch {
296
+ closed = true; // the runtime closed it under us; nothing left to write
297
+ }
298
+ };
299
+ ```
300
+
301
+ ```ts
302
+ // src/server/http.ts — make the documented defence actually defend
303
+ try { sink.error(toWireError(err)); } catch { /* stream already gone */ }
304
+ ```
305
+
306
+ A regression test can assert the weaker, verifiable property: after any turn
307
+ whose sink has been closed, the server still answers `/v1/models`.
308
+
309
+ ## 8. Smaller observations
310
+
311
+ - **`explain` is excellent** and did most of the diagnostic work here. Worth
312
+ advertising more prominently in the README — it answered in seconds what
313
+ reading `candidates.ts` did not.
314
+ - **Ledger attribution is clean.** `reported_usd`, `tier`, `served_slug`,
315
+ `escalation_signal` and `attempt` made per-task cost attribution trivial. The
316
+ rowid high-water-mark trick works well for slicing a run.
317
+ - **`wasted` never co-occurs with a cost.** Every `wasted = 1` row in the
318
+ production ledger has `reported_usd IS NULL`, so "wasted spend" is always
319
+ $0.00. Retry spend (`attempt > 0`) is the meaningful waste figure — worth
320
+ renaming or documenting, since the obvious reading of the column is wrong.
321
+ - **Compaction is worth little against a single-model baseline.** Replaying a
322
+ week of real ledger traffic, adding back the 12.4M prompt tokens compaction
323
+ removed changed the modelled Opus 5 bill by only ~$7 on $921 — on one model
324
+ that context is cache reads at $0.50/MTok. Its value is in staying under the
325
+ window and in surviving model switches, not in dollars.
326
+
327
+ ---
328
+
329
+ ## 9. Reproducing
330
+
331
+ ```bash
332
+ cd E:/projects/auto-router-marketing
333
+
334
+ bun run bench/validate-ladder.ts # prove graders are passable
335
+ bun run bench/run-h2h.ts --suite ladder --dry # prove graders bite
336
+ bun run bench/run-h2h.ts --suite ladder --tasks all --arms router,opus5 \
337
+ --budget 20 --max-time 10m --out bench/runs/ladder-N
338
+ bun run bench/analyze-h2h.ts bench/runs/full-3 bench/runs/full-3b
339
+
340
+ # escalation experiment
341
+ bun run bench/run-h2h.ts --suite ladder --tasks L5-savepoints --arms router \
342
+ --overlay bench/overlay-escalate.yml --budget 5 --out bench/runs/esc-N
343
+ ```
344
+
345
+ Raw per-turn omp streams, per-cell results and each run's router config and
346
+ `serve.log` are retained under `bench/runs/*/`.
@@ -125,6 +125,27 @@ export function readEmbedPort(path: string): number | null {
125
125
  }
126
126
  }
127
127
 
128
+ /**
129
+ * Checks the shared router actually answers before a subagent registers it.
130
+ * The port file outlives the process that wrote it, so a stale entry is normal:
131
+ * registering against a dead port would produce a provider whose every turn
132
+ * fails with connection refused. A failed health check means "bind your own".
133
+ */
134
+ export async function probeEmbed(port: number, timeoutMs = 1_000): Promise<boolean> {
135
+ try {
136
+ const ctl = new AbortController();
137
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
138
+ try {
139
+ const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: ctl.signal });
140
+ return res.ok;
141
+ } finally {
142
+ clearTimeout(timer);
143
+ }
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
128
149
  /**
129
150
  * Builds the provider config for `pi.registerProvider(EMBED_PROVIDER_ID, …)`
130
151
  * given the shared bound port. `models` are the router's own `profiles`, mapped
@@ -22,6 +22,7 @@
22
22
  import { homedir } from "node:os";
23
23
  import { join } from "node:path";
24
24
 
25
+ import { syncModelsYml } from "../src/cli/config-cmd.ts";
25
26
  import { loadConfig } from "../src/config/load.ts";
26
27
  import { startServer } from "../src/server/http.ts";
27
28
  import type { StartedServer } from "../src/server/http.ts";
@@ -35,6 +36,7 @@ import {
35
36
  EMBED_PROVIDER_ID,
36
37
  embedPortPath,
37
38
  readEmbedPort,
39
+ probeEmbed,
38
40
  resolveEmbedPort,
39
41
  writeEmbedPort,
40
42
  } from "./embed-logic.ts";
@@ -98,15 +100,31 @@ export default function (pi: ExtensionAPI): void {
98
100
  let app: StartedServer | null = null;
99
101
 
100
102
  pi.on("session_start", (_event, ctx) => {
101
- // Subagents and headless sessions do not bind their own router; they
102
- // route to the main's router via the shared port file. The main writes
103
- // the file before spawning subagents, so the port is available here.
104
103
  // The omp UI session id tags every request so the toast can scope its
105
104
  // notifications to that exact session (see router-toast.ts).
106
105
  const sessionId = ctx.sessionManager.getSessionId();
107
106
  if (!ctx.hasUI) {
108
- const port = readEmbedPort(portFile);
109
- if (port !== null) registerRouterProvider(pi, port, cfg, sessionId);
107
+ // Subagents and headless (-p) sessions prefer the main session's
108
+ // shared router: one process, one ledger, one place to inspect.
109
+ // The main writes the port file before spawning subagents.
110
+ const shared = readEmbedPort(portFile);
111
+ if (shared !== null && probeEmbed(shared)) {
112
+ registerRouterProvider(pi, shared, cfg, sessionId);
113
+ return;
114
+ }
115
+ // No live interactive session (headless batch runs, CI, the
116
+ // benchmark harness): fall back to binding a private router so
117
+ // `--model auto-model-router/auto` still resolves. Ephemeral by
118
+ // design — it dies with this process and never writes the shared
119
+ // port file, so it can never hijack another session's subagents.
120
+ const started = startServer(cfg);
121
+ if (started.server.port === undefined) return;
122
+ app = started;
123
+ registerRouterProvider(pi, started.server.port, cfg, sessionId);
124
+ pi.on("session_shutdown", () => {
125
+ void app?.stop().catch(() => {});
126
+ app = null;
127
+ });
110
128
  return;
111
129
  }
112
130
 
@@ -122,6 +140,15 @@ export default function (pi: ExtensionAPI): void {
122
140
 
123
141
  // Publish the shared port; subagents and the toast read it from here.
124
142
  writeEmbedPort(portFile, actualPort);
143
+
144
+ // Keep models.yml pointing at this port. Headless runs (`-p`) and
145
+ // subagent processes resolve models from models.yml in a FRESH registry
146
+ // — extension registration does not reach them — so without this they
147
+ // fail with "Model not found" when no interactive session is live
148
+ // (the print-mode gap the external benchmark hit). Registration still
149
+ // wins at runtime, so a live session always overrides a stale block.
150
+ const syncAction = syncModelsYml(cfg, actualPort);
151
+ if (syncAction !== null) pi.setLabel(`auto-model-router embed (models.yml ${syncAction})`);
125
152
  registerRouterProvider(pi, actualPort, cfg, sessionId);
126
153
 
127
154
  pi.on("session_shutdown", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.15",
3
+ "version": "0.2.20",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -317,6 +317,45 @@ export function ompModelsPath(): string {
317
317
  if (agentDir !== undefined && agentDir !== "") return join(agentDir, "models.yml");
318
318
  return join(homedir(), ".omp", "agent", "models.yml");
319
319
  }
320
+ /**
321
+ * Keeps omp's models.yml carrying an up-to-date `auto-model-router` provider
322
+ * block pointing at `port`, silently. The embedded extension calls this every
323
+ * time the main session binds its router, so headless (`-p`) runs, subagent
324
+ * processes, and any other consumer that builds a FRESH model registry (which
325
+ * extension registration does NOT reach) still resolve
326
+ * `auto-model-router/auto` — they read models.yml, not the live registry.
327
+ *
328
+ * Same splice + validation as `config --write`, minus the console output and
329
+ * the backup: this runs on every session start, and a `.bak` per launch would
330
+ * churn the directory. Failure to write is logged by the caller, never thrown —
331
+ * a read-only models.yml must degrade to "registration only", which is the
332
+ * pre-existing behavior.
333
+ *
334
+ * Returns the splice action, or null when nothing was written (already
335
+ * current, or the write failed).
336
+ */
337
+ export function syncModelsYml(cfg: RouterConfig, port: number, target = ompModelsPath()): SpliceResult["action"] | null {
338
+ try {
339
+ const pointed: RouterConfig = { ...cfg, server: { ...cfg.server, port } };
340
+ const block = renderProviderBlock(pointed, null);
341
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
342
+ const result = spliceProviderBlock(existing, block);
343
+ if (result.action === "replaced") {
344
+ // The guards make "replaced" cheap to detect but the text may still be
345
+ // byte-identical (same port, same costs): skip the write so the file
346
+ // mtime stays stable for tools watching it.
347
+ if (result.text === existing) return null;
348
+ }
349
+ assertUsableModelsYaml(result.text);
350
+ mkdirSync(dirname(target), { recursive: true });
351
+ writeFileSync(target, result.text, "utf8");
352
+ return result.action;
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+
358
+ /** omp's models.yml location: `$PI_CODING_AGENT_DIR` relocates the whole agent dir. */
320
359
 
321
360
  export async function configCommand(args: CliArgs): Promise<void> {
322
361
  const cfg = loadConfig(configOpts(args));
@@ -167,9 +167,24 @@ export const DEFAULT_CONFIG: RouterConfig = {
167
167
  // unbounded, this scope reached 15 memory entries = 23.5k chars (~5.9k
168
168
  // tokens) injected into every turn, against a 24k cap it was about to hit.
169
169
  memoryLimit: 8,
170
+ // Docs are WHOLE DOCUMENTS, so they are the easiest way to blow the cap:
171
+ // this was left unbounded and a single ashlands note-doc measured 41,921
172
+ // chars — larger than maxBlockChars on its own, with three of them
173
+ // assembling a 104k-char block. Bounded rather than off, because a scope
174
+ // whose docs are genuinely short summaries benefits from them; set 0 where
175
+ // docs mirror whole repo files (agentdox ingest does this), since the
176
+ // content is retrievable on demand via docs_read and does not belong in
177
+ // every prompt's prefix.
178
+ docsLimit: 2,
170
179
  // Session messages are cheap today but grow once recordTurns is on, and
171
180
  // they feed straight back into the next assembly.
172
181
  sessionLimit: 6,
182
+ // The project brief renders FIRST in the block (query-independent →
183
+ // cache-friendly) but grows one entry per recorded decision, so it is
184
+ // budgeted, not unbounded. 12k keeps the whole measured brief (8.9k across
185
+ // statics + all 14 decisions) inside a 24k block while leaving ~15k for
186
+ // the query-relevant memory/docs tail. 0 omits the brief.
187
+ briefChars: 12_000,
173
188
  recordTurns: true,
174
189
  maxQueue: 64,
175
190
  },
@@ -46,6 +46,8 @@ const tierConfig = z.strictObject({
46
46
  maxInputPerMtok: z.number().nonnegative().optional(),
47
47
  maxOutputPerMtok: z.number().nonnegative().optional(),
48
48
  qualityExponent: z.number().nonnegative().optional(),
49
+ qualityNormalization: z.boolean().optional(),
50
+ capabilityFloorUsd: z.number().positive().optional(),
49
51
  pin: z.array(z.string()).optional(),
50
52
  });
51
53
 
@@ -139,7 +141,9 @@ const context = z.strictObject({
139
141
  maxStalenessMs: z.number().int().nonnegative().optional(),
140
142
  maxBlockChars: z.number().int().positive().optional(),
141
143
  memoryLimit: z.number().int().positive().optional(),
144
+ docsLimit: z.number().int().nonnegative().optional(),
142
145
  sessionLimit: z.number().int().nonnegative().optional(),
146
+ briefChars: z.number().int().nonnegative().optional(),
143
147
  recordTurns: z.boolean().optional(),
144
148
  maxQueue: z.number().int().positive().optional(),
145
149
  });
@@ -108,6 +108,32 @@ export interface TierConfig {
108
108
  * above the floor (Pareto-style). Higher ⇒ pay for headroom above it.
109
109
  */
110
110
  qualityExponent: number;
111
+ /**
112
+ * Rank on quality NORMALISED WITHIN the candidate set instead of on the raw
113
+ * 0-100 index. Default false (raw).
114
+ *
115
+ * Why it exists: raw scores occupy a narrow band (69-78 on the coding axis)
116
+ * while prices span ~250x ($0.02-$5.00/MTok), so `(quality/100)^exponent`
117
+ * over a forecast cost is a bounded numerator over an unbounded denominator
118
+ * — price wins unless the exponent is enormous (measured: ~140 to select a
119
+ * frontier model, where 0.715^140 is ~1e-20 and numerically fragile).
120
+ * Normalising maps the set's worst quality to 0 and its best to 1, so the
121
+ * exponent becomes a legible "how much do I pay for the best available
122
+ * model" dial at single digits instead of triple.
123
+ */
124
+ qualityNormalization?: boolean;
125
+ /**
126
+ * Treat this tier as a CAPABILITY FLOOR rather than a cost ranking: pick the
127
+ * highest-quality candidate whose forecast turn cost is within this many USD,
128
+ * ignoring quality-per-dollar entirely. Unset ⇒ normal ranking.
129
+ *
130
+ * This is the top tier's real job. `hard` exists because the work needs a
131
+ * capable model, so "best quality under a spend cap" states the intent
132
+ * directly; ranking by quality/price cannot, since a bargain model always
133
+ * wins on the ratio however weak it is. Falls back to the ranked winner when
134
+ * no candidate fits the budget, so this can only ever upgrade a choice.
135
+ */
136
+ capabilityFloorUsd?: number;
111
137
  /** Slugs always allowed in this tier regardless of the quality floor. */
112
138
  pin: string[];
113
139
  }
@@ -374,8 +400,21 @@ export interface ContextConfig {
374
400
  maxBlockChars: number;
375
401
  /** Max memory entries agentdox may select for the block. */
376
402
  memoryLimit: number;
403
+ /** Max docs agentdox may select for the block. Docs are whole documents, so
404
+ * this is the easiest way to blow `maxBlockChars`; 0 disables them. */
405
+ docsLimit: number;
377
406
  /** Max recent session messages agentdox may select for the block. */
378
407
  sessionLimit: number;
408
+ /**
409
+ * Character budget for the project brief inside the assembled block, 0 to
410
+ * omit. The brief is query-independent curated context (overview, style,
411
+ * gotchas, decision log) that renders FIRST, where the prompt cache holds it.
412
+ * It grows by one entry per recorded decision, so it takes an explicit
413
+ * budget; measured on two live scopes, static sections ~1.6k-8.6k chars and
414
+ * the decision log the rest. Keep this well inside `maxBlockChars` so the
415
+ * query-relevant tail always fits too.
416
+ */
417
+ briefChars: number;
379
418
  /** Write settled turns back to agentdox sessions, tagged with the served model. */
380
419
  recordTurns: boolean;
381
420
  /** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */