opencode-thinking-fix 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ .env
3
+ *.log
4
+ .DS_Store
5
+ Thumbs.db
package/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # opencode-thinking-fix
2
+
3
+ You ask DeepSeek or Kimi a question. It picks a tool, calls it, works fine. Then you ask a follow-up and it dies:
4
+
5
+ ```
6
+ HTTP 400: The reasoning_content in the thinking mode must be passed back to the API
7
+ ```
8
+
9
+ Here's what's happening. DeepSeek V4 (and Kimi K2.7, GLM 5.x, MiMo V2.5) requires that `reasoning_content` from every prior assistant turn gets included in subsequent API requests. Their [docs](https://api-docs.deepseek.com/guides/thinking_mode) spell this out directly:
10
+
11
+ > *"Between two user messages, if the model performed a tool call, the intermediate assistant's reasoning_content must participate in the context concatenation and must be passed back to the API in all subsequent user interaction turns. If your code does not correctly pass back reasoning_content, the API will return a 400 error."*
12
+
13
+ OpenCode's provider layer strips it. Three PRs tried to fix this upstream ([#24250](https://github.com/anomalyco/opencode/pull/24250), [#24428](https://github.com/anomalyco/opencode/pull/24428), [#24895](https://github.com/anomalyco/opencode/pull/24895)) — none got merged. The field is non-standard per OpenAI, so both OpenCode and the AI SDK punt on it.
14
+
15
+ This repo fixes it. Three layers, pick what you need.
16
+
17
+ ---
18
+
19
+ ## Option 1: the plugin (stops the crashes)
20
+
21
+ One file. Drop it in `~/.config/opencode/plugins/` and restart:
22
+
23
+ ```bash
24
+ mkdir -p ~/.config/opencode/plugins
25
+ cp plugins/opencode-thinking-fix-universal.ts ~/.config/opencode/plugins/
26
+ ```
27
+
28
+ What it does: scans outgoing messages for any assistant turn that already has `reasoning_content`. If it finds one (meaning you're 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.
29
+
30
+ It also handles `reasoning` for the OpenCode Go provider, and patches empty `content` fields that OpenAI-compatible SDKs sometimes omit.
31
+
32
+ No config file changes. No build step. OpenCode compiles `.ts` plugins when it starts.
33
+
34
+ **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 doesn't see its earlier reasoning. Kimi K2.7 Code rejects empty strings entirely, it needs the real text.
35
+
36
+ ---
37
+
38
+ ## Option 2: the proxy (replays real reasoning)
39
+
40
+ 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.
41
+
42
+ Your model sees its full chain-of-thought from turn 1 on every subsequent turn. Noticeable difference on complex multi-turn coding sessions.
43
+
44
+ ### Two-proxy architecture
45
+
46
+ The proxy runs on **two ports**:
47
+
48
+ | Port | Purpose | Environment |
49
+ |---|---|---|
50
+ | **3457** | Direct providers (DeepSeek, Kimi, GLM, MiMo, GPT, Claude, Qwen, Gemini, etc.) | `PORT=3457` |
51
+ | **3458** | OpenCode Go provider | `PORT=3458` `UPSTREAM_URL=https://opencode.ai/zen/go/v1` |
52
+
53
+ Port 3457 auto-routes based on model name using the built-in route table. Port 3458 is a fixed-upstream proxy specifically for the OpenCode Go provider, which uses `delta.reasoning` (not `reasoning_content`) in its SSE streams. Both are handled by the same `proxy.js` binary — just different environment variables.
54
+
55
+ ```bash
56
+ # run port 3457 (direct providers)
57
+ node proxy/proxy.js
58
+
59
+ # run port 3458 (OpenCode Go)
60
+ PORT=3458 UPSTREAM_URL=https://opencode.ai/zen/go/v1 node proxy/proxy.js
61
+ ```
62
+
63
+ ### Install as systemd services (auto-start at boot)
64
+
65
+ ```bash
66
+ mkdir -p ~/.config/systemd/user
67
+ cp systemd/reasoning-cache.service ~/.config/systemd/user/
68
+ cp systemd/reasoning-cache-go.service ~/.config/systemd/user/
69
+ systemctl --user daemon-reload
70
+ systemctl --user enable --now reasoning-cache.service
71
+ systemctl --user enable --now reasoning-cache-go.service
72
+ ```
73
+
74
+ Then point OpenCode at it — in your `opencode.json`:
75
+
76
+ ```json
77
+ {
78
+ "provider": {
79
+ "deepseek-v4-pro": {
80
+ "baseURL": "http://127.0.0.1:3457/v1"
81
+ },
82
+ "opencode-go": {
83
+ "baseURL": "http://127.0.0.1:3458/v1"
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ Zero npm dependencies. It uses `http`, `https`, and `url` — nothing else.
90
+
91
+ **Kimi K2.7 Code and OpenCode Go need this.** The rest of the models benefit from it but don't technically require it.
92
+
93
+ ---
94
+
95
+ ## Option 3: watchdog (auto-recovery)
96
+
97
+ The watchdog script checks both proxy instances every 4 minutes and restarts any that are down:
98
+
99
+ ```bash
100
+ cp watchdog/watchdog.sh ~/reasoning-cache-proxy/
101
+ cp systemd/reasoning-proxy-watchdog.service ~/.config/systemd/user/
102
+ systemctl --user daemon-reload
103
+ systemctl --user enable --now reasoning-proxy-watchdog.service
104
+ ```
105
+
106
+ ---
107
+
108
+ ## How they work together
109
+
110
+ ```
111
+ OpenCode → [plugin patches missing reasoning_content/reasoning]
112
+ → [proxy injects cached real text]
113
+ → [watchdog keeps both proxies alive]
114
+ → API
115
+ ```
116
+
117
+ The plugin is the safety net. If the proxy goes down, the plugin still injects empty strings so you don't get 400s. If the proxy is up, its cached text takes priority because the plugin sees the field is already filled in. Either way, your conversation doesn't break.
118
+
119
+ ---
120
+
121
+ ## Affected models
122
+
123
+ | Model | Plugin helps | Proxy helps | What it needs |
124
+ |---|---|---|---|
125
+ | DeepSeek V4 Pro / Flash | Yes | Nice to have | Accepts `""` |
126
+ | Kimi K2.5 / K2.6 | Yes | Nice to have | Accepts `""` |
127
+ | **Kimi K2.7 Code** | **Not enough alone** | **Required** | Needs real text |
128
+ | GLM-5.x / Zhipu | Yes | Nice to have | Accepts `""` |
129
+ | MiMo V2.5 / MiniMax | Yes | Nice to have | Accepts `""` |
130
+ | OpenCode Go | Yes | Required | Uses `reasoning` field |
131
+ | Qwen, GPT, Claude, Gemini, Llama, Mistral | No | No | Don't use reasoning_content |
132
+
133
+ ---
134
+
135
+ ## Model routing (proxy port 3457)
136
+
137
+ The proxy auto-routes by model name prefix. All 15 supported prefixes:
138
+
139
+ | Prefix | Upstream | Reasoning |
140
+ |---|---|---|
141
+ | `deepseek` | `https://api.deepseek.com` | Yes |
142
+ | `kimi`, `moonshot` | `https://api.moonshot.ai/v1` | Yes |
143
+ | `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes |
144
+ | `minimax`, `mimo` | `https://api.minimax.io/v1` | Yes |
145
+ | `gpt`, `o1` | `https://api.openai.com` | No |
146
+ | `claude`, `anthropic` | `https://api.anthropic.com` | No |
147
+ | `qwen` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | No |
148
+ | `gemini` | `https://generativelanguage.googleapis.com/v1beta/openai` | No |
149
+ | `llama` | `https://api.together.xyz` | No |
150
+ | `mistral` | `https://api.mistral.ai` | No |
151
+
152
+ Unknown models fall back to `https://api.deepseek.com` with reasoning disabled.
153
+
154
+ ---
155
+
156
+ ## Is it working?
157
+
158
+ Turn 1 won't show anything — there's no history to patch yet. That's normal.
159
+
160
+ Turn 2+, check the console:
161
+
162
+ **Plugin working:**
163
+ ```
164
+ [ThinkingFix] patched 3 field(s) across 11 message(s)
165
+ ```
166
+
167
+ **Proxy working:**
168
+ ```
169
+ [Cache] session abcdefgh: stored reasoning turn 0 (1842 chars)
170
+ [Cache] session abcdefgh: replayed reasoning for turn 0 (1842 chars)
171
+ ```
172
+
173
+ If nothing shows up, either it's a non-reasoning model (correct) or the plugin didn't load (check for `[ThinkingFix] Plugin loaded` at startup).
174
+
175
+ Quick proxy health check:
176
+
177
+ ```bash
178
+ curl http://127.0.0.1:3457/health
179
+ # → {"ok":true,"uptime":42}
180
+ curl http://127.0.0.1:3458/health
181
+ # → {"ok":true,"uptime":42}
182
+ ```
183
+
184
+ Proxy logs:
185
+
186
+ ```bash
187
+ journalctl --user -u reasoning-cache.service -f
188
+ journalctl --user -u reasoning-cache-go.service -f
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Running the test suite
194
+
195
+ ```bash
196
+ npm test
197
+ # or directly:
198
+ node tests/test-plugin.js
199
+ node tests/test-proxy.js
200
+ ```
201
+
202
+ The plugin tests cover 12 cases: native reasoning model detection, OpenCode Go `reasoning` field detection, non-reasoning model passthrough, mixed messages, multiple assistant turns, already-complete messages, wrapper format (`{ info: Message }`), empty `reasoning_content`, empty arrays, tool_calls with reasoning and without, and null/undefined wrappers.
203
+
204
+ The proxy tests cover 15 cases: route resolution for all model prefixes, `patchRequestBody` injection from cache for both `reasoning_content` and `reasoning`, no-cache fallback to empty strings, user message isolation, multi-turn caching, and SSE stream parsing for `delta.reasoning_content`, `delta.reasoning`, content-triggered flush, and `finish_reason` flush.
205
+
206
+ ---
207
+
208
+ ## This bug is everywhere
209
+
210
+ OpenCode isn't the only tool that drops `reasoning_content`. Here's a partial list of places this same bug shows up:
211
+
212
+ **OpenCode (anomalyco/opencode):** [#24190](https://github.com/anomalyco/opencode/issues/24190), [#24104](https://github.com/anomalyco/opencode/issues/24104), [#24722](https://github.com/anomalyco/opencode/issues/24722), [#25311](https://github.com/anomalyco/opencode/issues/25311), [#25134](https://github.com/anomalyco/opencode/issues/25134), [#25000](https://github.com/anomalyco/opencode/issues/25000), [#24124](https://github.com/anomalyco/opencode/issues/24124), [#24130](https://github.com/anomalyco/opencode/issues/24130), [#24261](https://github.com/anomalyco/opencode/issues/24261), [#24442](https://github.com/anomalyco/opencode/issues/24442), [#24569](https://github.com/anomalyco/opencode/issues/24569)
213
+
214
+ **OpenClaw:** [#71435](https://github.com/openclaw/openclaw/issues/71435), [#71050](https://github.com/openclaw/openclaw/issues/71050)
215
+
216
+ **Kilo Code:** [#9501](https://github.com/Kilo-Org/kilocode/issues/9501)
217
+
218
+ **VS Code:** [#318920](https://github.com/microsoft/vscode/issues/318920)
219
+
220
+ **OpenAI Codex:** [#24500](https://github.com/openai/codex/issues/24500)
221
+
222
+ **GitHub Copilot:** [discussion #193953](https://github.com/orgs/community/discussions/193953)
223
+
224
+ **OmniRoute:** [#1628](https://github.com/diegosouzapw/OmniRoute/issues/1628)
225
+
226
+ **Reddit:** [r/opencodeCLI](https://www.reddit.com/r/opencodeCLI/comments/1svftic/), [r/DeepSeek](https://www.reddit.com/r/DeepSeek/comments/1tqvrup/), [r/RooCode](https://www.reddit.com/r/RooCode/comments/1sw7e54/)
227
+
228
+ **Blogs covering it:** [AkitaOnRails](https://akitaonrails.com/en/2026/05/04/llm-benchmarks-deepseek-unlocked-deepclaude/), [ClawHub](https://clawhub.ai/17329971/deepseek-v4-reasoning-bug)
229
+
230
+ ---
231
+
232
+ ## Files in this repo
233
+
234
+ ```
235
+ plugins/
236
+ opencode-thinking-fix-universal.ts # self-detection plugin (106 lines)
237
+ proxy/
238
+ proxy.js # reasoning cache proxy (333 lines, zero deps)
239
+ tests/
240
+ test-plugin.js # plugin unit tests (228 lines, 12 cases)
241
+ test-proxy.js # proxy unit tests (359 lines, 15 cases)
242
+ watchdog/
243
+ watchdog.sh # auto-recovery watchdog (64 lines)
244
+ systemd/
245
+ reasoning-cache.service # proxy systemd unit (port 3457)
246
+ reasoning-cache-go.service # OpenCode Go proxy unit (port 3458)
247
+ reasoning-proxy-watchdog.service # watchdog systemd unit
248
+ ```
249
+
250
+ ## Tested on
251
+
252
+ OpenCode v1.17.9, Kubuntu 24.04, DeepSeek V4 Pro, OpenCode Go.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "opencode-thinking-fix",
3
+ "version": "1.0.0",
4
+ "description": "Fix DeepSeek/Kimi/GLM/MiMo reasoning_content 400 errors in OpenCode. Plugin + proxy + watchdog.",
5
+ "license": "MIT",
6
+ "author": "tbosancheros39",
7
+ "keywords": [
8
+ "opencode",
9
+ "deepseek",
10
+ "kimi",
11
+ "glm",
12
+ "reasoning",
13
+ "reasoning_content",
14
+ "thinking",
15
+ "proxy",
16
+ "plugin"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/tbosancheros39/opencode-thinking-fix.git"
21
+ },
22
+ "files": [
23
+ "plugins/",
24
+ "proxy/",
25
+ "tests/",
26
+ "watchdog/",
27
+ "systemd/",
28
+ "README.md",
29
+ ".gitignore"
30
+ ],
31
+ "scripts": {
32
+ "test": "node tests/test-plugin.js && node tests/test-proxy.js"
33
+ }
34
+ }
@@ -0,0 +1,106 @@
1
+ import type { Plugin } from '@opencode-ai/plugin'
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // CONFIRMED FACTS (cross-validated across DeepSeek V4 Pro, Claude MAX, Kimi
5
+ // via live subagent research on OpenCode v1.17.9):
6
+ //
7
+ // 1. experimental.chat.messages.transform receives input: {} (always empty)
8
+ // input.model is NEVER available — confirmed by GitHub issue #25494
9
+ // Do NOT use input.model for provider detection — it is always undefined
10
+ //
11
+ // 2. Detection strategy: scan output.messages for any assistant turn that
12
+ // already HAS reasoning_content (set by OpenCode on the last response turn)
13
+ // If found → reasoning model (DeepSeek/Kimi/GLM/MiMo) → patch ALL turns
14
+ // If none → non-reasoning model (Qwen/GPT/Claude/Mistral) → pass through
15
+ // Zero false positives: non-reasoning models never produce reasoning_content
16
+ //
17
+ // 3. In-place mutation only — reassigning output.messages is a silent no-op
18
+ // Confirmed by GitHub issue #25754
19
+ //
20
+ // 4. Message wrapper shape: { info: Message, parts: Part[] } or bare Message
21
+ // Handle both via: wrapper?.info ?? wrapper
22
+ //
23
+ // DEPLOYMENT:
24
+ // Path: ~/.config/opencode/plugins/opencode-thinking-fix-universal.ts
25
+ // (plural "plugins/" — auto-discovery, zero config needed)
26
+ // Config: no opencode.json entry required for auto-discovery
27
+ // OR: "plugin": ["opencode-thinking-fix-universal"] (array, not object)
28
+ // Build: none — OpenCode compiles .ts plugins at load time
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ function patchMessages(messages: any[]): number {
32
+ let count = 0
33
+
34
+ // ── Step 1: Self-detect reasoning model ──────────────────────────────────
35
+ // Scan for any assistant message that already has reasoning_content.
36
+ // OpenCode sets this field on the most recent response turn.
37
+ // Non-reasoning providers (Qwen/GPT/Claude/Mistral/Llama) never have it.
38
+ const isReasoningModel = messages.some((wrapper) => {
39
+ const msg = wrapper?.info ?? wrapper
40
+ return msg?.role === 'assistant' && ('reasoning_content' in msg || 'reasoning' in msg)
41
+ })
42
+
43
+ // ── Step 2: Non-reasoning model — exit immediately, zero modification ─────
44
+ // Qwen/GPT/Claude reject unknown fields with HTTP 400.
45
+ // This guard is what prevents multi-session errors on non-reasoning models.
46
+ if (!isReasoningModel) return 0
47
+
48
+ // ── Step 3: Patch all assistant turns missing required fields ─────────────
49
+ for (const wrapper of messages) {
50
+ const msg = wrapper?.info ?? wrapper
51
+ if (!msg || msg.role !== 'assistant') continue
52
+
53
+ // Fix 1: content must be non-null string on every assistant turn.
54
+ // OpenAI-compatible SDK omits it when only tool_calls are present.
55
+ if (!msg.content && msg.content !== '') {
56
+ msg.content = msg.tool_calls?.length ? 'call tool' : ''
57
+ count++
58
+ }
59
+
60
+ // Fix 2: reasoning_content must be present on every assistant turn in history.
61
+ // Absence causes HTTP 400 from DeepSeek/Kimi/GLM/MiMo on turn 2+.
62
+ // Use "" (empty string) — confirmed working cross-provider.
63
+ // "" and null both work per DeepSeek docs; "" is safer cross-provider.
64
+ if (!('reasoning_content' in msg)) {
65
+ msg.reasoning_content = ''
66
+ count++
67
+ }
68
+
69
+ // Fix 3: reasoning must be present for OpenCode Go provider on every turn.
70
+ if (!('reasoning' in msg)) {
71
+ msg.reasoning = ''
72
+ count++
73
+ }
74
+ }
75
+
76
+ return count
77
+ }
78
+
79
+ export const ThinkingFixPlugin: Plugin = async ({ client }) => {
80
+ client.app.log({ body: { service: 'thinking-fix', level: 'info', message: 'Plugin loaded — universal reasoning_content fix active' } })
81
+
82
+ return {
83
+ 'chat.headers': async (input: any, output: any) => {
84
+ if (input.sessionID && !output.headers['x-session-id']) {
85
+ output.headers['x-session-id'] = input.sessionID
86
+ }
87
+ },
88
+
89
+ 'experimental.chat.messages.transform': async (_input: {}, output: any) => {
90
+ try {
91
+ const messages = output?.messages
92
+ if (!Array.isArray(messages) || messages.length === 0) return
93
+
94
+ const patched = patchMessages(messages)
95
+
96
+ if (patched > 0) {
97
+ client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `patched ${patched} field(s) across ${messages.length} message(s)` } })
98
+ }
99
+ } catch (err) {
100
+ client.app.log({ body: { service: 'thinking-fix', level: 'error', message: `Error — passing through unmodified: ${err}` } })
101
+ }
102
+ },
103
+ }
104
+ }
105
+
106
+ export default ThinkingFixPlugin