English · 简体中文
OpenCode on DeepSeek Harness
Gateway origin headers · Session affinity · Free-tier tool fallback · Live dual-mode quota meter
Quick Start · What this fixes · Interface · Configuration · Troubleshooting · Changelog · Contributing
dsh-opencode-patch is a DeepSeek Harness host plugin that keeps OpenCode Zen & Go models working inside DSH. Connect claude-sonnet-4-5, gpt-5.4, gemini-3.8-flash, deepseek-v4.1-flash, muse-spark-1.3, qwen3.8-flash and the rest of the OpenCode catalog without network rejections, Cloudflare challenges, entitlement mismatches, or invisible limits.
The default state. Point DSH at OpenCode with no plugin and the models do not work at all — not on one shape, on any of them:
- The request is not recognised as coming from a coding agent. DSH sends its own
User-Agent, and nox-opencode-client,x-opencode-projectorx-opencode-sessionat all, so the gateway sees an anonymous SDK call and the traffic falls under the abuse rules the vendor documents for clients that do not identify themselves. The free tier is a separate matter and this is not its cause: with every one of those headers restored, a CLIUser-Agent, a valid session id and theread/bashtool schemas the fallback injects, a Zen-freemodel still answers403 FreeTierErrorfrom outside OpenCode while a paid model on the same request answers200. The ids are still offered — the refusal names itself, and hiding a row the vendor may serve is the worse failure — and the headers it restores are for the paid routes and the vendor's stated client rules. - A turn without a session id is rejected or left unlinked. Every turn has to carry a stable
ses_…-shaped id, and DSH omitssessionIdentirely for subagents, background evaluations and modes like Auto Review. - Free-tier
/responsesbodies are refused withoutreadandbashintools, and DSH deliberately does not send them. - The catalog is thin. A bare
/modelslisting carries ids and little else, so the picker shows unnamed rows with no prices and no context windows.
What is left once those work is a different problem, and it is not about the account. OpenCode serves its models on four different API shapes, while DSH picks a transport from the provider row — one route can speak one shape. So a model served on Responses, Messages or Mistral answers 500 the moment the basics are restored, which is why this plugin routes per model rather than per provider.
What this does. It serves the two OpenCode routes itself — opencode and opencode-go, every model on the protocol the catalog names — and restores every missing protocol element at the network layer, strictly for those routes. All other traffic (DeepSeek, OpenAI, Anthropic, GitHub) passes through untouched.
What you get. A picker listing the whole OpenCode catalog with real names, prices and context windows; every model sent to the endpoint it is actually served on; and a live meter for the quota or the spend — no configuration.
Three things it is built around.
- Minimal intrusion. Nothing is patched globally: requests are intercepted only for the routes this plugin claims, and every other provider — DeepSeek, OpenAI, Anthropic, GitHub — passes through byte-identical. The composer meter is one switch away from being gone, not one screen away from being tolerated.
- A native DSH plugin. One
apply(), a settings card bound to its own namespace, services declared throughctx.inject, slots filled the way the host fills its own, and copy in both bundled languages. No private hooks, no patched host code. - OpenCode API compatibility. The traits the gateways actually check are restored at the network layer — a
ses_…id shaped exactly like the CLI's own, official origin headers, parent-session lineage, and the free-tierread/bashschema fallback.
Highlights
- 🔑 Deterministic
ses_<12hex><14base62>session hashing with KV-cache affinity across turns, subagents and forks - 🌐 Gateway origin restoration —
User-Agent,x-opencode-client,x-opencode-project, parent-session lineage - 🧰 Free-tier
read+bashtool-schema fallback, so a free-tier body carries the tool definitions its endpoint requires — one cause of a403removed, and not the whole of it: the gateway's gate is not in the request, measured - 📇 OpenCode-catalog-backed (
models.opencode.ai, the registry models.dev publishes) with offline shims and background SWR refresh — names, context windows, prices - ⭕ Live dual-mode composer meter — Go quota ring (5-hour / weekly / monthly) or Zen pay-as-you-go pill, plus session spend and model rate
- 🕵️ Go meter credential guard — the Go quota endpoint is never sent an
OPENCODE_API_KEYreference; it reads the Go entry'sapiKeyEnv(elseOPENCODE_GO_API_KEY) or a key a Go-plane request carried
Contents
- Quick Start
- What This Fixes
- Supported Models & Protocols
- The Interface
- Configuration Reference
- Removing or Disabling the Plugin
- Troubleshooting
- Changed in 1.x
- Compatibility & Verification
- Deep Dive — the protocol internals, out of this README
- Attribution & License
1. Install into your DSH Web profile (Node 24+):
cd ~/.dsh/profiles/web
npm install dsh-opencode-patchThe package is dsh-opencode-patch under every name it is used by: the dependency you install, the bundle entry in your profile, and the row the host resolves. It is also published as @viztor/dsh-opencode-patch, and the older name @viztor/dsh-opencode keeps publishing as a deprecated thin wrapper that re-exports it — an install still on that name keeps working, and moving to either of the other two is a rename rather than a fix.
2. Enable the bundle — add the package to the profile's dsh.profile.bundles array:
3. Add a credential so the quota meter can resolve a key — store OPENCODE_GO_API_KEY (Go subscription, sk-…) and/or OPENCODE_API_KEY (Zen pay-as-you-go, oc_sk_…) in DSH Credentials or your environment. On the Go plane, name it: give the opencode-go entry apiKeyEnv: OPENCODE_GO_API_KEY. A plane entry without apiKeyEnv authenticates by pi's own lookup, which reads OPENCODE_API_KEY on both planes, so a Go entry without one sends the Zen key.
4. Restart dsh web. The host bundle is only imported at boot (hmr root: []), so a restart is what loads lib/index.mjs; client UI changes (lib/client.js) only need a browser refresh.
Done — the OpenCode Patch card appears under Settings → Plugins, and the meter mounts in the composer dock as soon as an OpenCode model is active.
| Without the patch | With dsh-opencode-patch |
|---|---|
Zen free models fail with 403 FreeTierError |
Offered, and refused in the gateway's own words. The headers and tool fallbacks are restored for the paid routes, but they are not this error's cause — measured with all of them in place, a Zen -free id still answers 403 while a paid model answers 200, and the refusal comes from the service behind the console rather than from anything a request carries. So the ids stay in the picker and the refusal surfaces as 400 free_tier_restricted instead of a hidden row |
Only one API shape can be served per route — the rest answer 500 once the basics work |
Per-model routing — each model dispatched to the endpoint its own SDK names, read from the catalog |
Session IDs rejected with 400 MissingSessionID |
Deterministic ses_… session hashing and affinity across turns |
| Subagents lose conversation context | Parent session tracking (x-opencode-parent-session-id, x-parent-session-id) |
Auto Review calls fail with TRANSPORT: Connection error |
Fallback session turn capture preserving turn state across eval calls |
| Quotas and balances are invisible | Live dual-mode composer meter showing Go quota or Zen pay-as-you-go |
Zen keys cause 403 EntitlementError on Go usage |
The meter never reads an OPENCODE_API_KEY reference for Go. It uses, in order, a literal apiKey on the Go entry, a key captured on a Go-plane request, the Go entry's apiKeyEnv (an OPENCODE_API_KEY reference is read as OPENCODE_GO_API_KEY), any captured Go-plane key, then the opt-in store. A Go turn carries the key its entry names; an entry that names none sends pi's OPENCODE_API_KEY, the request patch files that key as a Go key, and from the first Go turn the meter sends it to the Go usage endpoint and gets this 403. Name apiKeyEnv: OPENCODE_GO_API_KEY on the Go entry so the turn and the meter agree, or set Credential Source to Declared key first in Settings |
Projects share a single "global" telemetry bucket |
Dynamic workspace attribution resolved from the active session.header.cwd |
Gateway /models returns a truncated, unnamed list |
Catalog enrichment (models.opencode.ai) with display names, context windows and prices |
One route per plane, every protocol on it. The plugin serves opencode (Zen) and opencode-go (Go) through its own adapter — a subclass of the host's own llm-pi-ai adapter, registered on the host's llm service with the host's own registerAdapter. llm-pi-ai keeps every other route you declare. Each model is dispatched on the protocol its catalog row names, so there is no second route to declare, mount or hide.
The gateway serves four protocol families, and OpenAI alone is two of them: Chat Completions and Responses are different wire formats, not two names for one thing. A model that needs Responses does not work on the completions endpoint, and the gateway does not fall back — it answers 500. All four are served, each with pi's own implementation of that protocol.
Two endpoint families. OpenCode answers on two separately entitled families. Modern — the default, and what endpoint: auto means — is the per-protocol inference family the catalog names: https://opencode.ai/inference/{openai,anthropic,google}/… on Zen and https://opencode.ai/inference/go/{openai,anthropic,google}/… on Go. Legacy (endpoint: legacy) is the older single base per plane, https://opencode.ai/zen/v1 and https://opencode.ai/zen/go/v1. A baseURL on the plane's own entry picks the family by its path, and any other host is used as written.
Those endpoints authenticate in three ways, because they speak three wire shapes: the OpenAI planes send Authorization: Bearer <key>, the Anthropic plane x-api-key: <key>, the Google plane x-goog-api-key: <key>.
- Anthropic Messages (
https://opencode.ai/inference/anthropic/v1/messages):claude-sonnet-4-5,claude-opus-4-7,claude-haiku-4-5,qwen3.8-flash - OpenAI Responses (
https://opencode.ai/inference/openai/v1/responses):gpt-5.4,gpt-5.2,gpt-5.1-codex-max,muse-spark-1.3,grok-4.7,muse-wonder-1.0 - OpenAI Chat Completions (
https://opencode.ai/inference/openai/v1/chat/completions):deepseek-v4.1-flash,kimi-k2.5,kimi-k3,minimax-m2.5,glm-5.2, plus free-tiermimo-v2.6-flash-free,nemotron-3.5-lightning-free,deepseek-v4.1-flash-free - Google Generative AI (
https://opencode.ai/inference/google/v1beta/models/*:streamGenerateContent):gemini-3.8-flash,gemini-3.1-pro,gemini-3.5-flash-lite
- OpenAI Chat Completions (
https://opencode.ai/inference/go/openai/v1/chat/completions):beam-free,deepseek-v4-pro,deepseek-v4.1-flash,glm-5.2,glm-5.3,glm-5.3-flash,hy3,hy4,hy4-preview,iceman-contributor-free,kimi-k2.7-code,kimi-k3,kimi-k3.1-pro,longcat-2.0,longcat-2.5-free,mimo-v2.5,mimo-v2.5-pro,mimo-v2.6-flash,mimo-v2.6-pro,minimax-m3.1-flash,step-5-preview - OpenAI Responses (
https://opencode.ai/inference/go/openai/v1/responses):gpt-5.6-luna,gpt-6-luna,grok-4.7,muse-spark-1.2-contributor,muse-spark-1.3-contributor - Anthropic Messages (
https://opencode.ai/inference/go/anthropic/v1/messages):claude-haiku-5-5,minimax-m3,qwen3.7-plus,qwen3.8-flash,qwen3.8-max - Google Generative AI (
https://opencode.ai/inference/go/google/v1beta/models/*:streamGenerateContent):gemini-4-argon, with image attachments and signed tool-call replay. Go has no legacy Google endpoint, so this one base serves both families. - Monitored by the live 3-window quota meter (5-hour rolling, weekly, monthly). The full set ships in the bundled catalog — see Authoritative Model Catalogs.
Zen's provider-level SDK is @ai-sdk/openai-compatible. models.dev names a different SDK per model only when that model needs one, so the presence of provider.npm is the signal — and it is what decides the wire protocol. Nothing is matched by hand:
models.dev provider.npm |
models | protocol |
|---|---|---|
| (absent) | 27 | OpenAI Chat Completions |
@ai-sdk/openai |
32 | OpenAI Responses |
@ai-sdk/anthropic |
18 | Anthropic Messages |
@ai-sdk/google |
10 | Google Generative AI |
@ai-sdk/mistral |
1 | OpenAI Chat Completions, as pi's own data files it |
Counts are the 88 active opencode models in models.opencode.ai as of 2026-11-13 — everything else the vendor lists is deprecated or retired, and the patch ships only what the gateway still serves. The same 88 are bundled offline in src/catalog-data.ts, so every one of them routes correctly before the first catalog refresh; regenerate that file with pnpm run catalog:shim -- --write (without --write it only checks).
Four properties follow:
- You keep choosing. Each plane serves exactly the models its
llm-pi-aientry lists — and the whole catalog when the entry lists none. With Enrich listings from the OpenCode catalog on (the default), Settings → Models → Fetch offers the whole catalog: pi's own list, plus every servable, non-retired model the catalog has that pi does not list. A row it offers carries the catalog's name, context window, output limit and modalities — what the composer serves — and pi's only where the catalog has none; one adopted from it saves like any other id and is served by the plugin. Any id you save is accepted and served: one the catalog does not classify is sent as Chat Completions, unless the plane's entry names a route-levelapi, which applies to exactly those ids. pi's own catalog never decides a protocol or a base, so a pi upgrade that reshapes it moves no turn. - Your row wins. A model's name, context window, output limit and input modalities come from your own model row first, then the catalog, then pi's installed data. Its reasoning efforts come from your row's
reasoningEfforts, then the catalog. Its cost and its protocol are not row fields: the cost comes from the catalog, then pi; the protocol from the catalog's SDK, then the plane entry's route-levelapi(only for ids the catalog does not classify), then Chat Completions. - A model whose reasoning is only on or off is offered as Off / On. Off appears only where the model's protocol can actually turn reasoning off; a model with a real effort list offers exactly its levels, and a Chat Completions model whose list names
nonealso offers Off, sent asreasoning_effort: "none"the way pi's own entries carry it. - One credential, by the host's own rule. A plane authenticates the way
llm-pi-aiwould: the entry'sapiKeyEnvthrough the credentials service (or the launch environment when no credentials service is mounted), a Sign-in record stored underllm-pi-ai/opencode, and otherwise pi's ownOPENCODE_API_KEYlookup, which is the same variable on both planes: nameapiKeyEnv: OPENCODE_GO_API_KEYon the Go entry. The installation's own OpenCode store is consulted only when no reference is named and the deployment opted in. A plane whose key does not resolve still lists its models, and the turn fails withMISSING_CREDENTIALbefore any request is sent.
Settings stays the host's. The plane entries live in llm-pi-ai's providers block exactly as before, and Settings → Models reads and writes them there. The plugin keeps llm-pi-ai from serving the two planes by handing it a copy of its configuration without them, and validates a Settings edit to a plane with llm-pi-ai's own checks, so a malformed entry is refused before it is written.
| Mode | What the patch does |
|---|---|
| Interactive multi-turn chat | KV prompt-cache affinity via a stable per-session ses_… id |
| Subagents & forks | Child and parent sessions both hashed; lineage carried in parent headers |
| Agent teams | Shared-workspace attribution preserved across orchestration turns |
| Experimental Auto Review | Background audit calls with an omitted sessionId still get a deterministic turn state, headers and tool fallback — and they take their route from the session, so the model the session is on is the model they run |
The model list is assembled from four sources, each answering a different question:
| Source | Answers |
|---|---|
| Bundled shim | what can be offered before any network call — cold start, offline |
models.opencode.ai |
the canonical spec, price and display name |
The gateway's /models |
what this account actually has |
Settings' fetch (discoverModels) |
on opencode/opencode-go: pi's own list, then every catalog model pi lacks |
Merging is additive: adapter rows are kept, canonical rows are appended when a model is missing, and a row is dropped only when the registry marks it status: "deprecated" — a fact, not a guess. Display names and prices come from the catalog (models.opencode.ai), because a gateway listing carries ids and little else.
Two things follow that you will notice in use. Cold start is never empty — the bundled shim answers before the first refresh, so the picker has models immediately. And a model the catalog has no row for still appears, with — where its rate would be: never a guess, and never the previous model's name.
Both generated files are checked in CI (catalog:shim, limits:shim); they exit non-zero when the vendor's data moved, which is the only way to notice the vendor's registry changing under you. A pull request fails on that only when it changes the code that generates the file; otherwise CI warns, and a nightly run proposes the refresh as a pull request of its own.
→ Protocol routing, the merge and the UI in technical detail: docs/protocol-routing-and-merge.md · 中文
Two surfaces, one reading. Both are pure views over the same Host data.
| Surface | Where | What it shows |
|---|---|---|
| Meter trigger | composer dock, beside the model selector | Go: a ring plus the window's percent. Zen: the session spend, and no ring |
| Meter panel | click the trigger | The windows, the monthly allowance, the session spend, the Zen credit, the action links |
| Settings card | Settings → Plugins → OpenCode Patch | Nine controls, plus a live Go usage summary above them |
Hovering the trigger explains its own number; clicking it opens the panel. The sections below show each surface, and docs/quota-meter.md documents every state it can be in.
Nine controls in four sections — the decisions a user actually makes. Everything renders from the platform's own primitives (Switch, Tag, Button, host tokens), and every knob carries a hint plus a reset-to-default affordance.
| Section | Control | Default | What it does |
|---|---|---|---|
| Gateway Requests | Inject User-Agent | on |
Restores the official OpenCode CLI User-Agent so Cloudflare WAF checks pass |
| Gateway Requests | Inject Origin Headers | on |
Injects x-opencode-client (and the origin header set) on gateway traffic |
| Gateway Requests | Attach Workspace Project | on |
Tags x-opencode-project with the active folder name; off omits the header |
| Models & Free Tier | Enrich listings from the OpenCode catalog | on |
Lets the gateway /models listing carry the full catalog, and offers it to Settings' model discovery. Does not add models to the picker by itself — the picker shows the models you declared, or the plane's catalog when its entry declares none, and this switch changes neither. To declare more, use Settings → Models → Fetch on an OpenCode plane: it offers pi's list plus the catalog's models pi lacks, and adopting a row and saving declares it. A declared model's own info (context, modalities, thinking levels) follows your row and the catalog whatever this says |
| Models & Free Tier | Inject Core Tools | on |
Adds the read + bash schemas free-tier /responses bodies require |
| Quota Meter | Enable Composer Usage Meter | on |
Mounts the live meter in the composer dock — the Go quota ring, or the Zen session spend |
| Account & Endpoint | API Endpoint | auto |
Which endpoint family requests go to: Automatic (same as Modern) · Legacy (zen/v1, zen/go/v1) · Modern (per-protocol inference/{openai,anthropic,google}/…, inference/go/… on Go) |
| Account & Endpoint | Credential Source | auto |
Which key wins when several are known: Automatic · Live request first · Declared key first |
| Account & Endpoint | Read the OpenCode installation's key | off |
Lets the plugin read the credential OpenCode itself stored, as the LAST resort — off by default |
Two questions decide where a request goes, and the Account & Endpoint section is where you answer them.
Which endpoint family. The gateway answers on two families — the older /zen/v1 (/zen/go/v1 on Go), one base for every protocol, and the newer per-protocol inference family (/inference/openai/v1, /inference/anthropic/v1, /inference/google/v1beta, with /inference/go/… on Go) — and they are entitled separately: an account that works on one is not guaranteed to work on the other. modern sends each protocol to its own inference path; auto, the default, is an alias of modern; legacy pins the older family, which is what you want when your account only works there.
Which credential wins. A turn on a plane authenticates by the host's own rule (see How a model's protocol is chosen). For the quota meter and the request patch, keySource orders the sources the plugin can see — auto prefers a key you declared, then one captured from a live request, then the declared reference; request promotes a captured key first; configured does the reverse. Below it, readOpencodeStore lets the plugin read the credential OpenCode itself stored, as the last resort only. Both are off/auto by default, so nothing changes until you say so.
Override-style knobs (literal strings, markers, route lists) are deliberately config-only so a default fits every documented setup — see Configuration Reference.
The composer panel also shows the session's accumulated cost and the active model's per-million-token rate, and there is no switch for it: it is part of the panel rather than an option, so it is on whenever the panel is.
The card also carries a live Go usage summary above the controls — the same reading the composer meter shows, in the layout the vendor's own console uses: one row per window, with what is left held out on the right.
Go usage Healthy
● 5 hours 0% used · resets in 4h 58m 100% left
● Weekly 28% used · resets in 3d 2h 72% left
● Monthly 14% used · resets Nov 7, 8:55 AM 86% left
Monthly allowance mimo-v2.6-flash · Go $60 · Plus $120 ⓘ
This build carries the API key path only. There is no sign-in row and no device flow: a credential comes from the deployment's configuration, from a key captured on a live request, or — when readOpencodeStore is on — from the OpenCode installation's own store. The Console login (device flow, session store, credential pool) lives on the v2 line and is deliberately not in main, so nothing here depends on a service the host may not mount.
If the card looks like the previous build after updating, the host process has not restarted. The host bundle is read once at process start, so a browser reload keeps running the previous lib/index.mjs; stop and restart dsh web. The row is the quickest way to tell, because it is new in the same build as everything else.
"Left" is a percentage, never a dollar figure: /usage publishes a percent per window and no balance at all, and the plan tier is not discoverable — so a dollar remainder would be a guess. The ⓘ states that. A quota that cannot be read says so and prints no numbers, rather than a row of zeroes that reads as a measurement.
The meter mounts next to DSH's native ContextMeter in conversation.composer.dock:
Per-state behaviour — the trigger's three states, both panels, what every row answers, and the three different kinds of "no number" — is documented in docs/quota-meter.md.
┌─────────────────────────────────────────────────────────────────┐
│ Type a message... │
│ │
│ [+] Attach [DeepSeek V4.1 Flash ⌄] [⬆]│
└─────────────────────────────────────────────────────────────────┘
[ ⭕ 73% Context ] [ ⭕ 42% Go Quota ] ← when OpenCode Go is active
[ ⭕ 73% Context ] [ ⭕ $0.00 ] ← when OpenCode Zen is active
- Adaptive ring: a real-time SVG ring for the window that answers the question. A window that is out — rate-limited, or at its cap — is shown, widest first (monthly → weekly → 5-hour), because a monthly cap explains a refusal the 5-hour window does not. Otherwise it shows the 5-hour window, which resets soonest and so is the one you can still act on. On a free model the ring renders hollow in the muted track colour — the plan's limit says nothing about a bill that cannot be charged, and red against
$0.00would read as "you are out of money". - Semantic colors: green below 80% (
--dsw-alias-state-success-primary), amber at ≥80% (--dsw-alias-state-warn-primary), red at the cap (--dsw-alias-state-error-primary). - Click panel: three window rows with live reset countdowns and a bar per window, a session-spend row naming the currently selected model, a Zen-overflow row, a rate-limited alert, and act-on-it links. Hovering the trigger shows a
Tooltiplisting every window (5 hours 11% · Weekly 33% · Monthly 16%), because the trigger can print only one of them and the next question is always "and the other two?". - A model the catalog has no price for reads
—, notFree. The panel names whatever the picker has selected, even when the catalog has no entry for it yet (it syncs on a schedule), because showing the previous model's name is worse than showing no price.
┌──────────────────────────────────────────────┐
│ OpenCode Go [Go Plan]│
├──────────────────────────────────────────────┤
│ • 5 hours ███████░░░░░░░░░ 42% │
│ Resets 3h 12m │
│ • Weekly ███░░░░░░░░░░░░░ 18% │
│ Resets 5d 8h │
│ • Monthly █████████████░░░ 65% │
│ Resets 22d 4h │
├──────────────────────────────────────────────┤
│ Monthly allowance │
│ mimo-v2.6-flash Go $60 · Plus $120│
├──────────────────────────────────────────────┤
│ Session Spend │
│ deepseek-v4.1-flash · $0.15 / $0.6 per 1M │
│ $0.42 │
│ Available Zen Balance │
│ Ready for overflow Ready │
├──────────────────────────────────────────────┤
│ ⟳ Updated 08:30 Upgrade Console › │
└──────────────────────────────────────────────┘
A Zen route renders no Go figure anywhere: there is no ring at all (a gauge with no measurement is decoration), the label is the spend rather than a percentage, a rate-limited Go plan does not paint the Zen trigger as an alert, and hovering carries the price (Session Spend $0.00 · Pay-as-you-go). A Go window is a fact about a plan this route never bills against, so it does not appear here.
- Zen trigger: the spend as its label, and no ring — the ring is a gauge, and Zen has no window to measure (
$0.00before anything has been priced,$0.42after). The figure is the only real number available — OpenCode exposes Zen balance through console server actions that require a browser session, so an API key cannot read it. - Pay-as-you-go panel: header with a
Pay-as-you-gobadge, the session-spend row (when the price switch is on), and one link — OpenCode Console, labelled Top up, because top-up is what a pay-as-you-go user wants from that page and it has no URL of its own.
┌───────────────────────────────────────────────┐
│ OpenCode Zen [Pay-as-you-go] │
├───────────────────────────────────────────────┤
│ Session Spend │
│ Space Bunny Free · Free $0.00│
├───────────────────────────────────────────────┤
│ ⟳ Updated 08:30 Top up › │
└───────────────────────────────────────────────┘
The monthly allowance, and why it is a total and not a balance. The three windows are shares of a per-model monthly dollar allowance — the vendor's own words: "Usage limits are defined as monthly dollar amounts… 5-hour — 20% of the monthly limit; weekly — 50%; and monthly — 100%." So the meter prints that allowance under the windows: for mimo-v2.6-flash, Go $60 · Plus $120. It is generated from the vendor's Go documentation (pnpm run limits:shim, checked on a schedule in CI) because the page states outright that "usage limits may change" — a hand-typed table would be wrong within a release, and a stale dollar figure looks authoritative.
It shows both tiers rather than one, because the plan is not discoverable: /limits, /plan, /subscription, /account and /credits all return 404, and /models carries no limit. Guessing a tier would misstate the money by 2–3x.
It is deliberately not a remaining balance. GET /zen/go/v1/usage takes no model parameter, so its percentages describe the account rather than the model you are reading — multiplying an account-level percentage by a per-model allowance yields a number with no referent. The remaining balance is in the console, where it is exact.
Zen balance & overflow. If an OPENCODE_API_KEY (or oc_sk_...) is configured, Zen pay-as-you-go is detected automatically. The Zen panel carries no balance row: OpenCode exposes that balance only through console server actions that need a browser session, so the panel links straight to the OpenCode Console rather than freeze a number it cannot keep current. The balance row lives on the Go panel instead, where it answers a question the panel can answer — whether an over-limit Go request will really be billed to that balance.
These exist in the schema but render no control — each is a literal, a marker, or a reference whose default fits every documented setup. They remain editable in the row's config; cordis.patch.yml is the reference.
| Knob | Default | Why it stays in config |
|---|---|---|
providers |
opencode, opencode-go |
Route ids whose turns the plugin decorates; both planes are always included, so only a relay route needs adding |
gatewayUrls |
opencode.ai/zen, opencode.ai/inference |
URL substrings marking gateway traffic; only a mirror or relay changes them |
userAgent |
empty (= canonical CLI UA) | Literal override; the default is what the gateway expects |
originClient |
cli |
Literal x-opencode-client value |
sessionIdEnv |
OPENCODE_SESSION_ID |
Names an env var only a caller-supplied session id uses |
freeModelMarker |
free |
Model-id substring; * forces the fallback; a blank value inherits the default; catalog-free models always qualify |
usageBaseURL |
https://opencode.ai/zen/go/v1 |
Endpoint override; auto-discovered from the composition |
credentialOrg |
— | The org id an account credential belongs to, when the deployment supplies one out of band |
debug / debugFile |
false / — |
Diagnostic JSONL logging, not a behaviour anyone tunes in the UI |
# cordis.patch.yml — the plugin's row; every key is optional.
- insert:
- id: dsh-opencode-patch
name: "dsh-opencode-patch"
config:
providers: # both planes are always included
- opencode
- opencode-go
gatewayUrls:
- opencode.ai/zen
- opencode.ai/inference
sessionIdEnv: "OPENCODE_SESSION_ID"
freeModelMarker: "free"
usageBaseURL: "https://opencode.ai/zen/go/v1"
endpoint: "auto" # auto | legacy | modern
keySource: "auto" # auto | request | configured
injectUserAgent: true
injectOriginHeaders: true
originClient: "cli"
injectProject: true # attach workspace folder (or 'global'); false omits the header
injectCoreTools: true
enrichModels: true # catalog into the gateway /models listing + Settings model discovery
usageEnabled: true # off = no meter and no price row
# File-level only debug options:
debug: false
debugFile: "/tmp/dsh-opencode-debug.jsonl"Host reload rule: restart
dsh webafter any host change (lib/index.mjs); a browser refresh is enough for client UI changes (lib/client.js).
Disabling or removing the plugin hands both planes back to llm-pi-ai, with nothing to clean up first: the plane entries were never moved out of its providers block.
llm-pi-aiserves the ids it knows again, on its own adapter, endpoint family and reasoning rules.- Ids it cannot serve drop out of the composer. That is an id its installed catalog does not describe — a catalog-unknown id, or a newer model than its pi carries — including a model adopted from Settings' fetch that pi does not list.
- The next Settings edit of that plane is refused until they are removed, because
llm-pi-aivalidates the whole plane entry on every save. Delete those ids from the plane's model list, and the edit goes through.
OpenCode's own Console plugin (npx @opencode/console setup deepseek-harness) can serve opencode and opencode-go as well. Its setup puts llm-pi-ai's row in a credentials realm of its own, writes the workspace's routes into it with the workspace token (apiKeyEnv: OPENCODE_CONSOLE_TOKEN), and marks each route with an x-opencode-plugin header. This plugin cannot read that realm, so it steps aside instead of serving those routes:
llm-pi-aiserves every plane the Console writes, with the Console's credential. A plane is left to it whenllm-pi-ai's row resolves credentials in a realm this plugin does not (a group'sisolatecounts for every row inside it), or when the plane's entry carries thex-opencode-pluginheader. A plane entry of your own, unmarked and outside such a realm, is still served by this plugin.- Nothing of this plugin's touches that traffic. No session or origin headers, no User-Agent, no key fill, no key capture, no tool fallback, and the meter does not count those turns: a turn on such a plane goes out exactly as
llm-pi-aisent it, whether the realm or the header left it there. - The meter never reads a Console route. Its Go key and endpoint come from your own entries, or from
OPENCODE_GO_API_KEYand the default endpoint; a Console route's key reference, the token behind it and its base are never used, even with that token exported. - A Settings edit that adds or removes the header moves the route at once, with no restart: to
llm-pi-aiwhen it is added, back to this plugin when it is removed. Movingllm-pi-ai's row into or out of such a realm moves its routes the same way, with one restart ofllm-pi-ai. - The purge button leaves Console routes alone. It rewrites only plane entries of your own; with none it says there is nothing to purge and writes nothing.
- Settings → Models → Fetch on a Console route is
llm-pi-ai's own list. No catalog model is added to it, and whilellm-pi-airestarts the fetch fails asllm-pi-ai's own does rather than answering the catalog; a plane entry of your own beside it keeps the catalog's models. - The log says so once per route, naming the realm or the header:
… dsh-opencode-patch leaves the route opencode to pi-ai. - A shared key name is read by both. Only the credentials service is split into realms; the launch environment is not.
OPENCODE_API_KEY(orOPENCODE_GO_API_KEY) exported in the environment is read by this plugin's meter and your own planes, and byllm-pi-aifor a Console route that names no key of its own. Keep the workspace token under the name the Console's setup gives it, which this plugin never reads.
| Symptom | Likely cause | Fix |
|---|---|---|
403 FreeTierError on free models |
The gateway's gate is not in the request: measured with a real account, its session token, its active org and every header the official client sends, a free-tier model still answers 403 |
The three injection switches above are still worth keeping on — they are what makes the traffic identifiable and they restore the tool schemas — but they do not open the free tier on their own. The paid models work with a key; see docs/opencode-1.19-api.md for what was excluded and how |
400 MissingSessionID |
No session header attached | Ensure dsh-opencode-patch is listed in the profile's dsh.profile.bundles |
Auto Review fails with TRANSPORT: Connection error |
Host process not restarted since the update | Stop and restart dsh web so the new lib/index.mjs loads |
| Quota ring never appears for a Go model | Restart dsh web, then read the browser console — the plugin is loaded through a link:, so lib/index.mjs is read once at process start and a page reload alone keeps the old host code; the meter reports which of its paths returned nothing |
The console names the cause: no remote to mount on, did not mount with the exception, still waiting for remote.opencodeGoUsage, no opencodeGoUsage service on the remote with the keys it does expose, or the answer is not a usage shape with what came back. A missing or unroutable credential is one cause of several, not the only one: name apiKeyEnv: OPENCODE_GO_API_KEY on the opencode-go entry and store that key in DSH Credentials |
| A catalog model is missing from Settings → Fetch, or a retired one persists | Stale host module, enrichment off, the route is not a plane the plugin serves (only opencode/opencode-go on the served llm-pi-ai row are complemented, and never a route left to another plugin such as OpenCode Console), or the catalog has not been read yet (offline, or models.opencode.ai blocked), so pi's own list, retired ids included, passes through and models newer than the bundled catalog are absent |
pnpm run build, restart dsh web, keep Enrich listings from the OpenCode catalog on, fetch from the matching route, search the exact id (e.g. gpt-5.3-codex-spark); if the catalog is unread, then once models.opencode.ai is reachable fetch once to start the catalog refresh (that answer is still the old list — the refresh runs behind the read, and nothing runs it on a timer), then fetch again a moment later |
| Popover shows “Limit reached” in red | 100% of a rolling/monthly window reached | Open the OpenCode Console and enable Use balance to overflow into Zen credits |
| Non-OpenCode models misbehaving | Unrelated to this patch | Traffic to other providers passes through untouched |
- A smaller public API. The package's entry exports
apply,Config,injectandname— the names the host reads — and the config types (PluginConfig,ResolvedPluginConfig,EndpointFamily,KeySourcePolicy). Everything else it used to re-export is internal now;ModelOverrideConfigandModelRouteConfigwent with the settings they typed. ./src/*is no longer an export.src/was never in the published files, so the subpath resolved in the repository and nowhere else.- The plugin serves
opencodeandopencode-goitself, and mounts no hidden route.opencode-responses,opencode-anthropic,opencode-googleand theiropencode-go-*twins are gone: every model is served on its plane's own route, on the protocol its catalog row names.internalRouteswas removed first, and a row that still carries it validates and the key is ignored.providersnow names the routes whose turns the plugin decorates, and always includes both planes. - A model whose reasoning is only on or off offers Off / On. It used to offer the whole level ladder, every rung sending the same "on". A saved selection of Minimal, Xhigh or Max on such a model no longer resolves: pick On (or Off) again. Off is offered only where the model's protocol can turn reasoning off.
- Chat Completions moved to the inference family by default. Completions turns go to
https://opencode.ai/inference/openai/v1on Zen andhttps://opencode.ai/inference/go/openai/v1on Go, where Responses, Messages and Google already went.endpoint: legacykeeps every protocol on/zen/v1(/zen/go/v1on Go), except Go's Google, which stays on/inference/go/google/v1beta. - A Go plane without
apiKeyEnvauthenticates withOPENCODE_API_KEY. A plane's key now resolves byllm-pi-ai's own rule (D20), and pi's own OpenCode lookup readsOPENCODE_API_KEYon both planes. Go's Responses, Messages and Google models used to default toOPENCODE_GO_API_KEYwhen the entry named nothing (its Chat Completions models already took pi's rule), so anopencode-goentry withoutapiKeyEnvnow sends the Zen key on every protocol. The meter follows: it captures the key a Go turn carries and prefers it, so after the first Go turn it sends the Zen key to the Go usage endpoint, is refused, and falls back to the Zen pill or "subscription required". AddapiKeyEnv: OPENCODE_GO_API_KEYto the Go entry; a Go key stored only in DSH Credentials authenticates a Go turn only through that reference. - The catalog's one
@ai-sdk/mistralmodel is offered and served. It used to be withheld, since its dialect was unmeasured; it is now sent as Chat Completions on the vendor's OpenAI root, the plugin's own rule for that SDK (the catalog's family table places it there). Which dialect the gateway serves for it is still not measured. - Any saved id is served. A model the catalog does not describe is accepted when you save it, and sent as Chat Completions; it used to be refused at save time.
- The keyless listing filter is gone. A plane whose key does not resolve lists its declared models, and the turn names the missing credential, rather than hiding the priced models.
modelOverrides,maxContextWindowandmodelRouteswere removed (and the documentedhiddenModels, which nothing ever read). A model's own row in the plane's entry carries its name, context window, output limit, input modalities and reasoning efforts, and the catalog fills the rest; a row'scontextWindowis the stand-in formaxContextWindow. A row that still carries the removed keys validates, and the keys are ignored.- No per-model protocol pin.
modelRoutescould send one model on a protocol of your choosing; nothing replaces that. A model the catalog describes always speaks the protocol its SDK names; the plane entry's route-levelapiapplies only to ids the catalog does not classify, andbaseURLis route-level only. Cost is not a row field either: it comes from the catalog, then pi. - Disabling or reloading the plugin restores the host methods it wrapped. Model discovery and the Settings graft come off on a disable, and a live reload replaces them instead of stacking another layer. The planes go back to
llm-pi-ai— see Removing or disabling the plugin. - Settings → Models → Fetch offers the catalog's models pi does not list. With Enrich listings from the OpenCode catalog on (the default), on both planes it answers pi's list first, then every servable, non-retired catalog model pi lacks (e.g. Zen
gpt-5.3-codex-spark), and whilellm-pi-airestarts it answers the catalog alone instead of an error. It used to answer pi's list only. A row it offers on a plane now carries the catalog's name, context window, output limit and modalities, and pi's only where the catalog has none, so a model adopted from it saves what the composer serves: adopting Zenclaude-sonnet-4-5saves a 1,000,000-token context window where it used to save pi's 200,000. With the switch off, Fetch answers pi's list alone, and a restart showsllm-pi-ai's error. - The Google thinking control is the host pi's own. Whether a Gemini request carries a thinking level or a token budget is decided by the host's pi, and the plugin re-derives nothing. The rewrite that sent
gemini-4-argona level on hosts whose pi does not name it, released in 1.4.3 (#130), is removed. Every supported host's pi, on npm installs and on both channels' desktop apps, is1.3.0or later and names that model, so its turns carry the level; on a pi1.1.0host (the0.2.1rc desktop app, no longer a supported channel build) everygemini-4-argonturn carried a thinking budget the gateway refuses. @viztor/dsh-opencodeis deprecated, and still published. It is a thin wrapper that re-exportsdsh-opencode-patch, so an install on the old name keeps resolving; the registry marks it deprecated and points at the canonical name. New installs should usedsh-opencode-patchor@viztor/dsh-opencode-patch.- A route another plugin owns is left to it. A plane entry in
llm-pi-ai's row is served byllm-pi-ai, not by this plugin, when that row resolves credentials in a realm this plugin does not or the entry carries thex-opencode-pluginheader — the shape OpenCode Console's setup writes — and no header, key or meter of this plugin's touches its traffic. In the released 1.x, a turn on such a route that the plugin moved onto one of its hidden Responses, Messages or Google routes was resolved with this plugin's credentials and failed for want of the Console's token, and every other turn on it carried this plugin's headers and had its key recorded for the meter; see With OpenCode Console installed.
Supported: the latest release of both DeepSeek Harness channels, rc and alpha, installed with npm or shipped in the desktop app (Node 24+)
Support policy. The plugin supports the latest release of both channels, as each one actually ships: rc is the newest build on the rc line, which the harness publishes under the next dist-tag ahead of dsh@latest (today 0.2.2-rc.2 on next, while latest is 0.2.2-rc.1; its bridge declares pi ^1.3.0), and alpha is dsh@alpha (today 0.2.2-alpha.3, pi ^1.3.0). Both run the same host core, cordis 4.1.0 on rc and 4.0.6-alpha.2 on alpha; this plugin's plane routing speaks it and the older core of the 0.2.1 builds alike, telling them apart from each configuration call's own shape, never from a version. An npm install runs pi unpatched, at the newest 1.x the bridge's range admits; the desktop app ships the pi the harness's own workspace pins (1.3.0 on both channels). CI runs the unit and offline mock suites on each channel's cluster with pi as npm resolves it (the cordis family at the versions the harness vendors) on every pull request, and on each channel's floor pi nightly; the published binary as npm installs it, cordis floating, runs in the e2e-host job. The plugin neither patches nor re-implements what the host's pi does: where a host's pi lacks something the gateway needs, that is a host limitation, recorded and reported upstream rather than worked around here.
The harness publishes two channels, alpha and rc, and they are parallel tracks rather than a version ordering: semver sorts 0.2.1-rc.2 above 0.2.1-alpha.5. The plugin targets two lines on both channels: 0.2.1, which both channels have moved past and the peer range still admits, and the 0.2.2 prereleases on both channels: alpha is at 0.2.2-alpha.3, and rc at 0.2.2-rc.2 (published under the next dist-tag; latest carries 0.2.2-rc.1). The test suite's default install runs 0.2.2-alpha.3; its rc install runs 0.2.2-rc.2. The @deepseek-ai/dsh-typert-protocol peer range says so exactly:
>=0.2.1-alpha.1 <0.3.0 || >=0.2.2-0 <0.3.0
The first clause covers the 0.2.1 line on both channels: its lower bound (alpha.1) sorts below every rc of the same line, so rc needs no separate entry. The second is not redundant — semver excludes prereleases unless a comparator in the same major.minor.patch tuple carries one, so <0.3.0 alone would silently drop every 0.2.2-* build, alpha and rc alike. Both clauses stop at 0.3.0, which leaves the next line a separate decision rather than a silent widening.
The optional @deepseek-ai/cordis peer follows the typert range. Each line moved the host core: the 0.2.1 builds peer ~4.0.5 (or a 4.0.5 prerelease), the 0.2.2 alphas a 4.0.6 prerelease, and the 0.2.2 rcs ~4.1.0. A fresh install takes the cordis the newest admitted typert build peers on, so a cordis peer that misses it fails with ERESOLVE; one that misses an older line's cordis fails beside a host already installed on that line. The peer therefore admits every one of them and stops at the next tuple:
~4.0.4 || >=4.0.5-0 <4.1.0 || >=4.0.6-0 <4.1.0 || ~4.1.0
Host limitations. What a supported host lacks today, recorded by the test suite as [HOST_LIMITATION] rows (detected from the host's own answer, never from a version) and reported upstream:
- A route-level
api: google-generative-aionalpha. alpha's bridge validates a route'sapiagainst the three protocols it serves on a route, Google not among them, so a plane entry that names it is refused; rc's bridge serves it. A route-levelapiapplies only to ids the catalog does not classify, so every catalog model is unaffected.
No known defect of this plugin's is open on a supported host: both channels' latest builds (cordis 4.0.6-alpha.2 on alpha, 4.1.0 on rc) hand configuration hooks a new shape, which the plane routing did not speak in its first cut, leaving both planes on pi-ai's own routing there; it speaks both shapes now, and the suite asserts on every host cell that both planes are this plugin's. Both builds save settings, which 0.2.2-alpha.2 as npm installed it could not, and both floors' pi (1.3.0) sends Gemini 4 its thinking level, which the 0.2.1 rc desktop app's pi 1.1.0 did not.
| Surface | Target |
|---|---|
| Harness line | 0.2.1 and the 0.2.2 prereleases, alpha and rc channels alike (peer: the range above) |
| Host core | @deepseek-ai/cordis 4.0.4 up to 4.1.x, including the 4.0.5 and 4.0.6 prereleases (optional peer: the range above) |
| Plugin package | dsh-opencode-patch |
| Host profile | DSH Web profile (patchReload: live) |
| Routes claimed | opencode, opencode-go — served by the plugin's own adapter; llm-pi-ai keeps every other route |
| Gateways | opencode.ai/inference/{openai,anthropic,google}/… and inference/go/{openai,anthropic,google}/… by default; opencode.ai/zen/v1 and zen/go/v1 with endpoint: legacy (Go's Google stays on inference/go/google/v1beta) |
| Catalog | Generated from models.opencode.ai/api.json; retirement follows the registry's own status: "deprecated" flag, with no hand-maintained id list |
| Upstream pi | Not bundled, not patched. The plane adapter loads whatever @earendil-works/pi-ai the harness installed, so a harness bump never skews a pinned copy; the suite runs each channel's pi as npm resolves it, and its floor |
| Verification gate | vp check clean, the deterministic unit and offline gateway-mock suites green, full schema validation, consumer install + load (scripts/check.ts) |
The header-injection matrix, session lineage, workspace attribution, API tiers and catalog internals live in docs/deep-dive.md. This README stays at the level of what you see and what you configure.
Inspired by nobu121/dsh-opencode-session by @nobu121, which pioneered session ID handling for OpenCode on DSH. This plugin is a separate implementation by @viztor: it took the idea and wrote the rest — Zen free-tier gateway compatibility, hierarchical subagent lineage, dynamic workspace project attribution, live dual-mode Go quota and Zen credit monitoring, and native Web UI integration.
Links: npm · Repository · Issues · Changelog · Contributing · OpenCode · models.dev
Licensed under the MIT License.