opencode-thinking-fix 1.1.8 → 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 +36 -1
- package/README.md +24 -17
- package/package.json +11 -1
- package/plugins/opencode-thinking-fix-universal.ts +64 -78
- package/proxy/proxy.js +102 -89
- package/tests/test-plugin.js +6 -6
- package/watchdog/watchdog.sh +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
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
|
+
|
|
26
|
+
## [1.1.9] - 2026-06-23
|
|
27
|
+
|
|
28
|
+
### Added
|
|
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.
|
|
31
|
+
- **Console fallback**: `writeLog` catches file write failures and emits `console.error` so failures are visible somewhere.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- `client.app.log()` calls now use `await` for proper async handling.
|
|
35
|
+
- Removal of `hook_fired` debug artifact, replaced by structured `inspect` event.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
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.
|
|
39
|
+
|
|
5
40
|
## [1.1.8] - 2026-06-23
|
|
6
41
|
|
|
7
42
|
### Fixed
|
|
@@ -14,7 +49,7 @@ All notable changes to `opencode-thinking-fix`.
|
|
|
14
49
|
### Fixed
|
|
15
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.
|
|
16
51
|
- **Plugin default export**: switched from v1 format (`{ server: Plugin }`) to v0 legacy format (bare `export default Plugin`), matching the proven opencode-wakatime pattern.
|
|
17
|
-
- **package.json**: removed `oc-plugin` field, removed `dependencies`, moved `@opencode-ai/plugin` to
|
|
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.
|
|
18
53
|
|
|
19
54
|
## [1.1.6] - 2026-06-23
|
|
20
55
|
|
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# opencode-thinking-fix
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/opencode-thinking-fix)
|
|
4
|
+
[](https://github.com/tbosancheros39/opencode-thinking-fix/actions/workflows/test.yml)
|
|
4
5
|
[](https://www.npmjs.com/package/opencode-thinking-fix)
|
|
5
|
-
[](https://github.com/tbosancheros39/opencode-thinking-fix/blob/
|
|
6
|
+
[](https://github.com/tbosancheros39/opencode-thinking-fix/blob/master/LICENSE.md)
|
|
6
7
|
[](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
|
|
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
|
|
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
|
|
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@
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
330
|
+
opencode-thinking-fix-universal.ts # self-detection plugin (92 lines)
|
|
324
331
|
proxy/
|
|
325
|
-
proxy.js # reasoning cache proxy (
|
|
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
|
|
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": "
|
|
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
|
}
|
|
@@ -1,83 +1,36 @@
|
|
|
1
1
|
import type { Plugin } from '@opencode-ai/plugin'
|
|
2
|
+
import { appendFileSync, mkdirSync } from 'fs'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
import { join } from 'path'
|
|
2
5
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 1. experimental.chat.messages.transform receives input: {} (always empty)
|
|
8
|
-
// input.model is NEVER available — confirmed by GitHub issue #25494
|
|
9
|
-
// Do NOT use input.model for provider detection — it is always undefined
|
|
10
|
-
//
|
|
11
|
-
// 2. Detection strategy: scan output.messages for any assistant turn that
|
|
12
|
-
// already HAS reasoning_content (set by OpenCode on the last response turn)
|
|
13
|
-
// If found → reasoning model (DeepSeek/Kimi/GLM/MiMo) → patch ALL turns
|
|
14
|
-
// If none → non-reasoning model (Qwen/GPT/Claude/Mistral) → pass through
|
|
15
|
-
// Zero false positives: non-reasoning models never produce reasoning_content
|
|
16
|
-
//
|
|
17
|
-
// 3. In-place mutation only — reassigning output.messages is a silent no-op
|
|
18
|
-
// Confirmed by GitHub issue #25754
|
|
19
|
-
//
|
|
20
|
-
// 4. Message wrapper shape: { info: Message, parts: Part[] } or bare Message
|
|
21
|
-
// Handle both via: wrapper?.info ?? wrapper
|
|
22
|
-
//
|
|
23
|
-
// DEPLOYMENT:
|
|
24
|
-
// Path: ~/.config/opencode/plugins/opencode-thinking-fix-universal.ts
|
|
25
|
-
// (plural "plugins/" — auto-discovery, zero config needed)
|
|
26
|
-
// Config: no opencode.json entry required for auto-discovery
|
|
27
|
-
// OR: "plugin": ["opencode-thinking-fix-universal"] (array, not object)
|
|
28
|
-
// Build: none — OpenCode compiles .ts plugins at load time
|
|
6
|
+
// LOGGING
|
|
7
|
+
// File: ~/.local/share/opencode/thinking-fix.log
|
|
8
|
+
// Format: JSON lines, one entry per event
|
|
9
|
+
// View: tail -f ~/.local/share/opencode/thinking-fix.log | jq
|
|
29
10
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
11
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// ── Step 1: Self-detect reasoning model ──────────────────────────────────
|
|
35
|
-
// Scan for any assistant message that already has reasoning_content.
|
|
36
|
-
// OpenCode sets this field on the most recent response turn.
|
|
37
|
-
// Non-reasoning providers (Qwen/GPT/Claude/Mistral/Llama) never have it.
|
|
38
|
-
const isReasoningModel = messages.some((wrapper) => {
|
|
39
|
-
const msg = wrapper?.info ?? wrapper
|
|
40
|
-
return msg?.role === 'assistant' && ('reasoning_content' in msg || 'reasoning' in msg)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
// ── Step 2: Non-reasoning model — exit immediately, zero modification ─────
|
|
44
|
-
// Qwen/GPT/Claude reject unknown fields with HTTP 400.
|
|
45
|
-
// This guard is what prevents multi-session errors on non-reasoning models.
|
|
46
|
-
if (!isReasoningModel) return 0
|
|
47
|
-
|
|
48
|
-
// ── Step 3: Patch all assistant turns missing required fields ─────────────
|
|
49
|
-
for (const wrapper of messages) {
|
|
50
|
-
const msg = wrapper?.info ?? wrapper
|
|
51
|
-
if (!msg || msg.role !== 'assistant') continue
|
|
52
|
-
|
|
53
|
-
// Fix 1: content must be non-null string on every assistant turn.
|
|
54
|
-
// OpenAI-compatible SDK omits it when only tool_calls are present.
|
|
55
|
-
if (!msg.content && msg.content !== '') {
|
|
56
|
-
msg.content = msg.tool_calls?.length ? 'call tool' : ''
|
|
57
|
-
count++
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Fix 2: reasoning_content must be present on every assistant turn in history.
|
|
61
|
-
// Absence causes HTTP 400 from DeepSeek/Kimi/GLM/MiMo on turn 2+.
|
|
62
|
-
// Use "" (empty string) — confirmed working cross-provider.
|
|
63
|
-
// "" and null both work per DeepSeek docs; "" is safer cross-provider.
|
|
64
|
-
if (!('reasoning_content' in msg)) {
|
|
65
|
-
msg.reasoning_content = ''
|
|
66
|
-
count++
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Fix 3: reasoning must be present for OpenCode Go provider on every turn.
|
|
70
|
-
if (!('reasoning' in msg)) {
|
|
71
|
-
msg.reasoning = ''
|
|
72
|
-
count++
|
|
73
|
-
}
|
|
74
|
-
}
|
|
12
|
+
const LOG_DIR = join(homedir(), '.local', 'share', 'opencode')
|
|
13
|
+
const LOG_FILE = join(LOG_DIR, 'thinking-fix.log')
|
|
75
14
|
|
|
76
|
-
|
|
15
|
+
function writeLog(entry: Record<string, unknown>): void {
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(LOG_DIR, { recursive: true })
|
|
18
|
+
appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', 'utf8')
|
|
19
|
+
} catch (err: any) {
|
|
20
|
+
try { console.error('[thinking-fix] log write failed:', err?.message) } catch {}
|
|
21
|
+
}
|
|
77
22
|
}
|
|
78
23
|
|
|
24
|
+
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Plugin
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
79
28
|
export const ThinkingFixPlugin: Plugin = async ({ client }) => {
|
|
80
|
-
|
|
29
|
+
mkdirSync(LOG_DIR, { recursive: true })
|
|
30
|
+
|
|
31
|
+
writeLog({ event: 'plugin_loaded', logFile: LOG_FILE })
|
|
32
|
+
|
|
33
|
+
await client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `Plugin loaded, logging to ${LOG_FILE}` } })
|
|
81
34
|
|
|
82
35
|
return {
|
|
83
36
|
'chat.headers': async (input: any, output: any) => {
|
|
@@ -86,21 +39,54 @@ export const ThinkingFixPlugin: Plugin = async ({ client }) => {
|
|
|
86
39
|
}
|
|
87
40
|
},
|
|
88
41
|
|
|
89
|
-
'experimental.chat.messages.transform': async (_input:
|
|
42
|
+
'experimental.chat.messages.transform': async (_input: any, output: any) => {
|
|
90
43
|
try {
|
|
91
44
|
const messages = output?.messages
|
|
92
45
|
if (!Array.isArray(messages) || messages.length === 0) return
|
|
93
46
|
|
|
94
|
-
|
|
47
|
+
// Self-detect: if any assistant turn has reasoning_content → reasoning model
|
|
48
|
+
const hasReasoning = messages.some((w: any) => {
|
|
49
|
+
const m = w?.info ?? w
|
|
50
|
+
return m?.role === 'assistant' && ('reasoning_content' in m || 'reasoning' in m)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (!hasReasoning) {
|
|
54
|
+
writeLog({ event: 'inspect', isReasoningModel: false, totalMessages: messages.length })
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
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 })
|
|
95
80
|
|
|
96
81
|
if (patched > 0) {
|
|
97
|
-
client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `patched ${patched} field(s) across ${messages.length} message(s)` } })
|
|
82
|
+
await client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `patched ${patched} field(s) across ${messages.length} message(s)` } })
|
|
98
83
|
}
|
|
99
|
-
} catch (err) {
|
|
100
|
-
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
writeLog({ event: 'error', message: err?.message ?? String(err) })
|
|
86
|
+
await client.app.log({ body: { service: 'thinking-fix', level: 'error', message: `Error, passing through unmodified: ${err?.message ?? err}` } })
|
|
101
87
|
}
|
|
102
88
|
},
|
|
103
89
|
}
|
|
104
90
|
}
|
|
105
91
|
|
|
106
|
-
|
|
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
|
|
102
|
+
const cache = new LRUCache(500)
|
|
75
103
|
|
|
76
104
|
function getSessionCache(sessionId) {
|
|
77
|
-
|
|
78
|
-
|
|
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)
|
|
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
|
-
//
|
|
146
|
-
//
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
|
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'))
|
package/tests/test-plugin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
-
// patchMessages
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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)
|
|
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}`)
|
package/watchdog/watchdog.sh
CHANGED
|
@@ -28,7 +28,7 @@ check_and_start() {
|
|
|
28
28
|
return 0
|
|
29
29
|
fi
|
|
30
30
|
|
|
31
|
-
log "Proxy on port $port is DOWN
|
|
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
|
|
58
|
+
log "Watchdog started, checking every ${CHECK_INTERVAL}s"
|
|
59
59
|
|
|
60
60
|
while true; do
|
|
61
61
|
check_and_start 3457 "$HEALTH_3457" ""
|