Skip to content

About

Restores reasoning content dropped from multi-turn OpenCode conversations (DeepSeek, Kimi, GLM, MiMo, MiniMax, InclusionAI/Ling, OpenRouter). V1 + V2 plugin, proxy, and watchdog.

Topics

Resources

Stars

14 stars

Watchers

0 watching

Forks

Repository files navigation

opencode-thinking-fix

npm version Test npm downloads License: MIT Node.js

npm install opencode-thinking-fix

Restores reasoning content that clients and SDKs drop from multi-turn conversations — DeepSeek, Kimi, GLM, MiMo, MiniMax, OpenCode Go, and OpenRouter.

Start with the plugin. It is a zero-config seatbelt that stops the 400 error. Add the proxy when you need the model to remember what it was thinking. Choose your setup.

Docs: OpenCode Plugins

Table of Contents

The Problem

When DeepSeek or Kimi answers you, it scribbles notes first. "Let me think... the user wants a login page... I should use React Hook Form... check the API docs..." These notes are reasoning_content. You never see them. But the AI needs them — the provider docs require the notes to be sent back on every later turn.

The client and its SDK throw the notebook away. Next turn, the AI reaches for it, but the request already went out without it. The AI doesn't crash. It just forgot everything it was thinking. That is why your AI seems dumber on turn 2. It's not dumber. It lost its notes.

The drop happens in SDK serializers and client transforms (truthy checks, missing type members, stripped signatures), not in the providers. The providers do exactly what their docs say.

Without the fix: "Build me a login page." → AI builds it. "Now add password reset." → notes gone, the answer comes back generic.

With the fix: "Now add password reset." → AI reads its notes: "I used React Hook Form for login, I'll extend that." → works.

Deprecated: the historical symptom of this drop — HTTP 400: The reasoning_content in the thinking mode must be passed back to the API — is resolved by this fix. This repo is about the drop itself, not the error it used to cause.

DeepSeek V4 (and Kimi K2.7, GLM 5.x, MiMo V2.5) require that reasoning_content from every prior assistant turn gets included in subsequent API requests. The docs describe the contract, and the strictness varies by provider and endpoint: some reject the request, some ignore the field, and the conversation still loses its context either way. All five providers confirm the passback requirement in their official documentation:

The field is non-standard per OpenAI, so SDKs and clients ignore it — the drop is systemic, not a single tool's bug. This repo fixes it. Three layers, pick what you need.

See it happen

You don't need a specific free-tier model. Use any reasoning model on your own API key (DeepSeek V4, Kimi K2.7, GLM-5, MiMo) or any reasoning-capable model from the routing table below.

1. The 4-step reproduction

  1. Start a session with a reasoning model and give it a task that requires a tool call ("List the files in this directory, then read the largest one").
  2. The model thinks, emits reasoning_content, and calls the tool.
  3. Ask a follow-up that depends on the first answer ("Now summarize what you just read").
  4. Capture the outgoing Turn-2 request. Every reasoning_content field from Turn 1 is missing or empty. The client dropped it.

2. The dataset (the actual proof)

If you don't want to run it yourself, the proof/ directory contains the raw data from a week-long passive tap of real production traffic, byte-for-byte unmodified across DeepSeek, OpenCode Go, OpenCode Zen, and OpenRouter:

  • 1,968 drops captured (turns that carried reasoning earlier in the session arrived at the API without it).
  • 2.29 million characters of reasoning erased.
  • 22 of those drops were on OpenRouter — proving it is not a direct-provider quirk.
  • 68% of drops happened with zero model change (same model, same session, drop mid-conversation).

Every number is recomputable from proof/reasoning-drop/drops.csv. The A/B controls isolating the client as the sole point of failure live in proof/free-models/. The headline is the dataset; the recipe is a courtesy.

Why you've probably never seen it

This bug is conditional, and the conditions are "you work the tool hard":

  • Short sessions never hit it. The drop happens on turn 2+, worst after tool calls. A two-message chat looks fine.
  • Most gateways don't enforce the contract. On the free opencode/zen tier and OpenRouter, a missing field is accepted, so nothing errors. The model just quietly loses context. No 400, no warning, only worse answers.
  • Non-reasoning models are unaffected. GPT, Claude, Qwen, Gemini never emit the field.

If you run long sessions, heavy tool calls, model switching, or subagents, you have probably been hit and blamed the model. That is who this fix is for. If you send three messages a day, you may genuinely never notice — and the plugin detects that and does nothing.

The Evidence / Proof

The numbers above come from four passive taps (DeepSeek, OpenCode Go, OpenCode Zen, OpenRouter) that forwarded traffic byte-for-byte untouched and logged only metadata. They ran for a week of real work, then were retired. Methodology and raw data: proof/ directory.

  • The SDK is innocent. @ai-sdk/openai-compatible round-trips the field correctly; the drop happens in the client's own transform. Captured side by side in proof/free-models/.
  • Model-switch baseline is in proof/reasoning-drop/switches.csv so the 68% zero-model-change number is auditable independently of the drops file.

For the skeptics

Three objections come up every time this is posted. Fair questions, with the evidence:

"Works on my side." Probably true. The drop is silent on most gateways — no 400, just worse answers, so nothing looks broken unless you compare turn-3 quality with and without. The A/B controls hold everything constant except the field. And the 68% stat rules out model switching as the explanation.

"Your config is broken." The configs this was captured on are published, redacted, in proof/ directory. The root cause is in the client's message transform, not config — the SDK control shows the field surviving when only the client is removed from the path.

"The model cards already say to pass reasoning back." They do. That is the point. The provider requires the field; the client drops it. The model card is the spec this repo implements.


ELI15 — what this thing actually does

Imagine you're doing homework with a friend (the AI) over text messages. Your friend is smart but forgetful between messages. Every time they answer, they first scribble their thinking on a sticky note (that's reasoning_content) — but you never see the note, and the note gets thrown away before your friend reads the next message.

So when you say "ok now change the color," your friend has no memory of why they picked the blue button, gets confused, and gives a worse answer (the "dumber on turn 2" feeling).

Three facts make this messy:

  1. The sticky notes are non-standard. Normal chat apps don't have a "thinking note" field. So the tool that carries your messages back and forth (OpenCode, Cursor, Roo, etc.) simply doesn't know the note exists and drops it.
  2. The teachers (Chinese AI companies: DeepSeek, Kimi, GLM, MiMo, MiniMax) demand the note back. Their rules say: "if you don't hand me the exact sticky note from last time, I refuse to answer."
  3. Some teachers are picky about the note being present, not missing. Kimi K2.7-Code accepts a present-but-empty note. Others (DeepSeek, Kimi K2.5/2.6, GLM, MiMo) accept a blank one but then they "forget" and do worse.

What this repo does about it:

  • The plugin is a tiny note-checker that runs inside OpenCode. It looks at every message and, if a note is missing, it slips a blank note in so the teacher doesn't yell. Cheap, but the teacher still forgets (blank note = no memory).
  • The proxy is a smarter middle-man that sits between OpenCode and the teacher. It reads the real sticky note off each answer, remembers it, and pastes the actual writing back into the next message. Now the teacher remembers, and your friend stays sharp. (v3 rewrote the proxy so it does this with almost zero slowdown — see below.)
  • The watchdog is just a babysitter that restarts the proxy if it ever crashes.

Bottom line: the proxy is what makes the AI actually remember its thinking. The plugin is a seatbelt for the rare hard 400 that still slips through. The watchdog keeps the lights on.


v3 proxy rewrite — performance + correctness

The proxy (proxy/proxy.js) was rewritten to remove the slowdown you were hitting:

  • Raw-byte streaming. Responses are now forwarded as-is; the stream is parsed only to cache the note. No more re-building every token before sending it to you.
  • Lazy patching. If OpenCode already hands the notes back correctly (newer builds do), the proxy does nothing — zero JSON rewriting, zero overhead.
  • Correct note placement (the big bug fix). Earlier versions pasted every note into the first slot, so turn 3+ forgot everything. v3 stores each note in its own correct slot, so every turn replays its own thinking.
  • Keep-alive connections + upstream timeout. Fewer TLS handshakes, no hung requests.

v3.1 — dialect-aware patching (intel-driven)

Provider-specific research showed the field name is not universal and the "echo everything" heuristic was causing cross-provider poisoning. v3.1 fixes that:

  • Echo only the field the upstream expects. Each route declares a reasoningKey: reasoning_content (DeepSeek/Kimi/Moonshot/GLM/Zhipu/MiMo), reasoning_details (MiniMax split mode), reasoning (OpenCode Go / fixed-upstream). Previously the proxy filled all three fields, so a GLM turn stored under reasoning could poison a later DeepSeek replay that expects reasoning_content.
  • Legacy DeepSeek aliases vs V4. DeepSeek retired the deepseek-chat / deepseek-reasoner aliases on 2026-07-24; the current catalog is deepseek-v4-flash, deepseek-v4-pro, and the experimental deepseek-v4-flash-vision-exp. While the R1 alias was live it had the inverted contract (echoed reasoning → 400), which is why the proxy carries a strip route: it actively removes reasoning fields before forwarding, kept today only as a compatibility guard for clients that still send the old name. V4 thinking mode uses the normal passback contract instead.
  • Never fabricate reasoning. Unknown models and providers that reject echo (Qwen, GPT, Claude, Gemini, Llama, Mistral, Cerebras-hosted GLM) default to reasoningKey: null — the body is forwarded untouched.

v3.4 — Z.ai Preserved Thinking (F12, intel-driven)

The proxy injects the field, but some providers strip it server-side before it reaches the model. Z.ai is the most common case: the standard Chat Completions endpoint defaults thinking.clear_thinking to true, so prior reasoning_content is removed from context even when the client passes it back correctly. The result is a GLM session that re-derives from scratch every turn, loops, and burns tokens — with the proxy fully active.

v3.4 fixes this on every Z.ai-reachable lane:

  • What is injected. thinking.clear_thinking: false on the request body, which opts the session into Z.ai Preserved Thinking. The field is idempotently merged with any client-supplied thinking (e.g. thinking.type: "enabled"), so client choices are preserved.
  • Where it fires. Any lane that ends at a Z.ai-reachable host: port 3457 universal (BigModel direct), port 3458 (OpenCode Go gateway), port 3462 (OpenRouter → Z.ai under the hood). The host allowlist is conservative — unknown upstreams are skipped.
  • What is skipped. The injection is skipped when the client explicitly disabled thinking (thinking: false) — forcing preserved thinking on top of disabled thinking would be incoherent. The Coding Plan endpoint already defaults clear_thinking: false, so the merge is a no-op there.
  • Source of truth. docs.z.ai/guides/capabilities/thinking-mode (Preserved Thinking section) and the chat completion API reference (the thinking.clear_thinking parameter, type boolean, default true).

"Another way" — do you even need the plugin now?

The reasoning-drop is fixed at the wire layer by the proxy, so you have three options:

Approach Pros Cons
Proxy-only (recommended) — keep a slim plugin that only tags sessions with x-session-id, let the proxy do all note-handling Real reasoning replay, near-zero overhead, correct across all turns One extra localhost process to run
Native / disable thinking — point at the provider's Anthropic-compatible endpoint (e.g. DeepSeek /anthropic) or set thinking: disabled No extra process, simplest Loses ~20–40% reasoning quality on hard coding tasks
Full plugin + proxy (old default) Maximum safety net Plugin's per-message transform adds overhead inside OpenCode

Recommendation: run the proxy, and slim the plugin down to just the x-session-id header injection (delete its message-transform hook). That gives you real reasoning replay with the least overhead.

v3.4.1 — InclusionAI Ling 3.0 Flash route (F19)

Three new entries in the port 3457 model-routing table cover InclusionAI's flagship flash reasoning model:

Model id Upstream Why this route
inclusionai/ling-3.0-flash (paid) https://openrouter.ai/api/v1 Primary distribution; OR exposes delta.reasoning + delta.reasoning_details on the response side and normalizes reasoning_content on the input side
inclusionai/ling-3.0-flash:free (free tier) https://openrouter.ai/api/v1 Same model on the same upstream; rate-limited per upstream (Novita/DeepInfra) but otherwise identical
opencode/ling-3.0-flash-fin-free https://opencode.ai/zen/v1 OpenCode Zen free-tier lane, same carrier as the other Zen free models; previously fell through to the DeepSeek default and 400'd

No code change to the proxy logic itself — only the route table. Reasoning replay works the same way as for the other Chinese reasoning models: the SSE parser reads delta.reasoning (and the array form from delta.reasoning_details), the proxy caches it keyed by x-session-id + assistant index, and the next turn echoes it back as reasoning_content (which OpenRouter normalizes on outbound to the upstream).

Live-verified with a two-turn reasoning-continuity test on port 3457. Turn 1 emitted 271 chars of reasoning on a 7×6 step-by-step; turn 2 (with the cached reasoning_content echoed back) referenced the prior step ("The user previously asked for 7 times 6, and I was in the process of showing step-by-step work. The result of 7 × 6 is 42.") and continued to 42+5=47. Upstream was Novita.

Sources: openrouter.ai/inclusionai/ling-3.0-flash (model page), ant-ling.com/en/docs/api-reference/openai/ (native ant-ling OpenAI-compatible API), huggingface.co/inclusionAI/Ling-3.0-flash (model card), github.com/inclusionAI (org), arxiv.org/abs/2606.15079 (Ling/Ring 2.6 technical report).

Plugin optionality and derived sessions

As of 3.2.0, the plugin is optional. The shipped npm plugin still contains the transform hook; without it, derived keys keep caching functional; with it, you add exact session IDs + schema padding. The proxy derives session identity from the request when x-session-id is absent. Formula: sha256(authHeader + '||' + modelName + '||' + firstUserMessageText), prefixed with derived: and truncated to 32 hex characters; header wins when present. The key is fixed at the first user message, so cross-talk is bounded to sessions sharing the same auth header, model, and first user prompt; replayed text is still valid reasoning for that model, so the impact is quality-level, never a 400.


Quick Install

Choose Your Setup

If you want to... What you need to install Go to section
Just stop the 400 error Plugin only (The Seatbelt) Quick Install: Plugin
Make the AI remember its thinking Plugin + Proxy (The Real Fix) Full Setup: Proxy
Run this automatically in background Add the Watchdog Auto-Recovery

Quick Install: Plugin

npm install opencode-thinking-fix

Then register it in opencode.json and restart OpenCode. The key depends on the OpenCode generation:

OpenCode opencode.json
v2 (current) "plugins": ["opencode-thinking-fix"]
v1.18.29+ (legacy) "plugin": ["opencode-thinking-fix"]

The same package covers both: OpenCode v2 reads the Plugin.define({ id, setup }) export, v1 reads the server() export on the same default export.

OpenCode loads the plugin itself. Registering it in opencode.json is the whole install. Loading it with node require() fails by design and is not a bug.

The plugin is a seatbelt, not the real fix. It keeps the field present so requests don't 400, but it does not replay the model's actual prior reasoning. To make the model remember its earlier thinking, see Full Setup: Proxy. For the trade-offs, see Plugin details.

Method Command Best for
TUI Ctrl+P → type install plugin → opencode-thinking-fix First-time users
CLI opencode plugin opencode-thinking-fix Scripting
Manual Add "plugins": ["opencode-thinking-fix"] (v2) or "plugin": [...] (v1) to opencode.json Version pinning

Already installed? Update

Versions before 3.3 do not cover OpenRouter or the OpenCode Zen free-tier lane. Update inside OpenCode (Ctrl+P → install plugin → opencode-thinking-fix) or with opencode plugin opencode-thinking-fix, then restart. The plugin self-detects reasoning models, so updating is safe on non-reasoning setups too.

Method 1: TUI (press Ctrl+P while OpenCode is running)

  1. Press Ctrl+P to open the command palette.
  2. Type install plugin and press Enter.
  3. Press Tab to switch the install scope to Global (recommended, works across all projects).
  4. Type opencode-thinking-fix.
  5. Press Enter. Restart OpenCode.

Check ~/.local/share/opencode/thinking-fix.log for plugin_loaded. See Is it working?.

Method 2: CLI (shell command)

opencode plugin opencode-thinking-fix

For a specific version:

opencode plugin opencode-thinking-fix@3.3.0

Restart OpenCode after installing.

Method 3: Manual config (add to opencode.json)

OpenCode v2 uses the plural key:

{
  "plugins": ["opencode-thinking-fix"]
}

On v1.18.29+ the same array goes under the legacy singular key instead:

{
  "plugin": ["opencode-thinking-fix"]
}

Config file location:

  • Linux/macOS: ~/.config/opencode/opencode.json (global) or .opencode/opencode.json (project)
  • Windows: %APPDATA%/OpenCode/opencode.json (global) or .opencode/opencode.json (project)

Restart OpenCode after adding. Check ~/.local/share/opencode/thinking-fix.log for plugin_loaded. See Is it working?.

See also: OpenCode plugin docs

On Windows? See Windows notes for PowerShell commands, NSSM service setup, and config paths.


Plugin details (the seatbelt)

Install via npm (recommended)

See Quick Install: Plugin above, use OpenCode TUI (Ctrl+P) or CLI (opencode plugin opencode-thinking-fix).

Manual install (for local development)

Drop the plugin file in your OpenCode plugins directory and restart:

mkdir -p ~/.config/opencode/plugins
cp plugins/opencode-thinking-fix-universal.ts ~/.config/opencode/plugins/
# Manual V1 copies also need the shared plugin package (npm installs and V2 hosts provide it):
cd ~/.config/opencode && npm i @opencode/plugin

Register the copy in opencode.json under "plugins" (v2) — a manual V1 copy uses "plugin" instead:

{
  "plugins": ["opencode-thinking-fix"]
}

The same package supports both APIs from one default export. V2 reads setup(); V1 (>= 1.18.29) reads server().

What the plugin does

It scans outgoing messages for any assistant turn that already has reasoning_content. If it finds one (meaning you are using a reasoning model), it adds reasoning_content: "" to every assistant turn missing it. If it finds nothing (Qwen, GPT, Claude, they never produce this field), it does nothing.

It also handles reasoning for the OpenCode Go provider, and patches empty content fields that OpenAI-compatible SDKs sometimes omit.

No config file changes. No build step. OpenCode compiles .ts plugins when it starts.

Why keep it when the 400 is mostly resolved? The hard 400 no longer fires on most gateways, but it still happens once in a while: on native DeepSeek tool-call turns, on present-required models like Kimi K2.7 Code, and whenever the proxy is down mid-session. Keeping the field present costs nothing and removes that failure class entirely, so it stays as insurance rather than the main fix.

The catch: the plugin fills in empty strings, not your model's actual prior thinking. DeepSeek, Kimi K2.5/K2.6, GLM, and MiMo accept empty strings fine, your conversation works but the model does not see its earlier reasoning. Kimi K2.7 Code requires the field to be present; present-but-empty is accepted. On native DeepSeek tool-call turns even a present-but-empty field is treated as missing, so the proxy's real cached text is what satisfies that contract.


Full Setup: Proxy

If you have not installed the plugin yet, start with Quick Install: Plugin. The proxy is the part that puts the model's real reasoning back into the conversation.

A Node.js proxy that catches API responses as they come back, pulls out the actual reasoning_content text, and caches it in memory. On the next request, it injects that real text back into the conversation history instead of empty strings.

Your model sees its full chain-of-thought from turn 1 on every subsequent turn. The difference is noticeable on complex multi-turn coding sessions.

Five local ports

The proxy runs as five instances of the same proxy.js binary, one per upstream wire:

Port Purpose Environment
3457 Direct providers (DeepSeek, Kimi, GLM, MiMo, GPT, Claude, Qwen, Gemini, etc.) — auto-routed PORT=3457
3458 OpenCode Go — chat + Responses wires PORT=3458 UPSTREAM_URL=https://opencode.ai/inference/go/openai/v1
3459 OpenCode Zen free lane — fixed upstream PORT=3459 UPSTREAM_URL=https://opencode.ai/zen/v1 REASONING_KEY=reasoning_content
3462 OpenRouter — fixed upstream PORT=3462 UPSTREAM_URL=https://openrouter.ai/api/v1 REASONING_KEY=reasoning_content
3463 OpenCode Go — Anthropic messages wire PORT=3463 UPSTREAM_URL=https://opencode.ai/inference/go/anthropic/v1

Port 3457 auto-routes by model name through the built-in route table. The other four pin a single upstream each, because the wire path or the passback key differs per lane. The Zen lane's free-tier entries track the live catalogue — 8 free ids on 2026-10-23 — and ids that upstream retired are removed rather than left pointing at an endpoint that answers 410 ModelDeprecated or 400 Model is unavailable.

OpenCode Go: four wires, three base URLs (measured 2026-10-23)

Go is not one protocol. Per the provider's own endpoint table, each model is served over exactly one of four wires, and the base URL differs per wire. Measured against the live gateway with a Go key:

Wire Base URL to put in UPSTREAM_URL Client api (pi-ai) Models
OpenAI chat https://opencode.ai/inference/go/openai/v1 openai-completions beam-free, glm-5.1/5.2/5.3/5.3-flash, hy3, hy4-preview, kimi-k2.6, kimi-k2.7-code, kimi-k3, longcat-2.0, mimo-v2.5, mimo-v2.5-pro, mimo-v2.6-flash, mimo-v2.6-pro, minimax-m2.5, minimax-m3.1-flash, omen-alpha, qwen3.6-plus, qwen3.7-max, step-5-preview, deepseek-v4.1-flash, deepseek-v4-pro
Anthropic messages https://opencode.ai/inference/go/anthropic/v1 anthropic-messages claude-haiku-5-5, minimax-m2.7, minimax-m3, qwen3.7-plus, qwen3.8-flash, qwen3.8-max
OpenAI Responses https://opencode.ai/inference/go/openai/v1 openai-responses gpt-6-luna, gpt-5.6-luna, grok-4.6, grok-4.7, muse-spark-1.2-contributor, muse-spark-1.3-contributor
Google https://opencode.ai/inference/go/google/v1beta/models/<id> google gemini-4-argon-contributor

What that means for this proxy, all four points measured rather than inferred:

  1. zen/go/v1 is not a substitute for the Anthropic wire. Chat works on both zen/go/v1 and inference/go/openai/v1, but an Anthropic /v1/messages call to zen/go/v1/messages returns 401 Go requires a credential while the same body and key at inference/go/anthropic/v1/messages returns 200. Run a second port for it: PORT=3463 UPSTREAM_URL=https://opencode.ai/inference/go/anthropic/v1 node proxy/proxy.js.
  2. An Anthropic client must not get a /v1 base. The Anthropic SDK appends /v1/messages itself, so a client base of http://127.0.0.1:3463/v1 produces /v1/v1/messages and a 404 with an empty body; use http://127.0.0.1:3463.
  3. Wire is per model, so a chat-only lane silently 404s. grok-4.6 rejects both /chat/completions and /v1/messages with ModelProtocolUnsupported and only answers on /responses. Group models by wire instead of listing them all under one provider.
  4. Auth works both ways. Authorization: Bearer <key> and Anthropic-style x-api-key both return 200 on all three proxy-addressable wires (the Google wire is not addressable through this proxy), so no header translation is needed. x-opencode-session remains mandatory; the proxy injects one when the client omits it (see the header block in proxy.js), which is what makes DeepSeek Harness usable against Go.

Two further catalogue facts: the live GET https://opencode.ai/inference/go/v1/models list is authoritative (36 ids on 2026-10-23, including eight the docs page does not list: glm-5.1, grok-4.6, kimi-k2.6, minimax-m2.5, minimax-m2.7, omen-alpha, qwen3.6-plus, qwen3.7-max), and longcat-2.5-preview-free is documented but absent from that list, so it answers 429 Endpoint is unavailable.

Port 3457 auto-routes by model name through the built-in route table; the other four pin a single upstream each (3458 chat + Responses, 3463 Anthropic messages, 3459 Zen, 3462 OpenRouter), because the wire path or the passback key differs per lane. All five are the same proxy.js binary with different environment variables.

# Linux / macOS / Windows (Node.js required)
node proxy/proxy.js

# OpenCode Go proxy
PORT=3458 UPSTREAM_URL=https://opencode.ai/inference/go/openai/v1 node proxy/proxy.js

Windows PowerShell: use $env:PORT=3457; node proxy/proxy.js (PowerShell) or set PORT=3457 && node proxy/proxy.js (CMD).

OpenCode Go setup (explicit and credentials-safe)

The npm package intentionally does not run a postinstall script. It never reads auth.json, edits your OpenCode configuration, starts a process, or enables a service without your explicit action.

  1. In OpenCode, run /connect and select OpenCode Go. Keep the credential in OpenCode's own local auth store; never paste it into this repository, opencode.json, a shell script, or a public issue.
  2. Install the package and start its Go proxy manually:
npm install opencode-thinking-fix
PORT=3458 UPSTREAM_URL=https://opencode.ai/inference/go/openai/v1 \
  node node_modules/opencode-thinking-fix/proxy/proxy.js
  1. Point only the OpenCode Go provider at the local proxy. The baseURL must be under provider.<id>.options:
{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "opencode-go": {
      "options": {
        "baseURL": "http://127.0.0.1:3458/v1"
      }
    }
  }
}

The proxy forwards the authorization header supplied by OpenCode to the configured upstream and does not persist the credential. Stop the proxy when it is not needed. The systemd and watchdog options below are deliberately opt-in alternatives for operators who want automatic restarts.

Install as systemd services (auto-start at login)

Fresh Linux procedure. Node.js 24 or newer is required. The units execute the proxy from a checkout at ~/opencode-thinking-fix (%h/opencode-thinking-fix/proxy/proxy.cjs).

  1. Clone to the path the units expect:
git clone https://github.com/tbosancheros39/opencode-thinking-fix.git ~/opencode-thinking-fix
  1. Enter the checkout:
cd ~/opencode-thinking-fix
  1. Install the runtime dependency:
npm install --omit=dev

This pulls eventsource-parser, the one runtime dep. Skipping it makes the units die at boot with ERR_MODULE_NOT_FOUND: Cannot find package 'eventsource-parser'.

  1. Copy the six units:
mkdir -p ~/.config/systemd/user
cp systemd/reasoning-cache.service ~/.config/systemd/user/
cp systemd/reasoning-cache-go.service ~/.config/systemd/user/
cp systemd/reasoning-cache-go-anthropic.service ~/.config/systemd/user/
cp systemd/reasoning-cache-zen.service ~/.config/systemd/user/
cp systemd/reasoning-cache-openrouter.service ~/.config/systemd/user/
cp systemd/reasoning-proxy-watchdog.service ~/.config/systemd/user/
  1. Reload user units:
systemctl --user daemon-reload
  1. Enable the router lane:
systemctl --user enable --now reasoning-cache.service

Repeat enable --now for the other lanes: reasoning-cache-go.service (3458), reasoning-cache-go-anthropic.service (3463), reasoning-cache-zen.service (3459), reasoning-cache-openrouter.service (3462), reasoning-proxy-watchdog.service.

  1. Verify:
systemctl --user status reasoning-cache.service
journalctl --user -u reasoning-cache.service

Alternative: if you installed via npm (node_modules/opencode-thinking-fix), deps are present already. Run node path/to/proxy/proxy.js directly with no clone. The shipped units point at ~/opencode-thinking-fix, so point them at that npm path instead before enabling.

Then point OpenCode at it, in your opencode.json:

{
  "provider": {
    "deepseek-v4-pro": {
      "options": {
        "baseURL": "http://127.0.0.1:3457/v1"
      }
    },
    "opencode-go": {
      "options": {
        "baseURL": "http://127.0.0.1:3458/v1"
      }
    }
  }
}

One runtime dependency (eventsource-parser). The proxy uses Node.js built-in http, https, and url for everything else.

Interleaved thinking support: GLM-5+ and MiniMax-M3 emit reasoning AFTER content in the same turn (interleaved thinking between tool calls). The proxy accumulates ALL reasoning across an entire assistant turn and flushes only on finish_reason, never on delta.content arrival. This prevents split/lost reasoning blocks.

Kimi K2.7 Code and OpenCode Go need this. The rest of the models benefit from it but do not technically require it.


Auto-Recovery

The watchdog only keeps the proxy alive, so start the proxy first. The script checks all five proxy instances (ports 3457/3458/3459/3462/3463) every 4 minutes and restarts any that are down:

cp watchdog/watchdog.sh ~/opencode-thinking-fix/watchdog/
cp systemd/reasoning-proxy-watchdog.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now reasoning-proxy-watchdog.service

Windows: NSSM or Task Scheduler

On Windows, NSSM (Non-Sucking Service Manager) or Task Scheduler can run the proxy or a PowerShell watchdog script in the background. A quick NSSM setup:

nssm install ReasoningCacheProxy node.exe proxy\proxy.js
nssm set ReasoningCacheProxy AppEnvironmentExtra PORT=3457
nssm start ReasoningCacheProxy

See Windows notes for the full setup.


How They Work Together

OpenCode → [plugin patches missing reasoning_content/reasoning]
         → [proxy injects cached real text]
         → [watchdog keeps all five proxies alive]
         → API

The plugin is partial insurance. The hard 400 is mostly gone from modern gateways, but it still fires once in a while — native DeepSeek tool-call turns, present-required models, or a proxy that is down mid-session. When that happens the plugin keeps the field present so the request survives; on empty-tolerant gateways the empty string is accepted, and on stricter ones the proxy's real cached text takes over because the plugin sees the field is already filled in. The proxy is the actual fix. The plugin is the seatbelt you hope never to need.


Affected Models

Model Plugin helps Proxy helps What it needs
DeepSeek V4 Pro / Flash Yes Nice to have Accepts "" (tool-call turns need real text)
DeepSeek V4 Flash Vision (exp) Yes Nice to have Text + image input on a separate model id; same passback contract
Kimi K2.5 / K2.6 Yes Nice to have Accepts ""
Kimi K2.7 Code Not enough alone Required Field must be present; real text keeps it coherent
GLM-5.x / Zhipu Yes Nice to have Accepts ""
MiMo V2.5 Yes Nice to have reasoning_content on api.xiaomimimo.com/v1
MiniMax-M3 Yes Recommended reasoning_details[] array; ~40% quality loss if stripped. Proxy injects reasoning_split:true to keep thinking separate from content.
OpenCode Go Yes Recommended for chat/message routes Chat uses reasoning; Anthropic messages are handled; unsupported response formats pass through
OpenRouter (reasoning models via openrouter.ai) Yes Recommended Field depends on the upstream model; the fixed-upstream proxy replays reasoning_content and passes everything else through
Qwen, GPT, Claude, Gemini, Llama, Mistral No No No reasoning_content

Model routing (proxy port 3457)

The proxy auto-routes by model name prefix. All routes:

Click to expand the full model routing table
Prefix Upstream Reasoning
deepseek-v4-flash, deepseek-v4-pro, deepseek-v4-flash-vision-exp https://api.deepseek.com Yes (reasoning_content)
deepseek-chat https://api.deepseek.com Non-thinking alias (no reasoning emitted; passback is a harmless no-op)
deepseek-r1, deepseek-reasoner https://api.deepseek.com Legacy — retired by DeepSeek 2026-07-24; kept as a compatibility guard with active stripping
kimi, moonshot https://api.moonshot.ai/v1 Yes (reasoning_content)
glm, zhipu https://open.bigmodel.cn/api/paas/v4 Yes (reasoning_content)
minimax https://api.minimax.io/v1 Yes (reasoning_details, reasoning_split:true)
mimo https://api.xiaomimimo.com/v1 Yes (reasoning_content)
inclusionai/ling-3.0-flash (and :free) https://openrouter.ai/api/v1 Yes — OR normalizes to delta.reasoning + delta.reasoning_details; passback as reasoning_content is honored
opencode/ling-3.0-flash-fin-free https://opencode.ai/zen/v1 Yes (reasoning_content)
gpt, o1 https://api.openai.com No
claude, anthropic https://api.anthropic.com No
qwen https://dashscope-intl.aliyuncs.com/compatible-mode/v1 No
gemini https://generativelanguage.googleapis.com/v1beta/openai No
llama https://api.together.xyz No
mistral https://api.mistral.ai No

Unknown models fall back to https://api.deepseek.com with reasoning disabled.

OpenRouter (port 3462)

OpenRouter wraps many upstream models, and reasoning models served through it lose their thinking the same way. Run a third fixed-upstream proxy and point your OpenRouter provider at it:

PORT=3462 UPSTREAM_URL=https://openrouter.ai/api/v1 REASONING_KEY=reasoning_content node proxy/proxy.js
{
  "provider": {
    "openrouter": {
      "options": {
        "baseURL": "http://127.0.0.1:3462/v1"
      }
    }
  }
}

The passive-tap corpus includes an OpenRouter lane: 22 session-confirmed drops, concentrated on minimax/minimax-m3:free within one long session — enough to show the pattern exists on OpenRouter, with the full model-by-model matrix left to a follow-up corpus. See The Evidence/Proof.


Troubleshooting

Is it working?

The plugin writes a structured JSON log: ~/.local/share/opencode/thinking-fix.log.

Every request logs an inspect event (field coverage, whether the model is a reasoning model, how many turns were patched), so the plugin is auditable even when it patches nothing. Non-reasoning models are skipped by design — zero false patches is a property of the self-detection, not a promise bolted on.

From a real 104-message session:

Before:  reasoning dropped on every turn → AI forgets its thinking
After:   34 fields patched across a 104-message session → reasoning replays correctly

Here is a live excerpt from that session:

{"ts":"2026-06-25T01:41:50.572Z","event":"inspect","isReasoningModel":true,
 "totalMessages":104,"patchedFields":34,"turns":[
   {"index":1,"fields":["text","reasoning"]},
   {"index":5,"fields":["text"]},
   {"index":9,"fields":["text"]},
   {"index":42,"fields":["reasoning"]}
 ]}

No output? Either you are on a non-reasoning model (correct, no patching needed) or the plugin did not load. Check:

grep plugin_loaded ~/.local/share/opencode/thinking-fix.log

Proxy health:

curl http://127.0.0.1:3457/health          # → {"ok":true,"uptime":1225}
curl http://127.0.0.1:3458/health          # → {"ok":true,"uptime":1225}
journalctl --user -u reasoning-cache.service -f
journalctl --user -u reasoning-cache-go.service -f

This bug is everywhere

OpenCode is not the only tool that drops reasoning_content. Here is a partial list of places this same bug shows up:

OpenCode (anomalyco/opencode): #24190, #24104, #24722, #25311, #25134, #25000, #24124, #24130, #24261, #24442, #24569

OpenClaw: #71435, #71050

Kilo Code: #9501

VS Code: #318920

OpenAI Codex: #24500

GitHub Copilot: discussion #193953

OmniRoute: #1628

Reddit: r/opencodeCLI, r/DeepSeek, r/RooCode

Blogs covering it: AkitaOnRails, ClawHub


Files in this repo

plugins/
  opencode-thinking-fix-universal.ts   # self-detection plugin
proxy/
  core.js                               # pure logic: routes, cache, patching, parser
  proxy.js                              # HTTP server + side effects (1 dep)
watchdog/
  watchdog.sh                           # auto-recovery watchdog
systemd/
  reasoning-cache.service               # universal auto-routing proxy (port 3457)
  reasoning-cache-go.service            # OpenCode Go chat + Responses unit (port 3458)
  reasoning-cache-go-anthropic.service  # OpenCode Go Anthropic messages unit (port 3463)
  reasoning-cache-zen.service           # Zen free lane unit (port 3459)
  reasoning-cache-openrouter.service    # OpenRouter unit (port 3462)
  reasoning-proxy-watchdog.service      # watchdog systemd unit
tests/
  test-proxy.js                         # 214 proxy assertions
  test-plugin.js                        # 12 plugin assertions
  live-minimax-replay.mjs               # self-contained replay gate (mock upstream)
proof/                                  # evidence: captures, controls, CSVs, repro scripts

Release highlights

  • v3.2.0: R1 strip sentinel (actively remove reasoning for deepseek-r1/deepseek-reasoner), multibyte-safe SSE decoding (StringDecoder), response hop-by-hop header stripping, oversized-reasoning skip-not-truncate, MiniMax replay shape behind MINIMAX_REASONING_DETAILS_SHAPE (default minimal), a real-core test suite (95 assertions), glm-5.2 go-mode reasoning_content key (live-verified across two turns), derived session keys, and x-session-id no longer forwarded upstream. See the changelog.
  • v3.1.4: proxy-owned structured JSONL logging with session identifiers truncated in logs.
  • v3.1.3: Anthropic-wire dialect detection for OpenCode Go routes.
  • v3.1.2: bounded cache/session memory, raw-stream preservation, hop-by-hop header removal, and safer parser failure handling.
  • v3.1.1: corrected MiMo routing, non-streaming reasoning caching, and fixed partial-turn patching.
  • v3.1: provider-aware reasoning fields, including the DeepSeek R1 no-echo route and safe passthrough for unknown models.

Tested on

Platform Plugin Proxy Watchdog Systemd
Linux (Kubuntu 24.04) ✅ ✅ ✅ (bash) ✅
macOS ✅ ✅ ✅ (bash) ❌ (use launchd)
Windows ✅ ✅ ❌ (bash) ❌

OpenCode v1.17.9+ (V1 plugin), OpenCode v2.0+ (V2 plugin), DeepSeek V4 Pro, Kimi K2.5/K2.6/K2.7, GLM-5.x, MiMo V2.5, MiniMax-M3, OpenCode Go.

Windows notes

Fresh Windows procedure. Windows has no systemd. Node.js 24 or newer is required.

  1. Install Node.js 24 or newer from nodejs.org, then verify:
node --version
  1. Get the code with deps. Pick one path. Path A (npm, deps included):
npm install opencode-thinking-fix

The proxy lives at node_modules/opencode-thinking-fix/proxy/proxy.js. Path B (clone, then install deps):

git clone https://github.com/tbosancheros39/opencode-thinking-fix.git
cd opencode-thinking-fix
npm install --omit=dev

This pulls eventsource-parser, the one runtime dep. Skipping it fails at start with ERR_MODULE_NOT_FOUND: Cannot find package 'eventsource-parser'.

  1. Start the proxy:
$env:PORT=3457
node proxy/proxy.js

CMD equivalent: set PORT=3457 && node proxy\proxy.js.

Path A start: run from the folder where you ran the install, pointing at the package path:

node node_modules/opencode-thinking-fix/proxy/proxy.js
  1. Auto-start. Pick one. Task Scheduler (built in):
schtasks /create /tn ReasoningCacheProxy /sc onlogon /tr "cmd /c set PORT=3457 && cd /d C:\path\to\opencode-thinking-fix && node proxy\proxy.js"

NSSM service (install once: winget install nssm):

nssm install ReasoningCacheProxy node.exe proxy\proxy.js
nssm set ReasoningCacheProxy AppDirectory C:\path\to\opencode-thinking-fix
nssm set ReasoningCacheProxy AppEnvironmentExtra PORT=3457
nssm start ReasoningCacheProxy

Repeat for the two Go proxies: PORT=3458 with UPSTREAM_URL=https://opencode.ai/inference/go/openai/v1 (chat + Responses) and PORT=3463 with UPSTREAM_URL=https://opencode.ai/inference/go/anthropic/v1 (Anthropic messages).

  1. The plugin part matches the Linux flow: opencode plugin opencode-thinking-fix, register plugins (OpenCode v2) or plugin (OpenCode v1.18.29+) in opencode.json, restart OpenCode.

OpenCode config paths on Windows:

Scope Path
Global %APPDATA%\OpenCode\opencode.json
Project <project>\.opencode\opencode.json
Plugins dir %APPDATA%\OpenCode\plugins\
npm cache %LOCALAPPDATA%\opencode\node_modules\

About

Restores reasoning content dropped from multi-turn OpenCode conversations (DeepSeek, Kimi, GLM, MiMo, MiniMax, InclusionAI/Ling, OpenRouter). V1 + V2 plugin, proxy, and watchdog.

Topics

Resources

Stars

14 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages