opencode-thinking-fix 1.1.9 → 2.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/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
@@ -17,17 +18,17 @@ npm install opencode-thinking-fix
17
18
 
18
19
  ## Quick Install
19
20
 
20
- **This is an OpenCode plugin. Install it inside OpenCode — no terminal needed.**
21
+ **This is an OpenCode plugin. Install it inside OpenCode, no terminal needed.**
21
22
 
22
23
  ### Method 1: TUI (press `Ctrl+P` while OpenCode is running)
23
24
 
24
25
  1. Press `Ctrl+P` to open the command palette.
25
26
  2. Type `install plugin` and press `Enter`.
26
- 3. Press `Tab` to switch the install scope to **Global** (recommended — works across all projects).
27
+ 3. Press `Tab` to switch the install scope to **Global** (recommended, works across all projects).
27
28
  4. Type `opencode-thinking-fix`.
28
29
  5. Press `Enter`. Restart OpenCode.
29
30
 
30
- You should see `[ThinkingFix] Plugin loaded — universal reasoning_content fix active` at startup.
31
+ You should see `[ThinkingFix] Plugin loaded, universal reasoning_content fix active` at startup.
31
32
 
32
33
  ### Method 2: CLI (shell command)
33
34
 
@@ -38,7 +39,7 @@ opencode plugin opencode-thinking-fix
38
39
  For a specific version:
39
40
 
40
41
  ```bash
41
- opencode plugin opencode-thinking-fix@1.1.4
42
+ opencode plugin opencode-thinking-fix@2.0.0
42
43
  ```
43
44
 
44
45
  Restart OpenCode after installing.
@@ -83,7 +84,13 @@ You ask DeepSeek a question. It picks a tool, calls it, works fine. Then you ask
83
84
  HTTP 400: The reasoning_content in the thinking mode must be passed back to the API
84
85
  ```
85
86
 
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.
87
+ 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:
88
+
89
+ - **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.
90
+ - **Z.AI / GLM**: "[Key: return reasoning_content to keep the reasoning coherent](https://docs.z.ai/en/api/thinking)."
91
+ - **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)."
92
+ - **MiniMax**: "[The complete model response must be append to the conversation history](https://platform.minimaxi.com/document/ChatCompletion%20v2)."
93
+ - **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
94
 
88
95
  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
96
 
@@ -95,7 +102,7 @@ This repo fixes it. Three layers, pick what you need.
95
102
 
96
103
  ### Install via npm (recommended)
97
104
 
98
- See [Quick Install](#quick-install) above — use OpenCode TUI (`Ctrl+P`) or CLI (`opencode plugin opencode-thinking-fix`).
105
+ See [Quick Install](#quick-install) above, use OpenCode TUI (`Ctrl+P`) or CLI (`opencode plugin opencode-thinking-fix`).
99
106
 
100
107
  ### Manual install (for local development)
101
108
 
@@ -106,13 +113,13 @@ mkdir -p ~/.config/opencode/plugins
106
113
  cp plugins/opencode-thinking-fix-universal.ts ~/.config/opencode/plugins/
107
114
  ```
108
115
 
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.
116
+ 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
117
 
111
118
  It also handles `reasoning` for the OpenCode Go provider, and patches empty `content` fields that OpenAI-compatible SDKs sometimes omit.
112
119
 
113
120
  No config file changes. No build step. OpenCode compiles `.ts` plugins when it starts.
114
121
 
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.
122
+ **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
123
 
117
124
  ---
118
125
 
@@ -131,7 +138,7 @@ The proxy runs on **two ports**:
131
138
  | **3457** | Direct providers (DeepSeek, Kimi, GLM, MiMo, GPT, Claude, Qwen, Gemini, etc.) | `PORT=3457` |
132
139
  | **3458** | OpenCode Go provider | `PORT=3458` `UPSTREAM_URL=https://opencode.ai/zen/go/v1` |
133
140
 
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.
141
+ 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
142
 
136
143
  ```bash
137
144
  # Linux / macOS / Windows (Node.js required)
@@ -154,7 +161,7 @@ systemctl --user enable --now reasoning-cache.service
154
161
  systemctl --user enable --now reasoning-cache-go.service
155
162
  ```
156
163
 
157
- Then point OpenCode at it — in your `opencode.json`:
164
+ Then point OpenCode at it, in your `opencode.json`:
158
165
 
159
166
  ```json
160
167
  {
@@ -169,9 +176,9 @@ Then point OpenCode at it — in your `opencode.json`:
169
176
  }
170
177
  ```
171
178
 
172
- Zero npm dependencies. It uses `http`, `https`, and `url` — nothing else.
179
+ One runtime dependency (`eventsource-parser`). The proxy uses Node.js built-in `http`, `https`, and `url` for everything else.
173
180
 
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.
181
+ **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
182
 
176
183
  **Kimi K2.7 Code and OpenCode Go need this.** The rest of the models benefit from it but do not technically require it.
177
184
 
@@ -242,7 +249,7 @@ Unknown models fall back to `https://api.deepseek.com` with reasoning disabled.
242
249
 
243
250
  ## Is it working?
244
251
 
245
- Turn 1 will not show anything — there is no history to patch yet. That is normal.
252
+ Turn 1 will not show anything, there is no history to patch yet. That is normal.
246
253
 
247
254
  Turn 2+, check the console:
248
255
 
@@ -320,9 +327,9 @@ OpenCode is not the only tool that drops `reasoning_content`. Here is a partial
320
327
 
321
328
  ```
322
329
  plugins/
323
- opencode-thinking-fix-universal.ts # self-detection plugin (106 lines)
330
+ opencode-thinking-fix-universal.ts # self-detection plugin (92 lines)
324
331
  proxy/
325
- proxy.js # reasoning cache proxy (333 lines, zero deps)
332
+ proxy.js # reasoning cache proxy (422 lines, 1 dep)
326
333
  tests/
327
334
  test-plugin.js # plugin unit tests (228 lines, 12 cases)
328
335
  test-proxy.js # proxy unit tests (359 lines, 15 cases)
@@ -346,7 +353,7 @@ OpenCode v1.17.9+, DeepSeek V4 Pro, Kimi K2.5/K2.6/K2.7, GLM-5.x, MiMo V2.5, Min
346
353
 
347
354
  ### Windows notes
348
355
 
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:
356
+ **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
357
 
351
358
  ```powershell
352
359
  # PowerShell
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "opencode-thinking-fix",
3
- "version": "1.1.9",
3
+ "version": "2.0.0",
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": ["server"],
7
8
  "author": "tbosancheros39",
8
9
  "keywords": [
9
10
  "opencode",
@@ -20,6 +21,9 @@
20
21
  "type": "git",
21
22
  "url": "https://github.com/tbosancheros39/opencode-thinking-fix"
22
23
  },
24
+ "bugs": {
25
+ "url": "https://github.com/tbosancheros39/opencode-thinking-fix/issues"
26
+ },
23
27
  "homepage": "https://github.com/tbosancheros39/opencode-thinking-fix#readme",
24
28
  "main": "./plugins/opencode-thinking-fix-universal.ts",
25
29
  "exports": {
@@ -42,6 +46,9 @@
42
46
  "scripts": {
43
47
  "test": "node tests/test-plugin.js && node tests/test-proxy.js"
44
48
  },
49
+ "dependencies": {
50
+ "eventsource-parser": "^3.1.0"
51
+ },
45
52
  "peerDependencies": {
46
53
  "@opencode-ai/plugin": ">=1.3.0"
47
54
  },
@@ -50,6 +57,9 @@
50
57
  "optional": false
51
58
  }
52
59
  },
60
+ "engines": {
61
+ "node": ">=18"
62
+ },
53
63
  "devDependencies": {
54
64
  "typescript": "^6.0.3"
55
65
  }
@@ -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" ""