dsh-opencode-gateway
English | 中文
A DeepSeek Harness (DSH) plugin that wires the local opencode ecosystem into DSH's model menu with two provider routes:
| Route | Endpoint | What it carries |
|---|---|---|
opencode-gateway |
http://127.0.0.1:8787/v1 (the local opencode-api-plugin gateway) |
Everything the gateway aggregates: the OpenCode Go subscription (reading the machine's local opencode login state), OpenCode Zen, and any custom upstreams in gateway.config.json |
opencode-free |
in-process loopback proxy (default http://127.0.0.1:10102/v1) |
The Zen free tier via the companion gateway's oc2api service (default http://127.0.0.1:18090, lanes.oc2api.port in gateway.config.json) — uTLS client fingerprinting, egress rotation; mode: "embedded" falls back to the inlined opencode2api port driving the machine's own opencode serve |
By design this plugin ships no usage panel; quota display lives in the gateway's /api/usage and the tray.
Features
- Curated OpenCode Go registry — the default menu follows the user's ZCode list (15 models); display names, context windows, effort vocabularies, vision flags and a refusal list for 41 entries, measured request-by-request and ported from AIMFllyYS/dsh-protocom-api (MIT) — including the measured quirks (Go spells its disable word
offand refusesnone; grok/muse serve/responsesonly, which the gateway converts transparently). - Live model discovery (soft-failing) — both routes mount immediately with configured/seed menus and upgrade in place once the endpoint's listing answers; a failed read keeps the current facts serving.
- Service lifecycle — in
servicemode (default) the free route talks to the oc2api service the companion gateway already owns: the gateway'slanes.oc2apiblock starts and stops it, so the plugin spawns nothing and only needs the endpoint + key.embeddedmode managesopencode serveitself. - In-process loopback bridge — pi-ai's transport needs a real URL, so the plugin exposes the free backend's OpenAI surface on an HTTP listener bound to loopback only (default 10102, configurable); in
servicemode it is an authenticated passthrough to the Go service, inembeddedmode it wraps the LocalProxy functions. The listener never holds the host's event loop, so headless tasks still exit.
System requirements
- DSH ≥ 0.1.7-rc.2 and < 0.3.0-0 (measured peer range); verified on 0.2.0-rc.2
- The free tier (default
servicemode) needs the companion gateway running with itslanes.oc2apiblock enabled — that block ownsbin\oc2api.exeand the port the plugin talks to (default18090). The plugin does not start it: setfree.serviceUrlto whateverlanes.oc2api.portis, andOPENCODE2API_SERVER_KEYto the same value aslanes.oc2api.apiKey. - The free tier in
embeddedmode needs the opencode CLI installed;opencode auth loginis recommended (better model list and limits, anonymous works too) - The gateway route needs the companion gateway EDDY597/opencode-api-plugin running (
start-gateway.cmd,start-tray-hidden.vbsor the tray) - The gateway route also requires the gateway's
opencode-goupstream to be enabled withprefix: "go"(the gateway's example config does this) andOPENCODE_GATEWAY_KEY=<gateway token>in~/.dsh/.env. Missing either one makes the Go card's refresh report "missing credential / HTTP 401 / empty listing" explicitly instead of failing silently.
Install
Desktop app (recommended)
Open the app, choose Plugins in the sidebar, click Add plugin and paste a local path:
D:/Agent/dsh-opencode-gateway
or a Git spec (once published):
github:<owner>/dsh-opencode-gateway#v0.1.0
Click Install, wait for pnpm, then click Enable now.
CLI
pnpm dsh plugin --profile web add "D:/Agent/dsh-opencode-gateway"
The installer runs a full compatibility sandbox install, which can take many minutes on this machine; hand-composing the profile is faster — see the Chinese README.
Configure
The plugin installs no settings UI; configuration lives in the composition layer (the profile's cordis.patch.yml, ~/.dsh/profiles/desktop/ on the desktop app) and takes effect on restart:
- id: opencode-gateway
name: dsh-opencode-gateway
config:
gateway:
provider: opencode-gateway
displayName: OpenCode Go
baseURL: http://127.0.0.1:8787/v1
apiKeyEnv: OPENCODE_GATEWAY_KEY
hiddenModels: ["free/"]
models: []
free:
provider: opencode-free
mode: service # "embedded" drives local opencode serve instead
serviceUrl: http://127.0.0.1:18090 # = gateway.config.json lanes.oc2api.port
serviceApiKeyEnv: OPENCODE2API_SERVER_KEY
manageService: false # the gateway owns bin\oc2api.exe; nothing to spawn here
proxyPort: 10102
# embedded-mode only:
# serverUrl: http://127.0.0.1:10001
# opencodePath: opencode
# manageBackend: true
# disableTools: true
# internalAllowedTools: [webfetch, bash, websearch]
Upgrading from the 18080 setup
Until recently the free route defaulted to http://127.0.0.1:18080 with manageService: true. Both are wrong for tool calling, and the spawn target no longer exists:
| Symptom | Cause |
|---|---|
| Free models answer text but never call tools | 18080 is the gateway's src/cli-server.ts shim — a buffered adapter that waits for the whole reply, rebuilds it as text and keeps only text parts, so every tool_calls is dropped. oc2api (:18090) forwards tools and streaming verbatim. Measured 2026-10-30 with one identical request: gateway free/big-pickle → tool_calls, :18090 directly → tool_calls, :18080 → none. |
| Free route burns its retries, then "gives up for this session" | manageService: true makes the plugin look for bin\opencode2api.exe, which no longer ships; a slow :18090 turns into a missing executable, not a slow start. |
No config change is needed if you left free at defaults: the current defaults already point at :18090 with manageService: false.
If your cordis.patch.yml spells the old values out explicitly, change them:
free:
serviceUrl: http://127.0.0.1:18090 # was http://127.0.0.1:18080
manageService: false # was true
And keep the key in step with the gateway:
# ~/.dsh/.env — must equal gateway.config.json lanes.oc2api.apiKey
OPENCODE2API_SERVER_KEY=df8a7e…your-gateway-key
If your gateway runs oc2api on a non-default port, set free.serviceUrl to that port — it is the only free-side setting that names it.
Restart DSH afterwards (the plugin reads this config at load). Then pick a free model and ask it to read a file; seeing a tool call means the free route is on the direct path.
Model manager page
DSH settings gain an OpenCode Gateway section (plugin v0.2.0+): one card per route (OpenCode Go / OpenCode Free) managing that route's model menu.
- Refresh pulls the live listing (OpenCode Go via the gateway's
/v1/models, OpenCode Free via the inlined proxy's backend listing). A refresh waits up to 60s; 401/timeout/network failures report their reason, and only a 200 with an empty list shows the empty-listing notice. The Go card keeps only thego/prefix — the gateway'sopencode-goupstream readinghttps://opencode.ai/zen/go/v1/models, which measured one for one against the official endpoint; the gateway-side upstream must therefore useprefix: "go"(the example config does). That same listing mixes in two other sources under look-alike prefixes, neither of which is the Go subscription, and both are dropped:opencode-go/is the localopencode servebackend's catalog (passed through by thelocal-freeupstream, whosemodelRewritemaps onlyopencode/), andfree/is that same backend's Zen catalog, paid models included. Readingopencode-go/as an alias spelling ofgo/is what putopencode-go/step-5-preview-free— a local entry the official endpoint does not list — on the Go card. - No frozen list: menu membership follows the live listing alone. A model the endpoint adds shows up (e.g.
go/beam-free); one it drops disappears;hiddenModelsremains available for extra exclusions inside the official set. There is no confirmed-unavailable blacklist — "this id cannot serve a turn" is a measurement of one moment, not a property of the model, and freezing it would both hide newly added official models and keep misreporting after the endpoint fixes something. An official id is kept even if a given request fails; the gateway's own error is then reported verbatim. - The free card takes the backend's own answer: in
servicemode the Go service's/v1/models(its companion config setsmodels.free_only: true, so the listing is exactly the free tier, bare ids likebig-pickle); inembeddedmode it comes fromopencode models, run withOPENCODE_API_KEYremoved and reduced to theopencode/prefix. Do not derive this from the local backend's provider config: that view is credentialed — with the key set, itsopencodeprovider also reports the paid catalog (measured 84 models), and filtering it bycostboth missed free models it does not list at all (beam-free,deepseek-v4.1-flash-free,glm-5.3-flash-free,nemotron-3.5-super-vl-free) and kept region-blocked ones (cat-jam-free,muse-spark-1.3-contributor-free). The answer is cached for 10 minutes and falls back to the last good result, so one failed read never reads as "the free tier vanished". - Checkboxes decide what lands in the model menu (composer chip included); newly discovered models are tagged and unchecked.
- Stale cleanup: a checked model a refresh no longer answers is tagged "not listed" and is never removed on its own. The card's "Remove N stale" button drops exactly those entries in one save — the only path that changes the menu without unchecking, and always an explicit click. Guard: an empty list yields no staleness verdict, and a refresh now distinguishes a 200-with-no-models from a 401/timeout/network failure; the latter shows its own error instead of clearing anything.
- Save checked commits one revision-fenced settings write; the menu and chip update immediately and survive restarts; a conflict reloads the snapshot and drops staging.
- Restore defaults unsets that route's
models— the menu falls back to the shipped list (or the compositionmodels, when configured).
Precedence: saved settings models (even an empty array) > composition cordis.patch.yml models > shipped defaults. Saved entries that carry only id/name/context are enriched by the host from the measured registry (missing effort vocabulary and vision flags; an explicit reasoningEfforts: false is never touched). Backend-structural fields (serverUrl / proxyPort / opencodePath …) ask for a restart when changed on the page; the model menu applies live.
Environment (~/.dsh/.env)
| Variable | Meaning |
|---|---|
OPENCODE_GATEWAY_KEY |
The gateway's Bearer token; any non-empty value works while the gateway runs without a token. A refresh without it reports "no credential resolved…" directly; a chat request fails before network I/O with MISSING_CREDENTIAL |
OPENCODE2API_SERVER_KEY |
The oc2api service's key (free route, service mode) — must equal the gateway's lanes.oc2api.apiKey. Falls back to the companion gateway's bin/config.json server_keys[0], which only works when the gateway checkout sits next to this plugin and its key matches |
OPENCODE_ZEN_API_KEY |
(embedded mode) Passed to the backend as OPENCODE_API_KEY to unlock paid models; falls back to the local opencode login state (v0.1 reads the process environment only, not the credentials seam) |
models[] entries
| Field | Meaning |
|---|---|
id |
The model id the endpoint accepts, forwarded verbatim |
name |
Selector label |
contextWindow / maxTokens |
Capacities; maxTokens also becomes that model's per-request default output cap |
reasoningEfforts |
false declares a non-reasoning model; a dict {level: wire spelling}, where only off may be empty (send nothing) |
input |
["text","image"] declares image input |
How it works
- Two routes, one adapter:
PiAiAdapterreads the current profiles snapshot once per operation; changed discovery facts atomicallyreplacethe registration — no restart, no disturbed in-flight stream. - Loopback bridge: request bodies buffer into a fetch
Request; responses stream back out. A mid-stream drop after the headers are flushed destroys the socket instead of throwingERR_HTTP_HEADERS_SENTinto the server callback. - Single retry layer: the plugin's
retryPolicydefaults tonormal/maxRetries 0— retries belong to the gateway (subscription routes) or the inlined proxy (free route), never both. - Thinking bridging:
reasoning_content,reasoning+reasoning_details, and inline think tags all flow through pi-ai's delta parser into DSH's reasoning-delta collapse (the gateway strips Go routes' inline tags server-side).
Known limits (v0.1)
- No usage panel (by design); quota lives in
/api/usage+ the tray. zenApiKeyEnvreads the process environment only; stored credential references are not resolved yet.- Headless one-shot tasks linger on exit: a control run with this plugin removed lingers the same way — DSH headless base behavior, not this plugin.
- Two processes racing to spawn the same backend port (10001): a failed spawn surfaces an error and a retry succeeds; health-check-before-spawn mitigates it.
Troubleshooting: the Go card refresh reports an empty listing
That notice is a safe failure: the menu is never modified. Check these three things in order; any one of them being wrong yields an empty listing.
| Check | Expected | If wrong |
|---|---|---|
The gateway's opencode-go upstream in gateway.config.json |
"enabled": true, "prefix": "go"; apiKey either omitted (local login) or a Go-subscribed key |
apply the template below, then tray → Restart service |
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8787/v1/models |
contains go/... |
only free/...: prefix or switch not applied; HTTP 401: token mismatch |
OPENCODE_GATEWAY_KEY in ~/.dsh/.env |
equals the gateway token |
set it, then restart DSH |
Minimal gateway-side config:
{
"port": 8787,
"token": "<gateway token>",
"upstreams": [
{
"id": "opencode-go",
"type": "opencode-go",
"prefix": "go",
"enabled": true
}
]
}
Notes:
- The Go card only accepts
go/. An upstream withprefix: "opencode-go"listsopencode-go/..., which the plugin drops as the local backend's catalog by design. - The key is not in the plugin. With no
apiKeythe gateway reads the localopencode auth loginstate (~/.local/share/opencode/auth.json, theopencode-goentry, elseopencode); switch accounts withopencode auth login, or setapiKey/OPENCODE_GO_API_KEYon the gateway. - The first refresh may be empty. On a cold gateway the listing returns the last good result within 8s and refreshes in the background; refresh again once it lands.
- A refresh now distinguishes a 200 empty listing from HTTP 401 and timeouts;
no credential resolved from OPENCODE_GATEWAY_KEYmeans.envis missing the variable. - The free tier is unaffected: the
OpenCode Freecard never goes through the 8787 gateway.
Two common chat-side errors:
403 An active OpenCode Go subscription is required: the gateway'sopencode-gokey has no Go subscription (the listing may still be fine — the catalog is public). OmitapiKeyto use the localopencode auth loginstate, or use a subscribed key, then restart the gateway.Connection error.: the gateway process is not running — not a key problem.curl http://127.0.0.1:8787/healthmust answer{"status":"ok"}.
Development
pnpm install
pnpm run build # tsdown → lib/index.js (Host, ESM); tsc -b → lib/types
pnpm run test # vitest
The lib/ build output is committed alongside the sources (git installs need no build) — run pnpm run build before committing code changes.
License
MIT © 2026 — contains the embedded opencode2api implementation ported from opencode-api-plugin (itself ported from TiaraBasori/opencode2api, MIT; license copy at assets/LICENSE.md), and the OpenCode Go registry measurements from dsh-protocom-api (MIT).
No comments yet. Be the first to write one.