opencode-thinking-fix 1.0.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/.gitignore +5 -0
- package/README.md +252 -0
- package/package.json +34 -0
- package/plugins/opencode-thinking-fix-universal.ts +106 -0
- package/proxy/proxy.js +333 -0
- package/systemd/reasoning-cache-go.service +17 -0
- package/systemd/reasoning-cache.service +16 -0
- package/systemd/reasoning-proxy-watchdog.service +14 -0
- package/tests/test-plugin.js +228 -0
- package/tests/test-proxy.js +359 -0
- package/watchdog/watchdog.sh +64 -0
package/proxy/proxy.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// OpenCode Reasoning Cache Proxy
|
|
5
|
+
//
|
|
6
|
+
// What it does:
|
|
7
|
+
// - Sits between OpenCode and DeepSeek/Kimi/GLM/MiMo on localhost:3457
|
|
8
|
+
// - On every response: extracts real reasoning_content from the stream
|
|
9
|
+
// and stores it in memory, keyed by sessionID + message index
|
|
10
|
+
// - On every request: replays the cached real reasoning_content into
|
|
11
|
+
// assistant history turns instead of empty strings
|
|
12
|
+
//
|
|
13
|
+
// What it does NOT do:
|
|
14
|
+
// - Touch non-reasoning providers (Qwen/GPT/Claude pass through untouched)
|
|
15
|
+
// - Persist cache to disk (in-memory only, resets on restart)
|
|
16
|
+
// - Add any dashboard, UI, or monitoring endpoints
|
|
17
|
+
//
|
|
18
|
+
// Deployment:
|
|
19
|
+
// node proxy.js
|
|
20
|
+
// Set UPSTREAM_URL env to change target (default: https://api.deepseek.com)
|
|
21
|
+
// Set PORT env to change listen port (default: 3457)
|
|
22
|
+
// In opencode.json: set baseURL to http://127.0.0.1:3457/v1
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const http = require('http')
|
|
26
|
+
const https = require('https')
|
|
27
|
+
const url = require('url')
|
|
28
|
+
|
|
29
|
+
const PORT = parseInt(process.env.PORT || '3457', 10)
|
|
30
|
+
|
|
31
|
+
// Optional fixed upstream override. When set, ALL requests are routed here
|
|
32
|
+
// instead of using the model-based routing table. Used for OpenCode Go:
|
|
33
|
+
// UPSTREAM_URL=https://opencode.ai/zen/go/v1 PORT=3458 node proxy.js
|
|
34
|
+
const UPSTREAM_URL = process.env.UPSTREAM_URL || ''
|
|
35
|
+
|
|
36
|
+
// ── Model routing table ────────────────────────────────────────────────────
|
|
37
|
+
// Each entry: { base: upstream API base URL, reasoning: does this model emit reasoning_content? }
|
|
38
|
+
// Prefix-based matching: "deepseek-v4-pro" matches the "deepseek" prefix
|
|
39
|
+
const ROUTES = {
|
|
40
|
+
deepseek: { base: 'https://api.deepseek.com', reasoning: true },
|
|
41
|
+
kimi: { base: 'https://api.moonshot.ai/v1', reasoning: true },
|
|
42
|
+
moonshot: { base: 'https://api.moonshot.ai/v1', reasoning: true },
|
|
43
|
+
glm: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoning: true },
|
|
44
|
+
zhipu: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoning: true },
|
|
45
|
+
minimax: { base: 'https://api.minimax.io/v1', reasoning: true },
|
|
46
|
+
mimo: { base: 'https://api.minimax.io/v1', reasoning: true },
|
|
47
|
+
gpt: { base: 'https://api.openai.com', reasoning: false },
|
|
48
|
+
o1: { base: 'https://api.openai.com', reasoning: false },
|
|
49
|
+
claude: { base: 'https://api.anthropic.com', reasoning: false },
|
|
50
|
+
anthropic: { base: 'https://api.anthropic.com', reasoning: false },
|
|
51
|
+
qwen: { base: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', reasoning: false },
|
|
52
|
+
gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', reasoning: false },
|
|
53
|
+
llama: { base: 'https://api.together.xyz', reasoning: false },
|
|
54
|
+
mistral: { base: 'https://api.mistral.ai', reasoning: false },
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Default: fall back to DeepSeek for unknown models
|
|
58
|
+
const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoning: false }
|
|
59
|
+
|
|
60
|
+
function route(modelName) {
|
|
61
|
+
if (!modelName) return DEFAULT_ROUTE
|
|
62
|
+
const lower = modelName.toLowerCase()
|
|
63
|
+
for (const [prefix, entry] of Object.entries(ROUTES)) {
|
|
64
|
+
if (lower.startsWith(prefix)) return entry
|
|
65
|
+
}
|
|
66
|
+
return DEFAULT_ROUTE
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── In-memory reasoning cache ─────────────────────────────────────────────
|
|
70
|
+
// Structure: Map<sessionID, Map<assistantIndex, reasoning_content_string>>
|
|
71
|
+
// sessionID comes from x-session-id header forwarded by OpenCode
|
|
72
|
+
// assistantIndex is the position of the assistant message in the history array
|
|
73
|
+
const cache = new Map()
|
|
74
|
+
|
|
75
|
+
function getSessionCache(sessionId) {
|
|
76
|
+
if (!cache.has(sessionId)) cache.set(sessionId, new Map())
|
|
77
|
+
return cache.get(sessionId)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Request body patching ─────────────────────────────────────────────────
|
|
81
|
+
// Injects cached real reasoning_content into assistant history turns
|
|
82
|
+
// Falls back to "" if no cache exists for that turn yet
|
|
83
|
+
function patchRequestBody(body, sessionId) {
|
|
84
|
+
let parsed
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(body)
|
|
87
|
+
} catch {
|
|
88
|
+
return body // not JSON, forward as-is
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!Array.isArray(parsed.messages)) return body
|
|
92
|
+
|
|
93
|
+
const sessionCache = getSessionCache(sessionId)
|
|
94
|
+
let assistantIndex = 0
|
|
95
|
+
let modified = false
|
|
96
|
+
|
|
97
|
+
for (const msg of parsed.messages) {
|
|
98
|
+
if (msg.role !== 'assistant') continue
|
|
99
|
+
|
|
100
|
+
// Fix content if missing
|
|
101
|
+
if (!msg.content && msg.content !== '') {
|
|
102
|
+
msg.content = msg.tool_calls?.length ? 'call tool' : ''
|
|
103
|
+
modified = true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Inject real cached reasoning if available, otherwise ""
|
|
107
|
+
// Check VALUE (not key existence) — plugin sets "" so key always exists
|
|
108
|
+
if (!msg.reasoning_content) {
|
|
109
|
+
const cached = sessionCache.get(assistantIndex)
|
|
110
|
+
msg.reasoning_content = cached ?? ''
|
|
111
|
+
if (cached) {
|
|
112
|
+
console.log(`[Cache] session ${sessionId.slice(-8)}: replayed reasoning_content for turn ${assistantIndex} (${cached.length} chars)`)
|
|
113
|
+
}
|
|
114
|
+
modified = true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// OpenCode Go uses reasoning instead of reasoning_content
|
|
118
|
+
if (!msg.reasoning) {
|
|
119
|
+
const cached = sessionCache.get(assistantIndex)
|
|
120
|
+
msg.reasoning = cached ?? ''
|
|
121
|
+
if (cached) {
|
|
122
|
+
console.log(`[Cache] session ${sessionId.slice(-8)}: replayed reasoning for turn ${assistantIndex} (${cached.length} chars)`)
|
|
123
|
+
}
|
|
124
|
+
modified = true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
assistantIndex++
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return modified ? JSON.stringify(parsed) : body
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── SSE stream parser ─────────────────────────────────────────────────────
|
|
134
|
+
// Parses streaming response chunks to extract reasoning_content
|
|
135
|
+
// Accumulates across chunks since a single reasoning block can span many
|
|
136
|
+
function createStreamParser(sessionId, onComplete) {
|
|
137
|
+
let buffer = ''
|
|
138
|
+
let reasoningBuffer = ''
|
|
139
|
+
let assistantIndex = 0 // tracks which assistant turn we're building
|
|
140
|
+
let inReasoning = false
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
feed(chunk) {
|
|
144
|
+
buffer += chunk.toString()
|
|
145
|
+
const lines = buffer.split('\n')
|
|
146
|
+
buffer = lines.pop() // keep incomplete last line
|
|
147
|
+
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
if (!line.startsWith('data: ')) continue
|
|
150
|
+
const data = line.slice(6).trim()
|
|
151
|
+
if (data === '[DONE]') {
|
|
152
|
+
// Flush accumulated reasoning for this turn
|
|
153
|
+
if (reasoningBuffer) {
|
|
154
|
+
onComplete(assistantIndex, reasoningBuffer)
|
|
155
|
+
reasoningBuffer = ''
|
|
156
|
+
}
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(data)
|
|
161
|
+
const delta = parsed.choices?.[0]?.delta
|
|
162
|
+
if (!delta) continue
|
|
163
|
+
|
|
164
|
+
// reasoning_content delta (DeepSeek/Kimi/GLM native)
|
|
165
|
+
if (typeof delta.reasoning_content === 'string') {
|
|
166
|
+
reasoningBuffer += delta.reasoning_content
|
|
167
|
+
inReasoning = true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// OpenCode Go uses delta.reasoning instead of reasoning_content
|
|
171
|
+
if (typeof delta.reasoning === 'string') {
|
|
172
|
+
reasoningBuffer += delta.reasoning
|
|
173
|
+
inReasoning = true
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// When content starts after reasoning, the reasoning block is done
|
|
177
|
+
if (typeof delta.content === 'string' && delta.content && inReasoning) {
|
|
178
|
+
if (reasoningBuffer) {
|
|
179
|
+
onComplete(assistantIndex, reasoningBuffer)
|
|
180
|
+
reasoningBuffer = ''
|
|
181
|
+
inReasoning = false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// finish_reason means the assistant turn is complete
|
|
186
|
+
if (parsed.choices?.[0]?.finish_reason) {
|
|
187
|
+
if (reasoningBuffer) {
|
|
188
|
+
onComplete(assistantIndex, reasoningBuffer)
|
|
189
|
+
reasoningBuffer = ''
|
|
190
|
+
inReasoning = false
|
|
191
|
+
}
|
|
192
|
+
assistantIndex++
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// malformed SSE chunk, skip
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
flush() {
|
|
200
|
+
if (reasoningBuffer) {
|
|
201
|
+
onComplete(assistantIndex, reasoningBuffer)
|
|
202
|
+
reasoningBuffer = ''
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Main proxy handler ────────────────────────────────────────────────────
|
|
209
|
+
const server = http.createServer((req, res) => {
|
|
210
|
+
// Health check — plugin uses this to detect running proxy
|
|
211
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
212
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
213
|
+
res.end(JSON.stringify({ ok: true, uptime: Math.floor(process.uptime()) }))
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Collect request body
|
|
218
|
+
const chunks = []
|
|
219
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
220
|
+
req.on('end', () => {
|
|
221
|
+
const rawBody = Buffer.concat(chunks)
|
|
222
|
+
const sessionId = req.headers['x-session-id'] || 'unknown'
|
|
223
|
+
const isPost = req.method === 'POST'
|
|
224
|
+
const isJson = (req.headers['content-type'] || '').includes('application/json')
|
|
225
|
+
|
|
226
|
+
// Determine upstream route
|
|
227
|
+
let modelName = ''
|
|
228
|
+
let routeTarget = DEFAULT_ROUTE
|
|
229
|
+
if (UPSTREAM_URL) {
|
|
230
|
+
// Fixed upstream mode (e.g. OpenCode Go)
|
|
231
|
+
routeTarget = { base: UPSTREAM_URL, reasoning: true }
|
|
232
|
+
} else if (isPost && isJson && rawBody.length > 0) {
|
|
233
|
+
// Model-based routing mode
|
|
234
|
+
try {
|
|
235
|
+
const bodyJson = JSON.parse(rawBody.toString('utf8'))
|
|
236
|
+
modelName = bodyJson.model || ''
|
|
237
|
+
routeTarget = route(modelName)
|
|
238
|
+
} catch { /* body not JSON yet, use defaults */ }
|
|
239
|
+
}
|
|
240
|
+
const upstream = url.parse(routeTarget.base)
|
|
241
|
+
const shouldPatch = isPost && isJson && routeTarget.reasoning
|
|
242
|
+
|
|
243
|
+
// Patch request body if this is a reasoning provider
|
|
244
|
+
let bodyToSend = rawBody
|
|
245
|
+
if (shouldPatch && rawBody.length > 0) {
|
|
246
|
+
const patched = patchRequestBody(rawBody.toString('utf8'), sessionId)
|
|
247
|
+
bodyToSend = Buffer.from(patched, 'utf8')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Build upstream request options
|
|
251
|
+
// If base URL has a path prefix (e.g. /api/paas/v4), prepend it to req.url
|
|
252
|
+
let upstreamPath = req.url
|
|
253
|
+
const basePath = upstream.path || '/'
|
|
254
|
+
if (basePath !== '/' && basePath !== '/v1') {
|
|
255
|
+
upstreamPath = basePath + req.url.replace(/^\/v1/, '')
|
|
256
|
+
}
|
|
257
|
+
const upstreamOptions = {
|
|
258
|
+
hostname: upstream.hostname,
|
|
259
|
+
port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80),
|
|
260
|
+
path: upstreamPath,
|
|
261
|
+
method: req.method,
|
|
262
|
+
headers: {
|
|
263
|
+
...req.headers,
|
|
264
|
+
host: upstream.hostname,
|
|
265
|
+
'content-length': bodyToSend.length,
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Use http or https depending on upstream
|
|
270
|
+
const transport = upstream.protocol === 'https:' ? https : http
|
|
271
|
+
|
|
272
|
+
const proxyReq = transport.request(upstreamOptions, (proxyRes) => {
|
|
273
|
+
// Forward response headers
|
|
274
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers)
|
|
275
|
+
|
|
276
|
+
const isStream = (proxyRes.headers['content-type'] || '').includes('text/event-stream')
|
|
277
|
+
const isOk = (proxyRes.statusCode || 500) < 400
|
|
278
|
+
|
|
279
|
+
if (shouldPatch && isStream && isOk) {
|
|
280
|
+
// Parse SSE stream to extract and cache reasoning_content
|
|
281
|
+
const parser = createStreamParser(sessionId, (index, reasoning) => {
|
|
282
|
+
const sc = getSessionCache(sessionId)
|
|
283
|
+
sc.set(index, reasoning)
|
|
284
|
+
console.log(`[Cache] session ${sessionId.slice(-8)}: stored reasoning turn ${index} (${reasoning.length} chars)`)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
proxyRes.on('data', chunk => {
|
|
288
|
+
parser.feed(chunk)
|
|
289
|
+
res.write(chunk) // forward chunk immediately (no buffering)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
proxyRes.on('end', () => {
|
|
293
|
+
parser.flush()
|
|
294
|
+
res.end()
|
|
295
|
+
})
|
|
296
|
+
} else {
|
|
297
|
+
// Non-streaming or non-reasoning: pipe directly
|
|
298
|
+
proxyRes.pipe(res)
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
proxyReq.on('error', (err) => {
|
|
303
|
+
console.error('[Proxy] upstream error:', err.message)
|
|
304
|
+
if (!res.headersSent) {
|
|
305
|
+
res.writeHead(502)
|
|
306
|
+
res.end(JSON.stringify({ error: 'upstream error', detail: err.message }))
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
proxyReq.write(bodyToSend)
|
|
311
|
+
proxyReq.end()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
req.on('error', (err) => {
|
|
315
|
+
console.error('[Proxy] request error:', err.message)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
320
|
+
if (UPSTREAM_URL) {
|
|
321
|
+
console.log(`[Proxy] fixed-upstream proxy on http://127.0.0.1:${PORT}`)
|
|
322
|
+
console.log(`[Proxy] upstream: ${UPSTREAM_URL}`)
|
|
323
|
+
} else {
|
|
324
|
+
console.log(`[Proxy] universal model-routing proxy on http://127.0.0.1:${PORT}`)
|
|
325
|
+
console.log(`[Proxy] ${Object.keys(ROUTES).length} model prefixes loaded`)
|
|
326
|
+
}
|
|
327
|
+
console.log(`[Proxy] in opencode.json set baseURL to http://127.0.0.1:${PORT}/v1`)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
server.on('error', (err) => {
|
|
331
|
+
console.error('[Proxy] server error:', err.message)
|
|
332
|
+
process.exit(1)
|
|
333
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=OpenCode Reasoning Cache Proxy — Go Provider
|
|
3
|
+
After=network-online.target reasoning-cache.service
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
WorkingDirectory=%h/reasoning-cache-proxy
|
|
8
|
+
ExecStart=/usr/bin/node %h/reasoning-cache-proxy/proxy.js
|
|
9
|
+
Restart=on-failure
|
|
10
|
+
RestartSec=5
|
|
11
|
+
Environment=PORT=3458
|
|
12
|
+
Environment=UPSTREAM_URL=https://opencode.ai/zen/go/v1
|
|
13
|
+
StandardOutput=journal
|
|
14
|
+
StandardError=journal
|
|
15
|
+
|
|
16
|
+
[Install]
|
|
17
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=OpenCode Reasoning Cache Proxy
|
|
3
|
+
After=network-online.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
WorkingDirectory=%h/reasoning-cache-proxy
|
|
8
|
+
ExecStart=/usr/bin/node %h/reasoning-cache-proxy/proxy.js
|
|
9
|
+
Restart=on-failure
|
|
10
|
+
RestartSec=5
|
|
11
|
+
Environment=PORT=3457
|
|
12
|
+
StandardOutput=journal
|
|
13
|
+
StandardError=journal
|
|
14
|
+
|
|
15
|
+
[Install]
|
|
16
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=OpenCode Proxy Watchdog — auto-restart if proxies die
|
|
3
|
+
After=network-online.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
ExecStart=%h/reasoning-cache-proxy/watchdog.sh
|
|
8
|
+
Restart=on-failure
|
|
9
|
+
RestartSec=10
|
|
10
|
+
StandardOutput=journal
|
|
11
|
+
StandardError=journal
|
|
12
|
+
|
|
13
|
+
[Install]
|
|
14
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,228 @@
|
|
|
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)
|