dsh-plugin-opencode-free
Use OpenCode Zen's anonymous free models inside DeepSeek Harness (DSH) through a
native dsh-llm adapter — the same free lane the OpenCode CLI uses without an
account or API key.
The installed OpenCode CLI is not modified or replaced. The plugin reproduces the wire shape of its requests:
POST https://opencode.ai/inference/openai/v1/chat/completionswithAuthorization: Bearer public- a canonical
ses_session id inx-opencode-session/x-opencode-session-id, user-agent: opencode/1.19.3(the exact inference UA the working global CLI sends),x-opencode-request,x-opencode-client: cli,x-opencode-project: global,- a streaming body with an agent-shaped tool list (
bash+read).
Zen rejects requests that do not carry this shape with
403 FreeTierError: OpenCode's free tier can only be used from within OpenCode,
even when a real API key is sent. This plugin is the missing bridge.
Install
Host-only bundle, no runtime dependencies.
From a local checkout (absolute path to this directory):
plugin_manager · install_bundle · target: <absolute path to this directory>
or directly from GitHub (pnpm-style spec, the same form DSH uses for any
github: dependency):
plugin_manager · install_bundle · target: github:go-enols/dsh-plugin-opencode-free
or with a DSH CLI:
dsh plugin --profile <profile> add github:go-enols/dsh-plugin-opencode-free
Restart DSH if the model picker does not refresh, then select provider
opencode-free in the model picker and pick one of the *-free models
(e.g. deepseek-v4.1-flash-free).
Configuration
The bundle patch inserts one row; override it from the profile's cordis.patch.yml:
- id: opencode-free
name: 'dsh-plugin-opencode-free'
config:
providerId: opencode-free # provider route shown in the model picker
baseURL: https://opencode.ai/inference
refreshSeconds: 600 # live catalog refresh cadence
# userAgent: 'opencode/1.19.3'
# modelIds: ['deepseek-v4.1-flash-free', 'big-pickle']
The plugin ships a snapshot of the free models (models.dev cost = 0) and intersects
it with the live /v1/models catalog, so delisted models disappear automatically.
The first four entries are live-verified.
How it works
index.js registers a structural LlmAdapter through ctx.llm.registerAdapter:
providerInfo,providerRetryPolicy,imageRequestPricing,listModels,resolveModel,prepareCall,streamlib/ids.js— canonicalses_id (bitwise-inverted timestamp + Base62 tail), CLI disguise headers, CLI user agentlib/wire.js— OpenAI-compatible SSE ↔ DSHStreamChunktranslation, free-lane gate tool injection, injected-tool-call rewriting (bash→ the caller'spwsh)lib/catalog.js— static free snapshot ∩ live/v1/modelslib/reasoning.js— per-model thinking levels and thereasoning_contentecho rulelib/tokens.js— prompt-token estimation calibrated against the live lane
The adapter keeps the agent's real tool list; it only appends whichever gate tools
are missing. When a request carries no tools at all (title/summary calls), it adds
bash + read and sends tool_choice: "none" so the model answers in plain text.
Thinking levels
Reasoning-capable free models expose a thinking-level selector in the DSH model
picker. The levels are the ones models.dev declares for each model (the same
metadata the OpenCode CLI builds its own selector from), handed to DSH through
LlmResolvedModelInfo.reasoning:
| model | advertised levels |
|---|---|
deepseek-v4.1-flash-free, glm-5.3-flash-free |
low, high, max |
minimax-m3.1-flash-free |
low, medium, high, xhigh, max |
nemotron-3.5-super-vl-free |
off, minimal, low, medium, high, xhigh |
models with no declared ladder (big-pickle, mimo-v2.6-flash-free, …) |
minimal, low, medium, high, xhigh, max |
The selected level is sent as reasoning_effort. off is sent as the wire spelling
none; an id the gateway would reject is dropped instead of risking a hard 400.
No defaultEffort is advertised, so the upstream default applies until the user
picks a level. off is only offered where models.dev declares none — the MiniMax
free models reject it outright (requires adaptive thinking).
Models that declare interleaved: reasoning_content also get their previous
thinking echoed back: any assistant message carrying tool calls is sent with a
reasoning_content field. That field is mandatory once thinking is on, and without
it the upstream answers
400 invalid_request_error: The reasoning_content in the thinking mode must be passed back to the API.
When the harness no longer has the reasoning text (a compacted turn), the empty
string is sent, which the upstream accepts.
Context window
The free lane enforces two limits per request: the prompt must fit the model's
input cap, and prompt + max_tokens must fit its total window. The adapter
therefore advertises min(context, input + output) from models.dev (e.g.
big-pickle declares a 160k input cap inside a 200k window, so it advertises
192k) and guards each call locally:
- the prompt is estimated with live-calibrated ratios (CJK ≈ 1 token/char,
English ≈ 1/4.7, JSON/code ≈ 1/2.35 — measured against the lane) and, when it
cannot leave 1024 tokens of room, the adapter reports
CONTEXT_WINDOW_EXCEEDEDwithout sending the request. DSH's compaction reacts to that code by compacting and retrying the step, so a conversation that outgrew its model recovers without a wasted round trip or an upstream error in the transcript. - the advertised output cap is the declared one, capped at half the window:
DSH's compaction reserves the full cap before sizing its message budget, so a
model declaring output == context (
nemotron-3.5-lightning-free) would otherwise leave zero budget and silently lose automatic compaction. - when the prompt fits but
max_tokenswould overflow the window, the output budget is clamped to the remaining room instead of failing the turn.
Switching from a large-window model (e.g. a 1M-token route) to a 200k free model while the history is already longer than 200k tokens therefore costs one compaction, not a failed turn.
Verification
node tests/selftest.mjs runs the adapter against the real free lane with a fake
Cordis context and asserts a streamed reply, the gate header/session shape, the
thinking-level metadata and wire mapping, the reasoning_content echo, the
context-window guard and output clamp, a live reasoning_effort: max tool-call
round-trip, and the error mapping for a model that upstream reports as
unavailable.
Caveats
- This uses OpenCode Zen's anonymous lane. It is quota-limited per egress IP,
contended, and some models are region-blocked;
429and400 Model is unavailableare upstream facts, not plugin bugs. - The lane is not an official third-party API. Using a disguised CLI shape may be against OpenCode's terms of service; use at your own discretion.
- Free model ids change. The shipped snapshot + live
/v1/modelsrefresh keeps the list fresh, but a model can still be delisted or region-blocked at call time.
No comments yet. Be the first to write one.