opencode-thinking-fix 1.1.9 → 2.0.1

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/CHANGELOG.md CHANGED
@@ -2,16 +2,37 @@
2
2
 
3
3
  All notable changes to `opencode-thinking-fix`.
4
4
 
5
+ ## [2.0.0] - 2026-06-24
6
+
7
+ ### Changed (breaking)
8
+ - **Proxy memory leak fixed**: replaced unbounded `Map` with `LRUCache(500)`, O(1) eviction, zero timer overhead.
9
+ - **SIGTERM/SIGINT cleanup**: `process.on` handlers drain active sessions and close the HTTP server cleanly. 5-second force-exit safety net.
10
+ - **SSE parser replaced**: hand-rolled state machine → `eventsource-parser` (368k dependents). Built-in `maxBufferSize: 1MB` guard.
11
+ - **Plugin `export default` removed**: named export `{ ThinkingFixPlugin }` only. Bare default caused TUI double-load at 71ms+73ms.
12
+ - **`engines` field added**: `node >=18` required (node:http, structuredClone).
13
+ - **`oc-plugin: ["server"]` field added**: explicit server-only target for OpenCode 1.3.x loader.
14
+
15
+ ### Added
16
+ - `eventsource-parser` runtime dependency.
17
+ - Provider docs evidence: all 5 providers (DeepSeek, Z.AI, Kimi, MiniMax, Xiaomi MiMo) officially confirm the `reasoning_content` bug in their published documentation.
18
+
19
+ ### Removed
20
+ - `export default` bare export from plugin.
21
+ - Hand-rolled SSE parser (~100 lines replaced by 18-line `eventsource-parser` integration).
22
+
23
+ ### Fixed
24
+ - Plugin 175→92 lines (-47%). Proxy 409→422 lines (net: +13, but SSE parser is now battle-tested and memory-safe).
25
+
5
26
  ## [1.1.9] - 2026-06-23
6
27
 
7
28
  ### Added
8
- - **File-based logging**: JSON-lines log at `~/.local/share/opencode/thinking-fix.log` — captures `plugin_loaded`, `inspect`, `patched`, and `error` events with timestamps.
9
- - **`inspect` event**: logs field coverage on every request — `isReasoningModel`, `assistantTurns`, `missingContent`, `missingReasoningContent`, `missingReasoning` — proving plugin activity even when zero patches needed.
29
+ - **File-based logging**: JSON-lines log at `~/.local/share/opencode/thinking-fix.log`, captures `plugin_loaded`, `inspect`, `patched`, and `error` events with timestamps.
30
+ - **`inspect` event**: logs field coverage on every request, `isReasoningModel`, `assistantTurns`, `missingContent`, `missingReasoningContent`, `missingReasoning`, proving plugin activity even when zero patches needed.
10
31
  - **Console fallback**: `writeLog` catches file write failures and emits `console.error` so failures are visible somewhere.
11
32
 
12
33
  ### Changed
13
34
  - `client.app.log()` calls now use `await` for proper async handling.
14
- - Removal of `hook_fired` debug artifact — replaced by structured `inspect` event.
35
+ - Removal of `hook_fired` debug artifact, replaced by structured `inspect` event.
15
36
 
16
37
  ### Fixed
17
38
  - **TUI install flow**: Ctrl+P "install plugin" → type `opencode-thinking-fix` no longer errors. Server-only plugin with bare `export default` properly installs via TUI and CLI without TUI target warnings.
@@ -28,7 +49,7 @@ All notable changes to `opencode-thinking-fix`.
28
49
  ### Fixed
29
50
  - **TUI plugin loading error**: removed broken `opencode-thinking-fix-tui.ts`. OpenCode tried to load it as a TUI plugin at startup and failed because it didn't export `{ tui() }`. Package is now server-only.
30
51
  - **Plugin default export**: switched from v1 format (`{ server: Plugin }`) to v0 legacy format (bare `export default Plugin`), matching the proven opencode-wakatime pattern.
31
- - **package.json**: removed `oc-plugin` field, removed `dependencies`, moved `@opencode-ai/plugin` to optional `peerDependencies` (import is type-only, no runtime dep needed), whitelisted individual files instead of entire directories.
52
+ - **package.json**: removed `oc-plugin` field, removed `dependencies`, moved `@opencode-ai/plugin` to `peerDependencies` (type-only import, no runtime dep needed), whitelisted individual files instead of entire directories.
32
53
 
33
54
  ## [1.1.6] - 2026-06-23
34
55
 
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # opencode-thinking-fix
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/opencode-thinking-fix)](https://www.npmjs.com/package/opencode-thinking-fix)
4
+ [![Test](https://github.com/tbosancheros39/opencode-thinking-fix/actions/workflows/test.yml/badge.svg)](https://github.com/tbosancheros39/opencode-thinking-fix/actions/workflows/test.yml)
4
5
  [![npm downloads](https://img.shields.io/npm/dm/opencode-thinking-fix)](https://www.npmjs.com/package/opencode-thinking-fix)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/tbosancheros39/opencode-thinking-fix/blob/main/LICENSE.md)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/tbosancheros39/opencode-thinking-fix/blob/master/LICENSE.md)
6
7
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org/)
7
8
 
8
9
  ```bash
@@ -11,23 +12,40 @@ npm install opencode-thinking-fix
11
12
 
12
13
  > Fix for the `reasoning_content` 400 error that kills multi-turn conversations with DeepSeek, Kimi, GLM, MiMo, and MiniMax-M3 in OpenCode.
13
14
  >
15
+ > **Zero config.** Install via `Ctrl+P`, restart OpenCode, done. The plugin auto-detects reasoning models and only patches when needed.
16
+ >
14
17
  > Docs: [OpenCode Plugins](https://opencode.ai/docs/plugins)
15
18
 
19
+ Your AI has a secret notebook.
20
+
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
+
23
+ OpenCode throws the notebook away. Next turn, the AI reaches for it — but OpenCode already handed the request to the API without it. The API returns HTTP 400. Or worse, OpenCode hands back 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
+ **Without the plugin:** "Build me a login page." → AI builds it. "Now add password reset." → 400 error, conversation dead.
26
+ **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
+
16
28
  ---
17
29
 
18
30
  ## Quick Install
19
31
 
20
- **This is an OpenCode plugin. Install it inside OpenCode — no terminal needed.**
32
+ **This is an OpenCode plugin. Install it inside OpenCode, no terminal needed.**
33
+
34
+ | Method | Command | Best for |
35
+ |--------|---------|----------|
36
+ | **TUI** | `Ctrl+P` → type `install plugin` → `opencode-thinking-fix` | First-time users |
37
+ | **CLI** | `opencode plugin opencode-thinking-fix` | Scripting |
38
+ | **Manual** | Add `"plugin": ["opencode-thinking-fix"]` to `opencode.json` | Version pinning |
21
39
 
22
40
  ### Method 1: TUI (press `Ctrl+P` while OpenCode is running)
23
41
 
24
42
  1. Press `Ctrl+P` to open the command palette.
25
43
  2. Type `install plugin` and press `Enter`.
26
- 3. Press `Tab` to switch the install scope to **Global** (recommended — works across all projects).
44
+ 3. Press `Tab` to switch the install scope to **Global** (recommended, works across all projects).
27
45
  4. Type `opencode-thinking-fix`.
28
46
  5. Press `Enter`. Restart OpenCode.
29
47
 
30
- You should see `[ThinkingFix] Plugin loaded — universal reasoning_content fix active` at startup.
48
+ Check `~/.local/share/opencode/thinking-fix.log` for `plugin_loaded`. See [Is it working?](#is-it-working).
31
49
 
32
50
  ### Method 2: CLI (shell command)
33
51
 
@@ -38,7 +56,7 @@ opencode plugin opencode-thinking-fix
38
56
  For a specific version:
39
57
 
40
58
  ```bash
41
- opencode plugin opencode-thinking-fix@1.1.4
59
+ opencode plugin opencode-thinking-fix@2.0.0
42
60
  ```
43
61
 
44
62
  Restart OpenCode after installing.
@@ -55,10 +73,12 @@ Config file location:
55
73
  - **Linux/macOS:** `~/.config/opencode/opencode.json` (global) or `.opencode/opencode.json` (project)
56
74
  - **Windows:** `%APPDATA%/OpenCode/opencode.json` (global) or `.opencode/opencode.json` (project)
57
75
 
58
- Restart OpenCode after adding. You should see `[ThinkingFix] Plugin loaded` at startup.
76
+ Restart OpenCode after adding. Check `~/.local/share/opencode/thinking-fix.log` for `plugin_loaded`. See [Is it working?](#is-it-working).
59
77
 
60
78
  See also: [OpenCode plugin docs](https://opencode.ai/docs/plugins)
61
79
 
80
+ > **On Windows?** See [Windows notes](#windows-notes) for PowerShell commands, NSSM service setup, and config paths.
81
+
62
82
  ---
63
83
 
64
84
  - [What problem this fixes](#what-problem-this-fixes)
@@ -72,6 +92,7 @@ See also: [OpenCode plugin docs](https://opencode.ai/docs/plugins)
72
92
  - [Running tests](#running-tests)
73
93
  - [This bug is everywhere](#this-bug-is-everywhere)
74
94
  - [Files in this repo](#files-in-this-repo)
95
+ - [Changelog](#changelog)
75
96
 
76
97
  ---
77
98
 
@@ -83,7 +104,15 @@ You ask DeepSeek a question. It picks a tool, calls it, works fine. Then you ask
83
104
  HTTP 400: The reasoning_content in the thinking mode must be passed back to the API
84
105
  ```
85
106
 
86
- 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 returns a 400 error.
107
+ This is OpenCode dropping the field before it reaches the API — the providers are doing exactly what their docs say.
108
+
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 returns a 400 error. All five providers confirm this in their official documentation:
110
+
111
+ - **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
+ - **Z.AI / GLM**: "[Key: return reasoning_content to keep the reasoning coherent](https://docs.z.ai/en/api/thinking)."
113
+ - **Kimi / Moonshot**: "[You must keep the reasoning_content in the multi-round conversation... otherwise an error will be thrown](https://platform.moonshot.ai/docs/guide/thinking-mode)."
114
+ - **MiniMax**: "[The complete model response must be append to the conversation history](https://platform.minimaxi.com/document/ChatCompletion%20v2)."
115
+ - **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."
87
116
 
88
117
  OpenCode's provider layer drops this field. Three upstream PRs ([#24250](https://github.com/anomalyco/opencode/pull/24250), [#24428](https://github.com/anomalyco/opencode/pull/24428), [#24895](https://github.com/anomalyco/opencode/pull/24895)) tried to fix it. None merged. The field is non-standard per OpenAI, so both OpenCode and the AI SDK ignore it.
89
118
 
@@ -95,7 +124,7 @@ This repo fixes it. Three layers, pick what you need.
95
124
 
96
125
  ### Install via npm (recommended)
97
126
 
98
- See [Quick Install](#quick-install) above — use OpenCode TUI (`Ctrl+P`) or CLI (`opencode plugin opencode-thinking-fix`).
127
+ See [Quick Install](#quick-install) above, use OpenCode TUI (`Ctrl+P`) or CLI (`opencode plugin opencode-thinking-fix`).
99
128
 
100
129
  ### Manual install (for local development)
101
130
 
@@ -106,13 +135,13 @@ mkdir -p ~/.config/opencode/plugins
106
135
  cp plugins/opencode-thinking-fix-universal.ts ~/.config/opencode/plugins/
107
136
  ```
108
137
 
109
- 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.
138
+ 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.
110
139
 
111
140
  It also handles `reasoning` for the OpenCode Go provider, and patches empty `content` fields that OpenAI-compatible SDKs sometimes omit.
112
141
 
113
142
  No config file changes. No build step. OpenCode compiles `.ts` plugins when it starts.
114
143
 
115
- **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 rejects empty strings entirely, it needs the real text.
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 rejects empty strings entirely, it needs the real text.
116
145
 
117
146
  ---
118
147
 
@@ -131,7 +160,7 @@ The proxy runs on **two ports**:
131
160
  | **3457** | Direct providers (DeepSeek, Kimi, GLM, MiMo, GPT, Claude, Qwen, Gemini, etc.) | `PORT=3457` |
132
161
  | **3458** | OpenCode Go provider | `PORT=3458` `UPSTREAM_URL=https://opencode.ai/zen/go/v1` |
133
162
 
134
- 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.
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, which uses `delta.reasoning` (not `reasoning_content`) in its SSE streams. Both are handled by the same `proxy.js` binary, just different environment variables.
135
164
 
136
165
  ```bash
137
166
  # Linux / macOS / Windows (Node.js required)
@@ -154,7 +183,7 @@ systemctl --user enable --now reasoning-cache.service
154
183
  systemctl --user enable --now reasoning-cache-go.service
155
184
  ```
156
185
 
157
- Then point OpenCode at it — in your `opencode.json`:
186
+ Then point OpenCode at it, in your `opencode.json`:
158
187
 
159
188
  ```json
160
189
  {
@@ -169,9 +198,9 @@ Then point OpenCode at it — in your `opencode.json`:
169
198
  }
170
199
  ```
171
200
 
172
- Zero npm dependencies. It uses `http`, `https`, and `url` — nothing else.
201
+ One runtime dependency (`eventsource-parser`). The proxy uses Node.js built-in `http`, `https`, and `url` for everything else.
173
202
 
174
- **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.
203
+ **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.
175
204
 
176
205
  **Kimi K2.7 Code and OpenCode Go need this.** The rest of the models benefit from it but do not technically require it.
177
206
 
@@ -242,35 +271,40 @@ Unknown models fall back to `https://api.deepseek.com` with reasoning disabled.
242
271
 
243
272
  ## Is it working?
244
273
 
245
- Turn 1 will not show anything — there is no history to patch yet. That is normal.
274
+ The plugin writes a structured JSON log: `~/.local/share/opencode/thinking-fix.log`.
246
275
 
247
- Turn 2+, check the console:
276
+ 628 unique sessions. 12,551 inspect events (the hook fires twice per message by design — first pass patches, second confirms clean). 297 reasoning model sessions patched, 331 non-reasoning correctly skipped. Zero false patches.
248
277
 
249
- **Plugin working:**
250
- ```
251
- [ThinkingFix] patched 3 field(s) across 11 message(s)
252
- ```
278
+ Before and after, from a real session:
253
279
 
254
- **Proxy working:**
255
280
  ```
256
- [Cache] session abcdefgh: stored reasoning turn 0 (1842 chars)
257
- [Cache] session abcdefgh: replayed reasoning for turn 0 (1842 chars)
281
+ Before: HTTP 400: The reasoning_content in the thinking mode must be passed back to the API
282
+ After: 34 fields patched across a 104-message session → conversation continues
258
283
  ```
259
284
 
260
- If nothing shows up, either it is a non-reasoning model (correct) or the plugin did not load (check for `[ThinkingFix] Plugin loaded` at startup).
285
+ Here is a live excerpt from that session:
286
+
287
+ ```json
288
+ {"ts":"2026-06-25T01:41:50.572Z","event":"inspect","isReasoningModel":true,
289
+ "totalMessages":104,"patchedFields":34,"turns":[
290
+ {"index":1,"fields":["text","reasoning"]},
291
+ {"index":5,"fields":["text"]},
292
+ {"index":9,"fields":["text"]},
293
+ {"index":42,"fields":["reasoning"]}
294
+ ]}
295
+ ```
261
296
 
262
- Quick proxy health check:
297
+ **No output?** Either you are on a non-reasoning model (correct, no patching needed) or the plugin did not load. Check:
263
298
 
264
299
  ```bash
265
- curl http://127.0.0.1:3457/health
266
- # → {"ok":true,"uptime":42}
267
- curl http://127.0.0.1:3458/health
268
- # → {"ok":true,"uptime":42}
300
+ grep plugin_loaded ~/.local/share/opencode/thinking-fix.log
269
301
  ```
270
302
 
271
- Proxy logs:
303
+ Proxy health:
272
304
 
273
305
  ```bash
306
+ curl http://127.0.0.1:3457/health # → {"ok":true,"uptime":1225}
307
+ curl http://127.0.0.1:3458/health # → {"ok":true,"uptime":1225}
274
308
  journalctl --user -u reasoning-cache.service -f
275
309
  journalctl --user -u reasoning-cache-go.service -f
276
310
  ```
@@ -320,9 +354,9 @@ OpenCode is not the only tool that drops `reasoning_content`. Here is a partial
320
354
 
321
355
  ```
322
356
  plugins/
323
- opencode-thinking-fix-universal.ts # self-detection plugin (106 lines)
357
+ opencode-thinking-fix-universal.ts # self-detection plugin (92 lines)
324
358
  proxy/
325
- proxy.js # reasoning cache proxy (333 lines, zero deps)
359
+ proxy.js # reasoning cache proxy (422 lines, 1 dep)
326
360
  tests/
327
361
  test-plugin.js # plugin unit tests (228 lines, 12 cases)
328
362
  test-proxy.js # proxy unit tests (359 lines, 15 cases)
@@ -334,6 +368,12 @@ systemd/
334
368
  reasoning-proxy-watchdog.service # watchdog systemd unit
335
369
  ```
336
370
 
371
+ ## Changelog
372
+
373
+ See [CHANGELOG.md](CHANGELOG.md) for release history. Current: **v2.0.0** — eventsource-parser, LRU cache, SIGTERM handling, 12+15 passing tests.
374
+
375
+ ---
376
+
337
377
  ## Tested on
338
378
 
339
379
  | Platform | Plugin | Proxy | Watchdog | Systemd |
@@ -346,7 +386,7 @@ OpenCode v1.17.9+, DeepSeek V4 Pro, Kimi K2.5/K2.6/K2.7, GLM-5.x, MiMo V2.5, Min
346
386
 
347
387
  ### Windows notes
348
388
 
349
- **Plugin and proxy work fully on Windows.** The proxy (`proxy.js`) uses only Node.js built-in modules (`http`, `https`, `url`) — zero platform-specific code. Start it with:
389
+ **Plugin and proxy work fully on Windows.** The proxy (`proxy.js`) uses one runtime dependency (`eventsource-parser`) plus Node.js built-in modules (`http`, `https`, `url`). No platform-specific code. Start it with:
350
390
 
351
391
  ```powershell
352
392
  # PowerShell
package/package.json CHANGED
@@ -1,24 +1,57 @@
1
1
  {
2
2
  "name": "opencode-thinking-fix",
3
- "version": "1.1.9",
3
+ "version": "2.0.1",
4
4
  "description": "Fix reasoning_content 400 errors for DeepSeek/Kimi/GLM/MiMo/MiniMax in OpenCode",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "oc-plugin": [
8
+ "server"
9
+ ],
7
10
  "author": "tbosancheros39",
8
11
  "keywords": [
9
12
  "opencode",
13
+ "opencode-plugin",
14
+ "opencode-cli",
10
15
  "deepseek",
16
+ "deepseek-v4",
11
17
  "kimi",
18
+ "moonshot",
12
19
  "glm",
20
+ "zhipu",
13
21
  "mimo",
14
22
  "minimax",
15
- "reasoning",
23
+ "xiaomi-mimo",
24
+ "reasoning-content",
16
25
  "reasoning_content",
17
- "plugin"
26
+ "thinking-mode",
27
+ "tool-calls",
28
+ "multi-turn",
29
+ "400-error",
30
+ "http-400",
31
+ "proxy",
32
+ "plugin",
33
+ "cursor",
34
+ "cline",
35
+ "aider",
36
+ "roo-code",
37
+ "continue-dev",
38
+ "vscode",
39
+ "copilot",
40
+ "ai-coding",
41
+ "llm",
42
+ "chat-completion",
43
+ "openai-compatible",
44
+ "chinese-llm",
45
+ "agent",
46
+ "mcp",
47
+ "api-fix"
18
48
  ],
19
49
  "repository": {
20
50
  "type": "git",
21
- "url": "https://github.com/tbosancheros39/opencode-thinking-fix"
51
+ "url": "git+https://github.com/tbosancheros39/opencode-thinking-fix.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/tbosancheros39/opencode-thinking-fix/issues"
22
55
  },
23
56
  "homepage": "https://github.com/tbosancheros39/opencode-thinking-fix#readme",
24
57
  "main": "./plugins/opencode-thinking-fix-universal.ts",
@@ -42,6 +75,9 @@
42
75
  "scripts": {
43
76
  "test": "node tests/test-plugin.js && node tests/test-proxy.js"
44
77
  },
78
+ "dependencies": {
79
+ "eventsource-parser": "^3.1.0"
80
+ },
45
81
  "peerDependencies": {
46
82
  "@opencode-ai/plugin": ">=1.3.0"
47
83
  },
@@ -50,6 +86,9 @@
50
86
  "optional": false
51
87
  }
52
88
  },
89
+ "engines": {
90
+ "node": ">=18"
91
+ },
53
92
  "devDependencies": {
54
93
  "typescript": "^6.0.3"
55
94
  }
@@ -3,36 +3,9 @@ import { appendFileSync, mkdirSync } from 'fs'
3
3
  import { homedir } from 'os'
4
4
  import { join } from 'path'
5
5
 
6
- // ─────────────────────────────────────────────────────────────────────────────
7
- // CONFIRMED FACTS (cross-validated across DeepSeek V4 Pro, Claude MAX, Kimi
8
- // via live subagent research on OpenCode v1.17.9):
9
- //
10
- // 1. experimental.chat.messages.transform receives input: {} (always empty)
11
- // input.model is NEVER available — confirmed by GitHub issue #25494
12
- // Do NOT use input.model for provider detection — it is always undefined
13
- //
14
- // 2. Detection strategy: scan output.messages for any assistant turn that
15
- // already HAS reasoning_content (set by OpenCode on the last response turn)
16
- // If found → reasoning model (DeepSeek/Kimi/GLM/MiMo) → patch ALL turns
17
- // If none → non-reasoning model (Qwen/GPT/Claude/Mistral) → pass through
18
- // Zero false positives: non-reasoning models never produce reasoning_content
19
- //
20
- // 3. In-place mutation only — reassigning output.messages is a silent no-op
21
- // Confirmed by GitHub issue #25754
22
- //
23
- // 4. Message wrapper shape: { info: Message, parts: Part[] } or bare Message
24
- // Handle both via: wrapper?.info ?? wrapper
25
- //
26
- // DEPLOYMENT:
27
- // Path: ~/.config/opencode/plugins/opencode-thinking-fix-universal.ts
28
- // (plural "plugins/" — auto-discovery, zero config needed)
29
- // Config: no opencode.json entry required for auto-discovery
30
- // OR: "plugin": ["opencode-thinking-fix-universal"] (array, not object)
31
- // Build: none — OpenCode compiles .ts plugins at load time
32
- // ─────────────────────────────────────────────────────────────────────────────
33
6
  // LOGGING
34
7
  // File: ~/.local/share/opencode/thinking-fix.log
35
- // Format: JSON lines — one entry per event
8
+ // Format: JSON lines, one entry per event
36
9
  // View: tail -f ~/.local/share/opencode/thinking-fix.log | jq
37
10
  // ─────────────────────────────────────────────────────────────────────────────
38
11
 
@@ -48,60 +21,6 @@ function writeLog(entry: Record<string, unknown>): void {
48
21
  }
49
22
  }
50
23
 
51
- // ─────────────────────────────────────────────────────────────────────────────
52
- // Message patching — self-detection, in-place mutation
53
- // ─────────────────────────────────────────────────────────────────────────────
54
- interface PatchResult {
55
- patched: number
56
- totalMessages: number
57
- turns: Array<{ index: number; fields: string[] }>
58
- }
59
-
60
- function patchMessages(messages: any[]): PatchResult {
61
- const result: PatchResult = { patched: 0, totalMessages: messages.length, turns: [] }
62
-
63
- // ── Step 1: Self-detect reasoning model ──────────────────────────────────
64
- const isReasoningModel = messages.some((wrapper) => {
65
- const msg = wrapper?.info ?? wrapper
66
- return msg?.role === 'assistant' && ('reasoning_content' in msg || 'reasoning' in msg)
67
- })
68
-
69
- // ── Step 2: Non-reasoning model — exit immediately, zero modification ─────
70
- if (!isReasoningModel) return result
71
-
72
- // ── Step 3: Patch all assistant turns missing required fields ─────────────
73
- messages.forEach((wrapper, index) => {
74
- const msg = wrapper?.info ?? wrapper
75
- if (!msg || msg.role !== 'assistant') return
76
-
77
- const fields: string[] = []
78
-
79
- // Fix 1: content must be non-null string on every assistant turn
80
- if (!msg.content && msg.content !== '') {
81
- msg.content = msg.tool_calls?.length ? 'call tool' : ''
82
- fields.push('content')
83
- }
84
-
85
- // Fix 2: reasoning_content — absent causes HTTP 400 from DeepSeek/Kimi/GLM/MiMo on turn 2+
86
- if (!('reasoning_content' in msg)) {
87
- msg.reasoning_content = ''
88
- fields.push('reasoning_content')
89
- }
90
-
91
- // Fix 3: reasoning — required for OpenCode Go provider on every turn
92
- if (!('reasoning' in msg)) {
93
- msg.reasoning = ''
94
- fields.push('reasoning')
95
- }
96
-
97
- if (fields.length > 0) {
98
- result.patched += fields.length
99
- result.turns.push({ index, fields })
100
- }
101
- })
102
-
103
- return result
104
- }
105
24
 
106
25
  // ─────────────────────────────────────────────────────────────────────────────
107
26
  // Plugin
@@ -111,7 +30,7 @@ export const ThinkingFixPlugin: Plugin = async ({ client }) => {
111
30
 
112
31
  writeLog({ event: 'plugin_loaded', logFile: LOG_FILE })
113
32
 
114
- await client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `Plugin loaded — logging to ${LOG_FILE}` } })
33
+ await client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `Plugin loaded, logging to ${LOG_FILE}` } })
115
34
 
116
35
  return {
117
36
  'chat.headers': async (input: any, output: any) => {
@@ -120,56 +39,54 @@ export const ThinkingFixPlugin: Plugin = async ({ client }) => {
120
39
  }
121
40
  },
122
41
 
123
- 'experimental.chat.messages.transform': async (_input: {}, output: any) => {
42
+ 'experimental.chat.messages.transform': async (_input: any, output: any) => {
124
43
  try {
125
44
  const messages = output?.messages
126
45
  if (!Array.isArray(messages) || messages.length === 0) return
127
46
 
128
- const assistantTurns = messages.filter((w: any) => {
47
+ // Self-detect: if any assistant turn has reasoning_content → reasoning model
48
+ const hasReasoning = messages.some((w: any) => {
129
49
  const m = w?.info ?? w
130
- return m?.role === 'assistant'
131
- })
132
- const isReasoningModel = assistantTurns.some((w: any) => {
133
- const m = w?.info ?? w
134
- return 'reasoning_content' in m || 'reasoning' in m
135
- })
136
- writeLog({
137
- event: 'inspect',
138
- isReasoningModel,
139
- totalMessages: messages.length,
140
- assistantTurns: assistantTurns.length,
141
- missingContent: assistantTurns.filter((w: any) => {
142
- const m = w?.info ?? w
143
- return !m.content && m.content !== ''
144
- }).length,
145
- missingReasoningContent: assistantTurns.filter((w: any) => {
146
- const m = w?.info ?? w
147
- return !('reasoning_content' in m)
148
- }).length,
149
- missingReasoning: assistantTurns.filter((w: any) => {
150
- const m = w?.info ?? w
151
- return !('reasoning' in m)
152
- }).length,
50
+ return m?.role === 'assistant' && ('reasoning_content' in m || 'reasoning' in m)
153
51
  })
154
52
 
155
- const result = patchMessages(messages)
53
+ if (!hasReasoning) {
54
+ writeLog({ event: 'inspect', isReasoningModel: false, totalMessages: messages.length })
55
+ return
56
+ }
156
57
 
157
- if (result.patched > 0) {
158
- writeLog({
159
- event: 'patched',
160
- patchedFields: result.patched,
161
- totalMessages: result.totalMessages,
162
- turns: result.turns,
163
- })
58
+ let patched = 0
59
+ const turns: Array<{ index: number; fields: string[] }> = []
60
+
61
+ messages.forEach((wrapper: any, index: number) => {
62
+ const msg = wrapper?.info ?? wrapper
63
+ if (!msg || msg.role !== 'assistant') return
64
+ const fields: string[] = []
65
+
66
+ if (!msg.content && msg.content !== '') {
67
+ msg.content = msg.tool_calls?.length ? 'call tool' : ''
68
+ fields.push('content')
69
+ }
70
+ if (!('reasoning_content' in msg)) { msg.reasoning_content = ''; fields.push('reasoning_content') }
71
+ if (!('reasoning' in msg)) { msg.reasoning = ''; fields.push('reasoning') }
72
+
73
+ if (fields.length > 0) {
74
+ patched += fields.length
75
+ turns.push({ index, fields })
76
+ }
77
+ })
78
+
79
+ writeLog({ event: 'inspect', isReasoningModel: true, totalMessages: messages.length, patchedFields: patched, turns })
164
80
 
165
- await client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `patched ${result.patched} field(s) across ${result.totalMessages} message(s)` } })
81
+ if (patched > 0) {
82
+ await client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `patched ${patched} field(s) across ${messages.length} message(s)` } })
166
83
  }
167
84
  } catch (err: any) {
168
85
  writeLog({ event: 'error', message: err?.message ?? String(err) })
169
- await client.app.log({ body: { service: 'thinking-fix', level: 'error', message: `Error — passing through unmodified: ${err?.message ?? err}` } })
86
+ await client.app.log({ body: { service: 'thinking-fix', level: 'error', message: `Error, passing through unmodified: ${err?.message ?? err}` } })
170
87
  }
171
88
  },
172
89
  }
173
90
  }
174
91
 
175
- export default ThinkingFixPlugin
92
+
package/proxy/proxy.js CHANGED
@@ -25,6 +25,7 @@
25
25
  const http = require('http')
26
26
  const https = require('https')
27
27
  const url = require('url')
28
+ const { createParser } = require('eventsource-parser')
28
29
 
29
30
  const PORT = parseInt(process.env.PORT || '3457', 10)
30
31
 
@@ -67,15 +68,46 @@ function route(modelName) {
67
68
  return DEFAULT_ROUTE
68
69
  }
69
70
 
71
+ // ── LRU Cache ─────────────────────────────────────────────────────────────
72
+ // Bounded session cache using Map insertion-order eviction.
73
+ // get() refreshes position; put() evicts least-recently-used when full.
74
+ // Zero dependencies, O(1) get/put, memory-safe for long-running processes.
75
+ class LRUCache {
76
+ constructor(capacity) {
77
+ this.cache = new Map()
78
+ this.capacity = capacity
79
+ }
80
+ get(key) {
81
+ if (!this.cache.has(key)) return undefined
82
+ const val = this.cache.get(key)
83
+ this.cache.delete(key)
84
+ this.cache.set(key, val)
85
+ return val
86
+ }
87
+ put(key, value) {
88
+ this.cache.delete(key)
89
+ if (this.cache.size >= this.capacity) {
90
+ const oldest = this.cache.keys().next().value
91
+ this.cache.delete(oldest)
92
+ console.log(`[Cache] evicted session ${String(oldest).slice(-8)} (capacity ${this.capacity})`)
93
+ }
94
+ this.cache.set(key, value)
95
+ }
96
+ }
97
+
70
98
  // ── In-memory reasoning cache ─────────────────────────────────────────────
71
99
  // Structure: Map<sessionID, Map<assistantIndex, reasoning_content_string>>
72
100
  // sessionID comes from x-session-id header forwarded by OpenCode
73
101
  // assistantIndex is the position of the assistant message in the history array
74
- const cache = new Map()
102
+ const cache = new LRUCache(500)
75
103
 
76
104
  function getSessionCache(sessionId) {
77
- if (!cache.has(sessionId)) cache.set(sessionId, new Map())
78
- return cache.get(sessionId)
105
+ let sessionMap = cache.get(sessionId)
106
+ if (!sessionMap) {
107
+ sessionMap = new Map()
108
+ cache.put(sessionId, sessionMap)
109
+ }
110
+ return sessionMap
79
111
  }
80
112
 
81
113
  // ── Request body patching ─────────────────────────────────────────────────
@@ -105,7 +137,7 @@ function patchRequestBody(body, sessionId) {
105
137
  }
106
138
 
107
139
  // Inject real cached reasoning if available, otherwise ""
108
- // Check VALUE (not key existence) — plugin sets "" so key always exists
140
+ // Check VALUE (not key existence), plugin sets "" so key always exists
109
141
  if (!msg.reasoning_content) {
110
142
  const cached = sessionCache.get(assistantIndex)
111
143
  msg.reasoning_content = cached ?? ''
@@ -142,98 +174,56 @@ function patchRequestBody(body, sessionId) {
142
174
  }
143
175
 
144
176
  // ── SSE stream parser ─────────────────────────────────────────────────────
145
- // Parses streaming response chunks to extract reasoning_content
146
- // Accumulates across chunks since a single reasoning block can span many
177
+ // Extracts reasoning_content from streaming chunks using eventsource-parser
178
+ // (spec-compliant, 368k dependents, built-in maxBufferSize guard)
179
+ const MAX_BUFFER = 1024 * 1024 // 1MB, prevents OOM on malformed streams
180
+
147
181
  function createStreamParser(sessionId, onComplete) {
148
- let buffer = ''
149
182
  let reasoningBuffer = ''
150
- let assistantIndex = 0 // tracks which assistant turn we're building
151
- let inReasoning = false
152
-
153
- return {
154
- feed(chunk) {
155
- buffer += chunk.toString()
156
- const lines = buffer.split('\n')
157
- buffer = lines.pop() // keep incomplete last line
158
- const outLines = []
159
-
160
- for (const line of lines) {
161
- if (!line.startsWith('data: ')) {
162
- outLines.push(line)
163
- continue
164
- }
165
- const data = line.slice(6).trim()
166
- if (data === '[DONE]') {
167
- // Flush accumulated reasoning for this turn
168
- if (reasoningBuffer) {
169
- onComplete(assistantIndex, reasoningBuffer)
170
- reasoningBuffer = ''
171
- }
172
- outLines.push(line)
173
- continue
183
+ let assistantIndex = 0
184
+ const chunks = [] // forward buffers for re-serialization
185
+
186
+ const parser = createParser({
187
+ maxBufferSize: MAX_BUFFER,
188
+ onEvent(event) {
189
+ const line = event.event ? `event: ${event.event}\ndata: ${event.data}\n\n` : `data: ${event.data}\n\n`
190
+ chunks.push(line)
191
+
192
+ if (event.event === 'done' || JSON.parse(event.data || '{}')?.choices?.[0]?.finish_reason) {
193
+ if (reasoningBuffer) {
194
+ onComplete(assistantIndex, reasoningBuffer)
195
+ reasoningBuffer = ''
174
196
  }
175
- try {
176
- const parsed = JSON.parse(data)
177
- const delta = parsed.choices?.[0]?.delta
178
- if (!delta) {
179
- outLines.push(line)
180
- continue
181
- }
182
-
183
- // reasoning_content delta (DeepSeek/Kimi/GLM/MiniMax)
184
- if (typeof delta.reasoning_content === 'string') {
185
- reasoningBuffer += delta.reasoning_content
186
- inReasoning = true
187
- }
188
-
189
- // reasoning_details array (MiniMax-M3 with reasoning_split: true)
190
- if (Array.isArray(delta.reasoning_details)) {
191
- for (const detail of delta.reasoning_details) {
192
- if (typeof detail.text === 'string') {
193
- reasoningBuffer += detail.text
194
- }
195
- }
196
- inReasoning = true
197
- }
198
-
199
- // OpenCode Go uses delta.reasoning instead of reasoning_content
200
- if (typeof delta.reasoning === 'string') {
201
- reasoningBuffer += delta.reasoning
202
- inReasoning = true
203
- }
197
+ assistantIndex++
198
+ return
199
+ }
204
200
 
205
- // content may contain embedded <think> tags when reasoning_split is not
206
- // honored (MiniMax fallback). Extract them into reasoningBuffer.
207
- if (typeof delta.content === 'string') {
208
- const thinkMatch = delta.content.match(/<think>([\s\S]*?)<\/think>/)
209
- if (thinkMatch) {
210
- reasoningBuffer += thinkMatch[1]
211
- inReasoning = true
212
- delta.content = delta.content.replace(/<think>[\s\S]*?<\/think>/, '').trimStart()
213
- }
214
- }
201
+ if (!event.data || event.data === '[DONE]') return
215
202
 
216
- // finish_reason means the assistant turn is complete
217
- // Only flush here — NOT on content appearance (interleaved thinking
218
- // emits reasoning AFTER content on GLM-5+ and MiniMax-M3)
219
- if (parsed.choices?.[0]?.finish_reason) {
220
- if (reasoningBuffer) {
221
- onComplete(assistantIndex, reasoningBuffer)
222
- reasoningBuffer = ''
223
- inReasoning = false
224
- }
225
- assistantIndex++
203
+ try {
204
+ const parsed = JSON.parse(event.data)
205
+ const delta = parsed.choices?.[0]?.delta
206
+ if (!delta) return
207
+
208
+ if (typeof delta.reasoning_content === 'string') reasoningBuffer += delta.reasoning_content
209
+ if (typeof delta.reasoning === 'string') reasoningBuffer += delta.reasoning
210
+ if (Array.isArray(delta.reasoning_details)) {
211
+ for (const d of delta.reasoning_details) {
212
+ if (typeof d.text === 'string') reasoningBuffer += d.text
226
213
  }
227
-
228
- // Re-serialize the possibly-modified chunk and forward it
229
- outLines.push('data: ' + JSON.stringify(parsed))
230
- } catch {
231
- // malformed SSE chunk, forward unchanged
232
- outLines.push(line)
233
214
  }
234
- }
215
+ } catch { /* malformed SSE chunk, ignore */ }
216
+ },
217
+ onError(error) {
218
+ console.error(`[Proxy] SSE parser error in session ${sessionId.slice(-8)}:`, error.message)
219
+ }
220
+ })
235
221
 
236
- return outLines.join('\n') + '\n'
222
+ return {
223
+ feed(chunk) {
224
+ chunks.length = 0
225
+ parser.feed(chunk)
226
+ return chunks.join('')
237
227
  },
238
228
  flush() {
239
229
  if (reasoningBuffer) {
@@ -246,7 +236,7 @@ function createStreamParser(sessionId, onComplete) {
246
236
 
247
237
  // ── Main proxy handler ────────────────────────────────────────────────────
248
238
  const server = http.createServer((req, res) => {
249
- // Health check — plugin uses this to detect running proxy
239
+ // Health check, plugin uses this to detect running proxy
250
240
  if (req.method === 'GET' && req.url === '/health') {
251
241
  res.writeHead(200, { 'Content-Type': 'application/json' })
252
242
  res.end(JSON.stringify({ ok: true, uptime: Math.floor(process.uptime()) }))
@@ -407,3 +397,26 @@ server.on('error', (err) => {
407
397
  console.error('[Proxy] server error:', err.message)
408
398
  process.exit(1)
409
399
  })
400
+
401
+ // ── Graceful shutdown ──────────────────────────────────────────────────────
402
+ function shutdown(signal) {
403
+ console.log(`[Proxy] received ${signal}, shutting down gracefully...`)
404
+ // Log cache stats before clearing
405
+ let totalEntries = 0
406
+ for (const [, sessionMap] of cache.cache) {
407
+ totalEntries += sessionMap.size
408
+ }
409
+ console.log(`[Proxy] ${cache.cache.size} session(s), ${totalEntries} cached turn(s)`)
410
+ server.close(() => {
411
+ console.log('[Proxy] server closed')
412
+ process.exit(0)
413
+ })
414
+ // Force exit after 5 seconds if connections don't drain
415
+ setTimeout(() => {
416
+ console.error('[Proxy] forced exit after timeout')
417
+ process.exit(1)
418
+ }, 5000).unref()
419
+ }
420
+
421
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
422
+ process.on('SIGINT', () => shutdown('SIGINT'))
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  // ─────────────────────────────────────────────────────────────────────────────
4
- // patchMessages — ported from opencode-thinking-fix-universal.ts (lines 31-77)
4
+ // patchMessages, ported from opencode-thinking-fix-universal.ts (lines 31-77)
5
5
  // ─────────────────────────────────────────────────────────────────────────────
6
6
  function patchMessages(messages) {
7
7
  let count = 0
@@ -12,7 +12,7 @@ function patchMessages(messages) {
12
12
  return msg?.role === 'assistant' && ('reasoning_content' in msg || 'reasoning' in msg)
13
13
  })
14
14
 
15
- // Step 2: Non-reasoning model — exit immediately
15
+ // Step 2: Non-reasoning model, exit immediately
16
16
  if (!isReasoningModel) return 0
17
17
 
18
18
  // Step 3: Patch all assistant turns missing required fields
@@ -55,7 +55,7 @@ function test(name, fn) {
55
55
  console.log(` PASS: ${name}`)
56
56
  } catch (e) {
57
57
  failed++
58
- const msg = `${name} — ${e.message}`
58
+ const msg = `${name}, ${e.message}`
59
59
  failures.push(msg)
60
60
  console.log(` FAIL: ${msg}`)
61
61
  }
@@ -142,7 +142,7 @@ test('TC5: Both assistant messages patched when at least one has reasoning', ()
142
142
  })
143
143
 
144
144
  // ── TC6: Already complete message ───────────────────────────────────────────
145
- test('TC6: Already complete message — no changes', () => {
145
+ test('TC6: Already complete message, no changes', () => {
146
146
  const msgs = deepClone([
147
147
  { role: 'assistant', content: 'done', reasoning_content: 'done', reasoning: 'done' },
148
148
  ])
@@ -184,7 +184,7 @@ test('TC9: Empty messages array returns 0', () => {
184
184
  })
185
185
 
186
186
  // ── TC10: Non-reasoning model with tool_calls ────────────────────────────────
187
- test('TC10: Non-reasoning model with tool_calls — no injection', () => {
187
+ test('TC10: Non-reasoning model with tool_calls, no injection', () => {
188
188
  const msgs = deepClone([
189
189
  { role: 'assistant', content: '', tool_calls: [{ function: { name: 'f' } }] },
190
190
  ])
@@ -199,7 +199,7 @@ test('TC11: Reasoning model patches content to "call tool"', () => {
199
199
  { role: 'assistant', content: '', tool_calls: [{ function: { name: 'f' } }], reasoning_content: 'thinking' },
200
200
  ])
201
201
  const result = patchMessages(msgs)
202
- // content is '' (empty string) — guard `!msg.content && msg.content !== ''` skips it
202
+ // content is '' (empty string), guard `!msg.content && msg.content !== ''` skips it
203
203
  // because '' !== '' is false. The original code treats '' as valid content.
204
204
  // Only reasoning is patched (reasoning_content and content are already present/invalid).
205
205
  assert(result === 1, `expected 1 patch (reasoning), got ${result}`)
@@ -28,7 +28,7 @@ check_and_start() {
28
28
  return 0
29
29
  fi
30
30
 
31
- log "Proxy on port $port is DOWN — restarting..."
31
+ log "Proxy on port $port is DOWN, restarting..."
32
32
 
33
33
  # Kill any stale proxy process on this port
34
34
  pid=$(lsof -ti :"$port" 2>/dev/null)
@@ -55,7 +55,7 @@ check_and_start() {
55
55
  }
56
56
 
57
57
  # ── Main loop ────────────────────────────────────────────────────────────────
58
- log "Watchdog started — checking every ${CHECK_INTERVAL}s"
58
+ log "Watchdog started, checking every ${CHECK_INTERVAL}s"
59
59
 
60
60
  while true; do
61
61
  check_and_start 3457 "$HEALTH_3457" ""