opencode-thinking-fix 2.0.1 → 3.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -57
- package/package.json +3 -7
- package/proxy/core.js +255 -0
- package/proxy/proxy.js +216 -330
- package/CHANGELOG.md +0 -106
- package/tests/test-plugin.js +0 -228
- package/tests/test-proxy.js +0 -359
package/CHANGELOG.md
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `opencode-thinking-fix`.
|
|
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
|
-
|
|
40
|
-
## [1.1.8] - 2026-06-23
|
|
41
|
-
|
|
42
|
-
### Fixed
|
|
43
|
-
- **package.json aligned with project Documents**: description shortened, `homepage` added, `peerDependenciesMeta.optional` set to `false`, `repository.url` normalized, keywords added (`mimo`, `minimax`), duplicate keywords removed.
|
|
44
|
-
- **Stale `node_modules/` deleted** from repo (was committed accidentally).
|
|
45
|
-
- **Stale npm cache entry** (`npm i opencode-thinking-fix`) removed from `~/.cache/opencode/packages/`.
|
|
46
|
-
|
|
47
|
-
## [1.1.7] - 2026-06-23
|
|
48
|
-
|
|
49
|
-
### Fixed
|
|
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.
|
|
51
|
-
- **Plugin default export**: switched from v1 format (`{ server: Plugin }`) to v0 legacy format (bare `export default Plugin`), matching the proven opencode-wakatime pattern.
|
|
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.
|
|
53
|
-
|
|
54
|
-
## [1.1.6] - 2026-06-23
|
|
55
|
-
|
|
56
|
-
### Fixed
|
|
57
|
-
- Exposed a no-op TUI plugin target so installation via `Ctrl+P` / `opencode plugin opencode-thinking-fix` completes without the "Package has no TUI target" warning.
|
|
58
|
-
|
|
59
|
-
## [1.1.5] - 2026-06-23
|
|
60
|
-
|
|
61
|
-
### Fixed
|
|
62
|
-
- **MiniMax-M3 JSON parse errors in OpenCode Go mode**: model name was not parsed in fixed-upstream mode, so `reasoning_split: true` was never injected for MiniMax models routed through port 3458. Now model name is parsed regardless of `UPSTREAM_URL`.
|
|
63
|
-
- **`<think>` tags leaked into `delta.content`**: added a fallback extractor that strips `<think>...</think>` blocks from content, moves them into the reasoning buffer, and forwards a sanitized SSE chunk.
|
|
64
|
-
- **Kimi K2.6/K2.7 parameter rejection**: strip all hardcoded sampling parameters (`temperature`, `top_p`, `top_k`, `presence_penalty`, `frequency_penalty`, `n`, and `thinking`/`reasoning_effort` for K2.7) before sending to Moonshot.
|
|
65
|
-
|
|
66
|
-
### Changed
|
|
67
|
-
- SSE stream parser now re-serializes and forwards modified chunks instead of forwarding the raw upstream bytes unchanged.
|
|
68
|
-
|
|
69
|
-
## [1.1.4] - 2026-06-23
|
|
70
|
-
|
|
71
|
-
### Fixed
|
|
72
|
-
- Strip all Moonshot-hardcoded sampling parameters for Kimi K2.6/K2.7 thinking mode, not just `top_p`/`top_k`.
|
|
73
|
-
|
|
74
|
-
## [1.1.3] - 2026-06-23
|
|
75
|
-
|
|
76
|
-
### Fixed
|
|
77
|
-
- Strip `top_p` and `top_k` from request body for Kimi/Moonshot models.
|
|
78
|
-
|
|
79
|
-
## [1.1.2] - 2026-06-23
|
|
80
|
-
|
|
81
|
-
### Fixed
|
|
82
|
-
- Interleaved thinking support: reasoning can now appear after content in the same streaming turn (GLM-5+, MiniMax-M3). Removed premature flush on content appearance.
|
|
83
|
-
- Inject `reasoning_split: true` for MiniMax models to keep reasoning out of `content`.
|
|
84
|
-
|
|
85
|
-
## [1.1.1] - 2026-06-23
|
|
86
|
-
|
|
87
|
-
### Fixed
|
|
88
|
-
- Parse MiniMax-M3 `reasoning_details[]` array format and replay it on subsequent turns.
|
|
89
|
-
|
|
90
|
-
## [1.1.0] - 2026-06-23
|
|
91
|
-
|
|
92
|
-
### Fixed
|
|
93
|
-
- Restored OpenCode 1.3.x plugin loader compatibility by exporting `{ server: ThinkingFixPlugin }` and adding `oc-plugin: ["server"]`.
|
|
94
|
-
|
|
95
|
-
## [1.0.1] - 2026-06-22
|
|
96
|
-
|
|
97
|
-
### Added
|
|
98
|
-
- MIT LICENSE.
|
|
99
|
-
- `files` whitelist in `package.json`.
|
|
100
|
-
|
|
101
|
-
## [1.0.0] - 2026-06-22
|
|
102
|
-
|
|
103
|
-
### Added
|
|
104
|
-
- Initial release: plugin + proxy + watchdog.
|
|
105
|
-
- Support for DeepSeek, Kimi, GLM, and MiMo `reasoning_content` replay.
|
|
106
|
-
- Two-proxy architecture (direct providers on port 3457, OpenCode Go on port 3458).
|
package/tests/test-plugin.js
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
-
// patchMessages, ported from opencode-thinking-fix-universal.ts (lines 31-77)
|
|
5
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
-
function patchMessages(messages) {
|
|
7
|
-
let count = 0
|
|
8
|
-
|
|
9
|
-
// Step 1: Self-detect reasoning model
|
|
10
|
-
const isReasoningModel = messages.some((wrapper) => {
|
|
11
|
-
const msg = wrapper?.info ?? wrapper
|
|
12
|
-
return msg?.role === 'assistant' && ('reasoning_content' in msg || 'reasoning' in msg)
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
// Step 2: Non-reasoning model, exit immediately
|
|
16
|
-
if (!isReasoningModel) return 0
|
|
17
|
-
|
|
18
|
-
// Step 3: Patch all assistant turns missing required fields
|
|
19
|
-
for (const wrapper of messages) {
|
|
20
|
-
const msg = wrapper?.info ?? wrapper
|
|
21
|
-
if (!msg || msg.role !== 'assistant') continue
|
|
22
|
-
|
|
23
|
-
if (!msg.content && msg.content !== '') {
|
|
24
|
-
msg.content = msg.tool_calls?.length ? 'call tool' : ''
|
|
25
|
-
count++
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (!('reasoning_content' in msg)) {
|
|
29
|
-
msg.reasoning_content = ''
|
|
30
|
-
count++
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (!('reasoning' in msg)) {
|
|
34
|
-
msg.reasoning = ''
|
|
35
|
-
count++
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return count
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
-
// Helpers
|
|
44
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
-
function deepClone(o) { return JSON.parse(JSON.stringify(o)) }
|
|
46
|
-
|
|
47
|
-
let passed = 0
|
|
48
|
-
let failed = 0
|
|
49
|
-
const failures = []
|
|
50
|
-
|
|
51
|
-
function test(name, fn) {
|
|
52
|
-
try {
|
|
53
|
-
fn()
|
|
54
|
-
passed++
|
|
55
|
-
console.log(` PASS: ${name}`)
|
|
56
|
-
} catch (e) {
|
|
57
|
-
failed++
|
|
58
|
-
const msg = `${name}, ${e.message}`
|
|
59
|
-
failures.push(msg)
|
|
60
|
-
console.log(` FAIL: ${msg}`)
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function assert(cond, msg) {
|
|
65
|
-
if (!cond) throw new Error(msg || 'assertion failed')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
-
// Test Cases
|
|
70
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
-
console.log('\n=== patchMessages Regression Tests ===\n')
|
|
72
|
-
|
|
73
|
-
// ── TC1: Native reasoning model (reasoning_content) ─────────────────────────
|
|
74
|
-
test('TC1: Native reasoning model detects via reasoning_content', () => {
|
|
75
|
-
const msgs = deepClone([
|
|
76
|
-
{ role: 'user', content: 'hello' },
|
|
77
|
-
{ role: 'assistant', content: 'hi there', reasoning_content: 'step 1...' },
|
|
78
|
-
])
|
|
79
|
-
const result = patchMessages(msgs)
|
|
80
|
-
assert(result === 1, `expected 1 patch, got ${result}`)
|
|
81
|
-
assert(msgs[1].reasoning === '', 'reasoning should be added as empty string')
|
|
82
|
-
assert(msgs[1].reasoning_content === 'step 1...', 'should not overwrite existing reasoning_content')
|
|
83
|
-
assert(msgs[0].role === 'user' && msgs[0].content === 'hello', 'user message must not be touched')
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
// ── TC2: OpenCode Go model (reasoning, NO reasoning_content) ─────────────────
|
|
87
|
-
test('TC2: OpenCode Go model detected via reasoning field', () => {
|
|
88
|
-
const msgs = deepClone([
|
|
89
|
-
{ role: 'assistant', content: 'ok', reasoning: 'step 1...' },
|
|
90
|
-
])
|
|
91
|
-
const result = patchMessages(msgs)
|
|
92
|
-
assert(result === 1, `expected 1 patch, got ${result}`)
|
|
93
|
-
assert(msgs[0].reasoning_content === '', 'reasoning_content should be added as empty string')
|
|
94
|
-
assert(msgs[0].reasoning === 'step 1...', 'should not overwrite existing reasoning')
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
// ── TC3: Non-reasoning model (Qwen/GPT/Claude) ──────────────────────────────
|
|
98
|
-
test('TC3: Non-reasoning model returns 0 with zero modifications', () => {
|
|
99
|
-
const msgs = deepClone([
|
|
100
|
-
{ role: 'user', content: 'hello' },
|
|
101
|
-
{ role: 'assistant', content: 'hi' },
|
|
102
|
-
])
|
|
103
|
-
const result = patchMessages(msgs)
|
|
104
|
-
assert(result === 0, `expected 0 patches, got ${result}`)
|
|
105
|
-
assert(!('reasoning_content' in msgs[1]), 'reasoning_content must not be injected')
|
|
106
|
-
assert(!('reasoning' in msgs[1]), 'reasoning must not be injected')
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
// ── TC4: Mixed messages (user + assistant + user) ───────────────────────────
|
|
110
|
-
test('TC4: Only assistant messages are patched, user messages untouched', () => {
|
|
111
|
-
const msgs = deepClone([
|
|
112
|
-
{ role: 'user', content: 'q1' },
|
|
113
|
-
{ role: 'assistant', content: 'a1', reasoning_content: 'thinking' },
|
|
114
|
-
{ role: 'user', content: 'q2' },
|
|
115
|
-
])
|
|
116
|
-
const result = patchMessages(msgs)
|
|
117
|
-
assert(result === 1, `expected 1 patch, got ${result}`)
|
|
118
|
-
assert(msgs[1].reasoning === '', 'assistant reasoning should be added')
|
|
119
|
-
assert(!('reasoning_content' in msgs[0]), 'user message must not get reasoning_content')
|
|
120
|
-
assert(!('reasoning' in msgs[0]), 'user message must not get reasoning')
|
|
121
|
-
assert(!('reasoning_content' in msgs[2]), 'second user message must not get reasoning_content')
|
|
122
|
-
assert(msgs[2].content === 'q2', 'second user message content unchanged')
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
// ── TC5: Multiple assistant turns ───────────────────────────────────────────
|
|
126
|
-
test('TC5: Both assistant messages patched when at least one has reasoning', () => {
|
|
127
|
-
const msgs = deepClone([
|
|
128
|
-
{ role: 'assistant', content: 'first', tool_calls: [{ function: { name: 'f' } }] },
|
|
129
|
-
{ role: 'assistant', content: 'second', reasoning: 'thinking' },
|
|
130
|
-
])
|
|
131
|
-
const result = patchMessages(msgs)
|
|
132
|
-
// msgs[0]: +reasoning_content, +reasoning (2). msgs[1]: +reasoning_content (1). Total = 3.
|
|
133
|
-
// msgs[0].content is 'first' (truthy) so no content patch.
|
|
134
|
-
assert(result === 3, `expected 3 patches, got ${result}`)
|
|
135
|
-
assert(msgs[0].reasoning_content === '', 'first assistant should get reasoning_content')
|
|
136
|
-
assert(msgs[0].reasoning === '', 'first assistant should get reasoning')
|
|
137
|
-
// first assistant content is 'first' (truthy) so NOT patched
|
|
138
|
-
assert(msgs[0].content === 'first', 'first assistant content unchanged')
|
|
139
|
-
assert(!('content' in Object.getOwnPropertyDescriptor(msgs[1], 'content') || true) || msgs[1].content === 'second', 'second assistant content unchanged')
|
|
140
|
-
assert(msgs[1].reasoning_content === '', 'second assistant should get reasoning_content')
|
|
141
|
-
assert(msgs[1].reasoning === 'thinking', 'second assistant reasoning unchanged')
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
// ── TC6: Already complete message ───────────────────────────────────────────
|
|
145
|
-
test('TC6: Already complete message, no changes', () => {
|
|
146
|
-
const msgs = deepClone([
|
|
147
|
-
{ role: 'assistant', content: 'done', reasoning_content: 'done', reasoning: 'done' },
|
|
148
|
-
])
|
|
149
|
-
const result = patchMessages(msgs)
|
|
150
|
-
assert(result === 0, `expected 0 patches, got ${result}`)
|
|
151
|
-
assert(msgs[0].reasoning_content === 'done', 'reasoning_content unchanged')
|
|
152
|
-
assert(msgs[0].reasoning === 'done', 'reasoning unchanged')
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
// ── TC7: Wrapper format ({ info: Message }) ─────────────────────────────────
|
|
156
|
-
test('TC7: Wrapper format correctly handled', () => {
|
|
157
|
-
const msgs = deepClone([
|
|
158
|
-
{ info: { role: 'user', content: 'hello' } },
|
|
159
|
-
{ info: { role: 'assistant', content: 'reply', reasoning: 'thinking' } },
|
|
160
|
-
])
|
|
161
|
-
const result = patchMessages(msgs)
|
|
162
|
-
assert(result === 1, `expected 1 patch, got ${result}`)
|
|
163
|
-
assert(msgs[1].info.reasoning_content === '', 'wrapped assistant should get reasoning_content')
|
|
164
|
-
assert(msgs[1].info.reasoning === 'thinking', 'wrapped assistant reasoning unchanged')
|
|
165
|
-
assert(!('reasoning_content' in msgs[0].info), 'wrapped user must not get reasoning_content')
|
|
166
|
-
assert(msgs[0].info.content === 'hello', 'wrapped user content unchanged')
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
// ── TC8: Empty reasoning_content string ─────────────────────────────────────
|
|
170
|
-
test('TC8: Empty reasoning_content still triggers detection', () => {
|
|
171
|
-
const msgs = deepClone([
|
|
172
|
-
{ role: 'assistant', content: 'ok', reasoning_content: '' },
|
|
173
|
-
])
|
|
174
|
-
const result = patchMessages(msgs)
|
|
175
|
-
assert(result === 1, `expected 1 patch (reasoning), got ${result}`)
|
|
176
|
-
assert(msgs[0].reasoning === '', 'reasoning should be added')
|
|
177
|
-
assert(msgs[0].reasoning_content === '', 'reasoning_content stays empty')
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
// ── TC9: Empty messages array ───────────────────────────────────────────────
|
|
181
|
-
test('TC9: Empty messages array returns 0', () => {
|
|
182
|
-
const result = patchMessages([])
|
|
183
|
-
assert(result === 0, `expected 0, got ${result}`)
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
// ── TC10: Non-reasoning model with tool_calls ────────────────────────────────
|
|
187
|
-
test('TC10: Non-reasoning model with tool_calls, no injection', () => {
|
|
188
|
-
const msgs = deepClone([
|
|
189
|
-
{ role: 'assistant', content: '', tool_calls: [{ function: { name: 'f' } }] },
|
|
190
|
-
])
|
|
191
|
-
const result = patchMessages(msgs)
|
|
192
|
-
assert(result === 0, `expected 0, got ${result}`)
|
|
193
|
-
assert(!('reasoning_content' in msgs[0]), 'must not inject reasoning_content')
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
// ── TC11: Reasoning model with empty content + tool_calls ───────────────────
|
|
197
|
-
test('TC11: Reasoning model patches content to "call tool"', () => {
|
|
198
|
-
const msgs = deepClone([
|
|
199
|
-
{ role: 'assistant', content: '', tool_calls: [{ function: { name: 'f' } }], reasoning_content: 'thinking' },
|
|
200
|
-
])
|
|
201
|
-
const result = patchMessages(msgs)
|
|
202
|
-
// content is '' (empty string), guard `!msg.content && msg.content !== ''` skips it
|
|
203
|
-
// because '' !== '' is false. The original code treats '' as valid content.
|
|
204
|
-
// Only reasoning is patched (reasoning_content and content are already present/invalid).
|
|
205
|
-
assert(result === 1, `expected 1 patch (reasoning), got ${result}`)
|
|
206
|
-
assert(msgs[0].content === '', 'content stays empty string')
|
|
207
|
-
assert(msgs[0].reasoning === '', 'reasoning added')
|
|
208
|
-
assert(msgs[0].reasoning_content === 'thinking', 'reasoning_content preserved')
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
// ── TC12: Null wrapper ──────────────────────────────────────────────────────
|
|
212
|
-
test('TC12: Null/undefined wrapper in array is skipped gracefully', () => {
|
|
213
|
-
const msgs = deepClone([
|
|
214
|
-
null,
|
|
215
|
-
{ role: 'assistant', content: 'hi', reasoning_content: 'think' },
|
|
216
|
-
undefined,
|
|
217
|
-
])
|
|
218
|
-
const result = patchMessages(msgs)
|
|
219
|
-
assert(result >= 1, `expected at least 1 patch, got ${result}`)
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
-
// Summary
|
|
224
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
225
|
-
const total = passed + failed
|
|
226
|
-
console.log(`\n=== Results: ${passed}/${total} passed, ${failed} failed ===\n`)
|
|
227
|
-
|
|
228
|
-
process.exit(failed > 0 ? 1 : 0)
|