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.
- package/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- 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
|
+
}
|