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 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
- # Temporary Holding Version
1
+ # dsh-model-pool
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
+ [![CI](https://github.com/BruceZhang1993/dsh-model-pool/actions/workflows/ci.yml/badge.svg)](https://github.com/BruceZhang1993/dsh-model-pool/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/dsh-model-pool.svg)](https://www.npmjs.com/package/dsh-model-pool)
5
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-model-pool.svg)](https://www.npmjs.com/package/dsh-model-pool)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/BruceZhang1993/dsh-model-pool/blob/master/LICENSE)
7
+ ![Node.js >= 20](https://img.shields.io/badge/node-%E2%89%A520-brightgreen.svg)
8
+ [![DeepSeek Harness plugin](https://img.shields.io/badge/DeepSeek_Harness-plugin-4d6bfe.svg)](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).