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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +12 -6
- package/docs/routing-benchmark-findings.md +346 -0
- package/omp-extension/embed-logic.ts +21 -0
- package/omp-extension/router-embed.ts +32 -5
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +39 -0
- package/src/config/defaults.ts +15 -0
- package/src/config/schema.ts +4 -0
- package/src/config/types.ts +39 -0
- package/src/context/agentdox.ts +8 -1
- package/src/context/bridge.ts +5 -2
- package/src/context/index.ts +2 -0
- package/src/cost/types.ts +6 -1
- package/src/router/candidates.ts +67 -1
- package/src/server/http.ts +9 -2
- package/src/wire/openai/sink.ts +15 -2
- package/test/context-bridge.test.ts +13 -7
- package/test/failover.test.ts +1 -1
- package/test/http-resilience.test.ts +58 -0
- package/test/tier-plan.test.ts +91 -0
- package/test/turn.test.ts +1 -1
- package/test/wire-sink.test.ts +31 -0
- package/tools/replay.ts +403 -0
|
@@ -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.
|
|
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.
|
|
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`
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
package/src/cli/config-cmd.ts
CHANGED
|
@@ -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));
|
package/src/config/defaults.ts
CHANGED
|
@@ -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
|
},
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
});
|
package/src/config/types.ts
CHANGED
|
@@ -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. */
|