opencode-thinking-fix 2.0.0 → 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/proxy/core.js ADDED
@@ -0,0 +1,255 @@
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
+
9
+ export function deriveSessionId(modelName, parsedBody, authHeader) {
10
+ const messages = parsedBody?.messages
11
+ if (!Array.isArray(messages)) return ''
12
+ const firstUser = messages.find((m) => m && m.role === 'user')
13
+ if (!firstUser) return ''
14
+ const text = typeof firstUser.content === 'string'
15
+ ? firstUser.content
16
+ : Array.isArray(firstUser.content)
17
+ ? firstUser.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('')
18
+ : ''
19
+ if (!text) return ''
20
+ return 'derived:' + crypto.createHash('sha256')
21
+ .update(`${authHeader || ''}||${modelName}||${text}`).digest('hex').slice(0, 32)
22
+ }
23
+
24
+ const LOG_FILE = process.env.LOG_FILE === ''
25
+ ? ''
26
+ : (process.env.LOG_FILE || join(homedir(), '.local', 'share', 'opencode', 'thinking-fix.log'))
27
+
28
+ export function writeLog(entry) {
29
+ if (!LOG_FILE) return
30
+ try {
31
+ mkdirSync(dirname(LOG_FILE), { recursive: true })
32
+ appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', 'utf8')
33
+ } catch (error) {
34
+ console.error('[Proxy] log write error:', error instanceof Error ? error.message : error)
35
+ }
36
+ }
37
+
38
+ const DEBUG = process.env.DEBUG === '1'
39
+
40
+ export const ROUTES = {
41
+ // R1 aliases: reasoning must not be echoed.
42
+ 'deepseek-r1': { base: 'https://api.deepseek.com', reasoningKey: null },
43
+ 'deepseek-v4-pro': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
44
+ 'deepseek-v4-flash': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
45
+ 'deepseek-chat': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
46
+ 'deepseek-reasoner': { base: 'https://api.deepseek.com', reasoningKey: null },
47
+ deepseek: { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
48
+ kimi: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
49
+ moonshot: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
50
+ glm: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoningKey: 'reasoning_content' },
51
+ zhipu: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoningKey: 'reasoning_content' },
52
+ minimax: { base: 'https://api.minimax.io/v1', reasoningKey: 'reasoning_details' },
53
+ mimo: { base: 'https://api.xiaomimimo.com/v1', reasoningKey: 'reasoning_content' },
54
+ qwen: { base: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', reasoningKey: null },
55
+ gpt: { base: 'https://api.openai.com', reasoningKey: null },
56
+ o1: { base: 'https://api.openai.com', reasoningKey: null },
57
+ claude: { base: 'https://api.anthropic.com', reasoningKey: null },
58
+ anthropic: { base: 'https://api.anthropic.com', reasoningKey: null },
59
+ gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', reasoningKey: null },
60
+ llama: { base: 'https://api.together.xyz', reasoningKey: null },
61
+ mistral: { base: 'https://api.mistral.ai', reasoningKey: null },
62
+ }
63
+
64
+ export const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoningKey: null }
65
+
66
+ export function route(modelName) {
67
+ if (!modelName) return DEFAULT_ROUTE
68
+ const lower = modelName.toLowerCase()
69
+ for (const [prefix, entry] of Object.entries(ROUTES)) {
70
+ if (lower.startsWith(prefix)) return entry
71
+ }
72
+ return DEFAULT_ROUTE
73
+ }
74
+
75
+ // Models served by opencode-go over the Anthropic /v1/messages wire format.
76
+ const ANTHROPIC_WIRE_PREFIXES = ['minimax', 'qwen']
77
+
78
+ export function fixedUpstreamRoute(modelName, requestUrl = '') {
79
+ const stripped = (modelName || '').toLowerCase().replace(/^opencode-go\//, '')
80
+ if (String(requestUrl).includes('/messages') && ANTHROPIC_WIRE_PREFIXES.some((p) => stripped.startsWith(p))) {
81
+ return { base: '', reasoningKey: 'anthropic' }
82
+ }
83
+ // Unconditional 'reasoning' for go mode: matches current working behavior
84
+ // for chat/completions models; /responses bodies have no `messages` array
85
+ // and pass through untouched.
86
+ return { base: '', reasoningKey: 'reasoning' }
87
+ }
88
+
89
+ export class LRUCache {
90
+ constructor(capacity) { this.cache = new Map(); this.capacity = capacity }
91
+ get(key) {
92
+ if (!this.cache.has(key)) return undefined
93
+ const value = this.cache.get(key)
94
+ this.cache.delete(key); this.cache.set(key, value)
95
+ return value
96
+ }
97
+ set(key, value) {
98
+ this.cache.delete(key)
99
+ this.cache.set(key, value)
100
+ while (this.cache.size > this.capacity) {
101
+ const oldest = this.cache.keys().next().value
102
+ this.cache.delete(oldest)
103
+ if (DEBUG) console.log(`[Cache] evicted session ${String(oldest).slice(-8)}`)
104
+ }
105
+ }
106
+ put(key, value) { this.set(key, value) }
107
+ }
108
+
109
+ const parsedCapacity = parseInt(process.env.CACHE_CAPACITY || '500', 10)
110
+ const CACHE_CAPACITY = Number.isFinite(parsedCapacity) && parsedCapacity >= 0 ? parsedCapacity : 500
111
+ const parsedMaxTurns = parseInt(process.env.MAX_TURNS_PER_SESSION || '200', 10)
112
+ const MAX_TURNS_PER_SESSION = Number.isFinite(parsedMaxTurns) && parsedMaxTurns > 0 ? parsedMaxTurns : 200
113
+ const parsedMaxReasoning = parseInt(process.env.MAX_REASONING_CHARS || '200000', 10)
114
+ const MAX_REASONING_CHARS = Number.isFinite(parsedMaxReasoning) && parsedMaxReasoning > 0 ? parsedMaxReasoning : 200000
115
+
116
+ class BoundedSessionMap extends Map {
117
+ set(key, value) {
118
+ super.set(key, typeof value === 'string' ? value.slice(0, MAX_REASONING_CHARS) : value)
119
+ while (this.size > MAX_TURNS_PER_SESSION) this.delete(this.keys().next().value)
120
+ return this
121
+ }
122
+ }
123
+
124
+ export const cache = new LRUCache(CACHE_CAPACITY)
125
+ export const stats = { sessions: 0, hits: 0, misses: 0, stored: 0, requests: 0, passthrough: 0 }
126
+
127
+ export function getSessionCache(sessionId) {
128
+ let sessionMap = cache.get(sessionId)
129
+ if (!sessionMap) { sessionMap = new BoundedSessionMap(); cache.set(sessionId, sessionMap); stats.sessions++ }
130
+ return sessionMap
131
+ }
132
+
133
+ export function patchRequestBody(body, sessionId, reasoningKey) {
134
+ const emptyReport = { assistantTurns: 0, missingText: 0, missingReasoning: 0, hits: 0, misses: 0, turns: [] }
135
+ if (!reasoningKey || typeof sessionId !== 'string' || !sessionId.trim() || sessionId.trim() === 'unknown') return { body, assistantCount: 0, modified: false, report: emptyReport }
136
+ let parsed
137
+ try { parsed = JSON.parse(body) } catch { return { body, assistantCount: 0, modified: false, report: emptyReport } }
138
+ if (!Array.isArray(parsed.messages)) return { body, assistantCount: 0, modified: false, report: emptyReport }
139
+
140
+ const sessionCache = getSessionCache(sessionId)
141
+ const report = { ...emptyReport, turns: [] }
142
+ let assistantIndex = 0
143
+ let modified = false
144
+ for (const msg of parsed.messages) {
145
+ if (msg.role !== 'assistant') continue
146
+ report.assistantTurns++
147
+ if (msg.content == null || msg.content === '') report.missingText++
148
+ const cached = sessionCache.get(assistantIndex)
149
+ let missingReasoning = false
150
+ if (reasoningKey === 'anthropic') {
151
+ // Anthropic wire: reasoning lives inside content[] as a thinking block.
152
+ const hasThinking = Array.isArray(msg.content) && msg.content.some((b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking'))
153
+ missingReasoning = !hasThinking
154
+ if (missingReasoning) report.missingReasoning++
155
+ if (cached) {
156
+ if (Array.isArray(msg.content)) {
157
+ if (!hasThinking) { msg.content.unshift({ type: 'thinking', thinking: cached }); stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['content'], source: 'hit' }); modified = true }
158
+ } else if (typeof msg.content === 'string') {
159
+ msg.content = [{ type: 'thinking', thinking: cached }, { type: 'text', text: msg.content }]
160
+ stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['content'], source: 'hit' }); modified = true
161
+ }
162
+ } else {
163
+ // No hard-400 contract on MiniMax/Qwen anthropic endpoints; never fabricate.
164
+ if (missingReasoning) { stats.misses++; report.misses++ }
165
+ }
166
+ } else if (reasoningKey === 'reasoning_details') {
167
+ const hasDetails = Array.isArray(msg.reasoning_details) ? msg.reasoning_details.length > 0 : !!msg.reasoning_details
168
+ missingReasoning = !hasDetails && !msg.reasoning_content
169
+ if (missingReasoning) report.missingReasoning++
170
+ if (!hasDetails && !msg.reasoning_content && cached) {
171
+ msg.reasoning_details = [{ text: cached, type: 'thinking' }]; stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['reasoning_details'], source: 'hit' }); modified = true
172
+ } else if (missingReasoning) {
173
+ stats.misses++; report.misses++
174
+ }
175
+ } else if (!msg[reasoningKey] || msg[reasoningKey] === '') {
176
+ missingReasoning = true
177
+ report.missingReasoning++
178
+ if (cached) { msg[reasoningKey] = cached; stats.hits++ }
179
+ else { msg[reasoningKey] = ''; stats.misses++ }
180
+ if (cached) { report.hits++; report.turns.push({ index: assistantIndex, fields: [reasoningKey], source: 'hit' }) }
181
+ else { report.misses++; report.turns.push({ index: assistantIndex, fields: [reasoningKey], source: 'miss' }) }
182
+ modified = true
183
+ }
184
+ assistantIndex++
185
+ }
186
+ return { body: modified ? JSON.stringify(parsed) : body, assistantCount: assistantIndex, modified, report }
187
+ }
188
+
189
+ export function extractReasoningFromJson(text) {
190
+ try {
191
+ const parsed = JSON.parse(text)
192
+ // Anthropic buffered format: content[] with thinking blocks.
193
+ if (Array.isArray(parsed?.content)) {
194
+ const thinking = parsed.content
195
+ .filter((b) => b && b.type === 'thinking' && typeof b.thinking === 'string')
196
+ .map((b) => b.thinking)
197
+ .join('\n')
198
+ if (thinking) return thinking
199
+ }
200
+ // OpenAI buffered format.
201
+ const msg = parsed?.choices?.[0]?.message
202
+ if (msg) {
203
+ if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) return msg.reasoning_content
204
+ if (typeof msg.reasoning === 'string' && msg.reasoning) return msg.reasoning
205
+ if (Array.isArray(msg.reasoning_details)) {
206
+ const reasoning = msg.reasoning_details.map((d) => d.text || '').join('')
207
+ if (reasoning) return reasoning
208
+ }
209
+ }
210
+ } catch { /* not JSON */ }
211
+ return null
212
+ }
213
+
214
+ export function createStreamParser(sessionId, baseIndex, onComplete) {
215
+ let reasoningBuffer = ''
216
+ const parser = createParser({
217
+ maxBufferSize: 1024 * 1024,
218
+ onEvent(event) {
219
+ if (!event.data || event.data === '[DONE]') {
220
+ if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
221
+ return
222
+ }
223
+ try {
224
+ const parsed = JSON.parse(event.data)
225
+ // Anthropic SSE: terminate on message_stop.
226
+ if (event.event === 'message_stop' || parsed.type === 'message_stop') {
227
+ if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
228
+ return
229
+ }
230
+ // Anthropic SSE: thinking deltas carry delta.thinking (type thinking_delta).
231
+ if (event.event === 'content_block_delta' || parsed.type === 'content_block_delta') {
232
+ const delta = parsed.delta
233
+ if (delta && typeof delta.thinking === 'string') reasoningBuffer += delta.thinking
234
+ return
235
+ }
236
+ // OpenAI SSE.
237
+ const delta = parsed.choices?.[0]?.delta
238
+ if (!delta) return
239
+ if (typeof delta.reasoning_content === 'string') reasoningBuffer += delta.reasoning_content
240
+ if (typeof delta.reasoning === 'string') reasoningBuffer += delta.reasoning
241
+ if (Array.isArray(delta.reasoning_details)) {
242
+ for (const detail of delta.reasoning_details) if (typeof detail.text === 'string') reasoningBuffer += detail.text
243
+ }
244
+ if (parsed.choices?.[0]?.finish_reason && reasoningBuffer) {
245
+ onComplete(baseIndex, reasoningBuffer); reasoningBuffer = ''
246
+ }
247
+ } catch { /* malformed SSE chunk, ignore */ }
248
+ },
249
+ onError(error) { if (DEBUG) console.error(`[Proxy] SSE parse error (${sessionId.slice(-8)}):`, error.message) },
250
+ })
251
+ return {
252
+ feed(chunk) { parser.feed(chunk.toString('utf8')) },
253
+ flush() { if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' } },
254
+ }
255
+ }