dsh-model-rotator 0.0.0-stage → 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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +543 -2
  3. package/README.zh.md +283 -0
  4. package/client.js +831 -0
  5. package/cordis.patch.yml +13 -0
  6. package/index.js +2685 -0
  7. package/package.json +63 -4
  8. package/vendor/node_modules/@deepseek-ai/cosmokit/LICENSE +21 -0
  9. package/vendor/node_modules/@deepseek-ai/cosmokit/README.md +30 -0
  10. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/index.js +418 -0
  11. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts +15 -0
  12. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts.map +1 -0
  13. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts +13 -0
  14. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts.map +1 -0
  15. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts +41 -0
  16. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts.map +1 -0
  17. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts +89 -0
  18. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts.map +1 -0
  19. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts +19 -0
  20. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts.map +1 -0
  21. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts +42 -0
  22. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts.map +1 -0
  23. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts +40 -0
  24. package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts.map +1 -0
  25. package/vendor/node_modules/@deepseek-ai/cosmokit/package.json +32 -0
  26. package/vendor/node_modules/@deepseek-ai/cosmokit/src/array.ts +42 -0
  27. package/vendor/node_modules/@deepseek-ai/cosmokit/src/index.ts +12 -0
  28. package/vendor/node_modules/@deepseek-ai/cosmokit/src/misc.ts +78 -0
  29. package/vendor/node_modules/@deepseek-ai/cosmokit/src/string.ts +113 -0
  30. package/vendor/node_modules/@deepseek-ai/cosmokit/src/time.ts +92 -0
  31. package/vendor/node_modules/@deepseek-ai/cosmokit/src/types.ts +168 -0
  32. package/vendor/node_modules/@deepseek-ai/cosmokit/src/volatile.ts +86 -0
  33. package/vendor/node_modules/@deepseek-ai/schemastery/LICENSE +21 -0
  34. package/vendor/node_modules/@deepseek-ai/schemastery/README.md +393 -0
  35. package/vendor/node_modules/@deepseek-ai/schemastery/lib/index.cjs +635 -0
  36. package/vendor/node_modules/@deepseek-ai/schemastery/lib/index.mjs +635 -0
  37. package/vendor/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts +211 -0
  38. package/vendor/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts.map +1 -0
  39. package/vendor/node_modules/@deepseek-ai/schemastery/package.json +39 -0
  40. package/vendor/node_modules/@deepseek-ai/schemastery/src/index.ts +962 -0
  41. package/vendor/node_modules/@standard-schema/spec/LICENSE +21 -0
  42. package/vendor/node_modules/@standard-schema/spec/README.md +198 -0
  43. package/vendor/node_modules/@standard-schema/spec/dist/index.cjs +18 -0
  44. package/vendor/node_modules/@standard-schema/spec/dist/index.d.cts +119 -0
  45. package/vendor/node_modules/@standard-schema/spec/dist/index.d.ts +119 -0
  46. package/vendor/node_modules/@standard-schema/spec/dist/index.js +0 -0
  47. package/vendor/node_modules/@standard-schema/spec/package.json +52 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chimney
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,544 @@
1
- # Temporary Holding Version
1
+ # dsh-model-rotator
2
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.
3
+ > [简体中文](./README.zh.md) | **English**
4
+
5
+ Model auto-ranking and transparent failover for DeepSeek Harness.
6
+
7
+ When the current model hits a quota wall, an expired credential, a rate limit or
8
+ a transient network error, this plugin switches to the next-best configured
9
+ model **inside the same turn** — and the user sees an ordinary reply.
10
+
11
+ ## What it does
12
+
13
+ 1. **Discovers** every dialable `provider/model` on this machine
14
+ (`ctx.llm.listProviders()` + each adapter's `listModels()`).
15
+ 2. **Filters** out families that cannot answer a chat turn — embeddings,
16
+ rerankers, ASR/TTS, OCR, image/video generation, content moderators and
17
+ judges. Dialing one of those can only fail the turn and burn a cooldown slot
18
+ that a real model could have used.
19
+ 3. **Skips the image-reading mode.** dsh-vision-router wraps a chat route into a
20
+ twin (`<provider>-vision`) whose only job is to add image reading to that same
21
+ model. It is one model reachable twice, not a second one, so failing a text
22
+ turn over into it would answer through the image pipeline — and the
23
+ same-model preference below would rank the twin *first*. Those routes are
24
+ therefore never candidates (a model id that merely *mentions* vision is still
25
+ a chat model and stays). The model list can switch one back on when a wrapper
26
+ name does not follow the convention.
27
+ 4. **Ranks** the survivors by measured connectivity (Laplace-smoothed success
28
+ rate), speed (median TTFT and throughput) and an active failure cooldown. A
29
+ model that *is* the current one — or a close relative of it — on another
30
+ provider carries a bonus, because the model you picked is usually the model
31
+ you want, just from a working channel.
32
+ 4. **Retries before it believes a failure.** A provider fails transiently: a
33
+ rate-limit blip, a reset socket, an overloaded channel. One miss therefore
34
+ does not count as a fault — the same model gets `retriesPerModel` more tries
35
+ (2 by default, 0–5), `retryBackoffMs` apart (300 ms, then the double of it),
36
+ before the failure is acted on. A retry is not a switch: it asks nothing and
37
+ changes nothing.
38
+ 5. **Fails over** at the `llm/stream` waterfall: attempt 1 is the untouched
39
+ harness path; a failure that produced **no content at all** abandons that
40
+ attempt, closes its upstream stream, and re-dispatches the next candidate as
41
+ a fresh `ctx.llm.stream()` — the same model first while retries remain, then
42
+ the ranking above.
43
+
44
+ Once any content chunk has been yielded the attempt is never retried — a second
45
+ attempt would duplicate visible text. That zero-content window is exactly the
46
+ retryable window.
47
+
48
+ A turn the caller cancelled (Stop, a deadline, a closed client) ends there: the
49
+ walk stops instead of burning attempts on a reply nobody will read.
50
+
51
+ ### What counts as content
52
+
53
+ "Flow" is the switch that closes the retry window, so it means *the user has been
54
+ shown reply text* — and nothing weaker. A `reasoning-delta` is not content: the
55
+ harness folds reasoning away again when a retry happens, which is exactly what
56
+ makes retrying a reasoning-loop or reasoning-effort failure safe. Markup chunks
57
+ (`block-start`, or a `block-end` carrying a reasoning block) are not content
58
+ either. Counting those was a real defect: a live model looked dead because a
59
+ block-start closed the window and the very next error was taken as final.
60
+
61
+ ### Automatic calls are not conversation turns
62
+
63
+ A compaction summary and a session title ride the same `llm/stream` seam but are
64
+ not a user turn. Their refusals are usually statements about *that lane* — a
65
+ proxy answering "bili owns compression here" — so asking a second provider to
66
+ carry the plumbing is the wrong reflex: it burns a real model on work nobody
67
+ reads, and it is the single loudest source of spurious switching. The plugin
68
+ therefore passes any call carrying `purpose: "compaction"` or
69
+ `purpose: "session-title"` straight through, error included. An unnamed call —
70
+ every ordinary turn — keeps failing over exactly as before.
71
+
72
+ ## Announcing a switch
73
+
74
+ A switch is otherwise silent: the reply simply arrives from a different model. By
75
+ default the plugin posts one line in the conversation when that happens —
76
+ `模型已切换:A → B`, with the failure kind in brackets when it is known
77
+ (`(quota)`, `(rate-limit)`).
78
+
79
+ | Behaviour | |
80
+ |-----------|--|
81
+ | Shape | the host's own notice mechanism: a `user/message` whose source is `{ kind: 'model-rotator/switch', form: 'notice', summary }`. The conversation UI renders that form as a context row, not as something you typed |
82
+ | Timing | at the turn boundary, via `agent/turn-stopping` + `agent.steer`. The switch already happened and the answer is already streaming, so announcing it mid-turn would insert a row in the middle of the reply it describes |
83
+ | Scope | the root conversation only. A switch inside a subagent transcript is somebody else's log |
84
+ | Off | `noticeSwitch: false` (or the settings-page toggle) |
85
+
86
+ The event type is deliberately **not** a private one. The persistence reader
87
+ refuses to interpret a session log containing a type it does not know
88
+ (`dsh-session`'s `KNOWN_SESSION_EVENT_TYPES`), so an invented event would make a
89
+ log carrying it fail to load. `user/message` is known, and `notice` is a form the
90
+ client already understands — the same pair the harness uses for its own
91
+ "model changed" line.
92
+
93
+ The vendor half of the label is the provider's own `displayName` when the host
94
+ publishes one (硅基流动 for `siliconflow`), falling back to the route id. The id
95
+ stays visible in the model list, because that is what `config.json`'s lists use.
96
+
97
+ ## Asking before switching
98
+
99
+ A failover replaces the model the user picked, and it can do so silently. The
100
+ default is therefore to ask first: when the current model fails with no content,
101
+ the plugin raises a question in the conversation through the harness's own
102
+ human-interaction seam (`ctx.userQuestions.askTimed()`), offering **切换** and
103
+ **保持当前模型**.
104
+
105
+ | Reply | Outcome |
106
+ |-------|---------|
107
+ | 切换 (or any answer that is not a refusal) | the next candidate serves the turn |
108
+ | 保持当前模型 (or a typed refusal — 不/否/取消/拒绝/no/cancel/stop/…) | nothing further is dialed; the turn ends on the original error |
109
+ | no answer inside the window | the switch proceeds — the request was time-boxed, not vetoed |
110
+
111
+ The question is raised **once per turn**: approving the first switch covers the
112
+ rest of that walk, so a cascade of dead models does not become a cascade of
113
+ identical cards. The wait is 20 s by default and configurable.
114
+
115
+ Failover is deliberately fail-open. If the seam is missing, the ask throws, the
116
+ turn has no live root agent (a delegated sub-agent has nobody to answer), or the
117
+ timeout is reached, the walk continues unconfirmed. An unattended harness must
118
+ not stall on a card nobody is watching.
119
+
120
+ ## The failure kind decides the rest
121
+
122
+ A failure is not just a string: it is classified, and the class decides how long
123
+ the model sits out.
124
+
125
+ **A quota wall is not a rate limit.** `429 insufficient balance` and `429 too many
126
+ requests` are the same HTTP status, yet one clears in seconds and the other does
127
+ not until the allowance resets. The plugin reads the kind out of the message -
128
+ `insufficient quota`, `out of credits, please top up`, `余额不足,请充值`, `额度已用尽`,
129
+ `402 Payment Required` and similar - and treats a balance problem differently:
130
+
131
+ | Kind | Rest | Retry first? |
132
+ |------|------|--------------|
133
+ | `quota` | until the next day at `quotaCooldownHour` (00:00 local by default), and never less than `quotaCooldownMs` | no - the balance does not refill between two dispatches |
134
+ | `auth` | ordinary exponential backoff | yes - a 403 from a burst-rate proxy does recover |
135
+ | `unsupported` | ordinary exponential backoff | no - the same provider returns the same refusal to the same request |
136
+ | `rate-limit` | ordinary exponential backoff | yes |
137
+ | `server` / `network` | ordinary exponential backoff | yes |
138
+ | unclassified | ordinary exponential backoff | yes |
139
+
140
+ `unsupported` is the request-shape refusal — *"provider X model Y does not
141
+ support reasoning effort high"*, *"unsupported model"*. It is decided before the
142
+ request is read, so the retry dispatches would repeat it verbatim; skipping them
143
+ is what turns a session-level mismatch into one switch instead of three.
144
+
145
+ An authentication failure is classified *before* a bare `429`, because waiting
146
+ never repairs a key: for `429 invalid api key` the honest name is `auth`, not
147
+ `rate-limit`. The distinction is recorded, not acted on - both take the same
148
+ backoff; only `quota` changes the policy.
149
+
150
+ The rest is at least "the following day at `quotaCooldownHour`". A wall hit at
151
+ 23:50 still pays the full `quotaCooldownMs` floor, so it returns at 11:50 the
152
+ next day rather than ten minutes later at midnight.
153
+
154
+ `model_rotator_status` shows it: the ranking marks such a row `QUOTA-REST` (with
155
+ the remaining hours) rather than `COOLDOWN`, the `type` column names the class,
156
+ and a `last failure seen` line lists each model's last error with its kind.
157
+
158
+ Measured state - counters, the last error and kind, the cooldown horizon -
159
+ **outlives the process**, because the restart a user performs right after being
160
+ told their balance is gone must not undo the rest. It is written to
161
+ `%DSH_HOME%\model-rotator\stats.json` (or `dirname(config.json)`), debounced, and
162
+ read back once on first use. Latency samples deliberately stay in memory: they
163
+ are a 20-sample sliding window, and a stale window is worse than a short one.
164
+ `statsPersistTtlMs` bounds the age of a restored file (24 h by default; 0
165
+ disables the file entirely).
166
+
167
+ ## What it does NOT do
168
+
169
+ It never reads, writes or rewrites model/provider configuration. It does not
170
+ touch `cordis.patch.yml`, `agent-default-model`, `llm-pi-ai` or any provider
171
+ registration. It only observes the stream waterfall at runtime.
172
+
173
+ ## Non-chat filtering
174
+
175
+ The harness adapter strips the catalog's own `wire: 'chat'` marker before
176
+ handing rows to `listModels()`, so capability cannot be read off the row.
177
+ Name matching is the available signal, and it is deliberately narrow — a
178
+ pattern must not touch a chat model that merely mentions a modality
179
+ (`Qwen3-VL-8B-Instruct` is chat; `Z-Image-Turbo` is not).
180
+
181
+ The filter denies families across vectors/ranking, speech, image generation,
182
+ document parsing and safety classifiers — in both the plain and the `-vision`
183
+ provider variants. It runs during discovery, so the count the plugin reports
184
+ (`discovered`) is already the post-filter pool, and a machine's numbers depend
185
+ entirely on which providers are configured. Ask a live install rather than
186
+ quoting a figure from here:
187
+
188
+ ```
189
+ model_rotator_status # "discovered" = candidates after this filter
190
+ ```
191
+
192
+ ## Tools
193
+
194
+ | Tool | Purpose |
195
+ |------|---------|
196
+ | `model_rotator_status` | Read-only: enabled state, config path, discovered pool, measured ranking. |
197
+ | `model_rotator_benchmark` | Probe candidates with a minimal live request to refresh TTFT/throughput and cooldowns (bounded, spends a little quota). |
198
+ | `model_rotator_select` | Pin/unpin the head of the failover order; the ranking still decides the tail. |
199
+
200
+ The status tool also reports the two manual lists and the vision rule, so a model
201
+ the user can see in the directory but not in the ranking can be explained without
202
+ reading the code:
203
+
204
+ ```
205
+ disabled models : workbuddy/hy3
206
+ vision twins : never a candidate (the dsh-vision-router mode wraps a model, it is not a second one)
207
+ model list route : /_dsh/model-rotator/models (GET the grouped list; POST {key, enabled} to switch one off or on)
208
+ ```
209
+
210
+ ## Configuration
211
+
212
+ Optional, at `%DSH_HOME%\model-rotator\config.json` (hot-reloaded on change).
213
+ Every field has a safe default, so the plugin works with no file at all.
214
+
215
+ ```json
216
+ {
217
+ "enabled": true,
218
+ "allowImageFailover": false,
219
+ "maxCandidates": 4,
220
+ "cooldownBaseMs": 60000,
221
+ "maxCooldownMs": 1800000,
222
+ "quotaCooldownMs": 43200000,
223
+ "quotaCooldownHour": 0,
224
+ "statsPersistTtlMs": 86400000,
225
+ "confirmSwitch": true,
226
+ "confirmTimeoutMs": 20000,
227
+ "adoptSwitchedModel": true,
228
+ "retriesPerModel": 2,
229
+ "retryBackoffMs": 300,
230
+ "preferSameModel": true,
231
+ "weights": { "connectivity": 0.5, "speed": 0.3 },
232
+ "excludeProviders": [],
233
+ "excludeModels": [],
234
+ "excludePatterns": [],
235
+ "disabledModels": [],
236
+ "enabledModels": [],
237
+ "pinned": ""
238
+ }
239
+ ```
240
+
241
+ - `enabled` — master switch.
242
+ - `allowImageFailover` — image turns are skipped by default: a text-only
243
+ fallback cannot read an image, so failing over there would produce a wrong
244
+ answer rather than a missing one.
245
+ - `maxCandidates` — cap per turn (hard ceiling 8).
246
+ - `cooldownBaseMs` / `maxCooldownMs` — exponential backoff bounds
247
+ (`base * 2^(n-1)`, clamped to `max`).
248
+ - `quotaCooldownMs` — minimum rest after a quota wall (default 12 h, ceiling
249
+ 26 h). Counted from the failure, not rounded to the hour.
250
+ - `quotaCooldownHour` — local hour (0–23) at which an exhausted model may be
251
+ tried again (default 0 = next midnight). The rest always reaches that hour of
252
+ the *following* day, so a wall hit at 00:05 does not revive the same evening.
253
+ - `statsPersistTtlMs` — how long measured state survives a restart (default
254
+ 24 h, ceiling 30 d; 0 = do not persist).
255
+ - `confirmSwitch` — ask in the conversation before leaving a failed model
256
+ (default `true`; see above).
257
+ - `confirmTimeoutMs` — how long that question waits before switching anyway
258
+ (default 20000; clamped to 1 s … 10 min).
259
+ - `adoptSwitchedModel` — after a switch, make the model that actually
260
+ answered the model of *this session*, so the conversation header shows what is
261
+ really in use (default `true`). Session-local only: no provider configuration
262
+ and no saved default is touched, and the harness's own "model changed" notice
263
+ then names the right pair.
264
+ - `retriesPerModel` — extra tries the same model gets before its failure is
265
+ believed (default 2, hard ceiling 5). A retry needs no confirmation because it
266
+ does not change the model.
267
+ - `retryBackoffMs` — pause before each retry (default 300; the nth retry waits
268
+ n times this, capped at 10 s).
269
+ - `preferSameModel` — rank the same model on another provider first, then close
270
+ relatives, then the measured ranking (default `true`).
271
+ - `noticeSwitch` — post the one-line switch notice in the conversation (default
272
+ `true`; see "Announcing a switch" above).
273
+ - `weights` — connectivity vs speed in the score.
274
+ - `excludeProviders` / `excludeModels` — keep specific routes out of the pool.
275
+ - `excludePatterns` — extra regex sources appended to the non-chat deny list.
276
+ An invalid pattern is skipped, never fatal.
277
+ - `disabledModels` — models switched off from the model list on the settings
278
+ page. Each entry is `"provider/model"` or a bare model id (which matches every
279
+ channel serving that model). A per-row entry outranks the channel rule.
280
+ - `disabledProviders` — whole channels switched off from a channel header's
281
+ button. One entry per provider id. Closing a channel also clears the per-row
282
+ `enabledModels` entries inside it, so the switch cannot be silently undone;
283
+ enabling a row afterwards writes a new entry that does take effect.
284
+ - `enabledModels` — the opposite, and the only way to put a vision twin back into
285
+ rotation when its wrapper name does not follow the `<provider>-vision`
286
+ convention. Written by the same switch.
287
+ - `pinned` — preferred head of the failover order.
288
+
289
+ ## Settings page
290
+
291
+ The plugin ships both halves of a real settings page: the server-side `Config`
292
+ schema the Harness serves for the `model-rotator` namespace, and a browser
293
+ bundle (`client.js`, declared through `dsh.client` plus `exports["./client"]`)
294
+ that renders the form in two host cells from one component:
295
+
296
+ - **Settings → Model failover**, its own entry in the Settings navigation
297
+ (`settings.section`, id `model-rotator`, order 20). The Harness renders the
298
+ plugin settings pages this way; the shell hands the cell `{ close }` and
299
+ filters by the registration id.
300
+ - the **model-rotator** card in the Plugins panel (`plugins.item`), which
301
+ renders the same form as its card body.
302
+
303
+ Both cells only appear while the host actually serves the namespace. Only the
304
+ fields that are safe to personalize appear there:
305
+
306
+ | Field | |
307
+ |-------|--|
308
+ | `enabled` | master switch |
309
+ | `allowImageFailover` | also fail over image turns |
310
+ | `maxCandidates` | models one turn may try |
311
+ | `cooldownBaseMs` | backoff base |
312
+ | `maxCooldownMs` | backoff cap |
313
+ | `quotaCooldownMs` | minimum rest after a quota wall |
314
+ | `quotaCooldownHour` | hour at which an exhausted model returns |
315
+ | `statsPersistTtlMs` | how long measurements survive a restart |
316
+ | `confirmSwitch` | ask before switching |
317
+ | `confirmTimeoutMs` | how long that question waits |
318
+ | `adoptSwitchedModel` | show the switched model on this session |
319
+ | `retriesPerModel` | tries before a failure is believed |
320
+ | `retryBackoffMs` | pause between those tries |
321
+ | `preferSameModel` | same model on another channel first |
322
+
323
+ `weights.connectivity` / `weights.speed` are **not** on the page: the form
324
+ model stages one top-level key at a time, so nested values stay in
325
+ `config.json`. Long lists meant for editing by hand or by tool —
326
+ `excludeProviders`, `excludeModels`, `excludePatterns`, `pinned` — stay
327
+ there too.
328
+
329
+ ### The model list
330
+
331
+ Below the settings form the same page renders **模型列表 / Models**: every
332
+ discovered `provider/model`, with its health and a switch — plus a switch on each
333
+ channel header that closes **every model behind that provider in one click**. The
334
+ pair of levels is deliberate: a channel switch is the useful action when a key or
335
+ an endpoint dies (everything behind it is unreachable at once), while a row switch
336
+ is what you reach for when one model is bad and its neighbours are fine.
337
+
338
+ The list is split into two sections, because they answer different questions:
339
+
340
+ | Section | Contents |
341
+ |---------|----------|
342
+ | **对话渠道** (chat channels) | the routes failover actually ranks — the candidates |
343
+ | **识图模式** (vision mode) | the `<provider>-vision` twins, each labelled with the channel it wraps (`amd-vision → amd`) |
344
+
345
+ **Three fold levels, all independent**: each section, each provider channel inside
346
+ it, and the roster as a whole. Chat channels start open and the vision section
347
+ starts **closed**: there are as many twins as everything else on this machine
348
+ (453 of 906 rows in the last live scan), and none of them is ever a candidate, so
349
+ leaving them folded is what makes the page readable at all. Two buttons —
350
+ **全部展开 / 全部收起** — open or close every section and channel at once.
351
+
352
+ Closing a channel writes a single `disabledProviders` entry rather than one entry
353
+ per model — the channel is the unit you are thinking in, and a list of a hundred
354
+ ids would be unreadable and impossible to undo honestly. Four rules keep the two
355
+ levels from fighting:
356
+
357
+ - the header button is labelled from the channel's own flag, not from whether its
358
+ rows happen to be in rotation — a channel whose rows are all off for per-row
359
+ reasons is still **open**, and one closed from the header must offer the action
360
+ that reopens it;
361
+ - a closed channel offers **启用本渠道**, and the row switches underneath stay
362
+ available for putting one model back without reopening the whole channel;
363
+ - closing a channel clears the stale per-row overrides inside it (so the button
364
+ does what it says), and a row you switch on **afterwards** wins (a fresh,
365
+ deliberate statement beats an older blanket one);
366
+ - a **vision** channel carries no channel switch at all. Its rows are kept out by
367
+ the twin rule, which the channel flag cannot lift, so a button there would write
368
+ an entry that changes nothing while reading like a promise. Use a row switch.
369
+
370
+ The channel label is the vendor name the host publishes on the registered route
371
+ (`硅基流动` for `siliconflow`), falling back to the route id when a route
372
+ publishes no label; the id is kept in parentheses when the two differ.
373
+
374
+ A folded line never hides what matters: every channel header (and every section
375
+ header) prints the same tally — derived from the rows, not from a host-supplied
376
+ counter, so the two layers cannot disagree. A collapsed provider still says
377
+ whether it is healthy. The page also rebuilds the sections from `channels` when
378
+ the host does not send `groups`, because the two halves are versioned together
379
+ but not *loaded* together.
380
+
381
+ ```
382
+ ▼ 对话渠道 453 个 · 可用 447 · 冷却 6 · 关闭 0
383
+ ▼ 硅基流动 (siliconflow) 65 个 · 可用 63 · 冷却 2 · 关闭 0 [关闭本渠道]
384
+ ● deepseek-v4.1-flash 冷却中 retry in 511m · rate-limit [关闭]
385
+ ▶ cline 55 个 · 可用 55 · 冷却 0 · 关闭 0 [关闭本渠道]
386
+ ▶ 识图模式(vision-router 创建) 453 个 · 可用 0 · 冷却 0 · 关闭 453
387
+ ```
388
+
389
+ | Column | Meaning |
390
+ |--------|---------|
391
+ | dot + label | `可用` (nothing resting, no failure on record), `未测` (never tried), `冷却中`, `额度耗尽` (until the next-day boundary), `异常` (last attempt failed), `已关闭` |
392
+ | detail | TTFT and success/attempt counts, or the remaining rest, or why the row is out of rotation |
393
+ | switch | 关闭 / 启用 |
394
+
395
+ The reasons a row can be out of rotation are named rather than collapsed:
396
+ `manual` (switched off here), `vision` (the vision-router twin, a default the
397
+ switch can overrule), `provider` / `excluded` (from `config.json`),
398
+ `non-chat` (a family that cannot answer a chat turn), `combo` (a virtual
399
+ route). Only the first two are switchable from the page — the rest are
400
+ structural or owned by the file.
401
+
402
+ The roster reads its data over HTTP rather than through the settings form. Its
403
+ route is `/_dsh/model-rotator/models`: `GET` returns the grouped list
404
+ (`groups` = the two sections, `channels` = one entry per provider carrying its
405
+ `kind` and the `sourceChannel` a twin wraps, `models` = every row; a section and a
406
+ channel carry the same `models`/`ok`/`resting`/`off` counters, so one row shape
407
+ renders both and a folded line still reports its state),
408
+ `POST {key, enabled, scope}` flips one switch and answers with the refreshed list
409
+ (`scope: "provider"` for a channel header, omitted or `"model"` for a row). The
410
+ form carries the user's *policy* (how many candidates, how long to wait), while
411
+ this is a *roster* that changes whenever a provider republishes — and its rows
412
+ save one at a time, straight to `config.json`, so a refresh cannot discard a
413
+ half-typed number above it. The route is registered through
414
+ `ctx.inject(['webServer'], …)` because the host publishes the web server
415
+ *after* plugins load; it is auth-gated by the host exactly like every other
416
+ non-public path.
417
+
418
+ The two layers merge as **defaults < config.json < settings page**, and the page
419
+ wins only for the keys it actually owns: a key the page never sets still comes
420
+ from the file. `model_rotator_select` never writes a page-owned key back into
421
+ `config.json`, so a stale file value can never masquerade as authoritative.
422
+
423
+ The namespace is the plugin's cordis id (`model-rotator`) — a server half
424
+ without a client half shows nothing, which is why both ship together. A host
425
+ that ships no generic settings form has to be given one: the page is the
426
+ plugin's own React form, so the fields above are exactly the ones this bundle
427
+ stages, not every key the `Config` schema accepts.
428
+
429
+ The `Config` export needs schemastery *inside the host process*. The plugin
430
+ ships its own copy under `vendor/node_modules/` and loads that copy by absolute
431
+ path when a bare import cannot be resolved from the plugin's real path, which is
432
+ what makes the page appear on installs where the manifest link resolves outside
433
+ the profile's `node_modules`.
434
+
435
+ `model_rotator_status` ends with an **in force right now** block that lists the
436
+ merged value of every effective key next to the layer it came from
437
+ (`[default]`, `[config.json]` or `[settings page]`), so what is actually
438
+ driving the plugin is visible without reading two files and doing the merge in
439
+ your head. Further lines report the live picture: `ask channel` (whether the
440
+ pre-switch question can actually reach you, and why not when it cannot),
441
+ `adopt on switch`, `model in use now` (read from this session's request
442
+ header), `session switch` (whether the plugin can record the swapped model on
443
+ the session), `failure verdict` and `switch order`.
444
+
445
+ ## Install
446
+
447
+ ```
448
+ dsh plugin --profile desktop add github:chimneyQ/dsh-model-rotator
449
+ ```
450
+
451
+ From a local checkout, the same command takes the directory instead:
452
+
453
+ ```
454
+ dsh plugin --profile desktop add <path-to-this-directory>
455
+ ```
456
+
457
+ The package declares `dsh.bundle.patch`, so its `cordis.patch.yml` wires the
458
+ plugin automatically, and it has no dependencies to build. A full app restart is
459
+ required for the host to load it.
460
+
461
+ To confirm the host actually wired it up (a failed first install can leave the
462
+ dependency recorded without adding the plugin to `dsh.profile.bundles`), check
463
+ that `dsh.profile.bundles` in `<DSH_HOME>/profiles/desktop/package.json` lists
464
+ `dsh-model-rotator`.
465
+
466
+ ## Test
467
+
468
+ ```
469
+ node test-offline.mjs # failover + consent gate, against a stub harness
470
+ node test-settings.mjs # server-side settings contract, against the host rules
471
+ node test-client.mjs # browser bundle: loader shape, card registration, fields
472
+ ```
473
+
474
+ All three run with no network and no DSH process. `test-offline.mjs` (135
475
+ assertions) covers failure-then-retry, no-retry-once-text-flows, image-turn skip,
476
+ all-candidates-failed, cooldown accounting, aborted-signal passthrough,
477
+ non-chat filtering through a real waterfall, tool registration, and the consent
478
+ gate (prompt off, approved, declined by button and by typed text, timeout,
479
+ one-question-per-walk, and every fail-open path). Failure classification and the
480
+ quota rest are asserted through the exported `classifyFailureKind()` and
481
+ `quotaRestUntil()` seams — day boundaries, the pre-midnight floor, a month
482
+ rollover, "a quota wall never burns a retry", "an unsupported request is not
483
+ retried", and the on-disk round trip including TTL expiry and a malformed file.
484
+ The non-chat assertions call the exported `isNonChatModelId()` seam so they
485
+ test the shipped pattern table rather than a copy of it.
486
+
487
+ Two later scenarios pin the defects fixed after the first live run: **16** — a
488
+ `-vision` provider is never dialed, while a *model named* something-vision
489
+ stays a candidate, and switching a twin back on really does restore it; and
490
+ **17** — a reasoning-only attempt still gets its retries, text still closes the
491
+ walk, and an empty `block-start` counts as nothing. The list route is driven
492
+ end to end in **14** through an opt-in HTTP harness (GET, POST, the file write,
493
+ a bare model id, an empty key, a hand-edited file, and the whole channel switch
494
+ including its precedence rules). **16** additionally
495
+ asserts the two page sections — the twins counted apart from the chat channels,
496
+ each naming the channel it wraps — and **15** that a `purpose: "compaction"` call
497
+ is passed through untouched while an ordinary turn still fails over.
498
+
499
+ Two more offline cases cover the record itself: a stored `lastErrorKind` is
500
+ re-derived from its prose when the file is loaded, so a pattern added in a later
501
+ build relabels the history it was written for (a stored quota wall keeps resting
502
+ until tomorrow instead of being retried the moment it is read back); and a failure
503
+ payload that arrives as an object is serialised rather than stringified into
504
+ `[object Object]` — unreadable, and unclassifiable, which would cost a real quota
505
+ wall its daily rest.
506
+
507
+ **18** covers the switch notice: the message shape the session accepts (a
508
+ `user/message` with form `notice`, a fresh id, a summary inside the host's
509
+ 120-character bound), the vendor-name bug above, the turn-boundary delivery
510
+ through `agent.steer`, one notice per switch rather than per boundary, no notice
511
+ in a subagent transcript, none on an aborted turn, and the off switch.
512
+
513
+ Persistence makes the suite order-sensitive by nature: a cooldown that survives
514
+ the process would let scenario 5's dead keys decide scenario 9's candidate
515
+ order. Every harness therefore mounts onto a stats file of its own, and the two
516
+ that mean to assert across applies opt into sharing one explicitly.
517
+
518
+ `test-settings.mjs` (34 assertions) asserts the host's own rules, not this
519
+ plugin's opinion of them: it copies the shipped `volatileForm()` walk and
520
+ checks that the exported `Config` yields exactly the intended page fields
521
+ (a non-volatile sibling is dropped), and that the page outranks `config.json`
522
+ for the keys it owns while the file still fills the rest. It loads the host's
523
+ real `schemastery` and `cosmokit`, so `createVolatile()` handles behave
524
+ exactly as they do in the app.
525
+
526
+ `test-client.mjs` (44 assertions) runs `client.js` against a stub module
527
+ loader: the `window.__ModuleLoader__.load()` shape, the exported `apply` /
528
+ `inject`, the two cells (`settings.section` and `plugins.item`) registered for
529
+ the `model-rotator` namespace, and — the anti-drift check — that the fields the card renders and
530
+ stages are exactly the volatile fields the schema walk above yields. A field the
531
+ bundle forgets to stage is a setting nobody can edit; one it invents throws
532
+ `plugin card has no field <name>` inside the real form.
533
+
534
+ Its last block goes further than source matching: it renders the roster with a real
535
+ hooks runtime (state, effects, memoized callbacks, re-renders) against a payload
536
+ shaped like the host's, presses the buttons a reader would press, and asserts what
537
+ comes back — both sections, the derived tally, the folded and expanded states of
538
+ each fold level, and that the list is fetched exactly once. A regex can prove a
539
+ feature is mentioned; only a render proves it works, and this is what caught a
540
+ re-fetch loop the source checks could not see.
541
+
542
+ ## License
543
+
544
+ MIT