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 +25 -4
- package/README.md +73 -33
- package/package.json +43 -4
- package/plugins/opencode-thinking-fix-universal.ts +36 -119
- package/proxy/proxy.js +102 -89
- package/tests/test-plugin.js +6 -6
- package/watchdog/watchdog.sh +2 -2
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
|
|
9
|
-
- **`inspect` event**: logs field coverage on every request
|
|
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
|
|
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
|
|
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
|
[](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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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@
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
274
|
+
The plugin writes a structured JSON log: `~/.local/share/opencode/thinking-fix.log`.
|
|
246
275
|
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
357
|
+
opencode-thinking-fix-universal.ts # self-detection plugin (92 lines)
|
|
324
358
|
proxy/
|
|
325
|
-
proxy.js # reasoning cache proxy (
|
|
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
|
|
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": "
|
|
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
|
-
"
|
|
23
|
+
"xiaomi-mimo",
|
|
24
|
+
"reasoning-content",
|
|
16
25
|
"reasoning_content",
|
|
17
|
-
"
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
if (!hasReasoning) {
|
|
54
|
+
writeLog({ event: 'inspect', isReasoningModel: false, totalMessages: messages.length })
|
|
55
|
+
return
|
|
56
|
+
}
|
|
156
57
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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" ""
|