@apinggithub/mydsh-auto-model-router 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-model-router contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,522 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # mydsh-auto-model-router
2
+
3
+ A hybrid auto model-router plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH). Routes each agent request through **four capability levels (L0–L3)** with three user-facing routing modes: a zero-latency **heuristic lock** (text-width thresholds), a **fixed-level** mode, and **fully-auto** mode (keyword scoring + optional LLM classifier). Failure fallback chains and budget control round it out.
4
+
5
+ > Design inspired by [open-world-project/model-router](https://github.com/open-world-project/model-router) (Hermes Agent) and [opencode-model-router](https://github.com/marco-jardim/opencode-model-router) (fast/medium/heavy tiers): tiered routing plus a cost mode that decides the drift direction on ambiguity.
6
+
7
+ ## Acknowledgements
8
+
9
+ This plugin builds on ideas from the following open-source projects:
10
+
11
+ - [open-world-project/model-router](https://github.com/open-world-project/model-router) — Hermes Agent's auto model-router with keyword scoring and cost-aware tier selection
12
+ - [opencode-model-router](https://github.com/marco-jardim/opencode-model-router) — OpenCode's fast/medium/heavy tier delegation plugin
13
+ - [openclaw-routing-yaml](https://github.com/glasshousehq-os/openclaw-routing-yaml) — OpenClaw's declarative per-task model routing via YAML
14
+ - [openmarkai/openclaw-router](https://github.com/openmarkai/openclaw-router) — Benchmark-driven model routing with real evaluation data
15
+ - [MoeAisaka/openclaw-model-policy-router](https://github.com/MoeAisaka/openclaw-model-policy-router) — Policy-driven fail-closed model routing for OpenClaw
16
+ - [NanmiCoder/dsh-auto-mode](https://github.com/NanmiCoder/dsh-auto-mode) — DSH's auto permission mode; inspired the client settings section pattern
17
+ - [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — The host platform this plugin is built for
18
+
19
+ - English (this file)
20
+ - [中文](README.zh.md)
21
+
22
+ ## How it works
23
+
24
+ One decision is made **per user input** (not per agent step). Steps within the
25
+ same input reuse the decision — the LLM classifier is never called per step.
26
+
27
+ ```
28
+ user input (new)
29
+ │
30
+ ▼
31
+ ┌─ current mode?
32
+ │
33
+ │ MODE A heuristic lock (default)
34
+ │ text width of the last N inputs (CJK=2, other=1)
35
+ │ ├─ ≤10 → L0
36
+ │ ├─ ≤100 → L1 (locked)
37
+ │ └─ >100 → ask once: pin a fixed level? enable fully-auto?
38
+ │
39
+ │ MODE B fixed level
40
+ │ use the pinned level; re-ask every askEveryInputs inputs
41
+ │ window falls back ≤100 → return to MODE A
42
+ │
43
+ │ MODE C fully-auto
44
+ │ ① keyword scoring: L1 hits +1, L2 +2, L3 +3 (weights configurable)
45
+ │ 0 → L0 · 1-5 → L1 · 6-15 → L2 · 16+ → L3 (bands configurable)
46
+ │ ② score 0 → LLM classifier on 本次+上次输入+上次回复
47
+ │ ③ window falls back ≤100 → return to MODE A
48
+ └──────────────────────────────────────────────────────
49
+ │
50
+ ▼
51
+ agent/request reuses the decision for every step of this input
52
+ │
53
+ ▼
54
+ LLM dispatch
55
+ ```
56
+
57
+ ### The four levels
58
+
59
+ | Level | Typical tasks | Model guidance |
60
+ | --- | --- | --- |
61
+ | `L0` | everyday chat, quick answers | cheapest/fastest |
62
+ | `L1` | small edits, simple tests | regular |
63
+ | `L2` | writing code, review, refactoring | strong |
64
+ | `L3` | complex bug fixes, multi-step tasks, reports | most powerful |
65
+
66
+ Each level is a user-assigned provider/model route with a **required**
67
+ `reasoningEffort` (defaults to `off` when omitted): `off` = no thinking mode,
68
+ `high`/`max` = thinking intensity. Levels may be left unconfigured; the router
69
+ then falls to the nearest configured level (direction depends on the cost
70
+ mode).
71
+
72
+ ## Auto mode in the model selector
73
+
74
+ The plugin registers a virtual `auto` provider, so the model selector shows an
75
+ **Auto Router** group with a single **Auto** model:
76
+
77
+ ```
78
+ Auto Router
79
+ └─ Auto
80
+ deepseek-official
81
+ ├─ deepseek-v4-flash
82
+ └─ deepseek-v4-flash
83
+ ```
84
+
85
+ - Pick **Auto** → this plugin routes the session through the L0–L3 levels.
86
+ - Pick any **real model** → the plugin passes the request through untouched;
87
+ your explicit choice wins and the router stays out of the way.
88
+
89
+ ### Settings section (browser)
90
+
91
+ The browser half registers an **Auto Router** page in DSH's settings panel
92
+ (gear icon → sidebar). It renders the current routing policy — the same
93
+ `<mydsh-auto-model-router-status>` report the host injects on session start —
94
+ with a hint telling you to change the configuration through AI conversation:
95
+ just tell the agent in chat (e.g. *"change L2 to deepseek-v4-flash with max
96
+ reasoning"* or *"switch cost mode to cost-first"*), and the AI edits
97
+ `cordis.patch.yml` for you; restart DSH to apply.
98
+
99
+ Selecting **Auto** in the model picker applies immediately — there is no
100
+ confirmation dialog.
101
+
102
+ No client build step is needed: `client.js` is a self-contained ModuleLoader
103
+ bundle maintained in this repository (`pnpm run build:client` regenerates it
104
+ from `client/`).
105
+
106
+ ## Install
107
+
108
+ ```bash
109
+ dsh plugin --profile web add ./mydsh-auto-model-router
110
+ ```
111
+
112
+ Or via the published npm package:
113
+
114
+ ```bash
115
+ npm install @apinggithub/mydsh-auto-model-router
116
+ dsh plugin --profile web add @apinggithub/mydsh-auto-model-router
117
+ ```
118
+
119
+ ## Host compatibility
120
+
121
+ DSH treats a profile's plugin tree as one composition: a bundle whose patch
122
+ fails can stop the whole Harness from starting, and a plugin entry that throws
123
+ is reported as *did not activate* — the router simply disappears from the
124
+ running session. This plugin is therefore written to **mount, degrade and
125
+ log** rather than fail on a host whose surface moved.
126
+
127
+ | Host line | Status |
128
+ | --- | --- |
129
+ | 0.1.x – 0.3.x | Tested (`0.2.1-rc.2` is the reference host) |
130
+ | 0.4.x – 1.x | Allowed to mount, reported as *untested* in the log and in `GET /api/mydsh-auto-model-router/status` |
131
+
132
+ At mount, `compat.mjs` probes every API the plugin touches
133
+ (`llm.registerAdapter`, `llm.listModels`, `llm.stream`, scoped `ctx.inject`,
134
+ `webServer.register`, the optional `tokenMeter`) and records the result in the
135
+ status payload under `host.compat`. What that buys you on an unknown host:
136
+
137
+ - **No hard dependency on `tokenMeter`** — it is looked up softly and token
138
+ spend falls back to the plugin's own estimator.
139
+ - **No virtual-provider requirement** — without `llm.registerAdapter` the
140
+ plugin still mounts and logs one warning; the `Auto` model simply does not
141
+ appear in the picker.
142
+ - **HTTP route aliases** — `webServer.register()`, `route()` or `addRoute()`
143
+ are all accepted; without any of them the settings page shows its "no report
144
+ yet" state instead of breaking.
145
+ - **Payload tolerance** — event handlers accept the current `{ agent }` /
146
+ `(session, event)` shapes *and* a bare-agent / `{ session, event }` payload,
147
+ and a handler that throws hands the host's own proposal back untouched. A
148
+ router bug can no longer fail a user request.
149
+ - **No process globals** — the adapter is created per mount and closes over
150
+ that mount's router, so several profiles in one process cannot overwrite
151
+ each other.
152
+ - **Version report** — when the host exposes its version (environment, app
153
+ service, or a resolvable manifest), a host outside `>=0.1.0-0 <0.4.0` logs
154
+ one warning naming both versions. Capability probes run regardless, so a
155
+ host that hides its version still reports what it actually provides.
156
+
157
+ The peer ranges (`>=0.1.0-0 <1.0.0`) and `engines.dsh` are deliberately wider
158
+ than the tested range so an upgrade does not hard-block installation; the
159
+ runtime warning is what tells you to file an issue.
160
+
161
+ ## Configuration
162
+
163
+ Add to your profile's `cordis.patch.yml`:
164
+
165
+ ```yaml
166
+ - insert:
167
+ - id: mydsh-auto-model-router
168
+ name: '@deepseek-ai/cordis-plugin-group'
169
+ group: true
170
+ isolate:
171
+ modelRouter: true
172
+ config:
173
+ - id: mydsh-auto-model-router-runtime
174
+ name: '@apinggithub/mydsh-auto-model-router'
175
+ config:
176
+ # ── four levels (the shipped defaults) ─────────────────────
177
+ # reasoningEffort: off = no thinking, medium/high/max = intensity.
178
+ levels:
179
+ L0: # everyday chat
180
+ provider: deepseek-official
181
+ model: deepseek-v4-flash
182
+ reasoningEffort: off
183
+ L1: # code & tests
184
+ provider: deepseek-official
185
+ model: deepseek-v4-flash
186
+ reasoningEffort: medium
187
+ L2: # writing & review
188
+ provider: deepseek-official
189
+ model: deepseek-v4-flash
190
+ reasoningEffort: high
191
+ L3: # complex multi-step
192
+ provider: deepseek-official
193
+ model: deepseek-v4-flash
194
+ reasoningEffort: max
195
+
196
+ # Keyword rules only take part in fully-auto mode. Empty by default.
197
+ rules: []
198
+
199
+ # ── MODE A: heuristic lock (zero latency) ──────────────────
200
+ heuristic:
201
+ windowSize: 3 # count the last N user inputs
202
+ counting: cjk2 # CJK char = 2, others = 1; or 'tokens'
203
+ thresholds: # width → level (user-adjustable)
204
+ - maxChars: 10
205
+ level: L0
206
+ - maxChars: 100
207
+ level: L1 # lock ceiling; >100 asks the user
208
+
209
+ # ── MODE C: keyword scoring (fully-auto) ───────────────────
210
+ scoring:
211
+ weights: # per-hit score per level
212
+ L1: 1
213
+ L2: 2
214
+ L3: 3
215
+ bands: # score → level (user-adjustable)
216
+ - maxScore: 0
217
+ level: L0
218
+ - maxScore: 5
219
+ level: L1
220
+ - maxScore: 15
221
+ level: L2
222
+ - maxScore: null
223
+ level: L3
224
+
225
+ # ── Tier 2: cost control (user-configurable) ───────────────
226
+ costControl:
227
+ enabled: true
228
+ mode: balanced # cost-first | quality-first | balanced
229
+ defaultLevel: L1 # level used when no rule matches
230
+ tokenBudgetPerSession: 0 # 0 = no budget limit (default);
231
+ # positive value caps per-session spend
232
+ # What happens when the session budget is exhausted:
233
+ # silent — switch to the cheapest level without telling the user
234
+ # notify — inject a user-visible notice explaining the switch
235
+ # ask — pop a dialog; user may keep the current model
236
+ # (waives the budget for the rest of the session)
237
+ downgradeBehavior: notify
238
+
239
+ # ── LLM classifier (fully-auto fallback; enabled by default) ──
240
+ llmClassifier:
241
+ enabled: true
242
+ model:
243
+ provider: deepseek-official
244
+ model: deepseek-v4-flash
245
+ requestTimeoutMs: 10000
246
+
247
+ # ── Tier 3: free-quota preference (optional) ───────────────
248
+ # Declare which routes are free-quota routes: they are preferred,
249
+ # and a failed one is demoted for the session.
250
+ freeQuota:
251
+ enabled: true # auto-disabled when nothing is declared
252
+ routes: # extra free routes that are not levels
253
+ - provider: glm
254
+ model: glm-4.7-flash
255
+ maxLevel: L1 # highest level it serves (inclusive), default L1
256
+ reasoningEffort: off
257
+ demoteOnError: true # one failure demotes it for the session
258
+ notify: true # say so once per session
259
+
260
+ # Fixed-level mode re-asks every N user inputs.
261
+ askEveryInputs: 3
262
+
263
+ # ── Tier 4: failure fallback chain (model-level) ───────────
264
+ fallbackChain:
265
+ - provider: deepseek-official
266
+ model: deepseek-v4-flash
267
+
268
+ maxRetries: 1
269
+ ```
270
+
271
+ ### costControl modes
272
+
273
+ | mode | default level (no rule hit) | ambiguity drift |
274
+ | --- | --- | --- |
275
+ | `cost-first` | lowest configured level | **down** (cheaper) |
276
+ | `quality-first` | highest configured level | **up** (stronger) |
277
+ | `balanced` | `costControl.defaultLevel` | stay put |
278
+
279
+ A rule hit always wins over the cost mode: a level the user explicitly matched
280
+ is never downgraded by the cost layer.
281
+
282
+ ### Budget downgrade is visible
283
+
284
+ `tokenBudgetPerSession` **defaults to 0 (no budget)** — the cost layer never
285
+ forces a downgrade from budget exhaustion, and `downgradeBehavior` is inert.
286
+ Set a positive value to cap spend per session:
287
+
288
+ - e.g. `tokenBudgetPerSession: 300000`: downgrade once the session uses 300k tokens.
289
+ - The downgrade behavior is controlled by `downgradeBehavior` (default `notify`):
290
+ - `notify`: inject a user-visible message explaining the budget is exhausted,
291
+ which model the remaining requests use, and how to raise the cap.
292
+ - `ask`: pop a dialog asking "switch to the cheaper model or keep the current
293
+ one?"; choosing "keep" waives the budget for the rest of the session.
294
+ - `silent`: switch silently (not recommended — the user wonders why answers
295
+ got worse).
296
+
297
+ ### Free-quota preference (freeQuota)
298
+
299
+ Many providers hand out free quota (Zhipu's `glm-4.7-flash`, for instance, is
300
+ permanently free), and a free route usually comes with the fine print: rate
301
+ limits, a quota that runs out, tighter parameter caps. This layer turns "free"
302
+ into a declarable route property and keeps a dead free route from failing the
303
+ whole turn.
304
+
305
+ Two declaration forms, which can be mixed:
306
+
307
+ ```yaml
308
+ levels:
309
+ L0: { provider: glm, model: glm-4.7-flash, reasoningEffort: off, freeQuota: true }
310
+ L1: { provider: glm, model: glm-4.7-flash, reasoningEffort: low, freeQuota: true }
311
+ L2: { provider: deepseek-account, model: deepseek-flash, reasoningEffort: high }
312
+ L3: { provider: deepseek-account, model: deepseek-flash, reasoningEffort: max }
313
+
314
+ # Or: keep the levels paid and let a declared free route claim their work.
315
+ freeQuota:
316
+ routes:
317
+ - { provider: glm, model: glm-4.7-flash, maxLevel: L1, reasoningEffort: off }
318
+ ```
319
+
320
+ What it does:
321
+
322
+ 1. **Preference** — a decision at or below `maxLevel` runs on the declared free
323
+ route (free routes always carry an explicit `reasoningEffort`, defaulting to
324
+ `off`, so they never fall into the adapter's own default thinking level).
325
+ 2. **Demotion on failure** — any failure on a free route (quota exhausted, 429
326
+ rate limit, rejected credentials, rejected parameters) marks that route as
327
+ burned **for this session** and the request is retried on the next viable
328
+ route — normally the paid route that level was configured with.
329
+ 3. **No retrying a dead route** — after a demotion the fallback walk skips every
330
+ burned free route, so one session never hits the same wall twice.
331
+ 4. **Visible** — with `notify: true` the plugin injects one message saying which
332
+ free route failed, why, and what is used instead (at most once per session).
333
+ 5. **Session-scoped** — the demotion lasts for the current session only; the
334
+ next session tries the free route again. With nothing declared free the whole
335
+ layer is inert, so existing profiles behave exactly as before.
336
+
337
+ > Note: free **vision** models often have a much smaller output cap (e.g.
338
+ > `glm-4v-flash` allows `max_tokens ≤ 1024`). Fix those on the provider's
339
+ > `models` entry with `maxTokens` — the router cannot guess them for you. If you
340
+ > see `INVALID_REQUEST: max_tokens …`, add `maxTokens` to that model.
341
+
342
+ ### On-load consultation (no client UI)
343
+
344
+ On the **first session** after installing or hot-reloading the plugin, DSH:
345
+
346
+ 1. Injects a status report message (level→model mapping, cost mode, budget,
347
+ fallback chain). The language follows `settings.yaml`'s
348
+ `locale.preference` (`zh`/`en`, default `en`).
349
+ 2. Pops a dialog with three questions (asked once per plugin load):
350
+
351
+ | Question | Options |
352
+ | --- | --- |
353
+ | Q1 Tier-1 rules | Keep current rules / **View current rules** (injects the rule list) |
354
+ | Q2 Tier-2 cost mode | Keep current / cost-first / balanced / quality-first (applies immediately) |
355
+ | Q3 Other config | Skip / **Check a setting** (type budget, fallbackChain, classifier, …; injects its current value) |
356
+
357
+ The injected lists and values let the user **decide** whether to adjust;
358
+ persistent changes still live in `cordis.patch.yml` (the messages say so).
359
+ Headless or no-question-channel setups degrade to report-only and never block.
360
+
361
+ ## Rule syntax
362
+
363
+ - Plain text: `match: 'refactor'` — case-insensitive substring.
364
+ - Regex literal: `match: '/\\bdebug\\b/'` — slashes delimit the pattern, optional flags after the last slash (`/.../i`).
365
+ - Rules are evaluated **in order**; the first match wins.
366
+ - `match` can target user message text, tool-call results text, or the session `cwd` (matched as `cwd:<path>`).
367
+
368
+ ## What the plugin hooks
369
+
370
+ | DSH event | Purpose |
371
+ | --- | --- |
372
+ | `agent/session-start` | reset per-session routing state |
373
+ | `agent/pre-step` | token accounting for budget tracking |
374
+ | `system-prompt/assemble` | keep `{{model}}` prompt variables consistent |
375
+ | `agent/request` | **the routing decision** — replace provider/model |
376
+ | `agent/request-error` | fallback chain → `{ kind: 'retry' }` |
377
+ | `session/event` | track spend from assistant messages |
378
+
379
+ ## Testing
380
+
381
+ ```bash
382
+ npm test # node --test *.test.mjs
383
+ ```
384
+
385
+ No external services are required — all layers are unit-tested against fixtures.
386
+
387
+ ## Known Issues
388
+
389
+ - **Fully-auto mode latency**: When the LLM classifier is enabled, the first
390
+ step of each new user input incurs an additional LLM call (classification).
391
+ While subsequent steps within the same input are cached and fast, the overall
392
+ round-trip for multi-step tasks is noticeably slower than directly selecting
393
+ a model. Contributions to optimize this are welcome — the classifier could
394
+ potentially be cached across sessions, or a lighter heuristic could be used
395
+ as a first pass before falling back to the LLM.
396
+
397
+ ## Companion plugin: cost by tier
398
+
399
+ `tier-cost/` ships the second plugin of this repository,
400
+ **`@apinggithub/dsh-tier-cost`**: it folds session logs into per-level (L0–L3)
401
+ token usage and spend, borrowing per-turn cost from the billing plugin
402
+ `@kenz1117/dsh-ui-usage-billing` (it owns no price table of its own) and adding a
403
+ "Tier Cost" settings section — because the four levels usually share one model,
404
+ a per-model panel cannot tell you which tier paid.
405
+
406
+ ```bash
407
+ dsh plugin --profile desktop add ./tier-cost
408
+ ```
409
+
410
+ See [tier-cost/README.md](tier-cost/README.md) for details.
411
+
412
+ ## License
413
+
414
+ MIT
415
+
416
+ ### Context-window guard (contextGuard)
417
+
418
+ A level may declare its model's `contextWindow`. When the session's live context
419
+ (the input side of the last request, including cache reads/writes) exceeds
420
+ `window × headroom` (default 0.85) the level is **skipped** and the router
421
+ drifts up to the nearest level that fits; if no declared window fits, the
422
+ original level stands (fail open) so the provider answers with a real error the
423
+ fallback chain can handle. Each skip is recorded once per (level, session) and
424
+ visible in `/status` under `contextGuard`. With no window declared anywhere the
425
+ layer is inert.
426
+
427
+ ```yaml
428
+ levels:
429
+ L0: { provider: bailian, model: qwen-max, contextWindow: 32768 } # guard small windows
430
+ contextGuard: { enabled: true, headroom: 0.85 }
431
+ ```
432
+
433
+ ## Deploying to another machine
434
+
435
+ One machine = two commands (this repository ships both plugins):
436
+
437
+ ```powershell
438
+ git clone https://github.com/apinggithub/mydsh-auto-model-router
439
+ cd mydsh-auto-model-router; cd ..
440
+ # On 0.2.x hosts the plugin's stale pinned peers make the installer refuse; grant the
441
+ # exact-version exemption first (per new plugin version):
442
+ dsh plugin --profile desktop allow-version '@apinggithub/mydsh-auto-model-router@0.1.1' --dsh-version 0.2.0-rc.2 --accept-risk
443
+ dsh plugin --profile desktop add ./mydsh-auto-model-router
444
+ dsh plugin --profile desktop add ./mydsh-auto-model-router/tier-cost # optional: per-tier cost panel
445
+ ```
446
+
447
+ Then write a full levels block into that machine's `profiles/<profile>/cordis.patch.yml` —
448
+ provider/model must be routes **that machine** can actually use (do not copy ids across machines):
449
+
450
+ ```yaml
451
+ - id: mydsh-auto-model-router-runtime
452
+ name: '@apinggithub/mydsh-auto-model-router'
453
+ config:
454
+ levels:
455
+ L0: { provider: ..., model: ..., reasoningEffort: off, contextWindow: ..., freeQuota: true }
456
+ L1: { provider: ..., model: ..., reasoningEffort: low, contextWindow: ..., freeQuota: true }
457
+ L2: { provider: ..., model: ..., reasoningEffort: high, contextWindow: ..., freeQuota: true }
458
+ L3: { provider: ..., model: ..., reasoningEffort: max, contextWindow: ... }
459
+ contextGuard: { enabled: true, headroom: 0.85 }
460
+ freeQuota: { enabled: true, demoteOnError: true, notify: true }
461
+ ```
462
+
463
+ ⚠️ The built-in default levels carry legacy values (`deepseek-v4-flash` + `medium`) that are
464
+ both invalid on 0.2.x hosts — a fresh machine must ship its own levels block or Auto fails
465
+ outright. `tier-cost` has zero hard dependencies beyond the host: without the router every
466
+ turn folds into manual/unknown, without the billing plugin tokens are counted and cost
467
+ stays 0 — both cases self-report in the panel notes.
468
+
469
+ ### Catalog freshness guard (modelsGuard)
470
+
471
+ When a provider retires a model id, the level pointing at it goes stale. The
472
+ guard reads what each provider actually advertises (`ctx.llm.listModels`) at
473
+ activation and every `refreshMs` (default 10 min): a stale level's requests
474
+ **drift to the nearest fresh level** instead of failing, and the status report
475
+ names same-family candidate ids so the fix stays a one-line edit. Unreadable or
476
+ empty catalogs fail open (never marked stale); environments without a catalog
477
+ reader leave the layer inert.
478
+
479
+ ```yaml
480
+ modelsGuard: { enabled: true, refreshMs: 600000 }
481
+ ```
482
+
483
+ > The static list remains configuration (`cordis.patch.yml`) on purpose: the
484
+ > plugin never decides *which* new model to adopt (windows, prices and
485
+ > capabilities all change) — it guarantees a dead tier is not selected and
486
+ > tells you what to switch to.
487
+
488
+ ## Publishing to npm
489
+
490
+ Both plugins live in this repository and publish independently:
491
+
492
+ ```bash
493
+ npm publish # @apinggithub/mydsh-auto-model-router (root)
494
+ cd tier-cost && npm publish # @apinggithub/dsh-tier-cost
495
+ ```
496
+
497
+ From 0.2.0 the peerDependencies are host-adaptive ranges, so fresh machines no
498
+ longer need the allow-version exemption (only 0.1.1 and earlier did). Run
499
+ `npm login --scope=apinggithub` once per package account setup.
500
+
501
+ ## The silent-billing trap of free quota (real incident · rules)
502
+
503
+ A true story from the author's machine: L1 pointed at the Bailian free model `glm-5.3`
504
+ (`freeQuota: true`). When the quota ran out the provider did **not** return the promised
505
+ 403 (`stop-when-exhausted` had not actually taken effect for that model) — calls kept
506
+ succeeding, now silently at pay-as-you-go rates, for a full day until a billing alert
507
+ arrived. `demoteOnError` had no error to catch.
508
+
509
+ Three rules, all required:
510
+
511
+ 1. **Verify "stop when exhausted" per model on the provider side** (make one over-quota
512
+ call and confirm a 403). The router can only switch lines on errors it can see;
513
+ a provider that silently converts a free route into paid billing is invisible to it.
514
+ 2. **Keep `costControl.defaultLevel` on a stable paid tier.** In this file's examples a
515
+ `defaultLevel: L1` where L1 is a free tier is exactly the incident shape. Prefer free
516
+ only at L0, or keep an explicit paid replacement for every free tier.
517
+ 3. **Watch the billing panel per tier.** A "free" tier whose cost line keeps growing and
518
+ never zeroes is burning money — drop it from the free routes immediately.
519
+
520
+ > Another sharp edge: quota pools are counted per **provider+model**. The same id on two
521
+ > providers (e.g. `qwen3.8-flash` on both `bailian` and `qianwen`) are **separate pools**;
522
+ > quota and billing never cross over.