Bundle
dsh-llm-ai
models.dev-cataloged multi-provider LLM adapter for the DeepSeek Harness LLM seam
- Source
- WooLeo1995
- License
- MIT
- Updated
- Updated 6 days ago
Readme
# dsh-llm-ai
English | [中文](README.zh.md)
A models.dev-cataloged multi-provider LLM adapter for the DeepSeek Harness: every provider and model fact comes from the community-maintained [models.dev](https://models.dev) `api.json` registry, and the request runtime is a harness-owned `openai-completions` streaming implementation (direct fetch + SSE) with no pi-ai dependency. It replaces `dsh-llm-pi-ai` on the same `ctx.llm` seam.
<img width="2280" height="1702" alt="image" src="https://github.com/user-attachments/assets/28b5c095-4bb2-4f8a-8292-56e1de8a44d7" />
- **Protocol support**: v1 serves `openai-completions` only (the overwhelming majority of OpenAI-compatible endpoints); `anthropic-messages` and other protocols are v2 work.
- **Verification status**: 205+ unit tests at per-file 100% coverage; deployed as a complete replacement on DSH Desktop 2.0.3 (the dsh 0.1.1-rc.2 family) and in daily use.
- **Version baseline**: developed against `@deepseek-ai/*@next` (the 0.1.1-rc.2 line); peers are compatible with the same generation.
## Install (npm)
Published as the unscoped package `dsh-llm-ai` (the `@deepseek-ai/dsh-llm-ai` name is the harness monorepo's integrated twin). With the official CLI:
```sh
dsh plugin --profile <name> add dsh-llm-ai
```
One command installs and mounts: the CLI forwards to `pnpm add` in the profile directory, sees this package's `dsh.bundle.patch` declaration, appends it to the `dsh.profile.bundles` layer stack, and the profile boot merges the bundled patch — which disables the bundled `llm-pi-ai` mount and inserts `llm-ai` (the two adapters cannot coexist: the configurable-provider directory keys provider ids globally and both declaring the same catalog id fails `DUPLICATE_DIRECTORY` at load).
Notes:
- Migrating from a manual mount: remove the old `llm-ai` insert and `llm-pi-ai` disable lines from the profile's own `cordis.patch.yml` to avoid a duplicate entry id.
- Bundled-UI builds (DSH Desktop / web-app releases predating the llm-ai migration) hardcode the `llm-pi-ai` namespace in their Models page — apply the single-string alias documented under Deployment, step 4, to the installed copy at `<profile>/node_modules/dsh-llm-ai/lib/index.js`.
- Provider profiles live in the `llm-ai:` settings section (see the configuration reference); credential references need no migration.
## Features
- **models.dev catalog**: `api.json` is fetched once at plugin load and cached to disk under the DSH home (`storages/models-dev-cache.json`); offline boots serve the last good snapshot; a fetch failure is loud only when nothing is cached. `catalogUrl` / `catalogCachePath` override the endpoint and cache location.
- **openai-completions runtime**: streaming SSE (eventsource-parser), text, tool calls with raw-string arguments, tiered reasoning, image input with oldest-first offload under `maxRequestImageBytes`, usage and cache-hit accounting, an idle watchdog (`streamIdleTimeoutMs`), and exactly one provider request per `stream()` call.
- **Stable error codes**: `AUTH` / `QUOTA` / `RATE_LIMIT` / `CONTEXT_WINDOW_EXCEEDED` / `INVALID_REQUEST` / `SERVER` / `HTTP_<n>` / `TRANSPORT` / `TIMEOUT` / `ABORTED` / `STREAM_CLOSED` / `MALFORMED_RESPONSE` / `EMPTY_RESPONSE` (retryable classification).
- **Compat switches**: `maxTokensField` / `supportsDeveloperRole` / `thinkingFormat` (`openai` | `deepseek` | `openrouter`), resolved per field **model → route → protocol default**; unknown and valueless keys are refused listing the offered set — nothing is silently dropped.
- **Reasoning declarations**: `reasoningEfforts` maps each selectable level to its wire spelling; `off` is tri-state (absent = not offered; declared without a value = send the disabled spelling; declared with a value = send it); an undeclared level is refused before any network I/O. Without a declaration, a registry model's offered levels come from models.dev `reasoning_options` effort values (each level spelled as itself; `none` → valueless `off`); `toggle`, empty, absent, or non-canonical-only options keep the protocol default set (`off`/`low`/`medium`/`high`); a profile declaration still reshapes either.
- **Dynamic configuration**: the `providers` dict merges with the user settings section per provider, effective on the next request without a restart; dormant mounting (zero routes with no providers); atomic re-registration when the route set changes.
- **Credentials**: configuration stores `apiKeyEnv` references only; each request resolves them through the credentials seam, then the trusted environment; format checks (`INVALID_CREDENTIAL`) and empty references (`MISSING_CREDENTIAL`) name the route and every configuration entry point and never any part of the key.
- **Endpoint interrogation**: `GET /models` discovery for hand-declared gateways (a 4 MiB received-bytes ceiling, a typed draft key winning over stored references, and the `DISCOVERY_*` error family).
- **Configurable-provider directory**: declares all 203 models.dev providers to configuration surfaces (including families it cannot yet serve, with honest metadata).
## Configuration reference
Cordis composition entry:
```yaml
- id: llm-ai
name: '@deepseek-ai/dsh-llm-ai'
# Omitting config mounts dormant (zero routes); a settings section can
# activate routes at any time.
config:
catalogUrl: https://models.dev/api.json # optional: self-hosted mirror
catalogCachePath: /path/to/cache.json # optional: cache location
providers:
openai: # Catalog route: endpoint, protocol, and models
apiKeyEnv: OPENAI_API_KEY # all inherited from models.dev.
zai-coding-cn: # Hand-declared route: api + baseURL + a
apiKeyEnv: ZAI_CODING_CN_API_KEY # non-empty models list are required.
api: openai-completions
baseURL: https://open.bigmodel.cn/api/coding/paas/v4
models:
- { id: glm-5.3, contextWindow: 1000000, maxTokens: 131072 }
```
Provider profile fields: `apiKeyEnv` (credential reference), `displayName`, `api` (`openai-completions` only in v1), `baseURL`, `models` (**replaces** the route's catalog; unset fields default from the registry entry of the same id), `modelOverrides` (reshapes individual models while the rest of the catalog keeps serving), `compat` (the three switches), `reasoning` (the deployment default level), `retryPolicy` (omission = normal mode with five retries), `headers`, `defaultContextWindow` / `defaultMaxTokens` / `defaultInput` (fallbacks for configured entries that state no capacity), `streamIdleTimeoutMs` (five-minute default), `maxRequestImageBytes` (20 MiB default).
Catalog resolution notes: a models.dev model with no context window is refused rather than guessed; a `modelOverrides` key naming a model the catalog does not describe is refused; `timeoutMs` is gone (it named pi-ai runtime behavior) and configuring it fails with migration directions.
## DSH Desktop deployment (the complete procedure)
The following is the verified deployment path (executed on DSH Desktop 2.0.3 / dsh 0.1.1-rc.2). The desktop loads plugins through the pnpm mini-workspace at `~/.dsh/profiles/desktop/` — **the .app bundle itself is never modified**.
### 1. Build a self-sufficient install directory
```sh
mkdir -p ~/Downloads/project/github/dsh-llm-ai-app
# Take the build outputs from the harness repo (tsc lib/types + bundled runtime)
cp -R <harness>/packages/llm/llm-ai/lib ~/Downloads/project/github/dsh-llm-ai-app/
```
The install directory's `package.json` declares the plugin plus every peer as a **real** dependency from npm's `@next` dist-tag (self-sufficient, the vibe-island pattern):
```json
{
"name": "@deepseek-ai/dsh-llm-ai",
"version": "0.1.1-rc.2",
"type": "module",
"main": "lib/index.js",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }
},
"dependencies": {
"@deepseek-ai/cordis": "next",
"@deepseek-ai/dsh-attachment": "next",
"@deepseek-ai/dsh-credentials": "next",
"@deepseek-ai/dsh-home-paths": "next",
"@deepseek-ai/dsh-launch-environment": "next",
"@deepseek-ai/dsh-llm": "next",
"@deepseek-ai/dsh-settings": "next",
"@deepseek-ai/dsh-timeout": "next",
"@deepseek-ai/schemastery": "next",
"eventsource-parser": "^3.1.1"
}
}
```
```sh
cd ~/Downloads/project/github/dsh-llm-ai-app && pnpm install
node --input-type=module -e "const m = await import('./lib/index.js'); console.log(typeof m.apply)" # smoke test: function
```
### 2. Wire it into the profile workspace
Add to the dependencies of `~/.dsh/profiles/desktop/package.json`:
```json
"@deepseek-ai/dsh-llm-ai": "link:/Users/<you>/Downloads/project/github/dsh-llm-ai-app"
```
```sh
cd ~/.dsh/profiles/desktop && pnpm install
```
### 3. Composition patch
Append to `~/.dsh/profiles/desktop/cordis.patch.yml` (keep any managed blocks already there, such as vibe-island):
```yaml
- id: llm-pi-ai
disabled: true
- insert:
- id: llm-ai
name: '@deepseek-ai/dsh-llm-ai'
```
### 4. Namespace compatibility (for the desktop's bundled UI)
**The key pitfall**: the Models page bundled with DSH Desktop (an upstream rc.2 build) hardcodes the `"llm-pi-ai"` namespace — the add-card's enable gate, protocol choices, form layout, and write target all recognize that name alone. Once llm-ai mounts, the page degrades to "other fields live in settings.yaml" hints.
The fix is a **single-string patch** on the plugin copy so it registers under `llm-pi-ai` (the settings section, directory entries, and discovery registration all flow from this one constant):
```js
// dsh-llm-ai-app/lib/index.js — the only occurrence in the file
- const NS = settingsNamespace("llm-ai");
+ const NS = settingsNamespace("llm-pi-ai");
```
Diagnostic message prefixes (`llm-ai: provider "..."`) need no change. When an upstream desktop release natively knows `llm-ai`, restore this string and rename the settings section to return to the canonical namespace.
### 5. settings.yaml migration
In `~/.dsh/settings.yaml`, migrate the previous `llm-pi-ai:` section to the curated routes (`anthropic-messages` routes must be removed — validation resolves the whole section, so one unserviceable profile refuses all of it). See the configuration reference above for a six-route example. Credential references (`apiKeyEnv` → environment variables / `~/.dsh/.credentials.yaml`) need no migration at all.
**Zero-downtime switch**: until the app restarts, the still-running pi-ai plugin keeps reading the old section — migrate with both sections present, restart, and delete the inert old section afterwards.
### 6. Pre-seed the models.dev cache (optional, recommended)
```sh
curl -s https://models.dev/api.json -o ~/.dsh/storages/models-dev-cache.json
```
This guarantees an offline-capable first boot; the plugin still tries a fresh fetch on every load and falls back to the cache on failure.
### 7. Restart and verify
Fully quit (⌘Q) and reopen DSH Desktop. Expected: the six routes are live, the model picker works, the Models page renders full editable cards (key / endpoint / protocol / model list), the add-provider card is usable, and the protocol dropdown offers `openai-completions` only.
## Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The Models page shows "Other fields live in settings.yaml; edit that section directly" | The bundled UI classifies `llm-ai` as an unknown layout — the step-4 namespace patch is not in effect; check that `settingsNamespace("llm-pi-ai")` occurs exactly once in the plugin copy |
| Writing files under `/Applications` fails with `EPERM` | macOS App Management (TCC) protects app bundles from every headless process (including node children of your own terminal) — which is why this procedure never touches the .app |
| The app fails to start after "disable the bundled UI entry + insert a replacement" | The desktop's composition loader rejects that substitution (verified to break boot); never replace the web-app's built-in client entries through a profile patch |
| A same-name link (shadowing a bundled package) does not take effect | Resolution precedence is not guaranteed to prefer the profile; when you need determinism, use a unique package name with an explicit entry, or an absolute path (the vibe-island precedent) |
| Every provider disappears at once | The settings section carries one unserviceable route (for example `anthropic-messages`) and was refused as a whole; fix or remove the route named in the error |
| First boot fails reporting a models.dev fetch failure | Nothing cached and the network is unreachable; run the step-6 seed |
**Full rollback**: delete the three patch entries from `cordis.patch.yml` → copy `settings.yaml.bak-llm-ai-swap` back over `settings.yaml` → restart. The install directory and profile link can stay (unreferenced means inert).
## Known limitations (v1)
- `openai-completions` only: the anthropic / google / bedrock / vertex / OAuth-only families stay visible in the directory but unserviceable, and naming them under `api` is refused; `anthropic-messages` is planned for v2.
- No replay envelope: cross-provider history converts provider-neutrally (no new session-log structure; logs recorded by pi-ai still load).
- The settings layer can add or override routes, never remove routes declared in the composition base (cordis.yml).
- One wire protocol per route: a mixed-protocol provider splits across two route keys.
- `tool_choice` and stop sequences are unsupported (an MVP cut shared with both predecessors).
## Development
```
src/
index.ts plugin apply: catalog load, dormant/atomic registration, the
settings section, directory and discovery registration
adapter.ts LlmAiAdapter: stream(), per-call snapshot freeze, timeout and
abort handling, error classification
catalog.ts profile → route/model resolution (models/modelOverrides/
compat/reasoning)
config.ts the schemastery Config schema and resolveProfiles
modelsdev.ts the api.json loader (fetch/cache/offline snapshot, injectable
fetchImpl)
serialize.ts request serialization, reasoning dispatch, image serialization
and offload
sse.ts eventsource-parser framing, the [DONE] sentinel, comment
watchdog pulses
translate.ts wire events → StreamChunk translation (usage precedes finish)
discovery.ts GET /models endpoint interrogation
provider.ts the protocol table (openai-completions only) and withheld
families
types.ts the wire vocabulary
```
```sh
pnpm install
npx tsc --noEmit # typecheck
npx vitest run # the whole suite (204+, no network)
npx vitest run --coverage # per-file 100% coverage gate
pnpm run build # tsdown: lib/ runtime bundle + declarations
```
The complete decision record lives in the deployment source repository under `.scratch/llm-ai/` (the spec plus twelve ticket resolutions); the harness monorepo's `packages/llm/llm-ai` is the integrated twin (with repo gates and doc regeneration). This directory is the origin for publication and standalone development.
## License
MIT (following the upstream DeepSeek Harness).
Install
dsh plugin --profile web add github:WooLeo1995/dsh-llm-ai
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-llm-ai 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.