xia-sc/dsh-opencode-go

dsh-opencode-go

DeepSeek Harness LLM provider plugin for OpenCode Go (zen-go route)

Uncategorizeddsh-plugins

dsh-opencode-go

中文

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/<version> (+url) dsh-opencode-go/<version>, 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, see #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). 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). 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

dsh plugin --profile web add <plugin-directory>
# restart dsh web, then pick zen-go/<model> in the session model picker

Local-source installs (dsh plugin add <dir>, the pnpm link: route) need one extra step first — otherwise boot crashes outright (see #2):

cd <plugin-source-dir>
node scripts/setup-local-deps.cjs   # locate the host tree, bridge node_modules/@deepseek-ai
dsh plugin --profile web add <plugin-source-dir>
# 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 <dir> 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):

# $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)

FieldDefaultNotes
apiKeyEnvOPENCODE_GO_API_KEYcredential ref name
apiBasehttps://opencode.ai/zen/gobase URL without the trailing /v1 prefix
requestTimeoutMs / streamIdleTimeoutMs60000 / 300000connect + first-byte timeout (timer stops at response headers; long streams aren't capped by total time) / stream idle watchdog
maxRequestImageBytes67108864 (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
enabledModelswhole tablewhich 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

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='<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 (Critical Natural): verified live that the upstream gateway accepts reasoning_effort=max and contributed the Max tier for the three deepseek-v4 models (#1); root-caused the local-source link-install boot crash (#2).