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.
- package/LICENSE +21 -0
- package/README.md +543 -2
- package/README.zh.md +283 -0
- package/client.js +831 -0
- package/cordis.patch.yml +13 -0
- package/index.js +2685 -0
- package/package.json +63 -4
- package/vendor/node_modules/@deepseek-ai/cosmokit/LICENSE +21 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/README.md +30 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/index.js +418 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts +15 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts.map +1 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts +13 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts.map +1 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts +41 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts.map +1 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts +89 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts.map +1 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts +19 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts.map +1 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts +42 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts.map +1 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts +40 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts.map +1 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/package.json +32 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/src/array.ts +42 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/src/index.ts +12 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/src/misc.ts +78 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/src/string.ts +113 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/src/time.ts +92 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/src/types.ts +168 -0
- package/vendor/node_modules/@deepseek-ai/cosmokit/src/volatile.ts +86 -0
- package/vendor/node_modules/@deepseek-ai/schemastery/LICENSE +21 -0
- package/vendor/node_modules/@deepseek-ai/schemastery/README.md +393 -0
- package/vendor/node_modules/@deepseek-ai/schemastery/lib/index.cjs +635 -0
- package/vendor/node_modules/@deepseek-ai/schemastery/lib/index.mjs +635 -0
- package/vendor/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts +211 -0
- package/vendor/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts.map +1 -0
- package/vendor/node_modules/@deepseek-ai/schemastery/package.json +39 -0
- package/vendor/node_modules/@deepseek-ai/schemastery/src/index.ts +962 -0
- package/vendor/node_modules/@standard-schema/spec/LICENSE +21 -0
- package/vendor/node_modules/@standard-schema/spec/README.md +198 -0
- package/vendor/node_modules/@standard-schema/spec/dist/index.cjs +18 -0
- package/vendor/node_modules/@standard-schema/spec/dist/index.d.cts +119 -0
- package/vendor/node_modules/@standard-schema/spec/dist/index.d.ts +119 -0
- package/vendor/node_modules/@standard-schema/spec/dist/index.js +0 -0
- 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
|
-
#
|
|
1
|
+
# dsh-model-rotator
|
|
2
2
|
|
|
3
|
-
|
|
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
|