Bundle
dsh-model-provider
Provider-first model selector for the DeepSeek Harness model seat: pick a provider, then a model from that provider — current model always shows Model · Provider; search and hardening (v0.3.2, verified against DSH 0.1.1-rc.2).
- Source
- pc439527
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 2 hours ago
Readme
# dsh-model-provider
[](https://awesome-dsh-plugin.com)
> **License:** MIT · **Platform:** DSH Web (client plugin)
**[English](README.md) · [简体中文](README.zh-CN.md)**
Provider-first model selector for [DeepSeek Harness](https://github.com/deepseek-ai/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)
<img width="1920" height="945" alt="image" src="https://github.com/user-attachments/assets/6fb97a93-43f7-4d05-b9bb-dc77273759f9" />
<img width="1920" height="945" alt="image" src="https://github.com/user-attachments/assets/e011060a-56d9-4744-ab76-359aab52f75e" />
- **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.
1. 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.)
2. `pnpm install` (materializes the link)
3. Restart the web host (client-plugin set changes apply on restart): `docker restart deepseek-harness` or the equivalent dsh web restart
4. 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):
- [x] v0.3 feature set: three-level selector (root → provider → single-provider model list), current-provider pin, failed-provider retry rows, inline search on both levels, lean `Model · Provider [· Effort]` trigger, consistent default effort
- [x] Verified against DeepSeek Harness 0.1.1-rc.2 (slot composition / seat contract / directory face / primitives / design tokens all unchanged in this line)
- [x] `peerDependencies` aligned to `^0.1.1-rc.2`
- [x] node --test unit tests (current-pinning / same-named models / defaultEffort / Esc stack / search filtering)
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
Install
dsh plugin --profile web add github:pc439527/dsh-model-provider
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-model-provider from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.