opencode-thinking-fix 2.0.1 → 3.1.4
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/README.md +148 -57
- package/package.json +3 -7
- package/proxy/core.js +255 -0
- package/proxy/proxy.js +216 -330
- package/CHANGELOG.md +0 -106
- package/tests/test-plugin.js +0 -228
- package/tests/test-proxy.js +0 -359
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
npm install opencode-thinking-fix
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
> Fix for
|
|
13
|
+
> Fix for reasoning being dropped from multi-turn conversations with DeepSeek, Kimi, GLM, MiMo, and MiniMax-M3.
|
|
14
14
|
>
|
|
15
15
|
> **Zero config.** Install via `Ctrl+P`, restart OpenCode, done. The plugin auto-detects reasoning models and only patches when needed.
|
|
16
16
|
>
|
|
@@ -20,13 +20,81 @@ Your AI has a secret notebook.
|
|
|
20
20
|
|
|
21
21
|
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.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
The client and its SDK throw the notebook away. Next turn, the AI reaches for it — but the request already went to the API without it. The request carries no notes, or a blank notebook. The AI doesn't crash, but it forgot everything it was thinking. That is why your AI seems dumber on turn 2. It's not dumber. It just lost its notes.
|
|
24
|
+
|
|
25
|
+
The drop happens in SDK serializers and client transforms (truthy checks, missing type members, stripped signatures), not in the providers — the providers are doing exactly what their docs say.
|
|
26
|
+
|
|
27
|
+
**Without the plugin:** "Build me a login page." → AI builds it. "Now add password reset." → AI lost its notes, conversation degrades.
|
|
24
28
|
|
|
25
|
-
**Without the plugin:** "Build me a login page." → AI builds it. "Now add password reset." → 400 error, conversation dead.
|
|
26
29
|
**With the plugin:** "Build me a login page." → AI builds it, notes saved. "Now add password reset." → AI reads its notes: "I used React Hook Form for login, I'll extend that for password reset." → Works.
|
|
27
30
|
|
|
28
31
|
---
|
|
29
32
|
|
|
33
|
+
## ELI15 — what this thing actually does
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
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).
|
|
38
|
+
|
|
39
|
+
Three facts make this messy:
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
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."
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
What this repo does about it:
|
|
46
|
+
|
|
47
|
+
- **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).
|
|
48
|
+
- **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.)
|
|
49
|
+
- **The watchdog** is just a babysitter that restarts the proxy if it ever crashes.
|
|
50
|
+
|
|
51
|
+
Bottom line: **the proxy is what makes the AI actually remember its thinking. The plugin is a safety net. The watchdog keeps the lights on.**
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## v3 proxy rewrite — performance + correctness
|
|
56
|
+
|
|
57
|
+
The proxy (`proxy/proxy.js`) was rewritten to remove the slowdown you were hitting:
|
|
58
|
+
|
|
59
|
+
- **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.
|
|
60
|
+
- **Lazy patching.** If OpenCode already hands the notes back correctly (newer builds do), the proxy does **nothing** — zero JSON rewriting, zero overhead.
|
|
61
|
+
- **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.
|
|
62
|
+
- **Keep-alive connections + upstream timeout.** Fewer TLS handshakes, no hung requests.
|
|
63
|
+
|
|
64
|
+
## v3.1 — dialect-aware patching (intel-driven)
|
|
65
|
+
|
|
66
|
+
Provider-specific research showed the field name is **not universal** and the "echo everything" heuristic was
|
|
67
|
+
causing cross-provider poisoning. v3.1 fixes that:
|
|
68
|
+
|
|
69
|
+
- **Echo only the field the upstream expects.** Each route declares a `reasoningKey`:
|
|
70
|
+
`reasoning_content` (DeepSeek/Kimi/Moonshot/GLM/Zhipu/MiMo), `reasoning_details`
|
|
71
|
+
(MiniMax split mode), `reasoning` (OpenCode Go / fixed-upstream). Previously the proxy
|
|
72
|
+
filled all three fields, so a GLM turn stored under `reasoning` could poison a later
|
|
73
|
+
DeepSeek replay that expects `reasoning_content`.
|
|
74
|
+
- **R1 vs V4 split.** `deepseek-reasoner` (R1) must **not** receive reasoning echoed back
|
|
75
|
+
(400 if you do) while V4 must. R1 now routes with `reasoningKey: null`.
|
|
76
|
+
- **Never fabricate reasoning.** Unknown models and providers that reject echo
|
|
77
|
+
(Qwen, GPT, Claude, Gemini, Llama, Mistral, Cerebras-hosted GLM) default to
|
|
78
|
+
`reasoningKey: null` — the body is forwarded untouched.
|
|
79
|
+
|
|
80
|
+
### "Another way" — do you even need the plugin now?
|
|
81
|
+
|
|
82
|
+
The reasoning-drop is fixed at the wire layer by the proxy, so you have three options:
|
|
83
|
+
|
|
84
|
+
| Approach | Pros | Cons |
|
|
85
|
+
|----------|------|------|
|
|
86
|
+
| **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 |
|
|
87
|
+
| **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 |
|
|
88
|
+
| **Full plugin + proxy** (old default) | Maximum safety net | Plugin's per-message transform adds overhead inside OpenCode |
|
|
89
|
+
|
|
90
|
+
**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.
|
|
91
|
+
|
|
92
|
+
## Plugin deprecation and derived sessions
|
|
93
|
+
|
|
94
|
+
As of 3.0, plugin is deprecated. Proxy derives session identity from 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. Rare bounded collision: conversations sharing same first user message, model, and auth key share cache key, causing minor reasoning cross-talk that self-heals as turns diverge.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
30
98
|
## Quick Install
|
|
31
99
|
|
|
32
100
|
**This is an OpenCode plugin. Install it inside OpenCode, no terminal needed.**
|
|
@@ -56,7 +124,7 @@ opencode plugin opencode-thinking-fix
|
|
|
56
124
|
For a specific version:
|
|
57
125
|
|
|
58
126
|
```bash
|
|
59
|
-
opencode plugin opencode-thinking-fix@
|
|
127
|
+
opencode plugin opencode-thinking-fix@3.1.4
|
|
60
128
|
```
|
|
61
129
|
|
|
62
130
|
Restart OpenCode after installing.
|
|
@@ -82,31 +150,27 @@ See also: [OpenCode plugin docs](https://opencode.ai/docs/plugins)
|
|
|
82
150
|
---
|
|
83
151
|
|
|
84
152
|
- [What problem this fixes](#what-problem-this-fixes)
|
|
85
|
-
- [Option 1: Plugin (
|
|
153
|
+
- [Option 1: Plugin (safety net)](#option-1-plugin-safety-net)
|
|
86
154
|
- [Option 2: Proxy (replays real reasoning)](#option-2-proxy-replays-real-reasoning)
|
|
87
155
|
- [Option 3: Watchdog (auto-recovery)](#option-3-watchdog-auto-recovery)
|
|
88
156
|
- [How they work together](#how-they-work-together)
|
|
89
157
|
- [Affected models](#affected-models)
|
|
90
158
|
- [Model routing](#model-routing)
|
|
91
159
|
- [Is it working?](#is-it-working)
|
|
92
|
-
- [Running tests](#running-tests)
|
|
93
160
|
- [This bug is everywhere](#this-bug-is-everywhere)
|
|
94
161
|
- [Files in this repo](#files-in-this-repo)
|
|
95
|
-
- [Changelog](#changelog)
|
|
96
162
|
|
|
97
163
|
---
|
|
98
164
|
|
|
99
165
|
## What problem this fixes
|
|
100
166
|
|
|
101
|
-
You ask DeepSeek a question. It picks a tool, calls it, works fine. Then you ask a follow-up and
|
|
167
|
+
You ask DeepSeek a question. It picks a tool, calls it, works fine. Then you ask a follow-up — and the AI has lost the reasoning it produced on every earlier turn.
|
|
102
168
|
|
|
103
|
-
|
|
104
|
-
HTTP 400: The reasoning_content in the thinking mode must be passed back to the API
|
|
105
|
-
```
|
|
169
|
+
The client/SDK dropped the field before it reached the API — the providers are doing exactly what their docs say. The drop happens in SDK serializers and client transforms.
|
|
106
170
|
|
|
107
|
-
|
|
171
|
+
> **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.
|
|
108
172
|
|
|
109
|
-
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](https://api-docs.deepseek.com/guides/thinking_mode) say it clearly: if you do not pass back `reasoning_content` correctly, the API
|
|
173
|
+
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](https://api-docs.deepseek.com/guides/thinking_mode) say it clearly: if you do not pass back `reasoning_content` correctly, the API rejects the request. All five providers confirm this in their official documentation:
|
|
110
174
|
|
|
111
175
|
- **DeepSeek**: "[The reasoning_content will be ignored by the API](https://api-docs.deepseek.com/guides/thinking_mode)", but the conversation history must contain the field.
|
|
112
176
|
- **Z.AI / GLM**: "[Key: return reasoning_content to keep the reasoning coherent](https://docs.z.ai/en/api/thinking)."
|
|
@@ -114,13 +178,12 @@ DeepSeek V4 (and Kimi K2.7, GLM 5.x, MiMo V2.5) require that `reasoning_content`
|
|
|
114
178
|
- **MiniMax**: "[The complete model response must be append to the conversation history](https://platform.minimaxi.com/document/ChatCompletion%20v2)."
|
|
115
179
|
- **Xiaomi MiMo**: "[Any assistant message with tool calls... must preserve its full reasoning_content field, otherwise the API will return a 400 error](https://dev.mi.com/doc/llm-api). Affected frameworks include TRAE, Cursor, Roo Code, Codex, GitHub Copilot CLI, Zed, AutoGen."
|
|
116
180
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
This repo fixes it. Three layers, pick what you need.
|
|
181
|
+
The field is non-standard per OpenAI, so SDKs and clients ignore it — the drop is
|
|
182
|
+
systemic, not a single tool's bug. This repo fixes it. Three layers, pick what you need.
|
|
120
183
|
|
|
121
184
|
---
|
|
122
185
|
|
|
123
|
-
## Option 1: plugin (
|
|
186
|
+
## Option 1: plugin (safety net)
|
|
124
187
|
|
|
125
188
|
### Install via npm (recommended)
|
|
126
189
|
|
|
@@ -141,7 +204,7 @@ It also handles `reasoning` for the OpenCode Go provider, and patches empty `con
|
|
|
141
204
|
|
|
142
205
|
No config file changes. No build step. OpenCode compiles `.ts` plugins when it starts.
|
|
143
206
|
|
|
144
|
-
**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
|
|
207
|
+
**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.
|
|
145
208
|
|
|
146
209
|
---
|
|
147
210
|
|
|
@@ -160,7 +223,7 @@ The proxy runs on **two ports**:
|
|
|
160
223
|
| **3457** | Direct providers (DeepSeek, Kimi, GLM, MiMo, GPT, Claude, Qwen, Gemini, etc.) | `PORT=3457` |
|
|
161
224
|
| **3458** | OpenCode Go provider | `PORT=3458` `UPSTREAM_URL=https://opencode.ai/zen/go/v1` |
|
|
162
225
|
|
|
163
|
-
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
|
|
226
|
+
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. It handles the provider's supported chat and Anthropic message dialects, while passing unsupported response formats through unchanged. Both are handled by the same `proxy.js` binary, just different environment variables.
|
|
164
227
|
|
|
165
228
|
```bash
|
|
166
229
|
# Linux / macOS / Windows (Node.js required)
|
|
@@ -172,6 +235,44 @@ PORT=3458 UPSTREAM_URL=https://opencode.ai/zen/go/v1 node proxy/proxy.js
|
|
|
172
235
|
|
|
173
236
|
> **Windows PowerShell:** use `$env:PORT=3457; node proxy/proxy.js` (PowerShell) or `set PORT=3457 && node proxy/proxy.js` (CMD).
|
|
174
237
|
|
|
238
|
+
### OpenCode Go setup (explicit and credentials-safe)
|
|
239
|
+
|
|
240
|
+
The npm package intentionally does **not** run a `postinstall` script. It never
|
|
241
|
+
reads `auth.json`, edits your OpenCode configuration, starts a process, or
|
|
242
|
+
enables a service without your explicit action.
|
|
243
|
+
|
|
244
|
+
1. In OpenCode, run `/connect` and select **OpenCode Go**. Keep the credential
|
|
245
|
+
in OpenCode's own local auth store; never paste it into this repository,
|
|
246
|
+
`opencode.json`, a shell script, or a public issue.
|
|
247
|
+
2. Install the package and start its Go proxy manually:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
npm install opencode-thinking-fix
|
|
251
|
+
PORT=3458 UPSTREAM_URL=https://opencode.ai/zen/go/v1 \
|
|
252
|
+
node node_modules/opencode-thinking-fix/proxy/proxy.js
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
3. Point only the OpenCode Go provider at the local proxy. The `baseURL` must
|
|
256
|
+
be under `provider.<id>.options`:
|
|
257
|
+
|
|
258
|
+
```json
|
|
259
|
+
{
|
|
260
|
+
"$schema": "https://opencode.ai/config.json",
|
|
261
|
+
"provider": {
|
|
262
|
+
"opencode-go": {
|
|
263
|
+
"options": {
|
|
264
|
+
"baseURL": "http://127.0.0.1:3458/v1"
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The proxy forwards the authorization header supplied by OpenCode to the
|
|
272
|
+
configured upstream and does not persist the credential. Stop the proxy when
|
|
273
|
+
it is not needed. The systemd and watchdog options below are deliberately
|
|
274
|
+
opt-in alternatives for operators who want automatic restarts.
|
|
275
|
+
|
|
175
276
|
### Install as systemd services (auto-start at boot)
|
|
176
277
|
|
|
177
278
|
```bash
|
|
@@ -189,10 +290,14 @@ Then point OpenCode at it, in your `opencode.json`:
|
|
|
189
290
|
{
|
|
190
291
|
"provider": {
|
|
191
292
|
"deepseek-v4-pro": {
|
|
192
|
-
"
|
|
293
|
+
"options": {
|
|
294
|
+
"baseURL": "http://127.0.0.1:3457/v1"
|
|
295
|
+
}
|
|
193
296
|
},
|
|
194
297
|
"opencode-go": {
|
|
195
|
-
"
|
|
298
|
+
"options": {
|
|
299
|
+
"baseURL": "http://127.0.0.1:3458/v1"
|
|
300
|
+
}
|
|
196
301
|
}
|
|
197
302
|
}
|
|
198
303
|
}
|
|
@@ -228,7 +333,7 @@ OpenCode → [plugin patches missing reasoning_content/reasoning]
|
|
|
228
333
|
→ API
|
|
229
334
|
```
|
|
230
335
|
|
|
231
|
-
The plugin is the safety net. If the proxy goes down, the plugin still injects empty strings so
|
|
336
|
+
The plugin is the safety net. If the proxy goes down, the plugin still injects empty strings so reasoning is never missing from the request. If the proxy is up, its cached text takes priority because the plugin sees the field is already filled in. Either way, your conversation keeps its thinking.
|
|
232
337
|
|
|
233
338
|
---
|
|
234
339
|
|
|
@@ -236,28 +341,29 @@ The plugin is the safety net. If the proxy goes down, the plugin still injects e
|
|
|
236
341
|
|
|
237
342
|
| Model | Plugin helps | Proxy helps | What it needs |
|
|
238
343
|
|---|---|---|---|
|
|
239
|
-
| DeepSeek V4 Pro / Flash | Yes | Nice to have | Accepts `""` |
|
|
344
|
+
| DeepSeek V4 Pro / Flash | Yes | Nice to have | Accepts `""` (tool-call turns need real text) |
|
|
240
345
|
| Kimi K2.5 / K2.6 | Yes | Nice to have | Accepts `""` |
|
|
241
|
-
| **Kimi K2.7 Code** | **Not enough alone** | **Required** |
|
|
346
|
+
| **Kimi K2.7 Code** | **Not enough alone** | **Required** | Field must be *present*; real text keeps it coherent |
|
|
242
347
|
| GLM-5.x / Zhipu | Yes | Nice to have | Accepts `""` |
|
|
243
|
-
| MiMo V2.5
|
|
348
|
+
| MiMo V2.5 | Yes | Nice to have | `reasoning_content` on `api.xiaomimimo.com/v1` |
|
|
244
349
|
| **MiniMax-M3** | Yes | **Recommended** | `reasoning_details[]` array; ~40% quality loss if stripped. Proxy injects `reasoning_split:true` to keep thinking separate from content. |
|
|
245
|
-
| OpenCode Go | Yes |
|
|
350
|
+
| OpenCode Go | Yes | Recommended for chat/message routes | Chat uses `reasoning`; Anthropic messages are handled; unsupported response formats pass through |
|
|
246
351
|
| Qwen, GPT, Claude, Gemini, Llama, Mistral | No | No | No reasoning_content |
|
|
247
352
|
|
|
248
353
|
---
|
|
249
354
|
|
|
250
355
|
## Model routing (proxy port 3457)
|
|
251
356
|
|
|
252
|
-
The proxy auto-routes by model name prefix. All
|
|
357
|
+
The proxy auto-routes by model name prefix. All routes:
|
|
253
358
|
|
|
254
359
|
| Prefix | Upstream | Reasoning |
|
|
255
360
|
|---|---|---|
|
|
256
|
-
| `deepseek-v4-pro` | `https://api.deepseek.com` | Yes |
|
|
257
|
-
| `deepseek` | `https://api.deepseek.com` |
|
|
258
|
-
| `kimi`, `moonshot` | `https://api.moonshot.ai/v1` | Yes |
|
|
259
|
-
| `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes |
|
|
260
|
-
| `minimax
|
|
361
|
+
| `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-chat` | `https://api.deepseek.com` | Yes (`reasoning_content`) |
|
|
362
|
+
| `deepseek-reasoner` | `https://api.deepseek.com` | **No — must NOT echo** (R1 contract) |
|
|
363
|
+
| `kimi`, `moonshot` | `https://api.moonshot.ai/v1` | Yes (`reasoning_content`) |
|
|
364
|
+
| `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes (`reasoning_content`) |
|
|
365
|
+
| `minimax` | `https://api.minimax.io/v1` | Yes (`reasoning_details`, `reasoning_split:true`) |
|
|
366
|
+
| `mimo` | `https://api.xiaomimimo.com/v1` | Yes (`reasoning_content`) |
|
|
261
367
|
| `gpt`, `o1` | `https://api.openai.com` | No |
|
|
262
368
|
| `claude`, `anthropic` | `https://api.anthropic.com` | No |
|
|
263
369
|
| `qwen` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | No |
|
|
@@ -278,8 +384,8 @@ The plugin writes a structured JSON log: `~/.local/share/opencode/thinking-fix.l
|
|
|
278
384
|
Before and after, from a real session:
|
|
279
385
|
|
|
280
386
|
```
|
|
281
|
-
Before:
|
|
282
|
-
After: 34 fields patched across a 104-message session →
|
|
387
|
+
Before: reasoning dropped on every turn → AI forgets its thinking
|
|
388
|
+
After: 34 fields patched across a 104-message session → reasoning replays correctly
|
|
283
389
|
```
|
|
284
390
|
|
|
285
391
|
Here is a live excerpt from that session:
|
|
@@ -309,23 +415,6 @@ journalctl --user -u reasoning-cache.service -f
|
|
|
309
415
|
journalctl --user -u reasoning-cache-go.service -f
|
|
310
416
|
```
|
|
311
417
|
|
|
312
|
-
---
|
|
313
|
-
|
|
314
|
-
## Running tests
|
|
315
|
-
|
|
316
|
-
```bash
|
|
317
|
-
npm test
|
|
318
|
-
# or directly:
|
|
319
|
-
node tests/test-plugin.js
|
|
320
|
-
node tests/test-proxy.js
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
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.
|
|
324
|
-
|
|
325
|
-
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.
|
|
326
|
-
|
|
327
|
-
---
|
|
328
|
-
|
|
329
418
|
## This bug is everywhere
|
|
330
419
|
|
|
331
420
|
OpenCode is not the only tool that drops `reasoning_content`. Here is a partial list of places this same bug shows up:
|
|
@@ -356,10 +445,8 @@ OpenCode is not the only tool that drops `reasoning_content`. Here is a partial
|
|
|
356
445
|
plugins/
|
|
357
446
|
opencode-thinking-fix-universal.ts # self-detection plugin (92 lines)
|
|
358
447
|
proxy/
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
test-plugin.js # plugin unit tests (228 lines, 12 cases)
|
|
362
|
-
test-proxy.js # proxy unit tests (359 lines, 15 cases)
|
|
448
|
+
core.js # pure logic: routes, cache, patching, parser (138 lines)
|
|
449
|
+
proxy.js # HTTP server + side effects (266 lines, 1 dep)
|
|
363
450
|
watchdog/
|
|
364
451
|
watchdog.sh # auto-recovery watchdog (64 lines)
|
|
365
452
|
systemd/
|
|
@@ -368,9 +455,13 @@ systemd/
|
|
|
368
455
|
reasoning-proxy-watchdog.service # watchdog systemd unit
|
|
369
456
|
```
|
|
370
457
|
|
|
371
|
-
##
|
|
458
|
+
## Release highlights
|
|
372
459
|
|
|
373
|
-
|
|
460
|
+
- **v3.1.4:** proxy-owned structured JSONL logging with session identifiers truncated in logs.
|
|
461
|
+
- **v3.1.3:** Anthropic-wire dialect detection for OpenCode Go routes.
|
|
462
|
+
- **v3.1.2:** bounded cache/session memory, raw-stream preservation, hop-by-hop header removal, and safer parser failure handling.
|
|
463
|
+
- **v3.1.1:** corrected MiMo routing, non-streaming reasoning caching, and fixed partial-turn patching.
|
|
464
|
+
- **v3.1:** provider-aware reasoning fields, including the DeepSeek R1 no-echo route and safe passthrough for unknown models.
|
|
374
465
|
|
|
375
466
|
---
|
|
376
467
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-thinking-fix",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.4",
|
|
4
4
|
"description": "Fix reasoning_content 400 errors for DeepSeek/Kimi/GLM/MiMo/MiniMax in OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,20 +61,15 @@
|
|
|
61
61
|
},
|
|
62
62
|
"files": [
|
|
63
63
|
"plugins/opencode-thinking-fix-universal.ts",
|
|
64
|
+
"proxy/core.js",
|
|
64
65
|
"proxy/proxy.js",
|
|
65
66
|
"systemd/reasoning-cache.service",
|
|
66
67
|
"systemd/reasoning-cache-go.service",
|
|
67
68
|
"systemd/reasoning-proxy-watchdog.service",
|
|
68
69
|
"watchdog/watchdog.sh",
|
|
69
|
-
"tests/test-plugin.js",
|
|
70
|
-
"tests/test-proxy.js",
|
|
71
70
|
"README.md",
|
|
72
|
-
"CHANGELOG.md",
|
|
73
71
|
"LICENSE.md"
|
|
74
72
|
],
|
|
75
|
-
"scripts": {
|
|
76
|
-
"test": "node tests/test-plugin.js && node tests/test-proxy.js"
|
|
77
|
-
},
|
|
78
73
|
"dependencies": {
|
|
79
74
|
"eventsource-parser": "^3.1.0"
|
|
80
75
|
},
|
|
@@ -90,6 +85,7 @@
|
|
|
90
85
|
"node": ">=18"
|
|
91
86
|
},
|
|
92
87
|
"devDependencies": {
|
|
88
|
+
"@types/node": "^18.19.130",
|
|
93
89
|
"typescript": "^6.0.3"
|
|
94
90
|
}
|
|
95
91
|
}
|
package/proxy/core.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
import { createParser } from 'eventsource-parser'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
export function deriveSessionId(modelName, parsedBody, authHeader) {
|
|
10
|
+
const messages = parsedBody?.messages
|
|
11
|
+
if (!Array.isArray(messages)) return ''
|
|
12
|
+
const firstUser = messages.find((m) => m && m.role === 'user')
|
|
13
|
+
if (!firstUser) return ''
|
|
14
|
+
const text = typeof firstUser.content === 'string'
|
|
15
|
+
? firstUser.content
|
|
16
|
+
: Array.isArray(firstUser.content)
|
|
17
|
+
? firstUser.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('')
|
|
18
|
+
: ''
|
|
19
|
+
if (!text) return ''
|
|
20
|
+
return 'derived:' + crypto.createHash('sha256')
|
|
21
|
+
.update(`${authHeader || ''}||${modelName}||${text}`).digest('hex').slice(0, 32)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LOG_FILE = process.env.LOG_FILE === ''
|
|
25
|
+
? ''
|
|
26
|
+
: (process.env.LOG_FILE || join(homedir(), '.local', 'share', 'opencode', 'thinking-fix.log'))
|
|
27
|
+
|
|
28
|
+
export function writeLog(entry) {
|
|
29
|
+
if (!LOG_FILE) return
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(dirname(LOG_FILE), { recursive: true })
|
|
32
|
+
appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', 'utf8')
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error('[Proxy] log write error:', error instanceof Error ? error.message : error)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEBUG = process.env.DEBUG === '1'
|
|
39
|
+
|
|
40
|
+
export const ROUTES = {
|
|
41
|
+
// R1 aliases: reasoning must not be echoed.
|
|
42
|
+
'deepseek-r1': { base: 'https://api.deepseek.com', reasoningKey: null },
|
|
43
|
+
'deepseek-v4-pro': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
|
|
44
|
+
'deepseek-v4-flash': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
|
|
45
|
+
'deepseek-chat': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
|
|
46
|
+
'deepseek-reasoner': { base: 'https://api.deepseek.com', reasoningKey: null },
|
|
47
|
+
deepseek: { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
|
|
48
|
+
kimi: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
|
|
49
|
+
moonshot: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
|
|
50
|
+
glm: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoningKey: 'reasoning_content' },
|
|
51
|
+
zhipu: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoningKey: 'reasoning_content' },
|
|
52
|
+
minimax: { base: 'https://api.minimax.io/v1', reasoningKey: 'reasoning_details' },
|
|
53
|
+
mimo: { base: 'https://api.xiaomimimo.com/v1', reasoningKey: 'reasoning_content' },
|
|
54
|
+
qwen: { base: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', reasoningKey: null },
|
|
55
|
+
gpt: { base: 'https://api.openai.com', reasoningKey: null },
|
|
56
|
+
o1: { base: 'https://api.openai.com', reasoningKey: null },
|
|
57
|
+
claude: { base: 'https://api.anthropic.com', reasoningKey: null },
|
|
58
|
+
anthropic: { base: 'https://api.anthropic.com', reasoningKey: null },
|
|
59
|
+
gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', reasoningKey: null },
|
|
60
|
+
llama: { base: 'https://api.together.xyz', reasoningKey: null },
|
|
61
|
+
mistral: { base: 'https://api.mistral.ai', reasoningKey: null },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoningKey: null }
|
|
65
|
+
|
|
66
|
+
export function route(modelName) {
|
|
67
|
+
if (!modelName) return DEFAULT_ROUTE
|
|
68
|
+
const lower = modelName.toLowerCase()
|
|
69
|
+
for (const [prefix, entry] of Object.entries(ROUTES)) {
|
|
70
|
+
if (lower.startsWith(prefix)) return entry
|
|
71
|
+
}
|
|
72
|
+
return DEFAULT_ROUTE
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Models served by opencode-go over the Anthropic /v1/messages wire format.
|
|
76
|
+
const ANTHROPIC_WIRE_PREFIXES = ['minimax', 'qwen']
|
|
77
|
+
|
|
78
|
+
export function fixedUpstreamRoute(modelName, requestUrl = '') {
|
|
79
|
+
const stripped = (modelName || '').toLowerCase().replace(/^opencode-go\//, '')
|
|
80
|
+
if (String(requestUrl).includes('/messages') && ANTHROPIC_WIRE_PREFIXES.some((p) => stripped.startsWith(p))) {
|
|
81
|
+
return { base: '', reasoningKey: 'anthropic' }
|
|
82
|
+
}
|
|
83
|
+
// Unconditional 'reasoning' for go mode: matches current working behavior
|
|
84
|
+
// for chat/completions models; /responses bodies have no `messages` array
|
|
85
|
+
// and pass through untouched.
|
|
86
|
+
return { base: '', reasoningKey: 'reasoning' }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class LRUCache {
|
|
90
|
+
constructor(capacity) { this.cache = new Map(); this.capacity = capacity }
|
|
91
|
+
get(key) {
|
|
92
|
+
if (!this.cache.has(key)) return undefined
|
|
93
|
+
const value = this.cache.get(key)
|
|
94
|
+
this.cache.delete(key); this.cache.set(key, value)
|
|
95
|
+
return value
|
|
96
|
+
}
|
|
97
|
+
set(key, value) {
|
|
98
|
+
this.cache.delete(key)
|
|
99
|
+
this.cache.set(key, value)
|
|
100
|
+
while (this.cache.size > this.capacity) {
|
|
101
|
+
const oldest = this.cache.keys().next().value
|
|
102
|
+
this.cache.delete(oldest)
|
|
103
|
+
if (DEBUG) console.log(`[Cache] evicted session ${String(oldest).slice(-8)}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
put(key, value) { this.set(key, value) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const parsedCapacity = parseInt(process.env.CACHE_CAPACITY || '500', 10)
|
|
110
|
+
const CACHE_CAPACITY = Number.isFinite(parsedCapacity) && parsedCapacity >= 0 ? parsedCapacity : 500
|
|
111
|
+
const parsedMaxTurns = parseInt(process.env.MAX_TURNS_PER_SESSION || '200', 10)
|
|
112
|
+
const MAX_TURNS_PER_SESSION = Number.isFinite(parsedMaxTurns) && parsedMaxTurns > 0 ? parsedMaxTurns : 200
|
|
113
|
+
const parsedMaxReasoning = parseInt(process.env.MAX_REASONING_CHARS || '200000', 10)
|
|
114
|
+
const MAX_REASONING_CHARS = Number.isFinite(parsedMaxReasoning) && parsedMaxReasoning > 0 ? parsedMaxReasoning : 200000
|
|
115
|
+
|
|
116
|
+
class BoundedSessionMap extends Map {
|
|
117
|
+
set(key, value) {
|
|
118
|
+
super.set(key, typeof value === 'string' ? value.slice(0, MAX_REASONING_CHARS) : value)
|
|
119
|
+
while (this.size > MAX_TURNS_PER_SESSION) this.delete(this.keys().next().value)
|
|
120
|
+
return this
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const cache = new LRUCache(CACHE_CAPACITY)
|
|
125
|
+
export const stats = { sessions: 0, hits: 0, misses: 0, stored: 0, requests: 0, passthrough: 0 }
|
|
126
|
+
|
|
127
|
+
export function getSessionCache(sessionId) {
|
|
128
|
+
let sessionMap = cache.get(sessionId)
|
|
129
|
+
if (!sessionMap) { sessionMap = new BoundedSessionMap(); cache.set(sessionId, sessionMap); stats.sessions++ }
|
|
130
|
+
return sessionMap
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function patchRequestBody(body, sessionId, reasoningKey) {
|
|
134
|
+
const emptyReport = { assistantTurns: 0, missingText: 0, missingReasoning: 0, hits: 0, misses: 0, turns: [] }
|
|
135
|
+
if (!reasoningKey || typeof sessionId !== 'string' || !sessionId.trim() || sessionId.trim() === 'unknown') return { body, assistantCount: 0, modified: false, report: emptyReport }
|
|
136
|
+
let parsed
|
|
137
|
+
try { parsed = JSON.parse(body) } catch { return { body, assistantCount: 0, modified: false, report: emptyReport } }
|
|
138
|
+
if (!Array.isArray(parsed.messages)) return { body, assistantCount: 0, modified: false, report: emptyReport }
|
|
139
|
+
|
|
140
|
+
const sessionCache = getSessionCache(sessionId)
|
|
141
|
+
const report = { ...emptyReport, turns: [] }
|
|
142
|
+
let assistantIndex = 0
|
|
143
|
+
let modified = false
|
|
144
|
+
for (const msg of parsed.messages) {
|
|
145
|
+
if (msg.role !== 'assistant') continue
|
|
146
|
+
report.assistantTurns++
|
|
147
|
+
if (msg.content == null || msg.content === '') report.missingText++
|
|
148
|
+
const cached = sessionCache.get(assistantIndex)
|
|
149
|
+
let missingReasoning = false
|
|
150
|
+
if (reasoningKey === 'anthropic') {
|
|
151
|
+
// Anthropic wire: reasoning lives inside content[] as a thinking block.
|
|
152
|
+
const hasThinking = Array.isArray(msg.content) && msg.content.some((b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking'))
|
|
153
|
+
missingReasoning = !hasThinking
|
|
154
|
+
if (missingReasoning) report.missingReasoning++
|
|
155
|
+
if (cached) {
|
|
156
|
+
if (Array.isArray(msg.content)) {
|
|
157
|
+
if (!hasThinking) { msg.content.unshift({ type: 'thinking', thinking: cached }); stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['content'], source: 'hit' }); modified = true }
|
|
158
|
+
} else if (typeof msg.content === 'string') {
|
|
159
|
+
msg.content = [{ type: 'thinking', thinking: cached }, { type: 'text', text: msg.content }]
|
|
160
|
+
stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['content'], source: 'hit' }); modified = true
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
// No hard-400 contract on MiniMax/Qwen anthropic endpoints; never fabricate.
|
|
164
|
+
if (missingReasoning) { stats.misses++; report.misses++ }
|
|
165
|
+
}
|
|
166
|
+
} else if (reasoningKey === 'reasoning_details') {
|
|
167
|
+
const hasDetails = Array.isArray(msg.reasoning_details) ? msg.reasoning_details.length > 0 : !!msg.reasoning_details
|
|
168
|
+
missingReasoning = !hasDetails && !msg.reasoning_content
|
|
169
|
+
if (missingReasoning) report.missingReasoning++
|
|
170
|
+
if (!hasDetails && !msg.reasoning_content && cached) {
|
|
171
|
+
msg.reasoning_details = [{ text: cached, type: 'thinking' }]; stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['reasoning_details'], source: 'hit' }); modified = true
|
|
172
|
+
} else if (missingReasoning) {
|
|
173
|
+
stats.misses++; report.misses++
|
|
174
|
+
}
|
|
175
|
+
} else if (!msg[reasoningKey] || msg[reasoningKey] === '') {
|
|
176
|
+
missingReasoning = true
|
|
177
|
+
report.missingReasoning++
|
|
178
|
+
if (cached) { msg[reasoningKey] = cached; stats.hits++ }
|
|
179
|
+
else { msg[reasoningKey] = ''; stats.misses++ }
|
|
180
|
+
if (cached) { report.hits++; report.turns.push({ index: assistantIndex, fields: [reasoningKey], source: 'hit' }) }
|
|
181
|
+
else { report.misses++; report.turns.push({ index: assistantIndex, fields: [reasoningKey], source: 'miss' }) }
|
|
182
|
+
modified = true
|
|
183
|
+
}
|
|
184
|
+
assistantIndex++
|
|
185
|
+
}
|
|
186
|
+
return { body: modified ? JSON.stringify(parsed) : body, assistantCount: assistantIndex, modified, report }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function extractReasoningFromJson(text) {
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(text)
|
|
192
|
+
// Anthropic buffered format: content[] with thinking blocks.
|
|
193
|
+
if (Array.isArray(parsed?.content)) {
|
|
194
|
+
const thinking = parsed.content
|
|
195
|
+
.filter((b) => b && b.type === 'thinking' && typeof b.thinking === 'string')
|
|
196
|
+
.map((b) => b.thinking)
|
|
197
|
+
.join('\n')
|
|
198
|
+
if (thinking) return thinking
|
|
199
|
+
}
|
|
200
|
+
// OpenAI buffered format.
|
|
201
|
+
const msg = parsed?.choices?.[0]?.message
|
|
202
|
+
if (msg) {
|
|
203
|
+
if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) return msg.reasoning_content
|
|
204
|
+
if (typeof msg.reasoning === 'string' && msg.reasoning) return msg.reasoning
|
|
205
|
+
if (Array.isArray(msg.reasoning_details)) {
|
|
206
|
+
const reasoning = msg.reasoning_details.map((d) => d.text || '').join('')
|
|
207
|
+
if (reasoning) return reasoning
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch { /* not JSON */ }
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createStreamParser(sessionId, baseIndex, onComplete) {
|
|
215
|
+
let reasoningBuffer = ''
|
|
216
|
+
const parser = createParser({
|
|
217
|
+
maxBufferSize: 1024 * 1024,
|
|
218
|
+
onEvent(event) {
|
|
219
|
+
if (!event.data || event.data === '[DONE]') {
|
|
220
|
+
if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(event.data)
|
|
225
|
+
// Anthropic SSE: terminate on message_stop.
|
|
226
|
+
if (event.event === 'message_stop' || parsed.type === 'message_stop') {
|
|
227
|
+
if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
// Anthropic SSE: thinking deltas carry delta.thinking (type thinking_delta).
|
|
231
|
+
if (event.event === 'content_block_delta' || parsed.type === 'content_block_delta') {
|
|
232
|
+
const delta = parsed.delta
|
|
233
|
+
if (delta && typeof delta.thinking === 'string') reasoningBuffer += delta.thinking
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
// OpenAI SSE.
|
|
237
|
+
const delta = parsed.choices?.[0]?.delta
|
|
238
|
+
if (!delta) return
|
|
239
|
+
if (typeof delta.reasoning_content === 'string') reasoningBuffer += delta.reasoning_content
|
|
240
|
+
if (typeof delta.reasoning === 'string') reasoningBuffer += delta.reasoning
|
|
241
|
+
if (Array.isArray(delta.reasoning_details)) {
|
|
242
|
+
for (const detail of delta.reasoning_details) if (typeof detail.text === 'string') reasoningBuffer += detail.text
|
|
243
|
+
}
|
|
244
|
+
if (parsed.choices?.[0]?.finish_reason && reasoningBuffer) {
|
|
245
|
+
onComplete(baseIndex, reasoningBuffer); reasoningBuffer = ''
|
|
246
|
+
}
|
|
247
|
+
} catch { /* malformed SSE chunk, ignore */ }
|
|
248
|
+
},
|
|
249
|
+
onError(error) { if (DEBUG) console.error(`[Proxy] SSE parse error (${sessionId.slice(-8)}):`, error.message) },
|
|
250
|
+
})
|
|
251
|
+
return {
|
|
252
|
+
feed(chunk) { parser.feed(chunk.toString('utf8')) },
|
|
253
|
+
flush() { if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' } },
|
|
254
|
+
}
|
|
255
|
+
}
|