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/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
+ }