opencode-thinking-fix 2.0.1 → 3.2.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/README.md +152 -59
- package/package.json +8 -11
- package/proxy/core.js +316 -0
- package/proxy/proxy.js +239 -333
- package/CHANGELOG.md +0 -106
- package/tests/test-plugin.js +0 -228
- package/tests/test-proxy.js +0 -359
package/proxy/core.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
import { createParser } from 'eventsource-parser'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
8
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
9
|
+
|
|
10
|
+
export function deriveSessionId(modelName, parsedBody, authHeader) {
|
|
11
|
+
const messages = parsedBody?.messages
|
|
12
|
+
if (!Array.isArray(messages)) return ''
|
|
13
|
+
const firstUser = messages.find((m) => m && m.role === 'user')
|
|
14
|
+
if (!firstUser) return ''
|
|
15
|
+
const text = typeof firstUser.content === 'string'
|
|
16
|
+
? firstUser.content
|
|
17
|
+
: Array.isArray(firstUser.content)
|
|
18
|
+
? firstUser.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('')
|
|
19
|
+
: ''
|
|
20
|
+
if (!text) return ''
|
|
21
|
+
return 'derived:' + crypto.createHash('sha256')
|
|
22
|
+
.update(`${authHeader || ''}||${modelName}||${text}`).digest('hex').slice(0, 32)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LOG_FILE = process.env.LOG_FILE === ''
|
|
26
|
+
? ''
|
|
27
|
+
: (process.env.LOG_FILE || join(homedir(), '.local', 'share', 'opencode', 'thinking-fix.log'))
|
|
28
|
+
|
|
29
|
+
export function writeLog(entry) {
|
|
30
|
+
if (!LOG_FILE) return
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(dirname(LOG_FILE), { recursive: true })
|
|
33
|
+
appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', 'utf8')
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('[Proxy] log write error:', error instanceof Error ? error.message : error)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEBUG = process.env.DEBUG === '1'
|
|
40
|
+
|
|
41
|
+
export const ROUTES = {
|
|
42
|
+
// R1 aliases: reasoning must not be echoed back — the 'strip' sentinel
|
|
43
|
+
// admits the request to patchRequestBody (to REMOVE any reasoning fields a
|
|
44
|
+
// client/plugin attached) while excluding it from response-side caching.
|
|
45
|
+
'deepseek-r1': { base: 'https://api.deepseek.com', reasoningKey: 'strip' },
|
|
46
|
+
'deepseek-v4-pro': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
|
|
47
|
+
'deepseek-v4-flash': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
|
|
48
|
+
'deepseek-chat': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
|
|
49
|
+
'deepseek-reasoner': { base: 'https://api.deepseek.com', reasoningKey: 'strip' },
|
|
50
|
+
deepseek: { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
|
|
51
|
+
kimi: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
|
|
52
|
+
moonshot: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
|
|
53
|
+
glm: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoningKey: 'reasoning_content' },
|
|
54
|
+
zhipu: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoningKey: 'reasoning_content' },
|
|
55
|
+
minimax: { base: 'https://api.minimax.io/v1', reasoningKey: 'reasoning_details' },
|
|
56
|
+
mimo: { base: 'https://api.xiaomimimo.com/v1', reasoningKey: 'reasoning_content' },
|
|
57
|
+
qwen: { base: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', reasoningKey: null },
|
|
58
|
+
gpt: { base: 'https://api.openai.com', reasoningKey: null },
|
|
59
|
+
o1: { base: 'https://api.openai.com', reasoningKey: null },
|
|
60
|
+
claude: { base: 'https://api.anthropic.com', reasoningKey: null },
|
|
61
|
+
anthropic: { base: 'https://api.anthropic.com', reasoningKey: null },
|
|
62
|
+
gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', reasoningKey: null },
|
|
63
|
+
llama: { base: 'https://api.together.xyz', reasoningKey: null },
|
|
64
|
+
mistral: { base: 'https://api.mistral.ai', reasoningKey: null },
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoningKey: null }
|
|
68
|
+
|
|
69
|
+
export function route(modelName) {
|
|
70
|
+
if (!modelName) return DEFAULT_ROUTE
|
|
71
|
+
const lower = modelName.toLowerCase()
|
|
72
|
+
for (const [prefix, entry] of Object.entries(ROUTES)) {
|
|
73
|
+
if (lower.startsWith(prefix)) return entry
|
|
74
|
+
}
|
|
75
|
+
return DEFAULT_ROUTE
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Models served by opencode-go over the Anthropic /v1/messages wire format.
|
|
79
|
+
const ANTHROPIC_WIRE_PREFIXES = ['minimax', 'qwen']
|
|
80
|
+
|
|
81
|
+
export function fixedUpstreamRoute(modelName, requestUrl = '') {
|
|
82
|
+
const stripped = (modelName || '').toLowerCase().replace(/^opencode-go(?:-(?:messages|responses))?\//, '')
|
|
83
|
+
if (String(requestUrl).includes('/messages') && ANTHROPIC_WIRE_PREFIXES.some((p) => stripped.startsWith(p))) {
|
|
84
|
+
return { base: '', reasoningKey: 'anthropic' }
|
|
85
|
+
}
|
|
86
|
+
// F11 (validated 2026-08-19): the opencode-go gateway rejects the `reasoning`
|
|
87
|
+
// field on glm-5.2 chat/completions echo (400 "Extra inputs are not
|
|
88
|
+
// permitted") but accepts `reasoning_content`. glm-5.2 was live-validated
|
|
89
|
+
// two-turn; other Go models were NOT changed by F11 and keep the existing
|
|
90
|
+
// `reasoning` behavior below (no exhaustive per-model validation claimed).
|
|
91
|
+
if (stripped === 'glm-5.2' && String(requestUrl).includes('/chat/completions')) {
|
|
92
|
+
return { base: '', reasoningKey: 'reasoning_content' }
|
|
93
|
+
}
|
|
94
|
+
// Unconditional 'reasoning' for go mode: matches current working behavior
|
|
95
|
+
// for chat/completions models; /responses bodies have no `messages` array
|
|
96
|
+
// and pass through untouched.
|
|
97
|
+
return { base: '', reasoningKey: 'reasoning' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class LRUCache {
|
|
101
|
+
constructor(capacity) { this.cache = new Map(); this.capacity = capacity }
|
|
102
|
+
get(key) {
|
|
103
|
+
if (!this.cache.has(key)) return undefined
|
|
104
|
+
const value = this.cache.get(key)
|
|
105
|
+
this.cache.delete(key); this.cache.set(key, value)
|
|
106
|
+
return value
|
|
107
|
+
}
|
|
108
|
+
set(key, value) {
|
|
109
|
+
this.cache.delete(key)
|
|
110
|
+
this.cache.set(key, value)
|
|
111
|
+
while (this.cache.size > this.capacity) {
|
|
112
|
+
const oldest = this.cache.keys().next().value
|
|
113
|
+
this.cache.delete(oldest)
|
|
114
|
+
if (DEBUG) console.log(`[Cache] evicted session ${String(oldest).slice(-8)}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
put(key, value) { this.set(key, value) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parsedCapacity = parseInt(process.env.CACHE_CAPACITY || '500', 10)
|
|
121
|
+
const CACHE_CAPACITY = Number.isFinite(parsedCapacity) && parsedCapacity >= 0 ? parsedCapacity : 500
|
|
122
|
+
const parsedMaxTurns = parseInt(process.env.MAX_TURNS_PER_SESSION || '200', 10)
|
|
123
|
+
const MAX_TURNS_PER_SESSION = Number.isFinite(parsedMaxTurns) && parsedMaxTurns > 0 ? parsedMaxTurns : 200
|
|
124
|
+
const parsedMaxReasoning = parseInt(process.env.MAX_REASONING_CHARS || '200000', 10)
|
|
125
|
+
const MAX_REASONING_CHARS = Number.isFinite(parsedMaxReasoning) && parsedMaxReasoning > 0 ? parsedMaxReasoning : 200000
|
|
126
|
+
|
|
127
|
+
// MiniMax reasoning_details replay shape.
|
|
128
|
+
//
|
|
129
|
+
// NOTE (F6, 2026-08-19): in our opencode-go deployment (port 3458, Anthropic
|
|
130
|
+
// /v1/messages wire) every MiniMax request resolves to reasoningKey 'anthropic'
|
|
131
|
+
// via fixedUpstreamRoute(), so this constant and the reasoning_details branch
|
|
132
|
+
// in patchRequestBody() are UNUSED in production. Retained defensively for the
|
|
133
|
+
// port-3457 universal-mode / provider-native MiniMax fallback (ROUTES.minimax).
|
|
134
|
+
//
|
|
135
|
+
// 'full' emits the openai-responses-v1 item { type, format, index, text };
|
|
136
|
+
// 'minimal' emits { type, text }. Default 'minimal' (smallest documented
|
|
137
|
+
// outbound shape).
|
|
138
|
+
const MINIMAX_REASONING_DETAILS_SHAPE = process.env.MINIMAX_REASONING_DETAILS_SHAPE === 'full' ? 'full' : 'minimal'
|
|
139
|
+
|
|
140
|
+
class BoundedSessionMap extends Map {
|
|
141
|
+
set(key, value) {
|
|
142
|
+
// Skip-not-truncate: an oversized reasoning value is dropped entirely
|
|
143
|
+
// rather than silently truncated (a truncated replay can be worse than a
|
|
144
|
+
// cache miss). Returns true when stored, false when skipped.
|
|
145
|
+
if (typeof value === 'string' && value.length > MAX_REASONING_CHARS) return false
|
|
146
|
+
super.set(key, value)
|
|
147
|
+
while (this.size > MAX_TURNS_PER_SESSION) this.delete(this.keys().next().value)
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const cache = new LRUCache(CACHE_CAPACITY)
|
|
153
|
+
export const stats = { sessions: 0, hits: 0, misses: 0, stored: 0, requests: 0, passthrough: 0 }
|
|
154
|
+
|
|
155
|
+
export function getSessionCache(sessionId) {
|
|
156
|
+
let sessionMap = cache.get(sessionId)
|
|
157
|
+
if (!sessionMap) { sessionMap = new BoundedSessionMap(); cache.set(sessionId, sessionMap); stats.sessions++ }
|
|
158
|
+
return sessionMap
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function patchRequestBody(body, sessionId, reasoningKey) {
|
|
162
|
+
const emptyReport = { assistantTurns: 0, missingText: 0, missingReasoning: 0, hits: 0, misses: 0, turns: [] }
|
|
163
|
+
if (!reasoningKey || typeof sessionId !== 'string' || !sessionId.trim() || sessionId.trim() === 'unknown') return { body, assistantCount: 0, modified: false, report: emptyReport }
|
|
164
|
+
let parsed
|
|
165
|
+
try { parsed = JSON.parse(body) } catch { return { body, assistantCount: 0, modified: false, report: emptyReport } }
|
|
166
|
+
if (!Array.isArray(parsed.messages)) return { body, assistantCount: 0, modified: false, report: emptyReport }
|
|
167
|
+
|
|
168
|
+
const sessionCache = getSessionCache(sessionId)
|
|
169
|
+
const report = { ...emptyReport, turns: [] }
|
|
170
|
+
let assistantIndex = 0
|
|
171
|
+
let modified = false
|
|
172
|
+
for (const msg of parsed.messages) {
|
|
173
|
+
if (msg.role !== 'assistant') continue
|
|
174
|
+
report.assistantTurns++
|
|
175
|
+
if (msg.content == null || msg.content === '') report.missingText++
|
|
176
|
+
const cached = sessionCache.get(assistantIndex)
|
|
177
|
+
let missingReasoning = false
|
|
178
|
+
if (reasoningKey === 'anthropic') {
|
|
179
|
+
// Anthropic wire: reasoning lives inside content[] as a thinking block.
|
|
180
|
+
const hasThinking = Array.isArray(msg.content) && msg.content.some((b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking'))
|
|
181
|
+
missingReasoning = !hasThinking
|
|
182
|
+
if (missingReasoning) report.missingReasoning++
|
|
183
|
+
if (cached) {
|
|
184
|
+
if (Array.isArray(msg.content)) {
|
|
185
|
+
if (!hasThinking) { msg.content.unshift({ type: 'thinking', thinking: cached }); stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['content'], source: 'hit' }); modified = true }
|
|
186
|
+
} else if (typeof msg.content === 'string') {
|
|
187
|
+
msg.content = [{ type: 'thinking', thinking: cached }, { type: 'text', text: msg.content }]
|
|
188
|
+
stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['content'], source: 'hit' }); modified = true
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// No hard-400 contract on MiniMax/Qwen anthropic endpoints; never fabricate.
|
|
192
|
+
if (missingReasoning) { stats.misses++; report.misses++ }
|
|
193
|
+
}
|
|
194
|
+
} else if (reasoningKey === 'reasoning_details') {
|
|
195
|
+
// F6 (defensive): provider-native MiniMax on port 3457 universal mode
|
|
196
|
+
// replays reasoning into `reasoning_details`. Never executed in the
|
|
197
|
+
// opencode-go deployment (MiniMax always routes as 'anthropic' there) —
|
|
198
|
+
// retained for the universal-mode / provider-native fallback.
|
|
199
|
+
const hasDetails = Array.isArray(msg.reasoning_details) ? msg.reasoning_details.length > 0 : !!msg.reasoning_details
|
|
200
|
+
missingReasoning = !hasDetails && !msg.reasoning_content
|
|
201
|
+
if (missingReasoning) report.missingReasoning++
|
|
202
|
+
if (!hasDetails && !msg.reasoning_content && cached) {
|
|
203
|
+
msg.reasoning_details = MINIMAX_REASONING_DETAILS_SHAPE === 'full'
|
|
204
|
+
? [{ type: 'reasoning.text', format: 'openai-responses-v1', index: assistantIndex, text: cached }]
|
|
205
|
+
: [{ type: 'reasoning.text', text: cached }]
|
|
206
|
+
stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['reasoning_details'], source: 'hit' }); modified = true
|
|
207
|
+
} else if (missingReasoning) {
|
|
208
|
+
stats.misses++; report.misses++
|
|
209
|
+
}
|
|
210
|
+
} else if (reasoningKey === 'strip') {
|
|
211
|
+
// R1 sentinel: deepseek-r1 / deepseek-reasoner 400 when reasoning is
|
|
212
|
+
// echoed back. Actively remove any reasoning field a client/plugin may
|
|
213
|
+
// have attached rather than replay it.
|
|
214
|
+
const stripped = []
|
|
215
|
+
if (msg.reasoning_content !== undefined) { delete msg.reasoning_content; stripped.push('reasoning_content') }
|
|
216
|
+
if (msg.reasoning !== undefined) { delete msg.reasoning; stripped.push('reasoning') }
|
|
217
|
+
if (msg.reasoning_details !== undefined) { delete msg.reasoning_details; stripped.push('reasoning_details') }
|
|
218
|
+
if (Array.isArray(msg.content)) {
|
|
219
|
+
const kept = msg.content.filter((b) => !(b && (b.type === 'thinking' || b.type === 'redacted_thinking')))
|
|
220
|
+
if (kept.length !== msg.content.length) { msg.content = kept; stripped.push('content') }
|
|
221
|
+
}
|
|
222
|
+
if (stripped.length > 0) {
|
|
223
|
+
modified = true
|
|
224
|
+
report.turns.push({ index: assistantIndex, fields: stripped, source: 'strip' })
|
|
225
|
+
}
|
|
226
|
+
} else if (!msg[reasoningKey] || msg[reasoningKey] === '') {
|
|
227
|
+
missingReasoning = true
|
|
228
|
+
report.missingReasoning++
|
|
229
|
+
if (cached) { msg[reasoningKey] = cached; stats.hits++ }
|
|
230
|
+
else { msg[reasoningKey] = ''; stats.misses++ }
|
|
231
|
+
if (cached) { report.hits++; report.turns.push({ index: assistantIndex, fields: [reasoningKey], source: 'hit' }) }
|
|
232
|
+
else { report.misses++; report.turns.push({ index: assistantIndex, fields: [reasoningKey], source: 'miss' }) }
|
|
233
|
+
modified = true
|
|
234
|
+
}
|
|
235
|
+
assistantIndex++
|
|
236
|
+
}
|
|
237
|
+
return { body: modified ? JSON.stringify(parsed) : body, assistantCount: assistantIndex, modified, report }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function extractReasoningFromJson(text) {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(text)
|
|
243
|
+
// Anthropic buffered format: content[] with thinking blocks.
|
|
244
|
+
if (Array.isArray(parsed?.content)) {
|
|
245
|
+
const thinking = parsed.content
|
|
246
|
+
// Explicit redacted_thinking guard (redundant with the
|
|
247
|
+
// b.type === 'thinking' equality below, but kept for contract
|
|
248
|
+
// clarity: redacted blocks must never be replayed as reasoning).
|
|
249
|
+
.filter((b) => b && b.type === 'thinking' && b.type !== 'redacted_thinking' && typeof b.thinking === 'string')
|
|
250
|
+
.map((b) => b.thinking)
|
|
251
|
+
.join('\n')
|
|
252
|
+
if (thinking) return thinking
|
|
253
|
+
}
|
|
254
|
+
// OpenAI buffered format.
|
|
255
|
+
const msg = parsed?.choices?.[0]?.message
|
|
256
|
+
if (msg) {
|
|
257
|
+
if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) return msg.reasoning_content
|
|
258
|
+
if (typeof msg.reasoning === 'string' && msg.reasoning) return msg.reasoning
|
|
259
|
+
if (Array.isArray(msg.reasoning_details)) {
|
|
260
|
+
const reasoning = msg.reasoning_details.map((d) => d.text || '').join('')
|
|
261
|
+
if (reasoning) return reasoning
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} catch { /* not JSON */ }
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function createStreamParser(sessionId, baseIndex, onComplete) {
|
|
269
|
+
let reasoningBuffer = ''
|
|
270
|
+
// StringDecoder buffers partial multibyte UTF-8 sequences across feed()
|
|
271
|
+
// calls, so a character split between two chunks is not corrupted.
|
|
272
|
+
const decoder = new StringDecoder('utf8')
|
|
273
|
+
const parser = createParser({
|
|
274
|
+
maxBufferSize: 1024 * 1024,
|
|
275
|
+
onEvent(event) {
|
|
276
|
+
if (!event.data || event.data === '[DONE]') {
|
|
277
|
+
if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(event.data)
|
|
282
|
+
// Anthropic SSE: terminate on message_stop.
|
|
283
|
+
if (event.event === 'message_stop' || parsed.type === 'message_stop') {
|
|
284
|
+
if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
// Anthropic SSE: thinking deltas carry delta.thinking (type thinking_delta).
|
|
288
|
+
if (event.event === 'content_block_delta' || parsed.type === 'content_block_delta') {
|
|
289
|
+
const delta = parsed.delta
|
|
290
|
+
if (delta && typeof delta.thinking === 'string') reasoningBuffer += delta.thinking
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
// OpenAI SSE.
|
|
294
|
+
const delta = parsed.choices?.[0]?.delta
|
|
295
|
+
if (!delta) return
|
|
296
|
+
if (typeof delta.reasoning_content === 'string') reasoningBuffer += delta.reasoning_content
|
|
297
|
+
if (typeof delta.reasoning === 'string') reasoningBuffer += delta.reasoning
|
|
298
|
+
if (Array.isArray(delta.reasoning_details)) {
|
|
299
|
+
for (const detail of delta.reasoning_details) if (typeof detail.text === 'string') reasoningBuffer += detail.text
|
|
300
|
+
}
|
|
301
|
+
if (parsed.choices?.[0]?.finish_reason && reasoningBuffer) {
|
|
302
|
+
onComplete(baseIndex, reasoningBuffer); reasoningBuffer = ''
|
|
303
|
+
}
|
|
304
|
+
} catch { /* malformed SSE chunk, ignore */ }
|
|
305
|
+
},
|
|
306
|
+
onError(error) { if (DEBUG) console.error(`[Proxy] SSE parse error (${sessionId.slice(-8)}):`, error.message) },
|
|
307
|
+
})
|
|
308
|
+
return {
|
|
309
|
+
feed(chunk) { parser.feed(decoder.write(chunk)) },
|
|
310
|
+
flush() {
|
|
311
|
+
const tail = decoder.end()
|
|
312
|
+
if (tail) parser.feed(tail)
|
|
313
|
+
if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
}
|