# dsh-opencode-go [中文](./README.md) An LLM provider plugin for DeepSeek Harness that serves OpenCode Go: it registers the `zen-go` route and sends a stable per-conversation `x-opencode-session` header on every outbound inference request. > **Runtime**: adapted to DeepSeek Harness `0.1.6-alpha.1` and above (peer > dependencies `@deepseek-ai/dsh-*` declared as `^0.1.6-alpha.1`). From 0.1.6 on, > image offload is the adapter's job (see "Over-budget images" below); this > plugin relies on that contract, which is what sets the floor. - **All three surfaces**: `chat/completions` (mimo / deepseek-v4 / glm / kimi / longcat / hy), `responses` (grok / gpt-5.6-luna / muse-spark, including the standalone `response.incomplete` terminal state), and `messages` (Anthropic-compatible: minimax / qwen, authenticated with `x-api-key`, `max_tokens` defaults to 8192 when unset). - **Session header**: `x-opencode-session` carries the conversation id, with a per-request UUID fallback so the header is never missing. - **Client identity**: `user-agent: deepseek-harness/ (+url) dsh-opencode-go/`, identical on all surfaces so operators can identify and permit exactly this traffic (never a generic string like `Go-http-client/1.1`). - **Seam-only credentials**: `credentials.resolve` layers process env over the managed store (`$DSH_HOME/.credentials.yaml`, hot-reloaded) over project/home `.env` files — storing or rotating the key needs no restart. There is no plaintext key field anywhere. - **Settings card**: a Models-page row plus a standalone Settings sidebar entry (key save/clear, model checklist with select-all, live catalog refresh, per-model context / max-output / surface / reasoning-levels / modality inputs, credential status dot). - **Reasoning levels**: responses models offer Minimal/Low/Medium/High/Xhigh (wired to `reasoning.effort`); chat models offer Low/Medium/High (`reasoning_effort` passthrough; deepseek-v4-flash/pro/flash-vision-exp additionally offer Max — verified live against the upstream gateway, thanks to [@34262315716](https://github.com/34262315716), see [#1](https://github.com/xia-sc/dsh-opencode-go/pull/1)); messages models offer none (Anthropic thinking is budget-based — explicit values are rejected). No defaults are declared: Default omits the field (the server decides). - **Multimodal**: vision models (`deepseek-v4-flash-vision-exp`, both `muse-spark-*-contributor`) declare `["text", "image"]` so the runtime passes images through; other known models are text-only (the runtime substitutes placeholders); unknown ids stay permissive and the server decides. Images resolve from the durable attachment store to inline base64 data URLs per surface (png/jpeg/webp/gif). **One image's size is decided by the attachment store's admission-time normalization, not by this plugin**: this deployment reduces every image to ≤ 4 MiB and ≤ 2048×2048 px (≤ 8192 per side; uploads are capped at 20 MiB each and 200 MiB per message), and `imageHostPath` hands back that normalized copy — so what gets inlined is never the original upload. The plugin's own 20 MiB per-image check therefore only fires for an attachment provider that does no normalization: it is a fallback, not the guard that matters. - **Over-budget images take the harness's offload path**: when one request's inline image bytes (the base64 the body carries) exceed `maxRequestImageBytes` (64 MiB by default) this plugin does **not** drop images itself — it fails with `IMAGE_OFFLOAD_REQUIRED` naming how many of the oldest occurrences must go, and `dsh-compaction-image-offload` records that decision and retries the step. Dropping images is a **durable session decision**, so only the harness may record it, and the check always happens before dispatch (a durable ref carries its encoded size, so nothing is read). Occurrences the harness already marked `offloaded` always travel as placeholder text and are never re-inlined — that bookkeeping is the harness's, not a route's to reverse. Note what triggers it: image *count*, not one image's size — a normalized image is about 5.4 MiB once base64-encoded, so the 64 MiB default is roughly a dozen images in one request. - **Unclassified models can be described by hand**: the operator's `/v1/models` discloses ids only, so a newly served model (say `deepseek-flash`) is classified `unknown` — which is why neither a reasoning level nor a modality can be set for it ([#4](https://github.com/xia-sc/dsh-opencode-go/issues/4)). The settings card now lets you state, per model, its **surface** (chat / responses / messages), its **reasoning levels** and its **modality**; leaving a control on `default` follows the built-in table. The surface decides both routing and the level vocabulary — chat sends `reasoning_effort`, responses sends `reasoning.effort`, messages has no level vocabulary — so the level options follow the surface you pick. A hand-declared `image` rewrites the declaration *and* the image-admission check, so a table-known text-only id is not rejected after you declare it multimodal. - **Surface and levels are one pair, and changing the surface settles the levels**: the host validates `surface` and `efforts` *together*, so `surface: responses` carrying the chat-only `max` is refused as a whole. The card therefore conforms the levels in the **same settings write** that changes the surface: what the new surface can carry is kept, what it cannot is dropped and named in a notice; switching to a surface with no level vocabulary at all (messages) clears them; handing the choice back to `default` reconciles against **that model's own default level list** (an unclassified id offers none, so the list is cleared). The card can therefore never write a combination the host would refuse — which would otherwise drop the whole section and silently revert routing to the composition defaults. - **Content-block policy**: a user message carries user payload (`text`/`image`) only; every other block — harness annotations such as `reasoning` and `tool-call`, plus merge-extensible additions — is dropped rather than rejected. A settled background subagent expands the child's final assistant content into a user-role notice, so such blocks legitimately appear in history; because that history is durable, throwing on one untranslatable block fails *every later turn* of the conversation ([#3](https://github.com/xia-sc/dsh-opencode-go/issues/3)). The same holds for assistant turns; only `image` still fails loudly, since uploaded binary content must not silently vanish. - **Usage**: normalized across all three surfaces (both `prompt_tokens` and `input_tokens` vocabularies), provider totals preserved verbatim, cache reads/writes and reasoning tokens reported in separate buckets. - **Usage ledger**: one row per call (model/session/sent `x-opencode-session`/ purpose/in/out/cache read/write/reasoning/finish) in `$DSH_HOME/plugin-data/dsh-opencode-go/usage.jsonl` (append-only, archived on clear, never leaves the machine); the card shows an Overview/Models tab pair with a large-cell daily heatmap plus a per-model table, with one-click clear. **Click any heatmap day** to expand that day's totals plus a per-dsh-session breakdown (including the actually sent session header). The route is deliberately named `zen-go`, not `opencode-go` — the latter is the natural name for user-owned pi-ai custom profiles, and the llm registry allows exactly one owner per route. `apply()` also refuses to boot with a clear `DUPLICATE_ADAPTER` error when the route is taken, so a collision can never silently break the other party. ## Install ```powershell dsh plugin --profile web add # restart dsh web, then pick zen-go/ in the session model picker ``` Local-source installs (`dsh plugin add `, the pnpm `link:` route) need one extra step first — otherwise boot crashes outright (see [#2](https://github.com/xia-sc/dsh-opencode-go/issues/2)): ```powershell cd node scripts/setup-local-deps.cjs # locate the host tree, bridge node_modules/@deepseek-ai dsh plugin --profile web add # restart dsh web ``` Why: a `link:` install is just a junction back at the source tree, so Node resolves the host half's `@deepseek-ai/*` peer imports upward from the REAL source path, where no `@deepseek-ai/*` exists. `--host ` pins the host tree explicitly (install root, `node_modules`, or the `@deepseek-ai` dir itself); `--dry-run` only probes. If boot ever complains `Cannot find package '@deepseek-ai/xxx'`, check the bridge is still a junction and re-run the script. Registry/github installs are unaffected — skip this step. Store the key (any one of these; effective immediately, no restart): ```yaml # $DSH_HOME/.credentials.yaml version: 1 refs: OPENCODE_GO_API_KEY: sk-your-key ``` Or export the `OPENCODE_GO_API_KEY` environment variable, or paste it in the settings card. ## Configuration (layer config / `llm-opencode-go` settings section) | Field | Default | Notes | | --- | --- | --- | | `apiKeyEnv` | `OPENCODE_GO_API_KEY` | credential ref name | | `apiBase` | `https://opencode.ai/zen/go` | base URL without the trailing `/v1` prefix | | `requestTimeoutMs` / `streamIdleTimeoutMs` | `60000` / `300000` | connect + first-byte timeout (timer stops at response headers; long streams aren't capped by total time) / stream idle watchdog | | `maxRequestImageBytes` | `67108864` (64 MiB) | inline image budget for one request, counted in the **base64 bytes the body carries**; above it the route fails with `IMAGE_OFFLOAD_REQUIRED` so the harness drops the oldest occurrences and retries. The operator publishes no request-size limit, so this is a deployment value: the default sits far above ordinary screenshot traffic (a normalized image is ~5.4 MiB encoded, so roughly a dozen in one request) — lower it toward a proxy's body cap | | `enabledModels` | whole table | which models are offered (the card checkboxes edit this) | | `modelCaps` | `[]` | `[{id, contextWindow?, maxTokens?, surface?, image?, efforts?}]`, user-filled capacity and capability overrides | The settings card reports its own refusals: when the section fails validation (say `modelCaps` pairs `surface: responses` with the chat-only `max` level) the plugin drops it whole and keeps the defaults, and the reason is shown above the model list — otherwise the card's selection count and the request path would each tell their own story. ## Test ```powershell node --test test/smoke.mjs # plugin self-consistency (host fully stubbed) node test/host-compat.mjs # real host contracts (real dsh-llm + real stream invariant) # live probes (spend a tiny amount of quota): $env:OPENCODE_GO_API_KEY=''; node --test test/smoke.mjs ``` `test/host-compat.mjs` imports the real `@deepseek-ai/dsh-llm`, `dsh-invariants` and `cordis` from `node_modules` and drives the real `llm` service plus the real stream-grammar invariant. Run it first after a host upgrade: `smoke.mjs` stubs every host surface, so it cannot notice the host tightening a contract (0.1.6 moving image offload from the runtime into the adapter is exactly that kind of change). ## Known limitations - High reasoning effort on the responses surface burns through the token budget fast; start with a generous maxTokens. - The official `/v1/models` endpoint discloses no context windows, so `modelCaps` is manual for now; first-class fields plug straight in once the operator adds them. - Pricing lives in the metering plugin's own price tables; this plugin only guarantees correct usage reporting. ## Acknowledgements - [@34262315716](https://github.com/34262315716) (Critical Natural): verified live that the upstream gateway accepts `reasoning_effort=max` and contributed the Max tier for the three deepseek-v4 models ([#1](https://github.com/xia-sc/dsh-opencode-go/pull/1)); root-caused the local-source link-install boot crash ([#2](https://github.com/xia-sc/dsh-opencode-go/issues/2)).