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/README.md +191 -67
- package/package.json +36 -11
- 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/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
|
+
}
|