DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

BruceZhang1993 /

BruceZhang1993/dsh-model-pool

Verified

DeepSeek Harness plugin: one model pool over every provider. Pick a pooled model once; a configurable strategy (priority, round-robin, weighted, least-used, fastest, cheapest) routes each call, with health-aware failover, thinking-depth and image mapping, sticky sessions, and a peak/idle policy from the fetched Chinese statutory holiday calendar.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHubProject homepage
READMESource: master@ae648ca1

dsh-model-pool

CI npm version npm downloads License: MIT Node.js >= 20 DeepSeek Harness plugin

English · 简体中文

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

  1. Open Settings → Model Pool. It lists every pooled model, how many providers serve it, its thinking depths, and a per-route state badge.
  2. 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.
  3. In the composer model picker, pick a Model Pool model and a thinking depth.
  4. 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.
  • maxAttempts bounds how many providers one call may try.
  • With fallback: false the 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:

  1. 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.
  2. Listens on the llm/stream waterfall. 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.
  3. 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 / providerName changes 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 priority if 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 inputModalities counts 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.

—/ 5

No ratings yet

Verified DSH bundle

Commit ae648ca1c3b6

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout