dsh-model-provider
License: MIT · Platform: DSH Web (client plugin)
English · 简体中文
Provider-first model selector for DeepSeek Harness (DSH): pick a provider first, then a model from that provider. The current model always reads as Model · Provider — without touching the harness's original model-calling or session-state logic.
In the DSH Web interface, the composer model seat is upgraded from "provider groups + full model list" to a three-level selector (v0.3.0):
Before After (this plugin v0.3.0)
DeepSeek V4 Flash DeepSeek V4 Flash · OpenCode Go
Try opening the dropdown (still tidy even with many models):
Level 1 (root menu) Level 2 (Provider) Level 3 (Model)
├─ Model DeepSeek V4 Flash > ├─ opencode-go 8 models · current > opencode-go
└─ Effort High > ├─ luckikey 2 models > ├─ MiniMax-M3
🔍 Search providers ├─ OpenRouter 20 models > ├─ Qwen3.7 Max
(filters Provider rows) ├─ DeepSeek 4 models > ├─ DeepSeek V4 Flash ✓
└─ OpenRouterX failed ⚠ retry ├─ DeepSeek V4 Pro
🔍 Search opencode-go models └─ GLM-5.1
(current provider only)
image
image
- Provider is its own level: the provider list pins the current provider to the top and marks it "· current"; the rest keep catalog order. The Model page renders only the selected provider's models — no more provider × model flattening.
- Failed providers are a row too: a provider that failed to load no longer lives only in a warning banner — it is a normal Provider row ("failed ⚠ retry") that reloads on click.
- Search (v0.3): both the Provider and Model pages have an inline search box — Provider page filters providers by name/ID; Model page filters only the current provider's models (no cross-provider search).
- Model page header: entering a provider shows "‹ {provider}" + a "{N} models" subtitle, making it feel like its own page.
- Lean trigger: shows
Model · Provider by default; "· Effort" is appended only when you actively pick a non-default reasoning effort — no wasted horizontal space for "Default".
- Esc walks back level by level: Model → Provider → root menu → close (clicking "‹" walks back the same way).
- The
Model · Provider [· Effort] trigger always matches the harness session state.
How it works
| Concern | Detail |
|---|
| Extension point | Official Slot system: conversation.input.model (single slot, session scope) — no DOM hack |
| Override | Same-named slot registered at priority: -1 — the slot renders the lowest-priority entry, so this component wins and the original seat is shadowed |
| Data | Reuses the harness's native modelDirectories service (per-session shared ModelDirectory); selection semantics and disable logic are unchanged; the provider list comes straight from state.groups, failures from state.failures |
| Fallback | The registration disappears on plugin unload (slots.inject effect teardown) and the original model seat is restored instantly and unchanged |
| Namespace | Own locale dictionary modelProvider (zh/en) — no intrusion into harness copy |
Compatibility
Verified against DeepSeek Harness 0.1.1-rc.2 (dsh CLI 0.1.1-rc.2 + web frontend). The APIs this plugin depends on are unchanged across the rc.8 → rc.2 release line:
| Surface | rc.2 status |
|---|
| Slot composition | ctx.slots.inject(key, cb) / slots.register with priority (ascending, lowest renders) + optional registrant — unchanged; same key at the same priority throws, a different priority shadows |
| Seat contract | conversation.input.model (single slot, session scope): owner share locked, locale t seat, inject face { available, directory, load, select } — unchanged |
| Directory face | Shared per-session ModelDirectory snapshot { current, routable, groups, failures, status, error } — unchanged |
| Primitives | IconChevronDown/Left/RightOutline14, IconCheck/Search/WarningOutline16, Toast { text, icon, anchor, onDone } — unchanged |
| Design tokens | --dsw-alias-*, --dsw-specific-menu, --dsw-shadow-lv3, --dsh-scrollbar-* — unchanged |
peerDependencies now track the rc.2 line (^0.1.1-rc.2, same pins the first-party client packages use).
The component keeps the original ModelSelect interaction baseline (shared directory & selection RPC / keyboard arrows & Esc / failure retry & Toast / effort page), only turning the two-level flat list into three-level navigation:
- Trigger: model name + · Provider (muted style); "· Effort" only when a non-default effort is chosen; title and aria carry the provider too
- Root menu: Model (→ provider list) and Effort (→ current model's effort levels)
- Provider page: "‹ Select provider" back to root; search filter; current provider pinned to top with a "· current" text mark (no full-row highlight); each row shows "N models"; failed providers render as retry rows
- Model page: "‹ {provider}" + "{N} models" subtitle; search box filters only this provider's models; the selected row is marked with ✓ (composite
providerId + modelId key — same-named models across providers don't collide)
- Default effort: switching to a model automatically carries its
defaultEffort into the selection (selectionFor is the single place that builds a Selection, so prebuilt choice and click-path semantics stay identical)
Layout
dsh-model-provider/
|- package.json # dsh.client declaration (platform: web, inject list)
|- pnpm-workspace.yaml # pnpm 11 setup (approves the esbuild build script)
|- build.mjs # esbuild bundle script → lib/client.js (ModuleLoader format)
|- tsconfig.json # noEmit typecheck (src + test)
|- src/
| |- index.ts # host half: empty apply (pure browser-surface plugin)
| |- client.tsx # client entry: apply() + slot wiring + compat exports
| |- locale.ts # modelProvider dictionary (zh/en)
| |- model/
| | ├─ types.ts # directory/selection wire types
| | └─ selection.ts # pure functions: selectionFor / sortGroupsForCurrent /
| | # search filtering / Esc stack (shared by UI and tests)
| |- components/
| | ├─ ModelSelector.tsx # trigger + menu shell + state orchestration
| | ├─ RootPane.tsx # level 1: model / effort
| | ├─ ProviderPane.tsx # level 2: providers (search + failed rows)
| | ├─ ModelPane.tsx # level 3: single-provider models (search + composite key)
| | ├─ EffortPane.tsx # reasoning effort
| | └─ StatusBlock.tsx # directory load/error banner
| |- hooks/
| | └─ useKeyboardNavigation.ts # Esc stack + arrow-key focus
| └─ model-provider.css # scoped styles (dshmp- prefix + design tokens)
|- test/model.test.ts # node --test unit tests (pure-function layer)
|- lib/ # build output (host serves /plugins/<id>/client.js)
|- assets/icon.svg
Build
pnpm install # first time: installs esbuild / typescript (npm works too, see below)
pnpm build # node build.mjs → lib/client.js
pnpm typecheck # tsc --noEmit
pnpm test # node --test (no DOM needed; runs the pure-function layer directly)
The build script resolves esbuild via require.resolve("esbuild") from this package's own devDependency — no machine-specific hardcoded paths, so it builds on any machine. (With npm: npm i && npm run build; the committed lockfile is pnpm-generated and npm resolves it itself.)
The output is the standard DeepSeek Harness client-plugin format:
window.__ModuleLoader__.load({ id: "dsh-model-provider", factory: (require) => { ... return module.exports; } });
Installing into a Web profile
The right way (since v0.1.0): this package declares dsh.bundle.patch + ships cordis.patch.yml, i.e. the standard dsh plugin shape (same as dsh-balance-meter / dsh-context). Don't write it as a "bundle without metadata" — that triggers the startup check error:
profile bundle "dsh-model-provider" declares no dsh.bundle in its package.json and a restart loop.
-
Link the dependency into the profile (/data/.dsh/profiles/web/package.json):
"dependencies": { "dsh-model-provider": "link:/data/opt/dev/dsh-provider-model" }
"dsh": { "profile": { "bundles": [ ..., "dsh-model-provider" ] } }
(Or use the official dsh CLI: dsh plugin --profile web add /data/opt/dev/dsh-provider-model — it reconciles into bundles automatically per the dsh.bundle.patch declaration.)
-
pnpm install (materializes the link)
-
Restart the web host (client-plugin set changes apply on restart): docker restart deepseek-harness or the equivalent dsh web restart
-
Verify:
curl -s http://127.0.0.1:3080/ | grep -o '"id":"dsh-model-provider"'
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/plugins/dsh-model-provider/client.js # 200
Changing only client.js (no plugin add/remove) needs no restart — rebuild and refresh the page.
Disabling / restoring the original UI
Remove this package from dsh.profile.bundles (or uninstall the plugin) and restart — the original ModelSelect seat becomes the only entry again and the UI is restored.
Roadmap
Shipped (v0.3.1):
Candidates:
- Plugin settings page (display modes: plain groups / model + provider / both; whether the current model shows its provider)
- Default model badge (depends on host wire exposing an isDefault field)
- Recent-use / favorites / model capabilities (context length, pricing) display enhancements
- Provider icons
License
MIT