auto-model-router 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
package/README.md ADDED
@@ -0,0 +1,639 @@
1
+ # auto-model-router
2
+
3
+ A local model router for [Oh My Pi](https://github.com/oh-my-pi). It presents
4
+ itself as one keyless OpenAI-compatible provider, then picks a concrete
5
+ OpenRouter model **per turn** based on measured price and estimated task
6
+ complexity — including mid-conversation, when a session shifts from mechanical
7
+ tool-loop churn to genuine reasoning work.
8
+
9
+ All LLM inference is offloaded to OpenRouter. Nothing runs on-device except
10
+ routing arithmetic.
11
+
12
+ auto-model-router runs **embedded inside the omp process** (as an omp extension) — no
13
+ separate server, no orphaned process. It binds a free OS-assigned port and
14
+ lives and dies with the omp session.
15
+
16
+ ## Why this exists when OpenRouter already ships routers
17
+
18
+ OpenRouter has `openrouter/auto` (market-spend classifier) and
19
+ `openrouter/pareto-code` (Artificial Analysis coding percentile → cheapest in
20
+ tier). Both are opaque, server-side, and — per Pareto's own docs — *"you can't
21
+ directly cap cost or latency per request."*
22
+
23
+ This router exists for the things a prompt classifier structurally cannot do:
24
+
25
+ | Lever | Why it needs to be local |
26
+ | --- | --- |
27
+ | **Agent-loop awareness** | OpenRouter sees a prompt. We see omp's tool array, tool-result depth, and whether the previous tool call failed. Most agent turns are mechanical post-tool-result continuations — the largest cost lever in agent traffic, and invisible upstream. |
28
+ | **Budget enforcement** | Per-turn, per-conversation, and rolling-24h caps, checked against a **cold-cache forecast** before dispatch, with forced downgrade at the ceiling. |
29
+ | **Mid-stream escalation** | Hold the first N tokens; on a malformed tool call, refusal, empty completion, or repeated tool call, abort and re-dispatch upward. omp never observes the failure. |
30
+ | **Cache-aware hysteresis** | Switching models forfeits the warm prompt cache. The decision is arithmetic, not vibes: expected saving must beat the forfeited cache-read discount by a configured margin. |
31
+ | **Closed-loop trust** | Per-model escalation and error rates from *your* traffic demote cheap-but-flaky models automatically. |
32
+ | **Explainability** | Every decision — candidates, rejections, forecasts, reasons — is persisted and replayable via `auto-model-router explain`. |
33
+
34
+ ## Architecture
35
+
36
+ ```mermaid
37
+ graph LR
38
+ omp[omp process] -->|OpenAI chat completions| wire[wire/openai]
39
+ wire -->|NormRequest| router[router]
40
+ catalog[catalog<br/>OpenRouter /models] --> router
41
+ cost[cost<br/>forecast + ledger] --> router
42
+ router -->|Decision| guard[escalation guard]
43
+ guard -->|rendered body| up[upstream/openrouter]
44
+ up -->|UpstreamChunk| guard
45
+ guard -->|commit or retry upward| wire
46
+ guard -->|usage + reported cost| cost
47
+ ```
48
+
49
+ The router runs in-process inside omp via the `router-embed` extension. The
50
+ core never parses a wire format. A front end produces a `NormRequest` and
51
+ consumes `UpstreamChunk`s, so a `pi-native` front end can be added later
52
+ without touching routing.
53
+
54
+ ### Module map
55
+
56
+ | Path | Responsibility |
57
+ | --- | --- |
58
+ | `src/catalog/` | Fetch and normalize OpenRouter `/api/v1/models`: pricing, capability flags, Artificial Analysis quality indices. SQLite-cached with TTL. |
59
+ | `src/cost/` | Cost forecasting per candidate; reconciliation against OpenRouter's authoritative `usage.cost`; the spend ledger; per-model trust; rolling blended rate. |
60
+ | `src/tokens/` | Token estimation with no tokenizer dependency, self-calibrating from observed `prompt_tokens` per tokenizer family. |
61
+ | `src/wire/` | Protocol boundary. `wire/openai/` implements chat completions in and SSE out. |
62
+ | `src/router/` | Feature extraction, complexity classification, candidate filtering and scoring, hysteresis, cache-breakpoint placement, budget guard, probe planning. |
63
+ | `src/upstream/` | OpenRouter transport: streaming dispatch, `session_id` stickiness, error classification, fallback arrays. |
64
+ | `src/config/` | Configuration loading, schema validation, and the built-in defaults. |
65
+ | `src/cli/` | `stats`, `models`, `explain`, `config` commands. |
66
+ | `omp-extension/` | The omp extensions: `router-embed.ts`, `router-toast.ts`, `router-configure.ts`. |
67
+
68
+ ### Two cost numbers, never conflated
69
+
70
+ - **Predicted** — our arithmetic over the catalog, computed *before* dispatch.
71
+ Drives routing and budget guards. Must model `pricing.overrides` tiers, or
72
+ long conversations are underestimated by ~50% exactly when it matters.
73
+ - **Reported** — `usage.cost` from OpenRouter, authoritative after the fact.
74
+ Drives the ledger, `stats`, and prediction-error calibration.
75
+
76
+ ## Requirements
77
+
78
+ - **omp** (the Oh My Pi harness) — the router runs as an omp extension.
79
+ - **Bun** `>= 1.2.0` — omp itself is a Bun process; the router code runs inside
80
+ it. No separate Bun install is needed for the embedded path.
81
+
82
+ ## Installation
83
+
84
+ There is nothing to install system-wide. Run the cross-platform installer
85
+ (Windows, macOS, Linux) from the repo:
86
+
87
+ ```bash
88
+ bun tools/install.ts
89
+ ```
90
+
91
+ It wires the auto-model-router extensions into omp's `~/.omp/agent/config.yml`
92
+ (`$PI_CODING_AGENT_DIR/config.yml` when that env var relocates the agent dir),
93
+ backing up the previous file first. It is idempotent — re-running is a no-op.
94
+
95
+ Options:
96
+
97
+ ```bash
98
+ bun tools/install.ts --no-toast --no-configure # only the required embed extension
99
+ ```
100
+
101
+ The installer adds:
102
+
103
+ - `router-embed.ts` — **required**; runs the router in-process.
104
+ - `router-toast.ts` — optional; chosen-model toasts.
105
+ - `router-configure.ts` — optional; the `/router` command.
106
+
107
+ Or add the paths by hand to omp's `~/.omp/agent/config.yml`:
108
+
109
+ ```yaml
110
+ # ~/.omp/agent/config.yml
111
+ extensions:
112
+ - /path/to/auto-model-router/omp-extension/router-embed.ts
113
+ - /path/to/auto-model-router/omp-extension/router-toast.ts # optional: chosen-model toasts
114
+ - /path/to/auto-model-router/omp-extension/router-configure.ts # optional: /router command
115
+ ```
116
+
117
+ Then restart the omp session (extensions load at session start).
118
+
119
+ or install it from the marketplace (see below). The plugin declares all three
120
+ extensions (`router-embed`, `router-toast`, `router-configure`), so installing
121
+ it wires the router in without editing `config.yml` by hand.
122
+
123
+ ### Install from the marketplace
124
+
125
+ This repo doubles as its own marketplace: it ships a catalog at
126
+ `.omp-plugin/marketplace.json` listing the `auto-model-router` plugin. Add the repo as
127
+ a marketplace source, then install the plugin:
128
+
129
+ ```bash
130
+ omp plugin marketplace add drewappling/auto-model-router
131
+ omp plugin install auto-model-router@auto-model-router
132
+ ```
133
+
134
+ or in the TUI:
135
+
136
+ ```
137
+ /marketplace add drewappling/auto-model-router
138
+ /marketplace install auto-model-router@auto-model-router
139
+ ```
140
+
141
+ ### Install from the Pi package marketplace
142
+
143
+ The repo is also a Pi package (see the `pi` manifest and `pi-package` keyword
144
+ in `package.json`), so it can be installed with the Pi CLI and listed on
145
+ [pi.dev/packages](https://pi.dev/packages):
146
+
147
+ ```bash
148
+ pi install npm:auto-model-router
149
+ ```
150
+
151
+ or from git:
152
+
153
+ ```bash
154
+ pi install git:github.com/drewappling/auto-model-router
155
+ ```
156
+
157
+ To publish to npm (which auto-indexes on pi.dev/packages):
158
+
159
+ ```bash
160
+ npm publish
161
+ ```
162
+
163
+ ### Hermes (day-1 support)
164
+
165
+ Hermes speaks the OpenAI-compatible wire, so it connects to the router with no
166
+ code change — just a custom provider pointing at the router's URL. Add a named
167
+ provider to `~/.hermes/config.yaml`:
168
+
169
+ ```yaml
170
+ providers:
171
+ auto-model-router:
172
+ base_url: http://127.0.0.1:8788/v1
173
+ api_key: no-key-required
174
+ default_model: auto
175
+ ```
176
+
177
+ The router serves `GET /v1/models` (returning the `auto`, `auto-cheap`,
178
+ `auto-max` profiles) and `POST /v1/chat/completions`, which Hermes's custom
179
+ endpoint discovery verifies. Select `auto-model-router/auto` as the model and
180
+ the router routes each turn by price and complexity. The router's own OpenRouter
181
+ key resolution (config → env → omp auth store) applies — Hermes does not need
182
+ its own OpenRouter key.
183
+
184
+ ### The OpenRouter key
185
+
186
+ ### The OpenRouter key
187
+
188
+ There should be exactly one OpenRouter key on the machine, and omp already owns
189
+ a credential store. Resolution order:
190
+
191
+ 1. `openrouter.apiKey` in `$AUTO_MODEL_ROUTER_HOME/config.yml`
192
+ 2. `OPENROUTER_API_KEY` (including any `.env` omp loaded into the environment)
193
+ 3. **omp's own auth store** — `~/.omp/agent/agent.db`, provider `openrouter`
194
+
195
+ So `/login openrouter` inside omp is sufficient setup; nothing needs copying.
196
+ The store is opened read-only and never written: omp owns it, including OAuth
197
+ refresh. An expired OAuth access token is rejected rather than sent, because
198
+ refreshing is omp's job and a stale bearer just burns a turn on a 401. Under
199
+ `OMP_AUTH_BROKER_URL` the local store is not consulted at all, since a broker
200
+ replaces it.
201
+
202
+ The embedded router reports the key source via its in-process `GET /health`
203
+ (`config` | `env` | `omp-auth-store` | `none`) — never the key itself.
204
+
205
+ ---
206
+
207
+ ## How it runs
208
+
209
+ At session start, the **main** omp session's `router-embed.ts`:
210
+
211
+ 1. binds a **free OS-assigned port** (`Bun.serve({ port: 0 })`) so several omp
212
+ sessions never collide on a fixed port;
213
+ 2. writes the actual bound port to the shared `$AUTO_MODEL_ROUTER_HOME/embed.port`;
214
+ 3. registers an `auto-model-router` provider with omp (`auto`, `auto-cheap`, `auto-max`
215
+ virtual models) pointing at `http://127.0.0.1:$PORT/v1`.
216
+
217
+ Subagents do **not** bind their own router. They are ephemeral worker processes
218
+ whose PIDs get recycled, so a per-process port file is a race. Instead every
219
+ subagent registers the same shared provider and routes to the main session's
220
+ single router, whose port lives in the one shared `embed.port` file — one
221
+ authoritative writer, no stale per-PID port.
222
+
223
+ The router lives and dies with the main omp session — no orphan process, no "is
224
+ the server running?" stopping the omp process frees the port automatically.
225
+
226
+ ### Multiple omp sessions, one machine
227
+
228
+ Each top-level omp session binds its own router on its own ephemeral port, so
229
+ they never conflict. The `X-Omp-Harness` header (from `server.harnessId`)
230
+ scopes budgets, toasts, and optional trust per harness.
231
+
232
+ ---
233
+
234
+ ## Selecting the provider / model
235
+
236
+ The router registers three virtual models under the `auto-model-router` provider:
237
+
238
+ | Profile | Min tier | Max tier | Use |
239
+ | --- | --- | --- | --- |
240
+ | `auto` | trivial | hard | Default — routes by complexity across the whole range. |
241
+ | `auto-cheap` | trivial | simple | Cost-first — caps at the `simple` tier. |
242
+ | `auto-max` | moderate | hard | Quality-first — never below `moderate`. |
243
+
244
+ Select one in omp via `/model` and pick `auto-model-router/auto` (or one of the
245
+ others). Or set it as the default for a role in `~/.omp/agent/config.yml`:
246
+
247
+ ```yaml
248
+ modelRoles:
249
+ default: auto-model-router/auto
250
+ ```
251
+
252
+ The router decides the concrete OpenRouter model **per turn**; omp only sees the
253
+ virtual profile it picked. Every routed response carries
254
+ `x-auto-model-router-model`, `x-auto-model-router-tier`, `x-auto-model-router-cost-usd`, and
255
+ `x-auto-model-router-attempts`.
256
+
257
+ ---
258
+
259
+ ## Configuring the router
260
+
261
+ The router's own config lives at `$AUTO_MODEL_ROUTER_HOME/config.yml` (default
262
+ `~/.auto-model-router/config.yml`). Every key is optional — unset keys use the
263
+ built-in defaults below. There are two ways to edit it:
264
+
265
+ ### Via `/router` (in-omp, native UI)
266
+
267
+ Install the `router-configure` extension, restart omp, then run `/router` in
268
+ the session prompt. It shows a section picker (Server, OpenRouter, Tiers,
269
+ Tasks, Filters, Classifier, Escalation, Hysteresis, Cache, Budget, Ledger,
270
+ Logging, Profiles). Each field prompts through omp's native UI dialogs —
271
+ empty input keeps the current value, `-` clears an optional field. `Save and
272
+ exit` writes the merged config (schema-checked and backed up first). Restart
273
+ the omp session after saving.
274
+
275
+ ### Via `auto-model-router config` (text wizard / CLI)
276
+
277
+ ```bash
278
+ auto-model-router config
279
+ ```
280
+
281
+ Same fields, prompted on the terminal. Also:
282
+
283
+ - `auto-model-router config --print` — prints the `models.yml` provider block.
284
+ - `auto-model-router config --write` — merges that block into omp's `models.yml`.
285
+
286
+ Both write paths validate the merged file against the schema before touching
287
+ disk and back up the previous file to a timestamped `.bak`.
288
+
289
+ ### Configuration file location
290
+
291
+ - Router config: `$AUTO_MODEL_ROUTER_HOME/config.yml` (default `~/.auto-model-router/config.yml`).
292
+ - Ledger DB: `$AUTO_MODEL_ROUTER_HOME/router.db` (SQLite, WAL).
293
+
294
+ ### Environment variables
295
+
296
+ | Variable | Purpose | Default |
297
+ | --- | --- | --- |
298
+ | `OPENROUTER_API_KEY` | OpenRouter key (overrides the auth store). | — |
299
+ | `AUTO_MODEL_ROUTER_HOME` | Config + database directory. | `~/.auto-model-router` |
300
+ | `AUTO_MODEL_ROUTER_PORT` | Pin a specific bind port (rarely needed; the embedded router picks a free one otherwise). | OS-assigned |
301
+ | `AUTO_MODEL_ROUTER_LOG` | Log level: `silent`/`error`/`warn`/`info`/`debug`. | `info` |
302
+ | `AUTO_MODEL_ROUTER_DB` | Override the ledger path. | `$AUTO_MODEL_ROUTER_HOME/router.db` |
303
+ | `AUTO_MODEL_ROUTER_URL` | Toast/base URL override (the toast reads the shared port file first). | — |
304
+ | `AUTO_MODEL_ROUTER_API_KEY` | Client bearer for the toast poll when `server.apiKey` is set. | — |
305
+ | `OMP_HARNESS_ID` | Per-harness toast scoping. | — |
306
+
307
+ ---
308
+
309
+ ## Configuration reference
310
+
311
+ This is the complete set of settings, grouped by section, with defaults and
312
+ what each one does. All values are optional; omit a key to use its default.
313
+
314
+ ### `server`
315
+
316
+ | Key | Default | Meaning |
317
+ | --- | --- | --- |
318
+ | `host` | `127.0.0.1` | Bind address. `0.0.0.0`/`::` listen on all interfaces (the provider still advertises loopback). |
319
+ | `port` | `0` | Bind port. `0` = let the OS pick a free ephemeral port (the embedded router's default). |
320
+ | `apiKey` | unset | Optional client bearer token. When set, every request must send `Authorization: Bearer <key>`. |
321
+ | `harnessId` | unset | Harness identity sent as `X-Omp-Harness`; scopes per-harness daily budgets and toasts. |
322
+
323
+ ### `openrouter`
324
+
325
+ | Key | Default | Meaning |
326
+ | --- | --- | --- |
327
+ | `baseUrl` | `https://openrouter.ai/api/v1` | Upstream OpenRouter endpoint. |
328
+ | `apiKey` | unset | OpenRouter key. Falls back to `OPENROUTER_API_KEY`, then omp's auth store. |
329
+ | `referer` | unset | HTTP `Referer` header sent upstream (OpenRouter attribution). |
330
+ | `title` | `auto-model-router` | Attribution title sent upstream. |
331
+ | `timeoutMs` | `600000` (10 min) | Upstream request timeout. Agent turns stream for minutes, so keep this high. |
332
+ | `catalogTtlMs` | `21600000` (6 h) | How long the model catalog is cached before a forced refetch. |
333
+ | `catalogRefreshMs` | `300000` (5 min) | Background catalog refetch interval; `0` disables it. |
334
+
335
+ ### `tiers` — per-tier economic envelope
336
+
337
+ Each tier (`trivial`, `simple`, `moderate`, `hard`) is a `tierConfig`:
338
+
339
+ | Key | Default | Meaning |
340
+ | --- | --- | --- |
341
+ | `minQuality` | `0/40/60/72` | Minimum quality score (on the task's axis) a model needs to be eligible. `0` admits unscored models. |
342
+ | `maxInputPerMtok` | `0.3/1.5/4.0` (hard: none) | Price ceiling on input, USD per million tokens. `hard` has no ceiling. |
343
+ | `maxOutputPerMtok` | unset | Optional output price ceiling, USD per million tokens. |
344
+ | `qualityExponent` | `0/0/1/3` | How strongly quality beats price when ranking candidates. `0` = cheapest above the floor; higher = prefer quality. |
345
+ | `pin` | `[]` | Force specific model slugs into this tier (they bypass the floor/ceiling). |
346
+
347
+ ### `tasks` — per-task-type capability and quality
348
+
349
+ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
350
+ `taskConfig`:
351
+
352
+ | Key | Default | Meaning |
353
+ | --- | --- | --- |
354
+ | `axis` | coding→`coding`, others→`intelligence` | Which quality axis to score on. |
355
+ | `minQuality` | unset | RAISES the tier floor for this task (never relaxed by adaptive floors). |
356
+ | `requireImage` | vision: `true`, others unset | Require image input support. |
357
+ | `prefer` | `[]` | Preferred model slugs for this task. |
358
+
359
+ ### `filters` — candidate allow/deny and trust
360
+
361
+ | Key | Default | Meaning |
362
+ | --- | --- | --- |
363
+ | `allow` | `[]` | Glob allowlist; when non-empty, only matching slugs are eligible. |
364
+ | `deny` | `[]` | Glob denylist; matching slugs are excluded. |
365
+ | `includeFree` | `false` | Include free models (rate-limited hard; usually excluded). |
366
+ | `requireToolSupport` | `true` | Only models that support tool calls. |
367
+ | `minTrust` | `0.7` | Minimum success rate; models below this (after `minTrustSamples`) are demoted. |
368
+ | `minTrustSamples` | `12` | Attempts before trust is enforced. |
369
+ | `trustScopedByHarness` | `false` | `true` = each harness reads only its own trust rows. |
370
+ | `contextHeadroom` | `1.25` | Fraction of context kept free (a model must fit prompt × this). |
371
+
372
+ ### `classifier` — complexity adjudication
373
+
374
+ | Key | Default | Meaning |
375
+ | --- | --- | --- |
376
+ | `ambiguityThreshold` | `0.6` | Below this heuristic confidence, the adjudicator model decides the tier. |
377
+ | `model` | `qwen/qwen3.7-flash` | Adjudicator model slug. |
378
+ | `maxCostFraction` | `0.02` | Adjudicator cost cap as a fraction of the turn's budget. |
379
+ | `maxCostUsd` | `0.002` | Absolute adjudicator cost cap, USD. |
380
+ | `timeoutMs` | `4000` | Adjudicator request timeout. |
381
+ | `cacheSize` | `512` | Adjudication result cache size. |
382
+ | `toolAxis` | `coding` | Quality axis for tool-heavy turns. |
383
+ | `chatAxis` | `intelligence` | Quality axis for chat turns. |
384
+ | `agenticLoopDepth` | `3` | Tool-loop depth at which a turn is treated as agentic. |
385
+
386
+ ### `escalation` — mid-stream retry upward
387
+
388
+ | Key | Default | Meaning |
389
+ | --- | --- | --- |
390
+ | `enabled` | `true` | Enable the mid-stream escalation guard. |
391
+ | `probeTokens` | `48` | Tokens held before deciding whether to escalate. |
392
+ | `maxHoldMs` | `8000` | Max time to hold the first tokens waiting for a verdict. |
393
+ | `maxAttempts` | `3` | Original try + retries. Direct dial between reliability and wasted spend. |
394
+ | `probeTiers` | `["trivial","simple","moderate"]` | Tiers that may escalate upward (`hard` has nowhere to go). |
395
+ | `triggers` | 5 signals | `malformed_tool_args`, `refusal`, `empty_completion`, `repeat_tool_call`, `missing_expected_tool_call`. |
396
+ | `escalateOnLengthStop` | `true` | Escalate on a `length` finish that truncated tool-call args. |
397
+
398
+ ### `hysteresis` — cache-aware model stickiness
399
+
400
+ | Key | Default | Meaning |
401
+ | --- | --- | --- |
402
+ | `holdTurns` | `2` | Hold a chosen model this many turns before it can downgrade. |
403
+ | `holdTurnsAfterEscalation` | `4` | Hold longer after an escalation. |
404
+ | `switchMargin` | `1.3` | Switching must beat the warm-cache discount by this factor. Lower = switch away from a warm model more readily. |
405
+ | `cacheWarmTtlMs` | `300000` (5 min) | How long a model's prompt cache is considered warm. |
406
+ | `maxDowngradePerTurn` | `1` | Max tiers a turn may drop in one step (avoids quality cliffs). |
407
+
408
+ ### `cache` — prompt-cache breakpoints
409
+
410
+ | Key | Default | Meaning |
411
+ | --- | --- | --- |
412
+ | `injectBreakpoints` | `true` | Insert prompt-cache breakpoints into long prompts. |
413
+ | `maxBreakpoints` | `4` | Max breakpoints (Anthropic allows 4; OpenRouter translates). |
414
+ | `minPromptTokens` | `2048` | Minimum prompt size before breakpoints are injected. |
415
+
416
+ ### `budget` — cost caps
417
+
418
+ | Key | Default | Meaning |
419
+ | --- | --- | --- |
420
+ | `perTurnUsd` | unset | Per-turn cap (checked against the cold forecast). |
421
+ | `perConversationUsd` | unset | Per-conversation cap. |
422
+ | `perDayUsd` | unset | Rolling 24h cap, scoped per harness when `harnessId` is set. |
423
+ | `onExceeded` | `downgrade` | `downgrade` = pick the cheapest viable model; `reject` = fail the turn. |
424
+
425
+ ### `profiles` — the virtual models omp sees
426
+
427
+ Each profile is a complete entry (arrays replace wholesale):
428
+
429
+ | Key | Default | Meaning |
430
+ | --- | --- | --- |
431
+ | `id` | `auto` / `auto-cheap` / `auto-max` | Model id omp selects. |
432
+ | `name` | `Auto (auto-model-router)` etc. | Display name. |
433
+ | `minTier` / `maxTier` | `trivial`/`hard`, `trivial`/`simple`, `moderate`/`hard` | Tier envelope. |
434
+ | `contextWindow` | `400000` | Advertised context window (drives omp's compaction). |
435
+ | `maxTokens` | `32000` | Advertised max output tokens. |
436
+ | `budget` | unset | Per-profile budget overrides. |
437
+
438
+ ### `ledger` — cost measurement
439
+
440
+ | Key | Default | Meaning |
441
+ | --- | --- | --- |
442
+ | `path` | `$AUTO_MODEL_ROUTER_HOME/router.db` | SQLite ledger path. |
443
+ | `blendWindowDays` | `7` | Window for the blended cost rate. |
444
+ | `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
445
+ | `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
446
+ | `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
447
+
448
+ ### Top-level
449
+
450
+ | Key | Default | Meaning |
451
+ | --- | --- | --- |
452
+ | `adaptiveTierFloors` | `true` | Derive tier floors from the models actually available (relaxing, never raising, the configured floors). |
453
+ | `logLevel` | `info` | `silent`/`error`/`warn`/`info`/`debug`. |
454
+
455
+ ## Multiple coding harnesses, one router
456
+
457
+ A single embedded router can serve several omp sessions without them stepping
458
+ on each other:
459
+
460
+ - **Per-conversation routing** (hysteresis, cache warmth, escalation, spend) is
461
+ keyed by conversation, so different sessions isolate naturally.
462
+ - **Per-harness daily budget** — each harness sends an `X-Omp-Harness` header
463
+ (from the provider block's `headers:`), and the router scopes the rolling
464
+ 24h `perDayUsd` ceiling to it. One harness can't exhaust the day for another.
465
+ - **Per-harness toasts** — set `OMP_HARNESS_ID` to the same value so the
466
+ extension only toasts that harness's model choices.
467
+
468
+ Configure a harness by setting `server.harnessId`; set the same id in that
469
+ harness's `OMP_HARNESS_ID` env var.
470
+
471
+ **Model trust is shared by default** (`filters.trustScopedByHarness: false`):
472
+ every harness's attempts count toward each model's reliability score, so the
473
+ demotion guard converges on more samples and stays effective even with a small
474
+ guardrail-narrowed catalog. Enable `trustScopedByHarness: true` to read each
475
+ harness's reliability from only its own ledger rows.
476
+
477
+ ---
478
+
479
+ ## Toast notifications for the chosen model
480
+
481
+ auto-model-router is headless and cannot draw into omp's TUI, so chosen-model toasts
482
+ come from a small omp extension that polls the router's in-process ledger:
483
+
484
+ ```ts
485
+ // omp-extension/router-toast.ts (shipped in this repo)
486
+ ```
487
+
488
+ It raises a TUI toast (`ctx.ui.notify`) like
489
+ `meta/muse-glimmer-30b [trivial] · $0.00001` whenever a new model is chosen.
490
+ Install it by adding the file's absolute path to omp's `extensions:` list.
491
+
492
+ Because the embedded router binds a random port, the toast resolves the router
493
+ base URL on every poll in this order: the embedded router's port file
494
+ (`$AUTO_MODEL_ROUTER_HOME/embed.port`), then `AUTO_MODEL_ROUTER_URL`, then `AUTO_MODEL_ROUTER_PORT`,
495
+ then the router's own `config.yml`, then `http://127.0.0.1:8788`. Reading the
496
+ port file each tick means the toast always polls the port the router actually
497
+ bound, even though it changes every session.
498
+
499
+ The toast logic is a pure, unit-tested module
500
+ (`omp-extension/toast-logic.ts`, covered by `test/toast-logic.test.ts`): it
501
+ toasts only decisions newer than the last seen one, skips `wasted` escalation
502
+ attempts, and prefers the actual serving slug over the requested one.
503
+
504
+ ---
505
+
506
+ ## Verifying
507
+
508
+ ```bash
509
+ bun run typecheck # strict, exactOptionalPropertyTypes + noUncheckedIndexedAccess
510
+ bun test # unit suite
511
+ bun run smoke # end-to-end against a scriptable mock OpenRouter
512
+ ```
513
+
514
+ `bun smoke` starts the embedded router against `tools/mock-openrouter.ts`, which
515
+ serves a genuine catalog fixture and synthesizes OpenRouter-shaped SSE. It
516
+ asserts the properties that matter: no `openrouter/*`, `~alias`, `:batch`, or
517
+ `stealth/*` slug is ever selected; a mechanical tool-result continuation routes
518
+ to a cheaper tier than an architecture question in the same conversation; a
519
+ malformed tool call is escalated to a stronger model without the client ever
520
+ seeing the failure; and the abandoned attempt is booked as wasted spend.
521
+
522
+ `auto-model-router explain --file request.json` routes a saved request and prints the
523
+ feature vector, classification reasoning, ranked candidates with forecasts, and
524
+ every rejection with its cause — without dispatching a completion.
525
+
526
+ ---
527
+
528
+ ## Where quality scores come from
529
+
530
+ Tier floors are points on the Artificial Analysis index, which OpenRouter
531
+ publishes per model under `benchmarks.artificial_analysis` (coding, agentic and
532
+ intelligence). Two things about that data drive the router's behaviour:
533
+
534
+ **`/models/user` omits it entirely.** The key-scoped endpoint is authoritative
535
+ for *availability* under your guardrails, but its records carry no `benchmarks`
536
+ block. Read on its own it makes every model **unscored**, and an unscored model
537
+ satisfies no floor above zero — so `simple`, `moderate` and `hard` all go
538
+ permanently empty, selection widens down, and every turn is served by the
539
+ cheapest `trivial` model no matter how hard the work is. The router therefore
540
+ fetches the public `/models` purely to join the scores back on by id.
541
+ Availability still comes solely from the key-scoped list. The join is
542
+ best-effort: if the public fetch fails, the catalog stays unscored and degraded
543
+ rather than the refresh failing.
544
+
545
+ **Roughly 60% of the catalog is unscored anyway.** Scores are never imputed
546
+ from price, so unscored models are only ever eligible where the floor is zero.
547
+
548
+ ---
549
+
550
+ ## Adaptive tier floors
551
+
552
+ The configured floors (`trivial` 0, `simple` 40, `moderate` 60, `hard` 72) are
553
+ absolute points tuned against the full ~420-model catalog. A guardrail can
554
+ narrow your available set to models that all sit below them, at which point an
555
+ absolute floor admits nothing and the router is trapped in the lowest tier.
556
+
557
+ With `adaptiveTierFloors: true` (the default), every catalog refresh ranks the
558
+ **available** scored models and splits them into four quantile bands, taking
559
+ each band's lower bound as that tier's adaptive floor. The floor actually
560
+ enforced is `min(configured, adaptive)`:
561
+
562
+ - a healthy catalog keeps the configured floors verbatim — no behaviour change;
563
+ - a narrowed catalog falls back to the adaptive floor, so `hard` still gets the
564
+ best quartile of what is available instead of nothing.
565
+
566
+ Relaxation is one-directional by design: an adaptive floor may only **lower** a
567
+ tier floor, never raise one. Two things are deliberately exempt:
568
+
569
+ - **Task floors are never relaxed.** `tasks.*.minQuality` is a capability
570
+ requirement (vision needs a model that can actually see), not an economic
571
+ envelope, so the effective floor is `max(taskFloor, adaptiveTierFloor)`.
572
+ - **Unscored catalogs relax to zero.** With no measured spread to rank on, all
573
+ four floors compute to 0 and the price ceiling plus `qualityExponent` do the
574
+ differentiating.
575
+
576
+ `auto-model-router models` shows any relaxation explicitly:
577
+
578
+ ```
579
+ [hard] quality floor 95 → 76.1 (adaptive) on the coding axis - 3 eligible, 16 excluded
580
+ ```
581
+
582
+ ---
583
+
584
+ ## Raising quality for coding work
585
+
586
+ Tier floors are economic envelopes; `tasks.*.minQuality` is the knob for "I
587
+ want coding turns to use competent models regardless of tier". It RAISES the
588
+ floor at every tier and is never relaxed by adaptive floors, while the tier
589
+ price ceilings still cap what each tier may spend:
590
+
591
+ ```yaml
592
+ tasks:
593
+ coding:
594
+ axis: coding
595
+ minQuality: 68
596
+ ```
597
+
598
+ `auto-model-router models` names whichever mechanism moved a floor, so a surprising
599
+ eligible set is always explainable.
600
+
601
+ This is usually the right dial for an agentic coding harness. Most turns after
602
+ the first are tool-result continuations, which the complexity heuristic scores
603
+ as mechanical — correct for a single file read, but it means a long, genuinely
604
+ hard session keeps classifying `trivial`. A task floor lifts the quality of
605
+ whatever tier is chosen without forcing every turn into an expensive tier.
606
+
607
+ ---
608
+
609
+ ## Tier rescue
610
+
611
+ The tier envelopes (price ceilings, quality floors, trust bar) are tuned against
612
+ the full catalog, but OpenRouter guardrails can shrink a key's *available* set
613
+ down to a handful of models — all of which may fail every strict tier. When that
614
+ happens the router does not fail the turn; it progressively relaxes the
615
+ economic constraints (price ceilings → quality floors → trust bar) until some
616
+ **available** model qualifies. The hard capability filters (tool/image/context
617
+ support) and the key-scoped allowlist are never lifted, so the rescue can never
618
+ select a model the key cannot serve. Every rescue is recorded in the decision
619
+ trail (`tier rescue: strict config excluded all available models; relaxed …`).
620
+
621
+ ## Status
622
+
623
+ Working end to end against a live `OPENROUTER_API_KEY` and a guardrail-limited
624
+ account; contracts are frozen in `src/**/types.ts`.
625
+
626
+ Known gaps:
627
+
628
+ - The `pi-native` front end is designed for but not implemented; only the
629
+ OpenAI-compatible wire exists today.
630
+ - Blended `cost` figures in `models.yml` are refreshed by re-running
631
+ `auto-model-router config --write`, not automatically.
632
+
633
+ ## License
634
+
635
+ MIT License. See [LICENSE](LICENSE) for the full text.
636
+
637
+ Copyright (c) 2026 drewappling. Released under the MIT License — free to use,
638
+ modify, and distribute, including commercially, provided the copyright notice
639
+ is preserved.
package/bun.lock ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "omp-router",
7
+ "dependencies": {
8
+ "yaml": "^2.7.0",
9
+ "zod": "^4.0.0",
10
+ },
11
+ "devDependencies": {
12
+ "@types/bun": "latest",
13
+ "typescript": "^5.7.0",
14
+ },
15
+ },
16
+ },
17
+ "packages": {
18
+ "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
19
+
20
+ "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
21
+
22
+ "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
23
+
24
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
25
+
26
+ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
27
+
28
+ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
29
+
30
+ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
31
+ }
32
+ }