DSH plugin: fixing OpenCode Go's 400 MissingSessionID

TroubleshootingPublished 2026-10-03Author: DeepSeek Plugin Market
DeepSeek HarnessDSHOpenCode Gollm-pi-aix-opencode-sessionmodel catalogMissingSessionIDprompt caching
opencode-go returns 400 MissingSessionID on every request, and deepseek-v4.1-flash never shows in the selector because pi-ai sends no x-opencode-session.

After adding an opencode-go route in llm-pi-ai, you hit two defects at once: every request is rejected with 400 {"type":"MissingSessionID"}, and deepseek-v4.1-flash is missing from every model selector. The first root cause is clean — OpenCode Go requires requests to carry an x-opencode-session header for per-session routing and prompt cache affinity, while pi-ai 0.85.1's shipped dist does not even contain that string: its only use of sessionId is the opt-in x-session-affinity, gated by compat.sendSessionAffinityHeaders, which the opencode-go catalog entry never sets (#6224). The second is stale data: models.dev registered the model long ago, but the selector is built entirely from the installed catalog, and 0.85.1's opencode-go.json does not have it (#6224). The good news is both can be worked around without forking, and one of them — "do not split the route" — is backed by measured cost: splitting pollutes the billing bucket and blinds plugins that probe quota by exact id.

Triage first: two defects on one route, but the fixes do not overlap

First confirm which one (or both) you have, because their fixes do not overlap at all.

CriterionDefect one: MissingSessionIDDefect two: model not selectable
Symptom400 on every requestthe model is absent from the selector
Error textRequest is missing x-opencode-session…no error, it is just invisible
Scopeall models on the routeonly the off-catalog model
Key/network related?nono
Key locationthe session-header logic in dist/api/openai-completions.jsdist/providers/data/opencode-go.json
One-line triage commanda direct curl control (below)count ids from GET {baseURL}/models

Step one, use a direct control to separate "the gateway requires it" from "the adapter does not send it". This is the most convincing measurement in the whole discussion — the same key, the same UA, changing only whether the header is present:

bash
# without the session header: 3/3 all 400
curl -s -X POST https://opencode.ai/zen/go/v1/chat/completions \
  -H "authorization: Bearer $OPENCODE_GO_API_KEY" \
  -H "content-type: application/json" \
  -d '{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}]}'
# 400 {"type":"MissingSessionID","message":"Error from provider (Console Go): Request is missing x-opencode-session and cannot be routed efficiently. Please see https://opencode.ai/docs/go/#where-can-i-use-it"}
bash
# with a stable, per-session opaque value: 3/3 return 200 and a real completion
curl -s -X POST https://opencode.ai/zen/go/v1/chat/completions \
  -H "authorization: Bearer $OPENCODE_GO_API_KEY" \
  -H "content-type: application/json" \
  -H "x-opencode-session: $ANY_STABLE_OPAQUE_VALUE" \
  -d '{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}]}'

Conclusion: this header is the only discriminating variable, and it requires no particular format — any stable, per-session opaque value is accepted. That also means the fix need not fuss over "the generation algorithm"; just guarantee "same value for the same session, different values for different sessions".

Step two, confirm the data gap on the model side. Ask the gateway for its own model table, then compare it with the installed catalog:

bash
curl -s -H "authorization: Bearer $OPENCODE_GO_API_KEY" \
  https://opencode.ai/zen/go/v1/models | jq '.data | length'
# 37

The installed catalog has only 27, and deepseek-v4.1-flash is not among them — that is the entire reason it cannot be selected.

Defect one: the session header is never sent (it is not that it fails the adapter, it is that the adapter never writes it)

The key conclusion of this section: the session identity has already been fed into pi-ai; it is the pi-ai side that has no code to consume it — because the opencode-go catalog entry does not turn that switch on.

1. The upstream chain

  • The host forwards session identity into pi-ai: packages/llm/llm-pi-ai/src/adapter.ts:380-388;
  • Dependency declaration: packages/llm/llm-pi-ai/package.json:44 says ^0.85.1, and 0.85.1 is what is actually installed;
  • But in pi-ai 0.85.1's shipped dist, x-opencode-session appears 0 times;
  • pi-ai's only use of sessionId is the opt-in x-session-affinity, gated by compat.sendSessionAffinityHeaders (dist/api/openai-completions.js:557, dist/api/anthropic-messages.js:725);
  • dist/providers/data/opencode-go.json has no entry declaring this compat flag.

Incidentally, the host's own compat gating even withholds the flag for profile entries (packages/llm/llm-pi-ai/src/catalog.ts:255). So a 400 on the opencode-go route is inevitable, not intermittent.

2. A corroborating point: this is "one adapter family apart"

The official dsh-llm-deepseek adapter does stamp session identity — x-deepseek-harness-session-id, in the artifact at lib/index.js:1666 — while the pi-ai adapter stamps nothing. So what is missing is not capability but the implementation on this one path.

3. Version status: next does not fix it either

Extract the published @deepseek-ai/dsh-llm-pi-ai@0.1.5-rc.2 tarball and diff it byte-for-byte against the installed adapter: completely identical. Meanwhile @earendil-works/pi-ai 0.85.1 (npm latest) does not contain the string x-opencode-session at all. Upgrading solves neither of these defects.

4. Fix one (no code change, easiest): hardcode a static header in the profile

dsh-llm-pi-ai passes a route profile's headers through verbatim into pi-ai's stream options (compiled artifact lib/index.js:1873, source config.ts:150-151), and pi-ai merges them into the client's default headers (merge point at adapter.ts:386-388). So:

yaml
llm-pi-ai:
  providers:
    opencode-go:
      apiKeyEnv: OPENCODE_GO_API_KEY
      headers:
        x-opencode-session: <a stable uuid, one per machine>
        x-deepseek-harness-session-id: <the same uuid>   # DSH's own native header

Three measured readings: 400 without the header (3/3); 200 with a stable per-session value (3/3); running through the harness itself (dsh --profile headless, with that route set as the agent's default model) gives dsh: INVALID_REQUEST: 400: {"type":"MissingSessionID",…} before the patch and ok after.

The cost must be stated clearly: all sessions share a single affinity id, so per-session prompt-cache routing is gone. If your use case is a single long-running session, that loss is acceptable; if it is many sessions in parallel, prefer fix two.

5. Fix two (the correct shape): derive the header at the adapter boundary

The session identity actually already reaches pi-ai's stream options, stamped at three call sites (verified on the installed package):

Call siteLocation
main loopdsh-agent-loop lib/index.js:1215
compactiondsh-compaction-basic :298
session titledsh-session-title-llm :215
session title (first prompt)dsh-session-title-first-prompt-llm — the only one missed

So the fix can live entirely at the adapter boundary: wrap the headers handed to streamSimple, add x-opencode-session when the route points at OpenCode, and let explicitly configured headers take priority case-insensitively.

The reference implementation (two commits, with tests) is on the reporter's fork branch, and its shape is the kind upstream should adopt:

text
https://github.com/mohamed-bashir-dev/deepseek-harness/compare/master...mohamed-bashir-dev:deepseek-harness:fix/llm-pi-ai-opencode-go

The adapter spec tests cover four cases: send the derived header on opencode-go, do not send it on other providers, deployment-configured overrides are case-insensitive, and do not send when the request has no session id.

6. Fix three (a user-level local patch): edit the artifact directly

Put the guard in the packaged …/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js (i.e. the adapter boundary layer). But note: npm i -g @deepseek-ai/dsh rebuilds this directory tree, so you must set up an idempotent re-patch script. The honest costs of this road: unsupported, must be re-patched on every upgrade, and it does not fix defect two.

7. A pitfall you must remember: later you have to delete the static header

pi-ai's main already has withOpenCodeSessionHeader in src/providers/opencode-headers.ts, and it uses hasHeader() to short-circuit. That is:

text
Once some pi-ai version builds in the derivation logic:
  the static x-opencode-session you hardcoded in the profile "gets seen first" → the derivation short-circuits and does not run
    → the real per-session id is shadowed by the hardcoded value
      → you lose the routing/cache benefit you added this header for in the first place

So after upgrading pi-ai, the first thing to do is delete the static x-opencode-session from the profile.

Defect two: the model is not in the catalog, and why "do not split the route"

The key conclusion of this section: the model being unselectable is purely stale data; the right place to add the data is the catalog, not a new route.

1. Why it is not in the selector

The selector is built entirely from the installed catalog (catalog.ts:1-16 imports exactly that JSON), and dist/providers/data/opencode-go.json does not contain deepseek-v4.1-flash, so it cannot appear anywhere.

2. Fix one: describe it yourself under the profile's models

A profile models entry can describe a model the installed catalog does not know (catalog.ts:1-6, resolved at catalog.ts:893-930). Copy the fields from the deepseek-v4-flash-vision-exp opencode-go entry and change id / name / cost. This is the most recommended approach: it does not touch host files and survives upgrades.

3. Fix two: add an entry to the installed catalog

Path and content:

text
node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json

Under openai-completions, add a clone of deepseek-v4-flash with the fields adjusted:

jsonc
{
  "id": "deepseek-v4.1-flash",
  "name": "DeepSeek V4.1 Flash",
  "api": "openai-completions",
  "baseUrl": "https://opencode.ai/zen/go/v1",
  "reasoning": true,
  "thinkingLevels": ["low", "high", "max"],
  "thinkingFormat": "deepseek",
  "cost": { /* adjust as needed */ },
  "limit": { "context": 1000000, "output": 384000 }
}

Two pitfalls: the file must be UTF-8 without BOM (a BOM makes JSON module parsing fail); and the patch is rebuilt away by a global reinstall, so an idempotent re-patch script is needed.

4. Why not split the route (measured cost)

"Open another opencode-go-v41 route and hardcode its route-level api" does make the model selectable, but the shape is wrong: the provider route id is the key for billing and settings. Two side effects measured:

Side effectBehavior
Billing buckets split apartthe usage ledger records the same model under two provider buckets: opencode-go-v41/deepseek-v4.1-flash separately at 21 calls and 177k input tokens, apart from opencode-go/*
Quota plugins go blind@linxin666/dsh-usage, which probes plans by exact id (ids: ['opencode-go']), shows no quota row on the new route, even though the quota itself is account-level

Adding the entry into the installed catalog is what keeps "one route, one bucket".

5. Good news: upstream will add it automatically

pi-ai regenerates this catalog from models.dev at publish time, and models.dev's opencode-go provider already lists deepseek-v4.1-flash:

text
tool_call: true
reasoning: true
context: 1000000
output: 384000
provider.npm: unset  ⇒ goes through openai-completions
last_updated: 2026-09-10

So the next @earendil-works/pi-ai version is expected to carry it automatically, and the local catalog patch is only transitional. (The pi-side report is earendil-works/pi#9737, auto-closed under that repo's new-contributor policy but still entering daily maintainer review.)

6. Measured after merging

After applying both patches and restarting dsh:

  • a single opencode-go route now normally offers deepseek-v4.1-flash alongside the original 27 models;
  • the first usage segment: 12 calls, 252k input / 17k output / 2.7M cache read — prompt caching is healthy;
  • the OpenCode Go plan probe still correctly reports the rolling / weekly / monthly windows on that route.

Going deeper: the discovery short-circuit and the structural block on per-model api

This section explains "why patches are always needed", and what is still missing to truly close the loop on "discoverable means usable".

1. The discovery logic never asks the gateway

packages/llm/llm-pi-ai/src/discovery.ts:273-285: when the route already has a catalog, discoverModels() returns catalogModels(provider) directly and does not touch the network at all. So for opencode-go, GET {baseURL}/models (37 ids at the time) is never consulted and the catalog has only 27 — every future new OpenCode model needs another catalog patch plus another guard test.

2. The shape of a patch that skips the short-circuit only on OpenCode routes

What the local patch does (a shape worth upstream's attention):

  1. Skip the short-circuit only for OpenCode routes and query {baseURL}/models;
  2. Send the list as openai-completions; the baseUrl must be taken from "the catalog entry whose api is openai-completions", not the first entry — anthropic-protocol entries have a base without /v1 and would 404;
  3. For ids the catalog knows, keep the catalog's capacity data;
  4. Fall back to the catalog list when the endpoint is unreachable;
  5. Leave every other provider untouched.

Measured result after merging: 37 ids, deepseek-v4.1-flash present, and deepseek-v4-flash still at 1000000 / 384000.

3. But "discoverable" does not yet imply "usable"

Even with a live query on the discovery side, a structural block remains: in resolveRouteModels() each entry's protocol is resolved as

ts
request.api ?? base?.api ?? routeApi

(catalog.ts:888), and a route-level api overrides the model's own protocol. So on the multi-protocol opencode-go route, declaring an off-catalog model changes the protocol of its anthropic-messages and openai-responses models as well; and an off-catalog id without a route-level api fails outright with "needs an api" (catalog.ts:889-892).

What would truly close the "discoverable means usable" seam is a profile schema that supports per-model api, or allowing a catalog route to carry extra entries. Until then, users either open a second route (cost above) or keep patching the catalog.

Troubleshooting notes

  1. Use a direct curl for single-variable control first, before suspecting DSH. Measured, the header is the only discriminating variable, and 3/3 vs 3/3 is crisp.
  2. Do not treat the 400 as a key or network problem. The text clearly says MissingSessionID, and it is independent of UA and of the model (every model on the route is hit).
  3. Watch for the intermediate state of "session identity delivered but nobody consumes it". The host really does forward it (adapter.ts:380-388); it is the pi-ai side that has no consumer — do not go hunting inside the host.
  4. A static header is a stopgap, not a fix. It sacrifices per-session prompt cache affinity; and once pi-ai builds in the derivation logic you must delete it, or the short-circuit costs you the benefit.
  5. Pair artifact edits with an idempotent re-patch script: npm i -g @deepseek-ai/dsh rebuilds the node_modules tree.
  6. Do not split the route just to get one model. Measured, it splits the billing bucket and hides quota from plugins that probe by exact id.
  7. Watch for BOM when editing the JSON catalog. UTF-8 with a BOM makes JSON module parsing fail.
  8. Prefer the profile's models over editing host files: the former survives upgrades, the latter needs re-patching every upgrade.
  9. After upgrading, revisit the profile: once pi-ai builds in the session-header derivation, remember to delete the static x-opencode-session.
  10. The reference implementation's shape is correct: derive at the adapter boundary from GenerateOptions.sessionId, explicit configuration wins case-insensitively, and do not send when there is no session id — these three points are worth adopting as acceptance criteria.

When integrating a third-party model service, the real time sink is usually not "swap in an API base URL" but confirming whether implicit contracts like session identity, cache affinity, and billing attribution are passed down through every layer. DSH Plugin Hub provides five screens: plugin market, installed list, custom install, settings, and system logs. The installed list labels each plugin's source, version, and update time and can locate its install directory directly; the system log page keeps install, uninstall, and diagnostic trails by category and level, with full-text export. When troubleshooting routing and adapter issues, using it to align environment and versions first saves far more effort than diving straight into config edits.

DSH Plugin Hub · Plugin market: search, sort by name/recent/stars/forks, and filter by all/installed/not installed

Source: Discussion #6224, Discussion #5654, reference implementation branch fix/llm-pi-ai-opencode-go, pi-side report earendil-works/pi#9737.

FAQ

Why is every request on the opencode-go route a 400 `MissingSessionID`?

Because the request never carries x-opencode-session. OpenCode Go uses that header for "per-session routing + prompt cache affinity" and rejects outright when it is missing; and pi-ai 0.85.1's shipped dist **does not even contain that string** — its only use of sessionId is the opt-in x-session-affinity, gated by compat.sendSessionAffinityHeaders, and the opencode-go.json catalog entry sets no such compat flag. So a 400 on this route is inevitable — it is not your key or your network.

Without changing code, can I make the 400 go away?

Yes, three ways. ① Easiest: hardcode a static header in that route's profile, headers: { x-opencode-session: <a fixed value> }, and requests go through immediately. The cost is that **all sessions share one affinity id**, so per-session prompt-cache routing is gone. ② More correct: wrap a layer at the adapter boundary, deriving the header from GenerateOptions.sessionId (the main loop, compaction, and session title all already carry session identity), and let explicitly configured headers take priority case-insensitively. ③ Or wait for a new pi-ai release — but note that once it is built in, you must **delete** the hardcoded static header, or the hardcoded value will shadow the real per-session id.

Why is `deepseek-v4.1-flash` completely invisible in the model selector?

Because the selector is built entirely from "the installed catalog": catalog.ts imports exactly dist/providers/data/opencode-go.json, and that file does not contain this model at all. models.dev registered it long ago, but pi-ai's catalog is generated at publish time and the 0.85.1 version lags models.dev. Three ways to patch it: describe a "model the catalog does not know" yourself under models in the profile (catalog.ts:1-6, resolved at catalog.ts:893-930); or add an entry directly to the installed opencode-go.json; do not use "open a second route", for the reason below.

Can't I just open a second route (say opencode-go-v41)? I can select the model.

Yes, but the shape is wrong. The provider route id is **the key for billing and settings**: measured, once you split the route, the usage ledger records the same model under two provider buckets (opencode-go-v41/deepseek-v4.1-flash separately at 21 calls and 177k input tokens, apart from opencode-go/*), and plugins that probe plan quota by exact id (@linxin666/dsh-usage, ids: ['opencode-go']) **show no quota row** on the new route — even though the quota is account-level. Adding the entry into the installed catalog keeps one route and one bucket.

What pitfalls are there when patching the installed catalog?

Two. ① The file must be **UTF-8 without BOM** — a BOM makes JSON module parsing fail; ② the patch gets rebuilt away by npm i -g @deepseek-ai/dsh (an upgrade swaps in a fresh runtime directory), so either set up an idempotent re-patch script or prefer a profile-level models override. The good news is that pi-ai regenerates the catalog from models.dev at publish time, and models.dev's opencode-go **already lists** deepseek-v4.1-flash (tool_call: true, reasoning: true, context 1000000, output 384000, provider.npm unset ⇒ openai-completions), so the next pi-ai version is expected to carry it automatically; the local patch is only transitional.

Related Terms

x-opencode-session
The request header OpenCode Go uses for "route requests per session + do prompt cache affinity". When missing, the gateway returns `400 MissingSessionID` outright. It requires no particular format — measured, any **stable, per-session opaque value** is accepted, so the point is not the generation algorithm but that "the same session sends the same value, different sessions send different values".— https://github.com/deepseek-ai/deepseek-harness/discussions/6224
sendSessionAffinityHeaders (compat switch)
The opt-in flag in pi-ai controlling "should `sessionId` be mapped to a session-affinity header". pi-ai 0.85.1 recognizes only this one session-related switch, and the `opencode-go.json` catalog entry does not declare it, so on the opencode-go route `sessionId` is ignored entirely — the direct reason the adapter "has the session identity but does not send it".— https://github.com/deepseek-ai/deepseek-harness/discussions/6224
discovery short-circuit (catalogModels returned directly)
`discovery.ts`'s behavior of returning `catalogModels(provider)` directly and not touching the network at all when the route has a catalog installed (`discovery.ts:273-285`). The consequence is that `GET {baseURL}/models` (37 models at the time) is never consulted while the catalog has only 27 — so every new model needs another catalog patch.— https://github.com/deepseek-ai/deepseek-harness/discussions/6224
route-level api overriding the model-level api
In `resolveRouteModels()` each entry's protocol is finally resolved as `request.api ?? base?.api ?? routeApi` (`catalog.ts:888`), meaning **a route-level api overrides the model's own protocol**. On the multi-protocol `opencode-go` route, declaring an off-catalog model changes the protocol of that route's anthropic-messages and openai-responses models as well; and an off-catalog id without a route-level api fails outright with "needs an api" (`:889-892`).— https://github.com/deepseek-ai/deepseek-harness/discussions/6224

Sources