dsh-model-pool 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/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +508 -2
- package/README.zh-CN.md +331 -0
- package/client.js +2847 -0
- package/cordis.patch.yml +15 -0
- package/docs/installation.md +115 -0
- package/docs/installation.zh-CN.md +107 -0
- package/icon.svg +17 -0
- package/index.js +718 -0
- package/lib/adapter.js +151 -0
- package/lib/capabilities.js +198 -0
- package/lib/catalog.js +175 -0
- package/lib/effort.js +151 -0
- package/lib/grouping.js +404 -0
- package/lib/health.js +175 -0
- package/lib/holiday-calendar.js +595 -0
- package/lib/peak.js +495 -0
- package/lib/router.js +584 -0
- package/lib/strategies.js +215 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +77 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
6
|
+
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
First public release. `dsh-model-pool` aggregates every registered provider's models into
|
|
13
|
+
one model pool: you pick a pooled model once and a configurable strategy routes each call to
|
|
14
|
+
a concrete provider, with failover, thinking-depth selection and unavailable-route markers.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- One virtual provider route (`model-pool`) built from the union of every registered
|
|
19
|
+
provider's models, grouped by normalized model name, so pooled models appear in the
|
|
20
|
+
ordinary composer picker with their thinking-depth menus.
|
|
21
|
+
- Six routing strategies: `priority`, `round-robin`, `weighted-random`, `least-used`,
|
|
22
|
+
`fastest` (EWMA of time-to-first-chunk) and `cheapest`. All are stable, with the provider
|
|
23
|
+
priority order as the common tie-break.
|
|
24
|
+
- Sticky sessions (on by default): a conversation keeps the route that served its first
|
|
25
|
+
call, failover moves the pin to the route that actually answered, a different pooled model
|
|
26
|
+
starts fresh, and conversations that never select the pool are untouched.
|
|
27
|
+
- Peak / idle provider ranking (`peakProviderOrder`, `offPeakProviderOrder`) as an overlay
|
|
28
|
+
on `providerOrder`: the six strategy ids are unchanged, and a policy with no ranking
|
|
29
|
+
configured is inert.
|
|
30
|
+
- A fetched Chinese statutory holiday calendar (`holiday-cn`), cached at
|
|
31
|
+
`$DSH_HOME/model-pool/calendar.json` and refreshed in the background. No date is ever
|
|
32
|
+
entered by hand, and the network can never break routing: a failed fetch keeps the cache,
|
|
33
|
+
and before the first successful sync the plain weekday rule applies.
|
|
34
|
+
- Live period reporting in Settings and over `/api/model-pool/state`: the current peak/idle
|
|
35
|
+
period, the local date and time, the configured windows, and when the period next flips.
|
|
36
|
+
- Thinking-depth union across a pool's candidates, mapped to the nearest tier the target
|
|
37
|
+
route actually supports instead of failing with `UNSUPPORTED_REASONING_EFFORT`, plus the
|
|
38
|
+
minimum context window across candidates.
|
|
39
|
+
- Input-modality union with per-call narrowing: an image-carrying call is filtered to
|
|
40
|
+
image-capable routes before the strategy, cooldown and sticky layers run, so the pool's
|
|
41
|
+
advertised image support is honoured rather than merely declared.
|
|
42
|
+
- Per-route health registry with `ok` / `degraded` / `unavailable` / `unknown` states,
|
|
43
|
+
failure codes and a cooldown window. Failover happens only while no content has reached
|
|
44
|
+
the consumer; `maxAttempts` bounds the candidates tried, and `fallback: false` turns the
|
|
45
|
+
pool back into a single route.
|
|
46
|
+
- Settings → Model Pool page: strategy selector, behaviour switches, peak/idle card with the
|
|
47
|
+
live period and calendar sync status, provider-priority editor, merged-group editor,
|
|
48
|
+
per-model rows with aggregate and per-route states, refresh and reset-health buttons.
|
|
49
|
+
- Composer dock badge showing the live route of the current session — provider, concrete
|
|
50
|
+
model id, thinking depth and pool model — settled before the terminal chunk and excluding
|
|
51
|
+
session-title and compaction calls.
|
|
52
|
+
- `modelGroups`: merge the same logical model across provider-specific ids, with
|
|
53
|
+
concrete-id, `provider/model` and glob selectors matched first-come. `merge: true` claims
|
|
54
|
+
routes away from their single-id rows; `merge: false` adds a curated pool alongside them.
|
|
55
|
+
Two groups ship by default (`DeepSeek V4.1 Flash`, `GLM 5.3 Flash`).
|
|
56
|
+
- Bilingual UI locale (`en` / `zh`) and plugin icon.
|
|
57
|
+
- HTTP state API under `/api/model-pool`: `state`, `session`, `refresh`,
|
|
58
|
+
`reset-health` and `refresh-calendar`.
|
|
59
|
+
- A dependency-free `node --test` suite (316 tests) covering grouping, strategies, health,
|
|
60
|
+
effort mapping, input capabilities, peak periods and the holiday calendar.
|
|
61
|
+
|
|
62
|
+
### Documented
|
|
63
|
+
|
|
64
|
+
- README with install paths, usage, strategy semantics, the full configuration reference and
|
|
65
|
+
the mechanism behind the pool.
|
|
66
|
+
- [`docs/CONTRACT.md`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/docs/CONTRACT.md)
|
|
67
|
+
— verified Host API evidence and the module contract the code is written against.
|
|
68
|
+
- [`docs/installation.md`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/docs/installation.md) — install, verify and troubleshooting
|
|
69
|
+
guide.
|
|
70
|
+
- [`docs/releasing.md`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/docs/releasing.md)
|
|
71
|
+
— tag-driven release process and the one-time `NPM_TOKEN` setup.
|
|
72
|
+
- Simplified Chinese translations of the README and both guides.
|
|
73
|
+
|
|
74
|
+
[Unreleased]: https://github.com/BruceZhang1993/dsh-model-pool/compare/v0.1.0...HEAD
|
|
75
|
+
[0.1.0]: https://github.com/BruceZhang1993/dsh-model-pool/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bruce Zhang
|
|
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,509 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-model-pool
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/BruceZhang1993/dsh-model-pool/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/dsh-model-pool)
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-model-pool)
|
|
6
|
+
[](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/LICENSE)
|
|
7
|
+

|
|
8
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
9
|
+
|
|
10
|
+
[**English**](README.md) · [简体中文](README.zh-CN.md)
|
|
11
|
+
|
|
12
|
+
A DeepSeek Harness plugin that turns **every registered provider into one model pool**.
|
|
13
|
+
|
|
14
|
+
You pick a model *once* — the pool decides which concrete provider serves each call,
|
|
15
|
+
according to a strategy you choose. If a provider is down, the pool skips it, fails over
|
|
16
|
+
to the next candidate, and marks it unavailable in the UI until it recovers.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
composer picker model pool concrete providers
|
|
20
|
+
───────────────── ────────── ──────────────────
|
|
21
|
+
Model Pool
|
|
22
|
+
DeepSeek V4 Pro ──────► strategy + health gate ──────► deepseek-account / deepseek-v4-pro
|
|
23
|
+
DeepSeek Flash (priority, round-robin, workbuddy / deepseek-v4-pro
|
|
24
|
+
GLM 5.3 Flash least-used, fastest, …) workbuddy-global / …
|
|
25
|
+
…
|
|
26
|
+
thinking depth: high ─────► mapped to each target's nearest tier
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
Providers overlap. `deepseek-v4-pro` may be served by the first-party account *and* by a
|
|
32
|
+
third-party route; `deepseek-v4.1-flash` may exist on several. Today you must pick one
|
|
33
|
+
route per session and remember which one is healthy. This plugin makes the *model* the
|
|
34
|
+
unit of choice and the *provider* an implementation detail.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
**Requirements:** DeepSeek Harness Desktop or CLI, Node.js **>= 20**. No build step and no
|
|
39
|
+
runtime dependencies — the Host half is plain ESM, the browser half is a hand-written
|
|
40
|
+
`window.__ModuleLoader__` module that only `require('react')`s. The two peer dependencies
|
|
41
|
+
(`@deepseek-ai/dsh-llm`, `@deepseek-ai/schemastery`) are provided by the Host, so nothing
|
|
42
|
+
has to be installed alongside the plugin.
|
|
43
|
+
|
|
44
|
+
### DSH Desktop
|
|
45
|
+
|
|
46
|
+
Open the **plugin manager**, search the registry for `dsh-model-pool`, and install it.
|
|
47
|
+
Restart DSH Desktop afterwards.
|
|
48
|
+
|
|
49
|
+
### CLI profiles
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
dsh plugin --profile web add dsh-model-pool # latest
|
|
53
|
+
dsh plugin --profile web add dsh-model-pool@0.1.0 # pin a version
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`dsh plugin` delegates to pnpm inside the profile directory and selects the bundle in
|
|
57
|
+
`dsh.profile.bundles`. Restart the profile afterwards.
|
|
58
|
+
|
|
59
|
+
### Local checkout
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
dsh plugin --profile web add /absolute/path/to/dsh-model-pool
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That is the development install: pnpm links the checkout instead of downloading it, so the
|
|
66
|
+
next restart picks up your edits.
|
|
67
|
+
|
|
68
|
+
### Verify
|
|
69
|
+
|
|
70
|
+
- **Settings → Model Pool** exists, and lists every pooled model with its provider count,
|
|
71
|
+
thinking depths and per-route state badges.
|
|
72
|
+
- The composer model picker contains a **Model Pool** entry; pick one of its models and each
|
|
73
|
+
call is routed through the pool.
|
|
74
|
+
|
|
75
|
+
Both halves ship in the same package: `dsh.bundle.patch` is the Host half (`index.js` +
|
|
76
|
+
`lib/`) and `dsh.client` is the browser half (`client.js`). Nothing is built at install
|
|
77
|
+
time.
|
|
78
|
+
|
|
79
|
+
The full guide, including troubleshooting, is in [`docs/installation.md`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/docs/installation.md).
|
|
80
|
+
|
|
81
|
+
## Use
|
|
82
|
+
|
|
83
|
+
1. Open **Settings → Model Pool**. It lists every pooled model, how many providers serve
|
|
84
|
+
it, its thinking depths, and a per-route state badge.
|
|
85
|
+
2. Choose a **strategy** (below) and the behaviour switches. Optionally give the peak and
|
|
86
|
+
the idle period their own provider ranking in the **峰谷时段(peak / idle)** card, which
|
|
87
|
+
also shows the live period, when it next flips, and the statutory holiday calendar that
|
|
88
|
+
the plugin keeps in sync by itself — there is nothing to fill in by hand.
|
|
89
|
+
3. In the composer model picker, pick a **Model Pool** model and a thinking depth.
|
|
90
|
+
4. Each call is routed; failures cool a route down and fail over to the next candidate.
|
|
91
|
+
|
|
92
|
+
## Strategies
|
|
93
|
+
|
|
94
|
+
| Strategy | What it does | When to use |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `priority` | Try providers in the configured priority order. | A clear first choice (e.g. first-party) with a backup. |
|
|
97
|
+
| `round-robin` | Rotate the starting provider on every call. | Spread load evenly across equivalent routes. |
|
|
98
|
+
| `weighted-random` | Draw at random, honouring each provider's weight. | Keep traffic mostly on a preferred route without hard pinning. |
|
|
99
|
+
| `least-used` | Prefer the provider that has served the fewest calls. | Balance without a strict rotation. |
|
|
100
|
+
| `fastest` | Prefer the lowest recent time-to-first-chunk. | Latency-sensitive work; adapts as providers slow down. |
|
|
101
|
+
| `cheapest` | Prefer the lowest declared cost hint. | Cost-sensitive bulk work. |
|
|
102
|
+
|
|
103
|
+
`priority`, `least-used`, `fastest` and `cheapest` are stable: equal keys keep the
|
|
104
|
+
priority order, so routing never becomes unpredictable. `fastest` learns from real calls
|
|
105
|
+
(an EWMA of time-to-first-chunk), so the first call on each route is a probe.
|
|
106
|
+
|
|
107
|
+
### Keeping one conversation on one route
|
|
108
|
+
|
|
109
|
+
`sticky` (on by default) pins a conversation to the route that served its first call:
|
|
110
|
+
|
|
111
|
+
| | Without sticky | With sticky |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `round-robin`, four turns in one chat | `A B C A` | `A A A A` |
|
|
114
|
+
| A second chat | continues the rotation | picks its own route, then stays |
|
|
115
|
+
|
|
116
|
+
The point is the provider's prompt cache: re-deciding every turn throws away the cached
|
|
117
|
+
prefix, so a long conversation gets slower and more expensive than it needs to be. With
|
|
118
|
+
sticky on, switching providers happens once per conversation instead of once per message.
|
|
119
|
+
|
|
120
|
+
It is a preference, not a lock:
|
|
121
|
+
|
|
122
|
+
- **Failover still works.** If the pinned route fails before producing output, the call
|
|
123
|
+
moves to the next candidate, and the *survivor* becomes the new pin.
|
|
124
|
+
- **Unhealthy routes are skipped.** A pin inside its cooldown stops matching, so the
|
|
125
|
+
strategy decides again.
|
|
126
|
+
- **A different pooled model starts fresh.** Re-selecting the pool with another model does
|
|
127
|
+
not inherit the previous model's provider.
|
|
128
|
+
- **Only pooled calls participate.** A conversation that never selects the pool is
|
|
129
|
+
untouched.
|
|
130
|
+
|
|
131
|
+
Turn it off in **Settings → Model Pool → Behaviour** when you want per-call spreading
|
|
132
|
+
(quota sharing across accounts) rather than cache reuse.
|
|
133
|
+
|
|
134
|
+
### Setting the provider priority
|
|
135
|
+
|
|
136
|
+
The `priority` strategy ranks providers by `providerOrder` — a list of provider ids from
|
|
137
|
+
highest to lowest. **Settings → Model Pool → Provider priority** lists every registered
|
|
138
|
+
provider by its display name; reorder with the arrow buttons and save. You never type an
|
|
139
|
+
id there. Only providers that currently contribute a model to the pool are listed — a
|
|
140
|
+
provider with no models cannot be routed to, so ranking it would change nothing. The same ranking can be set in `cordis.patch.yml`:
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
providerOrder: [deepseek-account, workbuddy, our-free-model]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
With nothing configured the list shows plain registration order, which is exactly what the
|
|
147
|
+
router falls back to. Providers you leave out follow the listed ones in registration order,
|
|
148
|
+
so a partial list is valid — move just your first choice up and everything else stays
|
|
149
|
+
behind it. Saving the untouched registration order stores an empty ranking rather than a
|
|
150
|
+
redundant full list. A provider that is registered but currently serves no models still
|
|
151
|
+
appears, because it is registered; one that has disappeared is kept and flagged rather
|
|
152
|
+
than dropped silently.
|
|
153
|
+
|
|
154
|
+
The same list also decides the tie-break for `least-used`, `fastest` and `cheapest`, and
|
|
155
|
+
the fallback order after a failover.
|
|
156
|
+
|
|
157
|
+
### 峰谷时段(peak / idle)
|
|
158
|
+
|
|
159
|
+
Providers are not equally attractive at every hour. A first-party account may be cheap and
|
|
160
|
+
fast outside business hours but rationed during them; a paid backup is often the reverse. So
|
|
161
|
+
the pool can rank providers differently depending on whether the current moment is **peak**
|
|
162
|
+
or **idle**.
|
|
163
|
+
|
|
164
|
+
The shipped default is a Chinese business-hours policy:
|
|
165
|
+
|
|
166
|
+
| Aspect | Default |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Time zone | `Asia/Shanghai` (北京时间) |
|
|
169
|
+
| Peak days | Monday–Friday |
|
|
170
|
+
| Peak windows | `09:00-12:00` and `14:00-18:00` |
|
|
171
|
+
| Peak exceptions | official 调休上班日 — a make-up Saturday is a working day |
|
|
172
|
+
| Everything else | **idle** — weekends all day, statutory holidays all day, plus the lunch break, evenings and nights |
|
|
173
|
+
|
|
174
|
+
A Beijing Friday 19:00, any ordinary Saturday and the whole National Day week are all idle; the
|
|
175
|
+
Saturday the State Council opens to make up for a holiday is not.
|
|
176
|
+
|
|
177
|
+
Two lists carry the rankings, and they obey the same rules as `providerOrder`: highest
|
|
178
|
+
first, a partial list is valid, and an empty list means *this period has no opinion* — it
|
|
179
|
+
inherits `providerOrder` rather than clearing the ranking. That is what keeps the feature
|
|
180
|
+
inert until you configure it: with both lists empty, routing is exactly what it was before.
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
peakProviderOrder: [workbuddy] # ranking while peak; empty = inherit providerOrder
|
|
184
|
+
offPeakProviderOrder: [deepseek-account] # ranking while idle; empty = inherit providerOrder
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Set them in **Settings → Model Pool → 峰谷时段(peak / idle)**, which offers the same
|
|
188
|
+
arrow-button editor as the provider priority, or in `cordis.patch.yml`. Because the overlay
|
|
189
|
+
reorders the candidates *before* the strategy runs, `priority` follows it directly and
|
|
190
|
+
`least-used`, `fastest` and `cheapest` inherit it as their tie-break — the same way
|
|
191
|
+
`providerOrder` already works. `round-robin` rotates through the period order, and
|
|
192
|
+
`weighted-random` still draws by weight, using that order only to settle equal-weight and
|
|
193
|
+
zero-weight routes.
|
|
194
|
+
|
|
195
|
+
The day itself is configurable too:
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
peakTimeZone: Asia/Shanghai
|
|
199
|
+
peakWindows: ['09:00-12:00', '14:00-18:00'] # 'HH:MM-HH:MM'; 24:00 is a valid end
|
|
200
|
+
peakWeekdays: [1, 2, 3, 4, 5] # ISO: 1 = Monday … 7 = Sunday
|
|
201
|
+
peakMakeupWorkdays: [] # extra 调休上班日 on top of the fetched ones
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
There is deliberately **no holiday list here**. 法定节假日 and 调休上班日 are fetched from a
|
|
205
|
+
maintained dataset and kept up to date by the plugin, so nobody types dates and nothing goes
|
|
206
|
+
stale when the State Council publishes next year's arrangement — see below.
|
|
207
|
+
|
|
208
|
+
#### 法定节假日自动同步
|
|
209
|
+
|
|
210
|
+
A worked Chinese calendar cannot be derived from a rule: 调休 moves days around every year.
|
|
211
|
+
The plugin therefore reads it as data, from
|
|
212
|
+
[`holiday-cn`](https://github.com/NateScarlet/holiday-cn) (NateScarlet, MIT) — one JSON
|
|
213
|
+
document per year, generated from the State Council notice and carrying that notice's URL in
|
|
214
|
+
its `papers` field. That is a real dependency worth knowing about: this plugin ships no
|
|
215
|
+
holiday table of its own.
|
|
216
|
+
|
|
217
|
+
```jsonc
|
|
218
|
+
{ "year": 2026,
|
|
219
|
+
"papers": ["https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm"],
|
|
220
|
+
"days": [ { "name": "元旦", "date": "2026-01-01", "isOffDay": true }, // 法定节假日
|
|
221
|
+
{ "name": "元旦", "date": "2026-01-04", "isOffDay": false } ] } // 调休上班日
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The document is fetched from jsDelivr, falling back to `raw.githubusercontent.com` when the
|
|
225
|
+
CDN is unreachable, and cached at `$DSH_HOME/model-pool/calendar.json` (under `~/.dsh` when
|
|
226
|
+
`DSH_HOME` is unset). A background refresh runs every `holidayUpdateDays` (7), and re-fetches
|
|
227
|
+
only when the cache is older than `holidayCacheDays` (30) or when a year the policy can
|
|
228
|
+
reach is missing — the second condition is what lets a November notice land without a
|
|
229
|
+
restart. The age threshold is read on every sync; the background cadence is fixed when the
|
|
230
|
+
plugin loads, so changing it takes effect on the next reload. A year the dataset has not
|
|
231
|
+
published yet — 2027 at the time of writing — comes back with no dates, and those days simply
|
|
232
|
+
follow the weekday rule. That is the conservative reading: inventing a holiday would silently
|
|
233
|
+
take providers out of rotation.
|
|
234
|
+
|
|
235
|
+
The fetched calendar carries both halves of the arrangement: the closed days are idle, and
|
|
236
|
+
the official 调休上班日 are working days, so a make-up Saturday is peak inside the normal
|
|
237
|
+
windows while every other Saturday is idle. `peakMakeupWorkdays` remains a normal setting
|
|
238
|
+
only as an escape hatch for an extra day of your own — it is unioned on top of the fetched
|
|
239
|
+
make-up days. A date that is both a holiday and a make-up workday is idle: a statutory
|
|
240
|
+
holiday wins.
|
|
241
|
+
|
|
242
|
+
**The network can never break routing.** Every routing decision reads the in-memory calendar;
|
|
243
|
+
a failed fetch keeps the cache and records the error, and a first run with neither a cache nor
|
|
244
|
+
a network falls back to the plain weekday rule rather than failing a call. Settings shows the
|
|
245
|
+
failure and offers **Sync now** to retry immediately.
|
|
246
|
+
|
|
247
|
+
The holiday block of the Settings card reports **the current year only** — the status (synced
|
|
248
|
+
/ stale / not synced / failed), that year's holiday and make-up counts, when it last updated,
|
|
249
|
+
the source id, and the official notice URLs — plus a switch to keep it updating automatically
|
|
250
|
+
and a **Sync now** button. Other loaded years stay in the payload because routing needs them
|
|
251
|
+
(the peak lookahead crosses New Year), but the card shows one year so the figures answer "is
|
|
252
|
+
today's calendar right" instead of "how much history is cached". A year the State Council has
|
|
253
|
+
not published yet reads **尚未发布 / Not published yet** rather than "0 holidays". `holidayOverride`
|
|
254
|
+
is the only hand-editable list: it adds extra
|
|
255
|
+
**idle** dates on top of the fetched calendar for a one-off correction, and a refresh never
|
|
256
|
+
clobbers it.
|
|
257
|
+
|
|
258
|
+
`holidaySync: false` (or `holidaySource: 'off'`) turns the calendar off entirely: the
|
|
259
|
+
fetched dates stop influencing routing, leaving only `holidayOverride` and the manual
|
|
260
|
+
`peakMakeupWorkdays` list, so every other day follows the weekday rule. `holidaySource`
|
|
261
|
+
normally names a source id (`holiday-cn`); giving it a full `https://` URL, or setting
|
|
262
|
+
`holidayUrlTemplate` to a `{year}` template, points the sync at a mirror of your own.
|
|
263
|
+
|
|
264
|
+
The period block above it shows the live reading — the current period, the local date and
|
|
265
|
+
time, the configured windows, and when the period next flips (a Friday evening reports
|
|
266
|
+
Monday 09:00, not the next midnight) — so you can see which ranking is actually in force. It
|
|
267
|
+
also warns when today is a statutory holiday, or a make-up workday that the policy is still
|
|
268
|
+
treating as idle. `peakEnabled: false` turns the overlay off entirely.
|
|
269
|
+
|
|
270
|
+
#### How it composes with sticky
|
|
271
|
+
|
|
272
|
+
Peak ranking does not fight `sticky`; it composes with it. With sticky on — the default —
|
|
273
|
+
the period decides when a conversation **starts**, not what it does on every turn: a chat
|
|
274
|
+
that began on the idle ranking keeps that provider when the peak window opens, because
|
|
275
|
+
sticky pins the route that served its first call and already overrides `providerOrder` the
|
|
276
|
+
same way. That is the whole point of sticky — re-deciding every turn throws away the
|
|
277
|
+
provider's prompt cache. Turn sticky off and the provider changes exactly at the boundary;
|
|
278
|
+
either way a new conversation honours the period on its first call.
|
|
279
|
+
|
|
280
|
+
## Thinking depth
|
|
281
|
+
|
|
282
|
+
The pool advertises the **union** of its candidates' thinking depths. Pick any depth you
|
|
283
|
+
like; the router maps it to the **nearest tier the target actually supports** — exact
|
|
284
|
+
match first, then the smallest rank distance, ties going to the higher tier. So `max` on
|
|
285
|
+
a pool containing a route that only offers `off/high` becomes `high` for that attempt
|
|
286
|
+
instead of failing with `UNSUPPORTED_REASONING_EFFORT`.
|
|
287
|
+
|
|
288
|
+
The pool also advertises the **minimum** context window across its candidates, so a
|
|
289
|
+
request that fits the pool always fits every route it may fail over to.
|
|
290
|
+
|
|
291
|
+
## Images and other input
|
|
292
|
+
|
|
293
|
+
The pool advertises the **union** of its candidates' input modalities, then routes each call
|
|
294
|
+
to a candidate that can actually take it. Pick a pooled model whose routes include an
|
|
295
|
+
image-capable provider and you can attach screenshots to it, without having to know or care
|
|
296
|
+
which provider serves that turn.
|
|
297
|
+
|
|
298
|
+
This is deliberate, and it used to be the opposite. The advertised set is what the Host
|
|
299
|
+
checks *before* the call exists: a prompt carrying an image is refused outright with
|
|
300
|
+
`MODEL_DOES_NOT_SUPPORT_IMAGES` when the selected model's `inputModalities` exists and omits
|
|
301
|
+
`image`. Advertising the **intersection** of the candidates therefore made a model whose
|
|
302
|
+
majority routes read images reject every upload — one text-only candidate (a regional
|
|
303
|
+
gateway, a free tier) was enough to make the whole pool look blind. Advertising the **union**
|
|
304
|
+
lets you attach the image, and the router then keeps that promise:
|
|
305
|
+
|
|
306
|
+
- A text-only call keeps every candidate, so the pool still balances and fails over freely.
|
|
307
|
+
- A call carrying an image is narrowed to the routes that accept one, *before* the strategy,
|
|
308
|
+
cooldown and sticky layers run, so the screenshot cannot silently become a placeholder.
|
|
309
|
+
- A candidate that declares nothing is treated as text-only for an image call. Unknown is not
|
|
310
|
+
the same as capable, and guessing wrong costs the whole call.
|
|
311
|
+
- If the only image-capable route is cooling down after a failure, it is still tried before a
|
|
312
|
+
healthy text-only one: a route that may have recovered beats an answer from a route that
|
|
313
|
+
never saw the image.
|
|
314
|
+
- If nothing in a stale snapshot is capable, the pool still tries rather than refusing: the
|
|
315
|
+
runtime then degrades the image to a placeholder, which is recoverable, instead of the call
|
|
316
|
+
failing.
|
|
317
|
+
|
|
318
|
+
Settings → Model Pool shows which candidates accept images, so a pool whose capability comes
|
|
319
|
+
from a single route is visible rather than implied.
|
|
320
|
+
|
|
321
|
+
## Seeing the live route in a conversation
|
|
322
|
+
|
|
323
|
+
A pooled model is a virtual route, so the composer would otherwise only ever show
|
|
324
|
+
"Model Pool · DeepSeek V4.1 Flash" with no hint of the real upstream. The plugin adds one
|
|
325
|
+
entry to the composer dock (beside the built-in turn and token statistics) that shows what
|
|
326
|
+
the **current session** actually ran on:
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
实际路由 workbuddy deepseek-v4.1-flash max DeepSeek V4.1 Flash
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- **Provider** — the concrete provider route that produced content.
|
|
333
|
+
- **Model** — that provider's own id for it, which is often not the pool id.
|
|
334
|
+
- **Thinking depth** — the effort actually used, when the route declares one.
|
|
335
|
+
- **Pool model** — the row you selected, for context.
|
|
336
|
+
|
|
337
|
+
The entry renders nothing when the session never ran through the pool, so ordinary
|
|
338
|
+
conversations are untouched. It follows failover: if the first candidate fails before
|
|
339
|
+
producing output, the badge shows the provider that actually answered, not the one that
|
|
340
|
+
was tried first. The value is recorded per session, so two conversations open at once each
|
|
341
|
+
show their own route.
|
|
342
|
+
|
|
343
|
+
The badge describes the **conversation's** calls. Auxiliary calls that reuse the same pooled
|
|
344
|
+
route — session-title generation, compaction summaries — pick their own thinking depth
|
|
345
|
+
(usually `off`) and never define the entry, so a title call cannot make a conversation that
|
|
346
|
+
ran at `High` report `Low`. The record is also settled the moment the answer's terminal
|
|
347
|
+
chunk is produced, so a middleware that rewrites or re-wraps the answer stream cannot leave
|
|
348
|
+
a stale value behind.
|
|
349
|
+
|
|
350
|
+
The composer dock is polled every few seconds, so the value settles shortly after a turn
|
|
351
|
+
finishes. It lives only in this Host generation — a restart clears it, and the entry is
|
|
352
|
+
rebuilt on the next pooled call.
|
|
353
|
+
|
|
354
|
+
## Availability markers
|
|
355
|
+
|
|
356
|
+
Every concrete route carries a state:
|
|
357
|
+
|
|
358
|
+
| State | Meaning |
|
|
359
|
+
|---|---|
|
|
360
|
+
| `ok` | Last attempt succeeded. |
|
|
361
|
+
| `degraded` | Failed at least once; the cooldown has elapsed, so it is eligible again. |
|
|
362
|
+
| `unavailable` | Failed and still inside the cooldown window; skipped while others are available. |
|
|
363
|
+
| `unknown` | Never used in this Host generation. |
|
|
364
|
+
|
|
365
|
+
A route that fails is recorded with its failure code (for example `AUTH`, `RATE_LIMIT`,
|
|
366
|
+
`QUOTA`) and stays out of rotation for `cooldownMs`. If **every** candidate is cooling
|
|
367
|
+
down the pool still tries them in strategy order — a transient outage must not become a
|
|
368
|
+
hard failure. **Reset health** clears the markers.
|
|
369
|
+
|
|
370
|
+
## Failover rules
|
|
371
|
+
|
|
372
|
+
- A call fails over only while **no output has reached you yet**. Once text or a tool call
|
|
373
|
+
has streamed, the error is reported as-is: partial answers are never replayed.
|
|
374
|
+
- `maxAttempts` bounds how many providers one call may try.
|
|
375
|
+
- With `fallback: false` the first candidate is the only candidate.
|
|
376
|
+
|
|
377
|
+
## Configure
|
|
378
|
+
|
|
379
|
+
Settings → Model Pool covers the everyday knobs. The rest live in this plugin's own row
|
|
380
|
+
in `cordis.patch.yml`, so they survive upgrades:
|
|
381
|
+
|
|
382
|
+
```yaml
|
|
383
|
+
- id: model-pool
|
|
384
|
+
name: dsh-model-pool
|
|
385
|
+
config:
|
|
386
|
+
strategy: least-used
|
|
387
|
+
fallback: true
|
|
388
|
+
skipUnavailable: true
|
|
389
|
+
cooldownMs: 60000
|
|
390
|
+
maxAttempts: 3
|
|
391
|
+
include: [] # provider allowlist; empty = all
|
|
392
|
+
exclude: [workbuddy] # provider denylist
|
|
393
|
+
excludeModels: [] # model ids or substrings
|
|
394
|
+
providerOrder: [deepseek-account, workbuddy] # priority ranking, highest first
|
|
395
|
+
peakEnabled: true
|
|
396
|
+
peakTimeZone: Asia/Shanghai
|
|
397
|
+
peakWindows: ['09:00-12:00', '14:00-18:00'] # peak windows; empty = never peak
|
|
398
|
+
peakWeekdays: [1, 2, 3, 4, 5] # ISO 1 = Mon … 7 = Sun; empty = never peak
|
|
399
|
+
peakMakeupWorkdays: [] # extra 调休上班日; the fetched ones are automatic
|
|
400
|
+
peakProviderOrder: [workbuddy] # ranking while peak; empty = inherit
|
|
401
|
+
offPeakProviderOrder: [deepseek-account] # ranking while idle; empty = inherit
|
|
402
|
+
# Statutory calendar: fetched, not configured. Defaults shown; rarely changed.
|
|
403
|
+
holidaySync: true # keep the calendar up to date automatically
|
|
404
|
+
holidaySource: holiday-cn # or a source id, or 'off'
|
|
405
|
+
holidayUrlTemplate: '' # '{year}' template; empty = source default
|
|
406
|
+
holidayCacheDays: 30 # refetch when the cache is older than this
|
|
407
|
+
holidayUpdateDays: 7 # background refresh cadence
|
|
408
|
+
holidayOverride: [] # one-off corrections, layered on the fetch
|
|
409
|
+
weights: { deepseek-account: 3, workbuddy: 1 }
|
|
410
|
+
costs: { deepseek-account: 0.4, workbuddy: 0.1 }
|
|
411
|
+
modelGroups:
|
|
412
|
+
- id: deepseek-v4.1-flash
|
|
413
|
+
name: DeepSeek V4.1 Flash
|
|
414
|
+
models: [deepseek-v4.1-flash*, deepseek-flash]
|
|
415
|
+
merge: true
|
|
416
|
+
- id: fast-coding
|
|
417
|
+
name: fast-coding
|
|
418
|
+
models: [deepseek-v4.1-flash, glm-5.3-flash]
|
|
419
|
+
merge: false
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Merging one model that has several provider ids
|
|
423
|
+
|
|
424
|
+
The same model usually carries a different id on each provider — one account calls it
|
|
425
|
+
`deepseek-flash`, another `deepseek-v4.1-flash`, a third
|
|
426
|
+
`opencode/deepseek-v4.1-flash-free`. Left alone those become three separate pool rows,
|
|
427
|
+
which defeats the point of a pool.
|
|
428
|
+
|
|
429
|
+
`modelGroups` fixes that. A group lists selectors that **claim** the matching routes and
|
|
430
|
+
present them as a single row:
|
|
431
|
+
|
|
432
|
+
| Selector form | Matches |
|
|
433
|
+
|---|---|
|
|
434
|
+
| `deepseek-v4.1-flash` | a concrete model id, or the normalized pool id |
|
|
435
|
+
| `workbuddy/deepseek-v4.1-flash` | one exact `provider/model` route |
|
|
436
|
+
| `deepseek-v4.1-flash*` | glob on any of the above (`*` = any run, `?` = one char) |
|
|
437
|
+
|
|
438
|
+
With `merge: true` (the default) the claimed routes leave their single-id rows, so the
|
|
439
|
+
pool shows one row per logical model. A natural row left with no routes disappears.
|
|
440
|
+
Set `merge: false` when you want an *additional* curated pool ("fast-coding") and the
|
|
441
|
+
automatic rows should stay.
|
|
442
|
+
|
|
443
|
+
Selectors are matched first-come: if two groups claim the same route, the earlier group
|
|
444
|
+
wins and the later one simply gets nothing from it.
|
|
445
|
+
|
|
446
|
+
Two groups ship by default so this works out of the box — `deepseek-v4.1-flash*` +
|
|
447
|
+
`deepseek-flash` merge into **DeepSeek V4.1 Flash**, and `glm-5.3-flash*` merges into
|
|
448
|
+
**GLM 5.3 Flash**. Edit them in **Settings → Model Pool → Merged groups**, or replace
|
|
449
|
+
the list in `cordis.patch.yml`. Changes are live: no restart.
|
|
450
|
+
|
|
451
|
+
## How it works
|
|
452
|
+
|
|
453
|
+
The Host's `llm` service exposes provider routes; the composer picker and Settings read
|
|
454
|
+
them through `listProviders()` / `listModels()` / `resolveModelInfo()`. This plugin:
|
|
455
|
+
|
|
456
|
+
1. Registers **one extra virtual route** (`model-pool`) whose models are the union of
|
|
457
|
+
every other provider's models, grouped by normalized model name. That is what makes
|
|
458
|
+
the pool appear in the ordinary picker, thinking-depth menu included. Each pooled model
|
|
459
|
+
advertises the union of its candidates' input modalities (see
|
|
460
|
+
[Images and other input](#images-and-other-input)), so a pooled model accepts a
|
|
461
|
+
screenshot when *any* of its providers can read one.
|
|
462
|
+
2. Listens on the **`llm/stream` waterfall**. A request naming the pool route is
|
|
463
|
+
intercepted *before* dispatch and re-issued against a concrete provider chosen by the
|
|
464
|
+
strategy. A request that carries an image only considers the candidates that accept one,
|
|
465
|
+
so the advertised capability is honoured instead of merely declared. The runtime then
|
|
466
|
+
performs exactly one correct projection of files, images and tool history for the real
|
|
467
|
+
target.
|
|
468
|
+
3. Keeps a **health registry** per concrete route, which feeds both the failover gate and
|
|
469
|
+
the Settings badges.
|
|
470
|
+
|
|
471
|
+
The adapter's own `stream()` never runs; it exists only to claim the route so the catalog
|
|
472
|
+
surfaces it. If it is ever reached, it fails with a clear message instead of silently
|
|
473
|
+
returning nothing.
|
|
474
|
+
|
|
475
|
+
See [`docs/CONTRACT.md`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/docs/CONTRACT.md) for the verified API evidence and the full
|
|
476
|
+
module contract.
|
|
477
|
+
|
|
478
|
+
## Scope and limits
|
|
479
|
+
|
|
480
|
+
- Pool state (health, round-robin cursor) is per Host generation: a restart clears it. The
|
|
481
|
+
fetched holiday calendar is the exception — it is cached on disk, so a restart reads it
|
|
482
|
+
instead of re-downloading. The very first sync needs network access; until it succeeds,
|
|
483
|
+
statutory holidays are simply not known and every weekday follows the weekday rule.
|
|
484
|
+
- The pool re-reads the provider topology on `llm/adapters-updated`, so installing or
|
|
485
|
+
removing a provider updates the pools without a restart.
|
|
486
|
+
- `providerId` / `providerName` changes need a restart to re-register the route.
|
|
487
|
+
- Routing is per call, not per session: two calls in one turn may use different providers.
|
|
488
|
+
That is intentional for balancing, but it does mean a provider-side prompt cache is not
|
|
489
|
+
shared across a balanced pool. Use `priority` if cache reuse matters more than balance.
|
|
490
|
+
- A pooled model's image support is the union of its candidates', so a conversation that
|
|
491
|
+
attached an image keeps working even when that turn fails over — but the *concrete* route
|
|
492
|
+
is narrowed per call, which means a sticky session may be moved onto a different provider
|
|
493
|
+
for an image turn and back afterwards. Sticky is a preference for cache reuse, not a
|
|
494
|
+
guarantee about capability.
|
|
495
|
+
- The pool cannot know more than its providers declare. A route that advertises no
|
|
496
|
+
`inputModalities` counts as text-only for an image call; if such a provider in fact reads
|
|
497
|
+
images, the pool will not use it for them until that provider declares the capability.
|
|
498
|
+
|
|
499
|
+
## Development
|
|
500
|
+
|
|
501
|
+
```
|
|
502
|
+
node --test test/*.test.mjs # pure logic: grouping, strategies, health, effort,
|
|
503
|
+
# capabilities, peak, calendar
|
|
504
|
+
npm run check
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
## License
|
|
508
|
+
|
|
509
|
+
MIT — see [`LICENSE`](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/LICENSE).
|