Skip to content

fix(opencode-go): send the session header Go now requires, add DeepSeek V4.1 Flash - #213

Merged
bman654 merged 3 commits into
bman654:mainfrom
TweedBeetle:feat/opencode-go-session-header
Sep 12, 2026
Merged

fix(opencode-go): send the session header Go now requires, add DeepSeek V4.1 Flash#213
bman654 merged 3 commits into
bman654:mainfrom
TweedBeetle:feat/opencode-go-session-header

Conversation

@TweedBeetle

Copy link
Copy Markdown
Contributor

What this changes for users

OpenCode Go models did not work through clodex: every request came back with MissingSessionID, because Go now refuses any request that does not carry a session id. Clodex now sends Claude Code's session id on every Go request, on both of Go's wire protocols and in both proxy and API-server modes, so Go models answer again. The Go catalog is also refreshed from the current models.dev feed, which adds DeepSeek V4.1 Flash and picks up the pricing changes Go made since August.

Problem and root cause

Reachable by anyone with an OpenCode Go key and a Go favorite. I measured this on 2026-09-11 with a live key against https://opencode.ai/zen/go:

  • /v1/chat/completions without the header: {"type":"error","error":{"type":"MissingSessionID","message":"Error from provider (Console Go): Request is missing x-opencode-session and cannot be routed efficiently. ..."}}
  • the same request with x-opencode-session: <uuid>: a normal completion.
  • /v1/messages behaves the same way (measured earlier the same day through the proxy with deepseek-v4.1-flash).

Go accepts any non-empty value (a UUID, abc123, a prefixed UUID all worked) and also accepts Claude Code's X-Claude-Code-Session-Id as an alias on Chat Completions. Clodex never sent either on the Go paths, so no Go model could answer.

The change

Commit 1 (fix(opencode-go)): a helper openCodeGoSessionHeaders(model, claudeSessionId) in src/data/opencode-go-models.ts, called on the four paths that reach Go:

  • src/proxy.ts Anthropic Messages passthrough (extraHeaders on relayAnthropicMessages),
  • src/proxy.ts SDK route (params.headers, which streamText/generateText take per call; SdkCallParams gains the field),
  • src/server/router.ts Anthropic Messages passthrough,
  • src/server/router.ts SDK route.

Claude Code's session id is used when the request carries one (body metadata or X-Claude-Code-Session-Id); otherwise one UUID minted per process, so Go always has something stable to route on. A model counts as Go by providerId, by the trimmed-fork sourceBackend spelling, or by pointing at the Go base URL, so a custom endpoint aimed at Go is covered too. Non-Go routes are untouched.

Commit 2 (feat(opencode-go)): deepseek-v4.1-flash added to the transport map as Anthropic Messages (it answers there with a thinking block plus text), catalog regenerated from the 2026-09-11 feed. The feed also ended the "(2x usage)" promotional pricing (GPT-5.6 Luna is now $0.20/$1.20 per M, cache read $0.02) and renamed DeepSeek V4 Pro to "DeepSeek V4 Pro (New)". The tripwire literals in tests/opencode-go.test.ts and tests/registry-refresh-models.test.ts are updated to those values on purpose.

Left out: nothing is done about deepseek-v4-pro / deepseek-v4-flash now answering 403 ... only available hosted in China and requires explicit opt in unless the workspace opts in; that is a Go account setting, not a clodex matter.

Evidence

  • pnpm typecheck && pnpm test && pnpm build: 2564 tests, 116 files, under CLODEX_HOME="$(mktemp -d)", node v22.23.2, no ambient proxy vars.
  • Feature-deletion mutation, full file: with the helper returning undefined, 7 of the 8 tests in tests/opencode-go-session-header.test.ts fail; with only the proxy SDK call site removed, exactly that path's test fails.
  • Reachability stated above.
  • Manual smoke through the proxy on Claude Code 2.1.268 (isolated CLODEX_HOME, this branch's dist): --model haikuHAIKU-OK (passthrough), --model flash (deepseek-v4.1-flash, Messages) → FLASH-OK, --model clodex:opencode-go:glm-5.2 (Chat Completions) → GLM-OK. OpenAI OAuth and OpenAI API-key routes are not touched by the diff and were not re-smoked here beyond the suite.
  • Commit summary lines read as release notes.

Tests: tests/opencode-go-session-header.test.ts drives the proxy and the API server against a local listener for the Messages paths and asserts the exact header value; the SDK paths assert params.headers on the mocked adapter; a non-Go SDK route asserts no header. The helper's model-detection branches and the per-process fallback are covered directly.

Not verified: the sourceBackend: 'opencode-go' spelling and the base-URL match are exercised in unit tests only; I do not have a registry entry of either kind.

Failure and rollback behavior

If Go ever stops requiring the header, the extra header is harmless. Nothing persisted in ~/.clodex changes; the regenerated catalog has the same fields as before. Reverting commit 2 alone restores the August catalog and the old tripwire values.

@bman654 bman654 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the wire evidence in the description (MissingSessionID with and without the header, on both protocols, against a live key) is exactly what we need for a provider we cannot exercise ourselves, and the catalog regeneration reproduces the committed JSON byte-for-byte from the public feed, so every changed tripwire literal checks out as a real upstream change. Two things need fixing before this can go in, plus a couple of smaller ones.

Must fix

1. The API server's /openai/v1/chat/completions route is still unpatched — for all 18 Go models.

handleOpenAIChatCompletions in src/server/router.ts has two branches and neither adds the session header: the raw relay for the 13 OpenAI-format models passes only model.headers as extraHeaders, and the SDK-translation branch for the 5 Anthropic-format models never receives session headers in its params. That route is advertised to OpenAI-compatible clients, so it's production-reachable, and every Go request through it still fails the way the description reports. A behavioural test against a local upstream confirms no header arrives on the wire.

The doc line this PR adds — "Clodex sends Claude Code's own session id on every Go request (proxy and API-server modes, both wire protocols)" — is therefore not true yet for that route. Once it's patched, please add a test for each branch alongside the existing four.

2. The base-URL detection reads a field production doesn't populate.

openCodeGoSessionHeaders checks baseUrl ?? apiUrl, but the runtime ServerModelInfo (src/server/models.ts, built in src/provider-catalog.ts) stores the OpenAI-SDK URL as apiBaseUrl. Canonical entries still match via providerId, but imported/migrated providers whose id drifted — a shape src/registry/resolve-template.ts explicitly supports — carry the drifted id at runtime, leaving the URL as the only signal, and it isn't consulted. A test built on the production shape (apiBaseUrl set, drifted providerId) fails; the existing test supplies apiUrl and a canonical id, so it can't see this.

While you're there: the sourceBackend arm is redundant with providerId in the only production builder and has no second use case. This repo's rule is not to generalise until there is one — please drop it. The base-URL arm is justified once it reads the right field.

Should fix

  • The SDK-route tests don't pin the wire. They mock model creation and the generation call, so they assert params.headers rather than what leaves the process. Overwriting the header with undefined inside src/sdk-adapter.ts's three generation calls passes the whole suite (2564/2564). The implementation is correct by construction today (each call spreads ...params) and your live GLM smoke is stronger evidence than we can produce, so this is a test gap, not a defect — but a local-listener test through the real @ai-sdk/openai-compatible path would close it.
  • The credential probe in src/provider-templates.ts treats MissingSessionID as inconclusive. Whether Go validates the key before checking for a session, nobody here has observed — so either tell us what an invalid key with no session returns, or add the header to the probe so it can't matter.

Notes, no action needed

  • The per-process UUID fallback means every session-less client of a shared clodex server presents the same value to Go. That allows correlation on Go's side; it does not disclose content, and it's the reasonable choice given the constraint. Worth a sentence in the doc.
  • Sending Claude Code's session id to Go on the SDK route is a new disclosure (on the Messages passthrough it was already in the forwarded metadata). It's a validated UUID with no identity text. Fine, just noting it's new.
  • Verified: helper-deletion fails 7/8 tests as you said; each patched call site has its own failing test; deepseek-v4.1-flash on Messages is consistent with its siblings; gate green (2564 tests, node 24, isolated CLODEX_HOME).

What we're taking on your word, since we have no Go key: the wire responses and the live smoke. That's fine — you've documented them well.

…quest

OpenCode Go rejects any request without `x-opencode-session` — both
`/v1/messages` and `/v1/chat/completions` answer `MissingSessionID: Request is
missing x-opencode-session and cannot be routed efficiently` (measured
2026-09-11 with a live key; the same request with the header succeeds). Every
Go model was therefore unusable through clodex.

Forward Claude Code's own session id on all four Go paths: the proxy's
Anthropic Messages passthrough and its SDK route, and the API server's
equivalents. `SdkCallParams` gains `headers`, which `streamText` /
`generateText` already accept per call. A request with no client session id
gets one stable per-process UUID so Go still has something to route on. A
model counts as Go by provider id, by the trimmed-fork backend spelling, or by
pointing at the Go base URL.

Tests cover all four paths against a local upstream (Messages) and the SDK
call params (Chat Completions), plus a non-Go SDK route that must stay
untouched. Deleting the helper fails 7/8; removing one call site fails its
test. Live smoke through the proxy on Claude Code 2.1.268: haiku passthrough,
deepseek-v4.1-flash (Messages) and glm-5.2 (Chat Completions) all answer.

Session: fb0d03fc-8705-4d1c-a9c5-a94839670666
DeepSeek V4.1 Flash answers on Go's Anthropic Messages endpoint (thinking
block plus text, measured 2026-09-11) and is now in the transport map. The
catalog is regenerated from the 2026-09-11 models.dev feed: 18 models, 5 on
Messages. The feed also ended the "(2x usage)" promotional pricing — GPT-5.6
Luna is now $0.20/$1.20 per M with cache read $0.02 — and renamed DeepSeek V4
Pro to "DeepSeek V4 Pro (New)" (that model now needs a China-hosting opt-in on
the workspace; Go answers 403 otherwise). The tripwire literals in the catalog
tests are updated on purpose to those values.

Session: fb0d03fc-8705-4d1c-a9c5-a94839670666
…ute too

Addresses the review on bman654#213.

The API server's /openai/v1/chat/completions route sent no session header on
either branch, so every Go request through it still failed MissingSessionID —
the route is advertised to OpenAI-compatible clients, so it is production
reachable. The session id now comes from the x-claude-code-session-id header
once, before the branch, and reaches the raw relay via extraHeaders and the
SDK-translation branch via params.headers. One test per branch, plus a negative
for a non-Go model; each reds without its fix.

The base-URL arm read `baseUrl ?? apiUrl`, neither of which the runtime
ServerModelInfo populates for openai-compatible providers — it stores the URL
as `apiBaseUrl`. So a provider whose id had drifted from the canonical
`opencode-go` (a shape registry/resolve-template.ts supports) was left with no
signal at all. The helper now reads apiBaseUrl first, and a test built on the
production shape (apiBaseUrl set, drifted id) reds without it. The redundant
`sourceBackend` arm is gone, per the repo's rule about not generalising before
there is a second use case.

Also from the review:
- tests/opencode-go-session-header-wire.test.ts pins the SDK route on the wire
  with no mocks — a real @ai-sdk/openai-compatible model against a local
  listener, asserting the received header. Verified it reds when the header is
  overwritten after the ...params spread in sdk-adapter's generation calls,
  which the mocked tests do not catch.
- The credential probe now sends the session header, so whether Go validates a
  key before checking for a session can no longer affect the verdict.
- The doc names the three routes explicitly and states what the per-process
  fallback means for a shared server (same id across session-less clients;
  correlation, no content).

Session: a5ae056f-79a5-4beb-831d-c16d7b2d37dc
@TweedBeetle
TweedBeetle force-pushed the feat/opencode-go-session-header branch from 0cac670 to 93e441f Compare September 12, 2026 07:22
@TweedBeetle

Copy link
Copy Markdown
Contributor Author

Pushed, rebased onto 2.12.1. Both must-fixes done, both should-fixes done.

1. /openai/v1/chat/completions — both branches now send the header. The session id is read from x-claude-code-session-id once, before the branch (an OpenAI-format body carries no Claude metadata block, so the header is the only source; absent one it falls through to the per-process fallback, since Go rejects a request with no header at all). The raw relay gets it via extraHeaders, the SDK-translation branch via params.headers. Three new tests in the API-server block: one per branch, plus a negative for a non-Go model. The raw-relay one asserts on the wire against a local listener; both red without the router change (verified by stashing it — exactly those two fail).

2. Base-URL detection now reads apiBaseUrl. You were right that the old baseUrl ?? apiUrl reads fields the runtime ServerModelInfo doesn't populate for openai-compatible providers, so the drifted-id case had no signal at all. It now reads apiBaseUrl ?? baseUrl ?? apiUrl, with a test on the production shape (apiBaseUrl set, providerId: 'opencode-go-imported-2') that reds without it. The sourceBackend arm is gone.

3. Wire-pinning test for the SDK routetests/opencode-go-session-header-wire.test.ts, no mocks at all: a real @ai-sdk/openai-compatible model pointed at a local listener, asserting the header the listener received. I confirmed it catches the defect you described: inserting headers: undefined after the ...params spread in sdk-adapter's three generation calls reds it (expected [ undefined ] to deeply equal [ Array(1) ]), while the mocked tests stay green.

4. Credential probe now sends the session header, so whether Go validates the key before checking for a session can no longer affect the verdict. I didn't try to answer that empirically — removing its relevance seemed better than recording an observation you'd have to take on trust.

Doc now names the three routes explicitly instead of claiming "both modes, both protocols", and states what the per-process fallback means for a shared server: the same id across session-less clients of one process, so Go can correlate them; no content disclosed, and the alternative is a fresh id per request with no prefix-cache locality.

Full suite green on the rebase: 2642 tests, 118 files.

On your note about three of our PRs pinning 2.1.268 as the cause — that was carelessness about what we had verified versus what we had installed. The #214 attribution is being corrected in that PR.

@bman654 bman654 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this closes everything from round 1, and the way it was closed is the way we'd have done it. Reviewed at 93e441f, which matches the reply comment (the PR body still describes the first head; more on that at the end).

Round-1 items, checked on this head

Must-fix 1, /openai/v1/chat/completions — closed. Both branches send the header now. We re-ran your "stash the router change" mutation: with goSessionHeaders forced to undefined on that route, exactly the two new branch tests go red (2 failed / 10 passed in the file); making it unconditional for every model reds exactly the non-Go negative. Beyond the tests, we drove the route with no mocks against a local listener, on both branches, streaming and non-streaming, with the production-shaped @ai-sdk/anthropic DeepSeek V4.1 Flash entry and an openai-format model without completionsUrl — the header arrives every time.

Must-fix 2, apiBaseUrl — closed. Reverting the URL expression to the old baseUrl ?? apiUrl reds exactly the drifted-id test; widening the regex to ^https://opencode\.ai reds exactly the unrelated-URL negative. We also ran all 18 catalog models through materializeRegistry under an imported provider id (imported-opencode, template opencode-go) and then through both production builders (localModelToRoute, localProvidersToServerModels): every one is detected on every route shape. sourceBackend is gone from the helper; the only production builder sets it from the same id as providerId, so nothing lost a signal.

Should-fix, credential probe — closed. The probe now sends x-opencode-session (confirmed on a local listener; deleting the spread makes our probe red). Choosing to make Go's validation order irrelevant instead of guessing at it was the right call.

Should-fix, SDK route on the wire — closed as asked, with a gap we're naming now, not blocking on. The new no-mock test does exactly what we asked for, and your mutation claim holds: overwriting headers after the ...params spread in all three sdk-adapter generation calls reds it while the mocked file stays green. What it pins, though, is the non-streaming generateText call only. We mutated the three calls one at a time: dropping the header from the main streamText call (src/sdk-adapter.ts:1150, the path proxy-mode Claude Code turns actually take) leaves both committed header files 13/13 green; so does the force-stream call. And the SDK-translation branch of /openai runs through src/openai-adapter.ts, whose three generation calls have no wire coverage at all — its committed test mocks generateOpenAiResponse (and uses npm: '@ai-sdk/openai-compatible' for an Anthropic-format model, where production is @ai-sdk/anthropic). None of this is a runtime defect: every call spreads ...params and we watched the header arrive on each of those paths. It's the same shape of gap as round 1, now covering 1 of 6 calls instead of 0. A follow-up that extends the listener test to streamAnthropicResponse and to the /openai SDK branch in production shape would finish it; happy to take that separately.

Notes from round 1 — the shared-server correlation sentence is in docs/opencode-go.md, and the per-process fallback behaves as documented (four session-less requests across both API-server routes all carried the same v4 UUID).

One follow-up worth doing

/openai/v1/chat/completions is the only one of the four call sites that doesn't run the client's x-claude-code-session-id through extractClaudeSessionId. The other three validate (UUID only, trimmed, lowercased) and fall back to the per-process id otherwise; this one forwards the raw value. Measured on a local listener at this head:

client sends /openai forwards /anthropic/v1/messages forwards
"" x-opencode-session: (present, empty) per-process UUID
attacker; x=1 verbatim per-process UUID
uppercase UUID uppercase lowercased

The empty case is the one that might matter: your description says Go accepts "any non-empty value", and nobody here has observed what it does with an empty one — if that counts as missing, a client that sets the header to a blank value gets the same MissingSessionID this PR fixes, where the fallback would have worked. Reachability is low (the client has to send that header deliberately, and Node's parser already rejects CR/LF/NUL, so no injection), so this is not blocking. The fix is one line — extractClaudeSessionId({}, openAiSessionIdHeader) — plus one parity test. We're merging this as is; a small follow-up PR with that change would be welcome, and if you know what Go does with an empty x-opencode-session, say so there.

Related wording nit: the comment at src/server/router.ts:687-690 and the doc sentence credit "Claude Code's session id" on this route, but clodex's own launch wiring never sends Claude Code to /openai/v1 — in practice every request there gets the per-process id unless an OpenAI-compatible client chooses to send the header. Worth saying that plainly in the same follow-up.

Small things, no action required

  • The credential-probe header has no committed test: deleting the spread leaves tests/opencode-go.test.ts, tests/provider-templates.test.ts, tests/opencode-go-session-header.test.ts and tests/opencode-go-session-header-wire.test.ts green (45/45). One expect(headers['x-opencode-session']) in the existing request-shape test would cover it.
  • The PR body still describes the first head (four paths, a sourceBackend arm, 2564 tests, helper-deletion 7/8). The reply comment is accurate for 93e441f; a refreshed body would save the next reader the reconciliation. Cosmetic.
  • Byte-exact URL matching means HTTPS://opencode.ai/zen/go or opencode.ai:443 would not be detected as Go. Only a hand-typed custom endpoint could hit it; canonical entries match by id. Leaving it.

Verified on 93e441f

Gate green in an isolated CLODEX_HOME with dead ambient proxies, node 24: pnpm typecheck, pnpm build, and the full suite at 2642 tests / 118 files — your number. (A later rerun on a busy machine timed out once in tests/server-index.test.ts; that file flakes the same way on the 2.12.1 base, so it isn't this PR.) Helper deletion now fails 11 of the 13 tests across the two header files (the 7/8 in the body was the old head). Catalog: 18 models, 5 on Messages, deepseek-v4.1-flash on @ai-sdk/anthropic, updater map agrees. The new wire test opens only 127.0.0.1 sockets (we hooked net.Socket.connect and dns.lookup across both header test files: 23 connects, 14 lookups, nothing to opencode.ai). No header leaks to a non-Go upstream on any route we could drive, including proxy default-route fallback and count_tokens. Still on your word, since we have no Go key: the live MissingSessionID responses and the smoke.

We'll squash on merge with a single user-facing subject so the changelog reads as one line; the DeepSeek V4.1 Flash addition will be named in it.

@bman654
bman654 merged commit 4295a39 into bman654:main Sep 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants