dsh-model-pool
A DeepSeek Harness plugin that turns every registered provider into one model pool.
You pick a model once — the pool decides which concrete provider serves each call, according to a strategy you choose. If a provider is down, the pool skips it, fails over to the next candidate, and marks it unavailable in the UI until it recovers.
composer picker model pool concrete providers
───────────────── ────────── ──────────────────
Model Pool
DeepSeek V4 Pro ──────► strategy + health gate ──────► deepseek-account / deepseek-v4-pro
DeepSeek Flash (priority, round-robin, workbuddy / deepseek-v4-pro
GLM 5.3 Flash least-used, fastest, …) workbuddy-global / …
…
thinking depth: high ─────► mapped to each target's nearest tier
Why
Providers overlap. deepseek-v4-pro may be served by the first-party account and by a
third-party route; deepseek-v4.1-flash may exist on several. Today you must pick one
route per session and remember which one is healthy. This plugin makes the model the
unit of choice and the provider an implementation detail.
Install
Requirements: DeepSeek Harness Desktop or CLI, Node.js >= 20. No build step and no
runtime dependencies — the Host half is plain ESM, the browser half is a hand-written
window.__ModuleLoader__ module that only require('react')s. The two peer dependencies
(@deepseek-ai/dsh-llm, @deepseek-ai/schemastery) are provided by the Host, so nothing
has to be installed alongside the plugin.
DSH Desktop
Open the plugin manager, search the registry for dsh-model-pool, and install it.
Restart DSH Desktop afterwards.
CLI profiles
dsh plugin --profile web add dsh-model-pool # latest
dsh plugin --profile web add dsh-model-pool@0.1.0 # pin a version
dsh plugin delegates to pnpm inside the profile directory and selects the bundle in
dsh.profile.bundles. Restart the profile afterwards.
Local checkout
dsh plugin --profile web add /absolute/path/to/dsh-model-pool
That is the development install: pnpm links the checkout instead of downloading it, so the next restart picks up your edits.
Verify
- Settings → Model Pool exists, and lists every pooled model with its provider count, thinking depths and per-route state badges.
- The composer model picker contains a Model Pool entry; pick one of its models and each call is routed through the pool.
Both halves ship in the same package: dsh.bundle.patch is the Host half (index.js +
lib/) and dsh.client is the browser half (client.js). Nothing is built at install
time.
The full guide, including troubleshooting, is in docs/installation.md.
Use
- Open Settings → Model Pool. It lists every pooled model, how many providers serve it, its thinking depths, and a per-route state badge.
- Choose a strategy (below) and the behaviour switches. Optionally give the peak and the idle period their own provider ranking in the 峰谷时段(peak / idle) card, which also shows the live period, when it next flips, and the statutory holiday calendar that the plugin keeps in sync by itself — there is nothing to fill in by hand.
- In the composer model picker, pick a Model Pool model and a thinking depth.
- Each call is routed; failures cool a route down and fail over to the next candidate.
Strategies
| Strategy | What it does | When to use |
|---|---|---|
priority |
Try providers in the configured priority order. | A clear first choice (e.g. first-party) with a backup. |
round-robin |
Rotate the starting provider on every call. | Spread load evenly across equivalent routes. |
weighted-random |
Draw at random, honouring each provider's weight. | Keep traffic mostly on a preferred route without hard pinning. |
least-used |
Prefer the provider that has served the fewest calls. | Balance without a strict rotation. |
fastest |
Prefer the lowest recent time-to-first-chunk. | Latency-sensitive work; adapts as providers slow down. |
cheapest |
Prefer the lowest declared cost hint. | Cost-sensitive bulk work. |
priority, least-used, fastest and cheapest are stable: equal keys keep the
priority order, so routing never becomes unpredictable. fastest learns from real calls
(an EWMA of time-to-first-chunk), so the first call on each route is a probe.
Keeping one conversation on one route
sticky (on by default) pins a conversation to the route that served its first call:
| Without sticky | With sticky | |
|---|---|---|
round-robin, four turns in one chat |
A B C A |
A A A A |
| A second chat | continues the rotation | picks its own route, then stays |
The point is the provider's prompt cache: re-deciding every turn throws away the cached prefix, so a long conversation gets slower and more expensive than it needs to be. With sticky on, switching providers happens once per conversation instead of once per message.
It is a preference, not a lock:
- Failover still works. If the pinned route fails before producing output, the call moves to the next candidate, and the survivor becomes the new pin.
- Unhealthy routes are skipped. A pin inside its cooldown stops matching, so the strategy decides again.
- A different pooled model starts fresh. Re-selecting the pool with another model does not inherit the previous model's provider.
- Only pooled calls participate. A conversation that never selects the pool is untouched.
Turn it off in Settings → Model Pool → Behaviour when you want per-call spreading (quota sharing across accounts) rather than cache reuse.
Setting the provider priority
The priority strategy ranks providers by providerOrder — a list of provider ids from
highest to lowest. Settings → Model Pool → Provider priority lists every registered
provider by its display name; reorder with the arrow buttons and save. You never type an
id there. Only providers that currently contribute a model to the pool are listed — a
provider with no models cannot be routed to, so ranking it would change nothing. The same ranking can be set in cordis.patch.yml:
providerOrder: [deepseek-account, workbuddy, our-free-model]
With nothing configured the list shows plain registration order, which is exactly what the router falls back to. Providers you leave out follow the listed ones in registration order, so a partial list is valid — move just your first choice up and everything else stays behind it. Saving the untouched registration order stores an empty ranking rather than a redundant full list. A provider that is registered but currently serves no models still appears, because it is registered; one that has disappeared is kept and flagged rather than dropped silently.
The same list also decides the tie-break for least-used, fastest and cheapest, and
the fallback order after a failover.
峰谷时段(peak / idle)
Providers are not equally attractive at every hour. A first-party account may be cheap and fast outside business hours but rationed during them; a paid backup is often the reverse. So the pool can rank providers differently depending on whether the current moment is peak or idle.
The shipped default is a Chinese business-hours policy:
| Aspect | Default |
|---|---|
| Time zone | Asia/Shanghai (北京时间) |
| Peak days | Monday–Friday |
| Peak windows | 09:00-12:00 and 14:00-18:00 |
| Peak exceptions | official 调休上班日 — a make-up Saturday is a working day |
| Everything else | idle — weekends all day, statutory holidays all day, plus the lunch break, evenings and nights |
A Beijing Friday 19:00, any ordinary Saturday and the whole National Day week are all idle; the Saturday the State Council opens to make up for a holiday is not.
Two lists carry the rankings, and they obey the same rules as providerOrder: highest
first, a partial list is valid, and an empty list means this period has no opinion — it
inherits providerOrder rather than clearing the ranking. That is what keeps the feature
inert until you configure it: with both lists empty, routing is exactly what it was before.
peakProviderOrder: [workbuddy] # ranking while peak; empty = inherit providerOrder
offPeakProviderOrder: [deepseek-account] # ranking while idle; empty = inherit providerOrder
Set them in Settings → Model Pool → 峰谷时段(peak / idle), which offers the same
arrow-button editor as the provider priority, or in cordis.patch.yml. Because the overlay
reorders the candidates before the strategy runs, priority follows it directly and
least-used, fastest and cheapest inherit it as their tie-break — the same way
providerOrder already works. round-robin rotates through the period order, and
weighted-random still draws by weight, using that order only to settle equal-weight and
zero-weight routes.
The day itself is configurable too:
peakTimeZone: Asia/Shanghai
peakWindows: ['09:00-12:00', '14:00-18:00'] # 'HH:MM-HH:MM'; 24:00 is a valid end
peakWeekdays: [1, 2, 3, 4, 5] # ISO: 1 = Monday … 7 = Sunday
peakMakeupWorkdays: [] # extra 调休上班日 on top of the fetched ones
There is deliberately no holiday list here. 法定节假日 and 调休上班日 are fetched from a maintained dataset and kept up to date by the plugin, so nobody types dates and nothing goes stale when the State Council publishes next year's arrangement — see below.
法定节假日自动同步
A worked Chinese calendar cannot be derived from a rule: 调休 moves days around every year.
The plugin therefore reads it as data, from
holiday-cn (NateScarlet, MIT) — one JSON
document per year, generated from the State Council notice and carrying that notice's URL in
its papers field. That is a real dependency worth knowing about: this plugin ships no
holiday table of its own.
{ "year": 2026,
"papers": ["https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm"],
"days": [ { "name": "元旦", "date": "2026-01-01", "isOffDay": true }, // 法定节假日
{ "name": "元旦", "date": "2026-01-04", "isOffDay": false } ] } // 调休上班日
The document is fetched from jsDelivr, falling back to raw.githubusercontent.com when the
CDN is unreachable, and cached at $DSH_HOME/model-pool/calendar.json (under ~/.dsh when
DSH_HOME is unset). A background refresh runs every holidayUpdateDays (7), and re-fetches
only when the cache is older than holidayCacheDays (30) or when a year the policy can
reach is missing — the second condition is what lets a November notice land without a
restart. The age threshold is read on every sync; the background cadence is fixed when the
plugin loads, so changing it takes effect on the next reload. A year the dataset has not
published yet — 2027 at the time of writing — comes back with no dates, and those days simply
follow the weekday rule. That is the conservative reading: inventing a holiday would silently
take providers out of rotation.
The fetched calendar carries both halves of the arrangement: the closed days are idle, and
the official 调休上班日 are working days, so a make-up Saturday is peak inside the normal
windows while every other Saturday is idle. peakMakeupWorkdays remains a normal setting
only as an escape hatch for an extra day of your own — it is unioned on top of the fetched
make-up days. A date that is both a holiday and a make-up workday is idle: a statutory
holiday wins.
The network can never break routing. Every routing decision reads the in-memory calendar; a failed fetch keeps the cache and records the error, and a first run with neither a cache nor a network falls back to the plain weekday rule rather than failing a call. Settings shows the failure and offers Sync now to retry immediately.
The holiday block of the Settings card reports the current year only — the status (synced
/ stale / not synced / failed), that year's holiday and make-up counts, when it last updated,
the source id, and the official notice URLs — plus a switch to keep it updating automatically
and a Sync now button. Other loaded years stay in the payload because routing needs them
(the peak lookahead crosses New Year), but the card shows one year so the figures answer "is
today's calendar right" instead of "how much history is cached". A year the State Council has
not published yet reads 尚未发布 / Not published yet rather than "0 holidays". holidayOverride
is the only hand-editable list: it adds extra
idle dates on top of the fetched calendar for a one-off correction, and a refresh never
clobbers it.
holidaySync: false (or holidaySource: 'off') turns the calendar off entirely: the
fetched dates stop influencing routing, leaving only holidayOverride and the manual
peakMakeupWorkdays list, so every other day follows the weekday rule. holidaySource
normally names a source id (holiday-cn); giving it a full https:// URL, or setting
holidayUrlTemplate to a {year} template, points the sync at a mirror of your own.
The period block above it shows the live reading — the current period, the local date and
time, the configured windows, and when the period next flips (a Friday evening reports
Monday 09:00, not the next midnight) — so you can see which ranking is actually in force. It
also warns when today is a statutory holiday, or a make-up workday that the policy is still
treating as idle. peakEnabled: false turns the overlay off entirely.
How it composes with sticky
Peak ranking does not fight sticky; it composes with it. With sticky on — the default —
the period decides when a conversation starts, not what it does on every turn: a chat
that began on the idle ranking keeps that provider when the peak window opens, because
sticky pins the route that served its first call and already overrides providerOrder the
same way. That is the whole point of sticky — re-deciding every turn throws away the
provider's prompt cache. Turn sticky off and the provider changes exactly at the boundary;
either way a new conversation honours the period on its first call.
Thinking depth
The pool advertises the union of its candidates' thinking depths. Pick any depth you
like; the router maps it to the nearest tier the target actually supports — exact
match first, then the smallest rank distance, ties going to the higher tier. So max on
a pool containing a route that only offers off/high becomes high for that attempt
instead of failing with UNSUPPORTED_REASONING_EFFORT.
The pool also advertises the minimum context window across its candidates, so a request that fits the pool always fits every route it may fail over to.
Images and other input
The pool advertises the union of its candidates' input modalities, then routes each call to a candidate that can actually take it. Pick a pooled model whose routes include an image-capable provider and you can attach screenshots to it, without having to know or care which provider serves that turn.
This is deliberate, and it used to be the opposite. The advertised set is what the Host
checks before the call exists: a prompt carrying an image is refused outright with
MODEL_DOES_NOT_SUPPORT_IMAGES when the selected model's inputModalities exists and omits
image. Advertising the intersection of the candidates therefore made a model whose
majority routes read images reject every upload — one text-only candidate (a regional
gateway, a free tier) was enough to make the whole pool look blind. Advertising the union
lets you attach the image, and the router then keeps that promise:
- A text-only call keeps every candidate, so the pool still balances and fails over freely.
- A call carrying an image is narrowed to the routes that accept one, before the strategy, cooldown and sticky layers run, so the screenshot cannot silently become a placeholder.
- A candidate that declares nothing is treated as text-only for an image call. Unknown is not the same as capable, and guessing wrong costs the whole call.
- If the only image-capable route is cooling down after a failure, it is still tried before a healthy text-only one: a route that may have recovered beats an answer from a route that never saw the image.
- If nothing in a stale snapshot is capable, the pool still tries rather than refusing: the runtime then degrades the image to a placeholder, which is recoverable, instead of the call failing.
Settings → Model Pool shows which candidates accept images, so a pool whose capability comes from a single route is visible rather than implied.
Seeing the live route in a conversation
A pooled model is a virtual route, so the composer would otherwise only ever show "Model Pool · DeepSeek V4.1 Flash" with no hint of the real upstream. The plugin adds one entry to the composer dock (beside the built-in turn and token statistics) that shows what the current session actually ran on:
实际路由 workbuddy deepseek-v4.1-flash max DeepSeek V4.1 Flash
- Provider — the concrete provider route that produced content.
- Model — that provider's own id for it, which is often not the pool id.
- Thinking depth — the effort actually used, when the route declares one.
- Pool model — the row you selected, for context.
The entry renders nothing when the session never ran through the pool, so ordinary conversations are untouched. It follows failover: if the first candidate fails before producing output, the badge shows the provider that actually answered, not the one that was tried first. The value is recorded per session, so two conversations open at once each show their own route.
The badge describes the conversation's calls. Auxiliary calls that reuse the same pooled
route — session-title generation, compaction summaries — pick their own thinking depth
(usually off) and never define the entry, so a title call cannot make a conversation that
ran at High report Low. The record is also settled the moment the answer's terminal
chunk is produced, so a middleware that rewrites or re-wraps the answer stream cannot leave
a stale value behind.
The composer dock is polled every few seconds, so the value settles shortly after a turn finishes. It lives only in this Host generation — a restart clears it, and the entry is rebuilt on the next pooled call.
Availability markers
Every concrete route carries a state:
| State | Meaning |
|---|---|
ok |
Last attempt succeeded. |
degraded |
Failed at least once; the cooldown has elapsed, so it is eligible again. |
unavailable |
Failed and still inside the cooldown window; skipped while others are available. |
unknown |
Never used in this Host generation. |
A route that fails is recorded with its failure code (for example AUTH, RATE_LIMIT,
QUOTA) and stays out of rotation for cooldownMs. If every candidate is cooling
down the pool still tries them in strategy order — a transient outage must not become a
hard failure. Reset health clears the markers.
Failover rules
- A call fails over only while no output has reached you yet. Once text or a tool call has streamed, the error is reported as-is: partial answers are never replayed.
maxAttemptsbounds how many providers one call may try.- With
fallback: falsethe first candidate is the only candidate.
Configure
Settings → Model Pool covers the everyday knobs. The rest live in this plugin's own row
in cordis.patch.yml, so they survive upgrades:
- id: model-pool
name: dsh-model-pool
config:
strategy: least-used
fallback: true
skipUnavailable: true
cooldownMs: 60000
maxAttempts: 3
include: [] # provider allowlist; empty = all
exclude: [workbuddy] # provider denylist
excludeModels: [] # model ids or substrings
providerOrder: [deepseek-account, workbuddy] # priority ranking, highest first
peakEnabled: true
peakTimeZone: Asia/Shanghai
peakWindows: ['09:00-12:00', '14:00-18:00'] # peak windows; empty = never peak
peakWeekdays: [1, 2, 3, 4, 5] # ISO 1 = Mon … 7 = Sun; empty = never peak
peakMakeupWorkdays: [] # extra 调休上班日; the fetched ones are automatic
peakProviderOrder: [workbuddy] # ranking while peak; empty = inherit
offPeakProviderOrder: [deepseek-account] # ranking while idle; empty = inherit
# Statutory calendar: fetched, not configured. Defaults shown; rarely changed.
holidaySync: true # keep the calendar up to date automatically
holidaySource: holiday-cn # or a source id, or 'off'
holidayUrlTemplate: '' # '{year}' template; empty = source default
holidayCacheDays: 30 # refetch when the cache is older than this
holidayUpdateDays: 7 # background refresh cadence
holidayOverride: [] # one-off corrections, layered on the fetch
weights: { deepseek-account: 3, workbuddy: 1 }
costs: { deepseek-account: 0.4, workbuddy: 0.1 }
modelGroups:
- id: deepseek-v4.1-flash
name: DeepSeek V4.1 Flash
models: [deepseek-v4.1-flash*, deepseek-flash]
merge: true
- id: fast-coding
name: fast-coding
models: [deepseek-v4.1-flash, glm-5.3-flash]
merge: false
Merging one model that has several provider ids
The same model usually carries a different id on each provider — one account calls it
deepseek-flash, another deepseek-v4.1-flash, a third
opencode/deepseek-v4.1-flash-free. Left alone those become three separate pool rows,
which defeats the point of a pool.
modelGroups fixes that. A group lists selectors that claim the matching routes and
present them as a single row:
| Selector form | Matches |
|---|---|
deepseek-v4.1-flash |
a concrete model id, or the normalized pool id |
workbuddy/deepseek-v4.1-flash |
one exact provider/model route |
deepseek-v4.1-flash* |
glob on any of the above (* = any run, ? = one char) |
With merge: true (the default) the claimed routes leave their single-id rows, so the
pool shows one row per logical model. A natural row left with no routes disappears.
Set merge: false when you want an additional curated pool ("fast-coding") and the
automatic rows should stay.
Selectors are matched first-come: if two groups claim the same route, the earlier group wins and the later one simply gets nothing from it.
Two groups ship by default so this works out of the box — deepseek-v4.1-flash* +
deepseek-flash merge into DeepSeek V4.1 Flash, and glm-5.3-flash* merges into
GLM 5.3 Flash. Edit them in Settings → Model Pool → Merged groups, or replace
the list in cordis.patch.yml. Changes are live: no restart.
How it works
The Host's llm service exposes provider routes; the composer picker and Settings read
them through listProviders() / listModels() / resolveModelInfo(). This plugin:
- Registers one extra virtual route (
model-pool) whose models are the union of every other provider's models, grouped by normalized model name. That is what makes the pool appear in the ordinary picker, thinking-depth menu included. Each pooled model advertises the union of its candidates' input modalities (see Images and other input), so a pooled model accepts a screenshot when any of its providers can read one. - Listens on the
llm/streamwaterfall. A request naming the pool route is intercepted before dispatch and re-issued against a concrete provider chosen by the strategy. A request that carries an image only considers the candidates that accept one, so the advertised capability is honoured instead of merely declared. The runtime then performs exactly one correct projection of files, images and tool history for the real target. - Keeps a health registry per concrete route, which feeds both the failover gate and the Settings badges.
The adapter's own stream() never runs; it exists only to claim the route so the catalog
surfaces it. If it is ever reached, it fails with a clear message instead of silently
returning nothing.
See docs/CONTRACT.md for the verified API evidence and the full
module contract.
Scope and limits
- Pool state (health, round-robin cursor) is per Host generation: a restart clears it. The fetched holiday calendar is the exception — it is cached on disk, so a restart reads it instead of re-downloading. The very first sync needs network access; until it succeeds, statutory holidays are simply not known and every weekday follows the weekday rule.
- The pool re-reads the provider topology on
llm/adapters-updated, so installing or removing a provider updates the pools without a restart. providerId/providerNamechanges need a restart to re-register the route.- Routing is per call, not per session: two calls in one turn may use different providers.
That is intentional for balancing, but it does mean a provider-side prompt cache is not
shared across a balanced pool. Use
priorityif cache reuse matters more than balance. - A pooled model's image support is the union of its candidates', so a conversation that attached an image keeps working even when that turn fails over — but the concrete route is narrowed per call, which means a sticky session may be moved onto a different provider for an image turn and back afterwards. Sticky is a preference for cache reuse, not a guarantee about capability.
- The pool cannot know more than its providers declare. A route that advertises no
inputModalitiescounts as text-only for an image call; if such a provider in fact reads images, the pool will not use it for them until that provider declares the capability.
Development
node --test test/*.test.mjs # pure logic: grouping, strategies, health, effort,
# capabilities, peak, calendar
npm run check
License
MIT — see LICENSE.
No comments yet. Be the first to write one.