auto-model-router 0.2.32 → 0.3.1
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 +225 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +117 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +190 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +46 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +28 -0
- package/src/config/types.ts +120 -2
- package/src/cost/cache-estimate.ts +52 -0
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +351 -0
- package/src/cost/types.ts +39 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +52 -4
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +10 -0
- package/src/server/http.ts +50 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +138 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +163 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/cache-estimate.test.ts +48 -0
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +521 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +343 -0
- package/test/report-logic.test.ts +93 -0
- package/test/report.test.ts +233 -0
- package/test/select.test.ts +151 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +173 -7
- package/tools/recompute-ollama-cache.ts +129 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# Code + ledger review — 2026-09-05
|
|
2
|
+
|
|
3
|
+
> **Status (same day, follow-up session):** the "Suggested order" below has been
|
|
4
|
+
> worked through. See the *Applied* section at the end for what shipped, what
|
|
5
|
+
> is config-only, what the replays showed, and what still needs a restart.
|
|
6
|
+
|
|
7
|
+
Full read of `src/` and `omp-extension/` plus an analysis of the live ledger
|
|
8
|
+
(`~/.auto-model-router/router.db`, 17,184 dispatches, 2026-08-21 → 2026-09-05,
|
|
9
|
+
$180.22). Baseline: `bun run typecheck` clean, `bun test` 513/513.
|
|
10
|
+
|
|
11
|
+
Numbers below are from the ledger unless marked *replay* (`tools/replay.ts`).
|
|
12
|
+
"7d" = the seven days ending 2026-09-05T14:43Z ($101.18, 9,336 dispatches).
|
|
13
|
+
|
|
14
|
+
## Where the money goes (7d)
|
|
15
|
+
|
|
16
|
+
| Slice | $ | Share | Note |
|
|
17
|
+
| --- | --- | --- | --- |
|
|
18
|
+
| Total | 101.18 | | ~$14.5/day |
|
|
19
|
+
| Sep 3 alone | 38.46 | 38% | key-scoped catalog was 8 models, glm absent 00:14–23:24 UTC; gemini-3.7/3.8-flash ($0.75/M) served everything |
|
|
20
|
+
| `hard` tier | 40.76 | 40% | heuristic 30.47 (mostly pre-v0.2.33 failed-tool rows), escalation 5.60, sticky 4.69 |
|
|
21
|
+
| model-switch turns (4% of dispatches) | 33.42 | 33% | cache hit 44% vs 91% on same-model turns; moderate→hard alone 25.61 |
|
|
22
|
+
| fresh prompt tokens | 49.40 | 56% | cache read 31.70, cache write 4.80, completion 1.99 |
|
|
23
|
+
| last 2 days, `hard` | 7.52 of 35.46 | 21% | escalation 4.42 now dominates; heuristic hard is down to 2.18 |
|
|
24
|
+
|
|
25
|
+
v0.2.33 (failed-tool damping) has been live since the 2026-09-03 restart
|
|
26
|
+
(damped rows appear from Sep 3). **v0.2.34 is not live**: both running
|
|
27
|
+
`omp.exe` processes started 2026-09-03T00:27–00:29Z, before the commit. All omp
|
|
28
|
+
windows need a restart for the circular-loop damping to take effect.
|
|
29
|
+
|
|
30
|
+
## Findings, ranked by expected $ impact
|
|
31
|
+
|
|
32
|
+
### 1. Adaptive tier floors now RELAX every tier below its configured floor
|
|
33
|
+
|
|
34
|
+
`tierPlanFor` takes `min(configured, quantile-band)`. The design comment assumes
|
|
35
|
+
a healthy catalog's bands sit above the configured floors; the 347-model catalog
|
|
36
|
+
the key admits since 2026-09-05 has a long tail of weak scored models, so the
|
|
37
|
+
bands sit far *below* them:
|
|
38
|
+
|
|
39
|
+
| Tier | Configured coding floor | Effective now |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| simple | 40 | 26.6 |
|
|
42
|
+
| moderate | 60 | 45.8 |
|
|
43
|
+
| hard | 72 | 59.9 |
|
|
44
|
+
|
|
45
|
+
Consequences measured on Sep 5: `inclusionai/ling-3.0-flash` (coding 50.6) wins
|
|
46
|
+
`moderate` outright (433 dispatches, $0.27), and *replay* shows
|
|
47
|
+
`google/gemma-4-26b-a4b-it` (coding 39.3) taking 1,175 of 4,000 recent
|
|
48
|
+
dispatches at `simple`/`trivial`. ling's probe-escalation rate is 3.6% (16/447)
|
|
49
|
+
against glm-5.3-flash's 0.26% (22/8,346), and each escalation lands on
|
|
50
|
+
`claude-opus-5` via the capability floor: 7 escalated turns cost $3.02 in two
|
|
51
|
+
days, eleven times the cost of all 433 ling turns combined.
|
|
52
|
+
|
|
53
|
+
*Replay* of the 717 Sep-5 dispatches with `adaptiveTierFloors=false`: moderate
|
|
54
|
+
returns to glm-5.3-flash (+347 dispatches), direct cost +$0.28 (+28%) — before
|
|
55
|
+
counting the escalations replay cannot model, which cost more than that.
|
|
56
|
+
|
|
57
|
+
**Fix (code):** relax only when the configured floor leaves the tier thin. In
|
|
58
|
+
`buildCandidates`, count rankable models passing the configured floor on the
|
|
59
|
+
request axis; use the adaptive band only when that count is below a small
|
|
60
|
+
minimum (e.g. 3). That preserves the guardrail-narrowed case the feature was
|
|
61
|
+
built for and makes a wide catalog behave exactly like the config says.
|
|
62
|
+
Interim (config, hot-reloads): `adaptiveTierFloors: false`.
|
|
63
|
+
|
|
64
|
+
### 2. Trust prices a failure as a same-model retry; the real price is the next tier
|
|
65
|
+
|
|
66
|
+
`effectiveUsd = expectedUsd / successRate` treats a 4% failure rate as a 4%
|
|
67
|
+
surcharge. The observed cost of a failure is a re-dispatch of the whole prompt
|
|
68
|
+
on the escalation target: for ling → opus that is ~700× the turn's own cost, so
|
|
69
|
+
the true surcharge is ~25×, not 4%. Every model's trust sits at 0.93–0.997, so
|
|
70
|
+
the divisor is effectively inert and `minTrust: 0.7` never trips.
|
|
71
|
+
|
|
72
|
+
**Fix (code):** `effectiveUsd = expectedUsd + escalationRate × forecast(escalation target)`,
|
|
73
|
+
where the target is the next tier's current winner. This is what makes cheap
|
|
74
|
+
flaky models lose to cheap reliable ones. Keep the divisor only for hard errors.
|
|
75
|
+
|
|
76
|
+
### 3. Escalation re-dispatches the model that just failed (49 of 71 in 7d)
|
|
77
|
+
|
|
78
|
+
Probe escalation sets `escalateFrom` but never adds the failing slug to
|
|
79
|
+
`excludeSlugs` (only HTTP-error failover does). 7d: `gemini-3.8-flash moderate →
|
|
80
|
+
gemini-3.8-flash hard` ×11, `gemini-3.7-flash moderate → gemini-3.7-flash hard`
|
|
81
|
+
×12, `gemini-3.7-flash simple → gemini-3.7-flash moderate` ×8, and so on — the
|
|
82
|
+
same model one tier up, paying the tier premium for a provider hiccup.
|
|
83
|
+
Escalated attempts cost $5.50 in 7d.
|
|
84
|
+
|
|
85
|
+
**Fix (code):** on a probe escalation, push `decision.slug` onto `failedSlugs`.
|
|
86
|
+
For `empty_completion`, `refusal` and `upstream_error` — signals that indict
|
|
87
|
+
the provider rather than the tier — try a same-tier sibling first, exactly as
|
|
88
|
+
`MAX_SAME_TIER_FAILOVERS` already does for 5xx/429, and only then step up.
|
|
89
|
+
|
|
90
|
+
### 4. Cross-tier switches forfeit the cache with no economic check
|
|
91
|
+
|
|
92
|
+
The stay/switch arithmetic in `select.ts` step 4 runs only when the warm model
|
|
93
|
+
is a candidate in the *new* tier. A tier change therefore always switches
|
|
94
|
+
cold: 7d moderate→hard 117 switches, $25.61, 34% cache hit. 84 of those 215
|
|
95
|
+
uncaptured switches carried `lastToolFailed`, which v0.2.33 has since damped
|
|
96
|
+
(2d hard-heuristic spend is $2.18). What remains is structural:
|
|
97
|
+
|
|
98
|
+
- **Myopic stay/switch keeps expensive models warm.** After an escalation put
|
|
99
|
+
`kimi-k3` in a conversation, "stay $0.0589 ≤ switch $0.1131 × 1.3" held it at
|
|
100
|
+
`moderate` for 33 dispatches ($4.12, $0.125/turn) where glm would have been
|
|
101
|
+
$0.003/turn warm. The comparison is correct for one turn and wrong for the
|
|
102
|
+
12-turn run that followed. Amortise the switch cost over an expected horizon
|
|
103
|
+
(deep loops average 25 dispatches per user turn): switch when
|
|
104
|
+
`H × (stayWarm − newWarm) > switchCold − stayWarm`, with H ≈ 5–10.
|
|
105
|
+
*Replay* with `switchMargin=1.0` changes 0 decisions — the margin is not the
|
|
106
|
+
lever, the horizon is.
|
|
107
|
+
- When a tier change is driven by a single soft signal on a mechanical
|
|
108
|
+
continuation, consider requiring the signal to persist for two turns before
|
|
109
|
+
paying a cold switch to a 20–50× model.
|
|
110
|
+
|
|
111
|
+
### 5. Sep 3: a catalog collapse cost ~$32 in one day, silently
|
|
112
|
+
|
|
113
|
+
`no candidates in trivial (8 rejected)` on every Sep-3 row: the key-scoped
|
|
114
|
+
catalog had 8 models and glm was absent for 23 hours. `gemini-3.7-flash` served
|
|
115
|
+
833 dispatches ($19.05) and `gemini-3.8-flash` 666 ($13.19) at 10× glm's rate.
|
|
116
|
+
Whether that was a guardrail edit or an upstream blip, the router accepted a
|
|
117
|
+
352→8 shrink with no warning (`doRefresh` only rejects an *empty* payload).
|
|
118
|
+
|
|
119
|
+
**Fix (code):** log at `warn` and expose on `/health` when a refresh shrinks
|
|
120
|
+
the catalog by more than ~50%; optionally keep the previous snapshot for one
|
|
121
|
+
refresh interval before adopting the shrink, so a transient blip does not
|
|
122
|
+
reroute a whole day.
|
|
123
|
+
|
|
124
|
+
### 6. Client abort after the generation finished is recorded as an error
|
|
125
|
+
|
|
126
|
+
2,068 rows (12%) carry `error = "request aborted"`, and 1,842 of them have a
|
|
127
|
+
`finish_reason` and full usage — the upstream generation completed and the
|
|
128
|
+
client closed before `[DONE]` was read. On those turns `runTurn` returns from
|
|
129
|
+
`onUpstreamError` without saving state: `turn` is not incremented (the next row
|
|
130
|
+
reuses the same turn number in 1,087 of 1,107 cases), `currentSlug`,
|
|
131
|
+
`cacheWarmSlug`, `lastPromptTokens` and `compactionPlan` are not updated, the
|
|
132
|
+
agentdox transcript is skipped, and the row is excluded from latency stats.
|
|
133
|
+
The rate tracks the omp binary: 16–19%/day before the 2026-09-02 omp update,
|
|
134
|
+
2–7% since — so the client-side cause is mostly gone, but the router should
|
|
135
|
+
not depend on it.
|
|
136
|
+
|
|
137
|
+
**Fix (code, `turn.ts`):** in the stream `catch`, an abort with
|
|
138
|
+
`finishReason !== null` is a completed generation: fall through to the normal
|
|
139
|
+
commit path (the dead sink no-ops), record `error: null`, save state.
|
|
140
|
+
|
|
141
|
+
### 7. Compaction re-plans every turn because the budget is unreachable
|
|
142
|
+
|
|
143
|
+
`overBudget = compactedTokens > budgetTokens` is always true: post-compaction
|
|
144
|
+
prompts are 100–160k against a 40k budget, so `floorRatio` never rations and
|
|
145
|
+
a new edit is added the moment a tool result ages past `protectRecentTurns`.
|
|
146
|
+
7d, same-model turns: plan changed 1,031× at 79.5% cache hit and $0.0120/turn
|
|
147
|
+
vs 92.6% and $0.0067 when the plan held. That churn is worth ~$3–5/week.
|
|
148
|
+
|
|
149
|
+
**Fix (code):** ration new edits — only extend the plan when the compacted
|
|
150
|
+
prompt has grown ≥ X% (say 10%) since the last re-plan, or every K turns;
|
|
151
|
+
carried edits still apply verbatim in between.
|
|
152
|
+
|
|
153
|
+
### 8. Token calibration pairs the wrong bytes with the wrong tokens
|
|
154
|
+
|
|
155
|
+
`estimatePromptTokens` records *pre-compaction* bytes; `ledger.record` pairs
|
|
156
|
+
them with *post-compaction, post-context-block* billed tokens. With ~43k tokens
|
|
157
|
+
compacted per turn the learned ratio absorbs compaction: `actual / rawEstimate`
|
|
158
|
+
≈ 1.10 but `actual / compactedEstimate` ≈ 1.50 — the size selection actually
|
|
159
|
+
uses is 33% low (forecasts, `minContext`, price tiers, switch arithmetic).
|
|
160
|
+
|
|
161
|
+
Separately, `nex-agi/nex-n2-mini` reports prompt tokens **8.4×** the estimate
|
|
162
|
+
(same bytes glm bills at 1.1×) and has poisoned the `qwen3` family to
|
|
163
|
+
1.61 bytes/token; 27 catalog models carry that tokenizer and will be
|
|
164
|
+
over-estimated ~2×.
|
|
165
|
+
|
|
166
|
+
**Fix (code):** calibrate on dispatched bytes (`promptBytes − savedBytes +
|
|
167
|
+
contextBlock.length`), and reject calibration samples whose `actual/estimate`
|
|
168
|
+
is outside ~[0.4, 2.5].
|
|
169
|
+
|
|
170
|
+
### 9. Hold-length experiment is diluted; exploration verdicts are blind
|
|
171
|
+
|
|
172
|
+
- `breakHoldOnMechanical` (on since Aug 30) breaks the hold on 96% of turns:
|
|
173
|
+
7d 492 holds broken vs 233 held; sticky rows in the 4 turns after an
|
|
174
|
+
escalation total $0.97. Per-turn cost by arm (2/3/4) is 0.0103/0.0110/0.0086
|
|
175
|
+
with wildly different conversation lengths — no separable signal. Close it.
|
|
176
|
+
- Exploration's "cheaper tier sufficed" verdict is the probe, which rejects
|
|
177
|
+
~1% of turns at every tier. Explored turns: hard→moderate 2/160 rejected,
|
|
178
|
+
moderate→simple 3/855 — indistinguishable from baseline. The probe only sees
|
|
179
|
+
structural failure, so exploration cannot learn quality. (It did save money:
|
|
180
|
+
160 hard→moderate turns cost $1.26 against ~$24 at hard.) Either stop it, or
|
|
181
|
+
replace the verdict with an outcome proxy — loop length after the turn, or
|
|
182
|
+
whether the next tool call succeeded.
|
|
183
|
+
|
|
184
|
+
### 10. Smaller classifier and scoring items
|
|
185
|
+
|
|
186
|
+
- **Stale-image weight.** `W_IMAGES` fires on `hasImages` (any image in
|
|
187
|
+
history); 5,603 of 7d heuristic rows carry one. 339 rows ($3.61) sit one tier
|
|
188
|
+
higher only because of that +0.04. Switch to `hasNewImage`, the same
|
|
189
|
+
principle `classifyTask` already applies.
|
|
190
|
+
- **Latency scoring is inert.** The reference wait uses 1,024 expected
|
|
191
|
+
completion tokens (34s at 30 tok/s) while actual completions average ~200;
|
|
192
|
+
glm's 9.1s mean TTFT and 152 turns >30s TTFT in 7d never earn a penalty, and
|
|
193
|
+
`maxExpectedWaitMs: 160000` never fires. Use the ledger's measured mean
|
|
194
|
+
completion tokens as the expected completion.
|
|
195
|
+
- **Reasoning weight** `medium: 0.07` rode on 662 dispatches ($5.80) in 7d;
|
|
196
|
+
still the single largest constant offset after the loop-depth ramp.
|
|
197
|
+
|
|
198
|
+
## Code defects (not ledger-visible)
|
|
199
|
+
|
|
200
|
+
| Where | Defect | Effect |
|
|
201
|
+
| --- | --- | --- |
|
|
202
|
+
| `src/server/http.ts` `handleChatCompletions` | `acquireTurn()` succeeds, then a JSON/parse failure returns before `runTurn`, so `releaseTurn` never runs | after 24 malformed bodies the router answers 429 forever until restart |
|
|
203
|
+
| `src/router/escalate.ts` + `turn.ts` | `maxHoldMs` is checked only inside `observe()`, i.e. when a chunk arrives; keep-alive comments are dropped by `parseSse` | a stream that emits nothing holds the client until the upstream gives up: 47 `empty_completion` rows waited 9–150s (gemini-3.8-flash 14×, avg 40s). **Deliberately left alone**: enforcing the 8s ceiling on a timer would escalate every glm-5.3-flash turn whose first token arrives after 8s — its mean TTFT is 9.1s — so the accidental leniency is protective. A timer-driven check only makes sense with `maxHoldMs` raised to ~2× typical TTFT (30s). |
|
|
204
|
+
| `src/server/turn.ts` | probe escalation does not exclude the failing slug (finding 3) | 69% of escalations retry the same model |
|
|
205
|
+
| `src/server/turn.ts` | abort-after-finish path skips state save (finding 6) | stale hysteresis/cache/compaction state on 2–19% of turns |
|
|
206
|
+
| `src/router/tier-plan.ts` | `min(configured, adaptive)` with no thinness test (finding 1) | floors relaxed on a wide catalog |
|
|
207
|
+
| `src/tokens/estimate.ts` + `src/cost/ledger.ts` | calibration bytes/tokens mismatch, no outlier rejection (finding 8) | biased estimates, poisoned tokenizer family |
|
|
208
|
+
| `src/router/candidates.ts` | trust divisor (finding 2) | flaky cheap models never lose |
|
|
209
|
+
| `src/router/tier-plan.ts` `tierPlanFor` | memo keyed on the config object, which hot reload mutates in place | a `filters.includeFree` edit is ignored until the next catalog refresh (≤5 min); cosmetic |
|
|
210
|
+
|
|
211
|
+
## Verified fine
|
|
212
|
+
|
|
213
|
+
- Cache-breakpoint placement, compaction carry-forward and validation, cost
|
|
214
|
+
breakdown arithmetic, unattributable-error trust exclusion, hot reload, the
|
|
215
|
+
per-slug newest-first index. Same-model cache hit is 91–92% inside the 5-min
|
|
216
|
+
warm window and 44% beyond it; the `cacheWarmTtlMs` model matches reality.
|
|
217
|
+
- Per-turn routing cost: `buildCandidates` runs in 20–28ms per tier on the
|
|
218
|
+
347-model catalog including trust/latency queries (31ms for 31 slugs). Not a
|
|
219
|
+
bottleneck; the `trustWindowDays` note about growth still applies at 75k rows.
|
|
220
|
+
- The LLM adjudicator remains dead (0 `llm` rows); `ambiguityThreshold: 0` is
|
|
221
|
+
correct.
|
|
222
|
+
- `empty_completion` escalations are genuine empty streams, not the 8s hold
|
|
223
|
+
ceiling firing early (latency 2–150s, `ttft` null, `finish` stop/null).
|
|
224
|
+
|
|
225
|
+
## Suggested order
|
|
226
|
+
|
|
227
|
+
1. Config now (hot-reloads, routing-neutral for the narrowed-catalog case):
|
|
228
|
+
`adaptiveTierFloors: false`; restart all omp windows for v0.2.34.
|
|
229
|
+
2. Code: findings 3 + 6 + the `http.ts` slot leak (small, low risk).
|
|
230
|
+
3. Code: finding 1 (thinness-gated relaxation) so adaptive floors can go back on.
|
|
231
|
+
4. Code: findings 2 and 4 (escalation-aware effective cost, horizon-amortised
|
|
232
|
+
switch). Validate each with `tools/replay.ts` on the post-Sep-5 population.
|
|
233
|
+
5. Code: findings 7 and 8; catalog shrink warning (5).
|
|
234
|
+
6. Close the hold-length experiment; decide what exploration should measure.
|
|
235
|
+
|
|
236
|
+
## Applied (2026-09-05, follow-up session)
|
|
237
|
+
|
|
238
|
+
Typecheck clean, 539 tests pass. Schema is now **v16**. Everything below is in
|
|
239
|
+
the working tree (and, for `turn.ts`/`http.ts`, already in commit `332bfbe`,
|
|
240
|
+
which another session swept up while committing its own batched-signals
|
|
241
|
+
change — the message on that commit does not mention them).
|
|
242
|
+
|
|
243
|
+
| # | Finding | What shipped | Live effect |
|
|
244
|
+
| --- | --- | --- | --- |
|
|
245
|
+
| 1 | Adaptive floors relaxed on the wide catalog | `tier-plan.ts`: a configured floor stands whenever ≥3 rankable models meet it (`MIN_FLOOR_ADMITS`); only a thin tier relaxes to the band | config `adaptiveTierFloors: false` (hot-reloaded, in effect now); delete the key once every omp window runs this build |
|
|
246
|
+
| 2 | Trust divisor misprices failure | `filters.escalationCostWeight` (default 0) + `Ledger.escalationCost()`: a model's measured escalation rate × the ledger's measured $/prompt-token of escalated retries | **left off.** Replay on 5,000 Sep 2–5 dispatches at weight 1: 586 decisions move off ling-3.0-flash onto solar-pro4/mistral-nemo, direct cost +1.3%; the avoided-escalation benefit is not something replay can model. With the floors fixed the expensive case (ling at moderate → opus) no longer arises, so the term is a hedge, not a fix. Enable if ling-class models keep winning `simple`. |
|
|
247
|
+
| 3 | Escalation re-dispatches the same slug | `turn.ts`: the failing slug joins `excludeSlugs` on every probe escalation; `empty_completion`/`refusal`/`upstream_error` try a same-tier sibling (bounded by `MAX_SAME_TIER_FAILOVERS`) before stepping up | on restart |
|
|
248
|
+
| 4 | Cross-tier switch / myopic stay-switch | `hysteresis.switchHorizonTurns` (default 1): `H × stayWarm` vs `switchCold + (H−1) × newWarm` | replay at 8: **0 of 5,000 decisions change** while glm is in the catalog (the one-turn rule already switches away from a dear warm model when the winner is cheap cold). It only bites when the winner is itself dear cold — the Sep 3 kimi case. Add `switchHorizonTurns: 4` to config **after** the restart (the old schema rejects unknown keys); a commented block is in place. |
|
|
249
|
+
| 5 | Catalog collapse was silent | `openrouter-catalog.ts`: a refresh keeping <50% of ≥20 models logs at `warn` and is exposed as `catalog.shrink` on `/health`; cleared on recovery | on restart |
|
|
250
|
+
| 6 | Abort-after-finish recorded as error | `turn.ts`: an abort with `finishReason` set is a settled generation — commit path, state saved, `error: null` | on restart |
|
|
251
|
+
| 7 | Compaction re-plans every turn | `compaction.replanGrowthRatio` (default 1) + `conversations.compaction_plan_tokens` (v16): extend a plan only once the compacted prompt has grown by the ratio; fit-to-window never rationed | add `replanGrowthRatio: 1.1` **after** the restart; commented block in place |
|
|
252
|
+
| 8 | Calibration bias / poisoned qwen3 | `adjustPendingEstimate()` pairs billed tokens with the dispatched bytes (post-compaction, plus context block); samples outside 1.5–8 bytes/token are rejected; **v16 deletes every `token_calibration` row** so families relearn on correctly paired data | on restart (first open runs the migration) |
|
|
253
|
+
| 9 | Hold-length experiment diluted | config `exploration.holdTurns.enabled: false` | hot-reloaded |
|
|
254
|
+
| 10 | Stale-image weight | `classify.ts`: `W_IMAGES` fires on `hasNewImage` | on restart |
|
|
255
|
+
| defect | `http.ts` slot leak | released on parse failure; regression test | on restart |
|
|
256
|
+
| reviewer | unreachable "user-visible failure" branch | `features.ts` scans the tool run behind the newest user turn, so the full failed-tool weight is reachable; damping helper deduplicated; stale header comments and README row fixed | on restart |
|
|
257
|
+
|
|
258
|
+
Not done, by choice: the timer-driven `maxHoldMs` (see the defects table — it
|
|
259
|
+
would mis-escalate glm), and the exploration verdict redesign (a product
|
|
260
|
+
decision: what should "the cheaper tier sufficed" measure?).
|
|
261
|
+
|
|
262
|
+
**Deploy order.** Restart every omp window (two `omp.exe` processes started
|
|
263
|
+
2026-09-03T00:27–00:29Z are still on v0.2.33 code). Then add the two
|
|
264
|
+
commented config keys and delete `adaptiveTierFloors: false`. Watch the first
|
|
265
|
+
ledger rows for `[re-plan rationed]`, `failover: … empty_completion`, and
|
|
266
|
+
`compaction_plan_tokens` populating; `token_calibration` will be empty until
|
|
267
|
+
~20 samples per family have landed.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure configuration-driving logic for the `/router
|
|
2
|
+
* Pure configuration-driving logic for the `/router config` slash command.
|
|
3
3
|
*
|
|
4
4
|
* Reuses the router's existing wizard field definitions and validation
|
|
5
5
|
* (`src/cli/config-wizard.ts`) so the in-omp UI edits exactly the same set of
|
|
@@ -10,14 +10,17 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { FieldSpec, SectionSpec } from "../src/cli/config-wizard.ts";
|
|
13
|
-
import { CLEAR_TOKEN,
|
|
13
|
+
import { CLEAR_TOKEN, displayValue, validateField } from "../src/cli/config-wizard.ts";
|
|
14
14
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
15
15
|
|
|
16
|
+
/** A selector entry: omp renders the description dimmed beside the label and returns the label. */
|
|
17
|
+
export type SelectOption = string | { label: string; description?: string };
|
|
18
|
+
|
|
16
19
|
export interface ConfigUi {
|
|
17
20
|
/** Show a selector, return the chosen option label, or undefined on cancel. */
|
|
18
|
-
select(title: string, options:
|
|
21
|
+
select(title: string, options: SelectOption[]): Promise<string | undefined>;
|
|
19
22
|
/** Show a text input with a placeholder, or undefined on cancel. */
|
|
20
|
-
input(title: string, placeholder?: string
|
|
23
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
21
24
|
/** Yes/no confirmation. */
|
|
22
25
|
confirm(title: string, message: string): Promise<boolean>;
|
|
23
26
|
/** Surface a status/result line. */
|
|
@@ -25,36 +28,50 @@ export interface ConfigUi {
|
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
|
|
31
|
+
/** `Listen port (ms) · current: 8788` — every dialog names the value it would replace. */
|
|
32
|
+
function promptTitle(field: FieldSpec, current: unknown): string {
|
|
33
|
+
const hint = field.hint !== undefined ? ` (${field.hint})` : "";
|
|
34
|
+
return `${field.label}${hint} · current: ${displayValue(field, current)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
/**
|
|
29
38
|
* Prompts for one field, returning the parsed value or null when the user kept
|
|
30
|
-
* the current value.
|
|
31
|
-
*
|
|
39
|
+
* the current value. The current value is shown in the dialog title, marked
|
|
40
|
+
* in select pickers, and used as the input placeholder; an empty answer keeps
|
|
41
|
+
* it and CLEAR_TOKEN clears an optional field. Returns `undefined` when the
|
|
42
|
+
* user cancelled the dialog.
|
|
32
43
|
*/
|
|
33
44
|
export async function promptField(
|
|
34
45
|
ui: ConfigUi,
|
|
35
46
|
field: FieldSpec,
|
|
36
47
|
current: unknown,
|
|
37
48
|
): Promise<{ value: unknown; changed: boolean } | undefined> {
|
|
38
|
-
const
|
|
49
|
+
const title = promptTitle(field, current);
|
|
50
|
+
const mark = (label: string, isCurrent: boolean): SelectOption => (isCurrent ? { label, description: "current" } : label);
|
|
39
51
|
|
|
40
52
|
if (field.kind === "boolean") {
|
|
41
|
-
|
|
53
|
+
// An optional boolean can also be cleared back to "unset" (its default).
|
|
54
|
+
const unset = current === undefined || current === null;
|
|
55
|
+
const options: SelectOption[] = [mark("true", current === true), mark("false", current === false)];
|
|
56
|
+
if (field.optional === true) options.push(mark("unset", unset));
|
|
57
|
+
const chosen = await ui.select(title, options);
|
|
42
58
|
if (chosen === undefined) return undefined;
|
|
59
|
+
if (chosen === "unset") return { value: null, changed: !unset };
|
|
43
60
|
const value = chosen === "true";
|
|
44
61
|
return { value, changed: value !== current };
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
if (field.kind === "enum") {
|
|
48
|
-
const options = field.options ?? [];
|
|
49
|
-
const
|
|
50
|
-
const chosen = await ui.select(label, [...options], idx >= 0 ? idx : 0);
|
|
65
|
+
const options = (field.options ?? []).map((o) => mark(o, o === current));
|
|
66
|
+
const chosen = await ui.select(title, options);
|
|
51
67
|
if (chosen === undefined) return undefined;
|
|
52
68
|
return { value: chosen, changed: chosen !== current };
|
|
53
69
|
}
|
|
54
70
|
|
|
55
|
-
// string | number | stringArray: free-text input.
|
|
56
|
-
|
|
57
|
-
const
|
|
71
|
+
// string | number | stringArray | numberArray: free-text input. Secrets
|
|
72
|
+
// show set/unset rather than the value.
|
|
73
|
+
const placeholder = `${displayValue(field, current)} (Enter keeps${field.optional === true ? `, ${CLEAR_TOKEN} clears` : ""})`;
|
|
74
|
+
const answer = await ui.input(title, placeholder);
|
|
58
75
|
if (answer === undefined) return undefined;
|
|
59
76
|
if (answer.trim() === "") return { value: current, changed: false }; // keep
|
|
60
77
|
if (answer.trim() === CLEAR_TOKEN) {
|
|
@@ -67,7 +84,46 @@ export async function promptField(
|
|
|
67
84
|
ui.notify(`invalid: ${result.error}`, "warn");
|
|
68
85
|
return { value: current, changed: false };
|
|
69
86
|
}
|
|
70
|
-
|
|
87
|
+
// Arrays are re-parsed from text every time, so compare by content or an
|
|
88
|
+
// unchanged list would register as an edit.
|
|
89
|
+
const changed = Array.isArray(result.value) && Array.isArray(current)
|
|
90
|
+
? JSON.stringify(result.value) !== JSON.stringify(current)
|
|
91
|
+
: result.value !== current;
|
|
92
|
+
return { value: result.value, changed };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Section editor for the omp command: a picker listing every field of the
|
|
97
|
+
* section with its current value (or the pending edit, marked), so the user
|
|
98
|
+
* sees the settings before choosing which one to change. Picking a field
|
|
99
|
+
* prompts for it; "Back" returns. Returns true if any field changed.
|
|
100
|
+
*/
|
|
101
|
+
export async function editSectionMenu(
|
|
102
|
+
ui: ConfigUi,
|
|
103
|
+
section: SectionSpec,
|
|
104
|
+
cfg: RouterConfig,
|
|
105
|
+
answers: Record<string, unknown>,
|
|
106
|
+
): Promise<boolean> {
|
|
107
|
+
let any = false;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const options: SelectOption[] = section.fields.map((field) => {
|
|
110
|
+
const pending = field.path in answers;
|
|
111
|
+
const current = pending ? answers[field.path] : getPathValue(cfg, field.path);
|
|
112
|
+
return { label: field.label, description: `${displayValue(field, current)}${pending ? " (pending)" : ""}` };
|
|
113
|
+
});
|
|
114
|
+
options.push("Back");
|
|
115
|
+
const chosen = await ui.select(`${section.title}${any ? " (edited)" : ""}`, options);
|
|
116
|
+
if (chosen === undefined || chosen === "Back") return any;
|
|
117
|
+
const field = section.fields.find((f) => f.label === chosen);
|
|
118
|
+
if (field === undefined) continue;
|
|
119
|
+
const current = field.path in answers ? answers[field.path] : getPathValue(cfg, field.path);
|
|
120
|
+
const result = await promptField(ui, field, current);
|
|
121
|
+
if (result === undefined) continue; // cancelled the field dialog: back to the picker
|
|
122
|
+
if (result.changed) {
|
|
123
|
+
answers[field.path] = result.value;
|
|
124
|
+
any = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
71
127
|
}
|
|
72
128
|
|
|
73
129
|
/**
|
|
@@ -43,12 +43,62 @@ declare module "@oh-my-pi/pi-coding-agent" {
|
|
|
43
43
|
getSessionId(): string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** A TUI component: rows at a width, optional key handling and teardown. */
|
|
47
|
+
export interface Component {
|
|
48
|
+
render(width: number): readonly string[];
|
|
49
|
+
handleInput?(data: string): void;
|
|
50
|
+
invalidate?(): void;
|
|
51
|
+
dispose?(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The subset of omp's theme the report hub paints with. */
|
|
55
|
+
export interface Theme {
|
|
56
|
+
fg(color: string, text: string): string;
|
|
57
|
+
bg(color: string, text: string): string;
|
|
58
|
+
bold(text: string): string;
|
|
59
|
+
boxRound: {
|
|
60
|
+
topLeft: string;
|
|
61
|
+
topRight: string;
|
|
62
|
+
bottomLeft: string;
|
|
63
|
+
bottomRight: string;
|
|
64
|
+
horizontal: string;
|
|
65
|
+
vertical: string;
|
|
66
|
+
teeDown: string;
|
|
67
|
+
teeUp: string;
|
|
68
|
+
teeLeft: string;
|
|
69
|
+
teeRight: string;
|
|
70
|
+
};
|
|
71
|
+
nav: { cursor: string };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TUI {
|
|
75
|
+
terminal?: { rows: number; columns: number };
|
|
76
|
+
requestRender(): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface KeybindingsManager {
|
|
80
|
+
matches(data: string, keybinding: string): boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface OverlayOptions {
|
|
84
|
+
width?: number | string;
|
|
85
|
+
maxHeight?: number | string;
|
|
86
|
+
anchor?: string;
|
|
87
|
+
fullscreen?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
46
90
|
/** Mirrors `ConfigUi` in configure-logic.ts, which is what /router drives. */
|
|
47
91
|
export interface ExtensionUI {
|
|
48
|
-
|
|
49
|
-
|
|
92
|
+
/** Returns the chosen label; an option may carry a dimmed description. */
|
|
93
|
+
select(title: string, options: Array<string | { label: string; description?: string }>): Promise<string | undefined>;
|
|
94
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
50
95
|
confirm(title: string, message: string): Promise<boolean>;
|
|
51
96
|
notify(text: string, level?: "info" | "warn" | "error"): void;
|
|
97
|
+
/** Show a custom component with keyboard focus; `overlay: true` floats it over the transcript. */
|
|
98
|
+
custom<T>(
|
|
99
|
+
factory: (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: T) => void) => Component,
|
|
100
|
+
options?: { overlay?: boolean; overlayOptions?: OverlayOptions },
|
|
101
|
+
): Promise<T>;
|
|
52
102
|
}
|
|
53
103
|
|
|
54
104
|
export interface ExtensionContext {
|
|
@@ -66,11 +116,38 @@ declare module "@oh-my-pi/pi-coding-agent" {
|
|
|
66
116
|
handler(args: string, ctx: ExtensionContext): void | Promise<void>;
|
|
67
117
|
}
|
|
68
118
|
|
|
119
|
+
/**
|
|
120
|
+
* A custom transcript message. `display: true` renders it in the TUI;
|
|
121
|
+
* `content` is markdown. (Real type: `CustomMessagePayload<T>`.)
|
|
122
|
+
*/
|
|
123
|
+
export interface CustomMessagePayload {
|
|
124
|
+
customType?: string;
|
|
125
|
+
content?: string;
|
|
126
|
+
display?: boolean;
|
|
127
|
+
details?: unknown;
|
|
128
|
+
}
|
|
129
|
+
|
|
69
130
|
export interface ExtensionAPI {
|
|
70
131
|
setLabel(label: string): void;
|
|
71
132
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>): void;
|
|
72
133
|
registerProvider(id: string, registration: ProviderRegistration): void;
|
|
73
134
|
unregisterProvider(id: string): void;
|
|
74
135
|
registerCommand(name: string, command: CommandDefinition): void;
|
|
136
|
+
/** Appends a custom message to the session; `triggerTurn: false` leaves the agent idle. */
|
|
137
|
+
sendMessage(message: CustomMessagePayload | string, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): void;
|
|
75
138
|
}
|
|
76
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* LOCAL stub for omp's TUI toolkit, which omp resolves for extensions at load
|
|
143
|
+
* time (its bundled example extensions import it the same way). Only the
|
|
144
|
+
* helpers the report hub uses are declared.
|
|
145
|
+
*/
|
|
146
|
+
declare module "@oh-my-pi/pi-tui" {
|
|
147
|
+
/** Columns a string occupies on screen, ignoring ANSI styling. */
|
|
148
|
+
export function visibleWidth(text: string): number;
|
|
149
|
+
/** Cuts a (possibly styled) string to at most `width` columns. */
|
|
150
|
+
export function truncateToWidth(text: string, width: number, ellipsis?: string): string;
|
|
151
|
+
/** Matches raw terminal input against a key id such as `"left"` or `"ctrl+c"`. */
|
|
152
|
+
export function matchesKey(data: string, keyId: string): boolean;
|
|
153
|
+
}
|