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/proxy.js
CHANGED
|
@@ -1,391 +1,291 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
-
// OpenCode Reasoning Cache Proxy
|
|
4
|
+
// OpenCode Reasoning Cache Proxy — v3.1 (performance + correctness + dialect)
|
|
5
5
|
//
|
|
6
6
|
// What it does:
|
|
7
|
-
// - Sits between OpenCode and DeepSeek/Kimi/GLM/MiMo on localhost:3457
|
|
8
|
-
// - On every response: extracts
|
|
9
|
-
//
|
|
10
|
-
// - On every request: replays the cached real reasoning_content into
|
|
11
|
-
// assistant history turns instead of empty strings
|
|
7
|
+
// - Sits between OpenCode and DeepSeek/Kimi/GLM/MiMo/MiniMax on localhost:3457
|
|
8
|
+
// - On every response: extracts the REAL reasoning_content from the stream and
|
|
9
|
+
// stores it in memory, keyed by sessionID + the assistant turn index.
|
|
10
|
+
// - On every request: replays the cached real reasoning_content into the
|
|
11
|
+
// assistant history turns instead of empty strings.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// Performance model (v3):
|
|
14
|
+
// 1. Responses are forwarded as RAW BYTES — no SSE re-serialization. The
|
|
15
|
+
// stream is parsed in parallel ONLY to populate the cache. This removes the
|
|
16
|
+
// per-chunk serialization bottleneck that added latency to every token.
|
|
17
|
+
// 2. Request patching is LAZY: if OpenCode already round-trips reasoning
|
|
18
|
+
// (non-empty reasoning_content/reasoning on assistant turns), the body is
|
|
19
|
+
// forwarded untouched — zero JSON work. Patching only runs when a turn is
|
|
20
|
+
// actually missing its reasoning.
|
|
21
|
+
// 3. Upstream connections are kept alive (TLS/handshake reuse) via an agent.
|
|
22
|
+
// 4. gzip is disabled on the upstream leg so bytes pass through verbatim.
|
|
23
|
+
//
|
|
24
|
+
// Correctness fix (v3):
|
|
25
|
+
// The cache index for a response is the number of assistant messages already
|
|
26
|
+
// present in the request that produced it. This aligns the stored reasoning
|
|
27
|
+
// with the slot patchRequestBody() looks up on the NEXT request, so every
|
|
28
|
+
// turn replays its OWN reasoning (not turn 0's).
|
|
29
|
+
//
|
|
30
|
+
// Dialect fix (v3.1):
|
|
31
|
+
// Each route declares which reasoning field its upstream requires
|
|
32
|
+
// (reasoning_content vs reasoning_details vs null). Patching now echoes ONLY
|
|
33
|
+
// that field — no more cross-provider poisoning (GLM stored under `reasoning`
|
|
34
|
+
// used to 400 against DeepSeek which expects `reasoning_content`).
|
|
35
|
+
// deepseek-reasoner (R1) is null: it must NOT receive reasoning echoed back.
|
|
36
|
+
// Unknown models default to null (never fabricate reasoning).
|
|
17
37
|
//
|
|
18
38
|
// Deployment:
|
|
19
39
|
// node proxy.js
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// In opencode.json: set baseURL to http://127.0.0.1
|
|
40
|
+
// PORT=3457 (universal model-routing proxy)
|
|
41
|
+
// PORT=3458 UPSTREAM_URL=https://opencode.ai/zen/go/v1 (OpenCode Go proxy)
|
|
42
|
+
// In opencode.json: set baseURL to http://127.0.0.1:<PORT>/v1
|
|
43
|
+
//
|
|
44
|
+
// Optional env:
|
|
45
|
+
// DEBUG=1 verbose cache logging
|
|
46
|
+
// CACHE_CAPACITY=500 max sessions
|
|
47
|
+
// UPSTREAM_TIMEOUT=30000 ms request timeout
|
|
23
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
49
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
50
|
+
import http from 'node:http'
|
|
51
|
+
import https from 'node:https'
|
|
52
|
+
import url from 'node:url'
|
|
53
|
+
import {
|
|
54
|
+
ROUTES, cache, stats, route, fixedUpstreamRoute, getSessionCache, patchRequestBody,
|
|
55
|
+
extractReasoningFromJson, createStreamParser, writeLog, deriveSessionId,
|
|
56
|
+
} from './core.js'
|
|
29
57
|
|
|
30
|
-
const PORT
|
|
58
|
+
const PORT = parseInt(process.env.PORT || '3457', 10)
|
|
59
|
+
const DEBUG = process.env.DEBUG === '1'
|
|
60
|
+
const UPSTREAM_TIMEOUT = parseInt(process.env.UPSTREAM_TIMEOUT || '30000', 10)
|
|
31
61
|
|
|
32
|
-
//
|
|
33
|
-
// instead of using the model-based routing table. Used for OpenCode Go:
|
|
34
|
-
// UPSTREAM_URL=https://opencode.ai/zen/go/v1 PORT=3458 node proxy.js
|
|
62
|
+
// Fixed-upstream mode (OpenCode Go): every request goes to one endpoint.
|
|
35
63
|
const UPSTREAM_URL = process.env.UPSTREAM_URL || ''
|
|
36
64
|
|
|
37
|
-
// ──
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const ROUTES = {
|
|
41
|
-
'deepseek-v4-pro': { base: 'https://api.deepseek.com', reasoning: true },
|
|
42
|
-
deepseek: { base: 'https://api.deepseek.com', reasoning: true },
|
|
43
|
-
kimi: { base: 'https://api.moonshot.ai/v1', reasoning: true },
|
|
44
|
-
moonshot: { base: 'https://api.moonshot.ai/v1', reasoning: true },
|
|
45
|
-
glm: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoning: true },
|
|
46
|
-
zhipu: { base: 'https://open.bigmodel.cn/api/paas/v4', reasoning: true },
|
|
47
|
-
minimax: { base: 'https://api.minimax.io/v1', reasoning: true },
|
|
48
|
-
mimo: { base: 'https://api.minimax.io/v1', reasoning: true },
|
|
49
|
-
gpt: { base: 'https://api.openai.com', reasoning: false },
|
|
50
|
-
o1: { base: 'https://api.openai.com', reasoning: false },
|
|
51
|
-
claude: { base: 'https://api.anthropic.com', reasoning: false },
|
|
52
|
-
anthropic: { base: 'https://api.anthropic.com', reasoning: false },
|
|
53
|
-
qwen: { base: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', reasoning: false },
|
|
54
|
-
gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', reasoning: false },
|
|
55
|
-
llama: { base: 'https://api.together.xyz', reasoning: false },
|
|
56
|
-
mistral: { base: 'https://api.mistral.ai', reasoning: false },
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Default: fall back to DeepSeek for unknown models
|
|
60
|
-
const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoning: false }
|
|
61
|
-
|
|
62
|
-
function route(modelName) {
|
|
63
|
-
if (!modelName) return DEFAULT_ROUTE
|
|
64
|
-
const lower = modelName.toLowerCase()
|
|
65
|
-
for (const [prefix, entry] of Object.entries(ROUTES)) {
|
|
66
|
-
if (lower.startsWith(prefix)) return entry
|
|
67
|
-
}
|
|
68
|
-
return DEFAULT_ROUTE
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ── LRU Cache ─────────────────────────────────────────────────────────────
|
|
72
|
-
// Bounded session cache using Map insertion-order eviction.
|
|
73
|
-
// get() refreshes position; put() evicts least-recently-used when full.
|
|
74
|
-
// Zero dependencies, O(1) get/put, memory-safe for long-running processes.
|
|
75
|
-
class LRUCache {
|
|
76
|
-
constructor(capacity) {
|
|
77
|
-
this.cache = new Map()
|
|
78
|
-
this.capacity = capacity
|
|
79
|
-
}
|
|
80
|
-
get(key) {
|
|
81
|
-
if (!this.cache.has(key)) return undefined
|
|
82
|
-
const val = this.cache.get(key)
|
|
83
|
-
this.cache.delete(key)
|
|
84
|
-
this.cache.set(key, val)
|
|
85
|
-
return val
|
|
86
|
-
}
|
|
87
|
-
put(key, value) {
|
|
88
|
-
this.cache.delete(key)
|
|
89
|
-
if (this.cache.size >= this.capacity) {
|
|
90
|
-
const oldest = this.cache.keys().next().value
|
|
91
|
-
this.cache.delete(oldest)
|
|
92
|
-
console.log(`[Cache] evicted session ${String(oldest).slice(-8)} (capacity ${this.capacity})`)
|
|
93
|
-
}
|
|
94
|
-
this.cache.set(key, value)
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ── In-memory reasoning cache ─────────────────────────────────────────────
|
|
99
|
-
// Structure: Map<sessionID, Map<assistantIndex, reasoning_content_string>>
|
|
100
|
-
// sessionID comes from x-session-id header forwarded by OpenCode
|
|
101
|
-
// assistantIndex is the position of the assistant message in the history array
|
|
102
|
-
const cache = new LRUCache(500)
|
|
103
|
-
|
|
104
|
-
function getSessionCache(sessionId) {
|
|
105
|
-
let sessionMap = cache.get(sessionId)
|
|
106
|
-
if (!sessionMap) {
|
|
107
|
-
sessionMap = new Map()
|
|
108
|
-
cache.put(sessionId, sessionMap)
|
|
109
|
-
}
|
|
110
|
-
return sessionMap
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ── Request body patching ─────────────────────────────────────────────────
|
|
114
|
-
// Injects cached real reasoning_content into assistant history turns
|
|
115
|
-
// Falls back to "" if no cache exists for that turn yet
|
|
116
|
-
function patchRequestBody(body, sessionId) {
|
|
117
|
-
let parsed
|
|
118
|
-
try {
|
|
119
|
-
parsed = JSON.parse(body)
|
|
120
|
-
} catch {
|
|
121
|
-
return body // not JSON, forward as-is
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (!Array.isArray(parsed.messages)) return body
|
|
125
|
-
|
|
126
|
-
const sessionCache = getSessionCache(sessionId)
|
|
127
|
-
let assistantIndex = 0
|
|
128
|
-
let modified = false
|
|
129
|
-
|
|
130
|
-
for (const msg of parsed.messages) {
|
|
131
|
-
if (msg.role !== 'assistant') continue
|
|
132
|
-
|
|
133
|
-
// Fix content if missing
|
|
134
|
-
if (!msg.content && msg.content !== '') {
|
|
135
|
-
msg.content = msg.tool_calls?.length ? 'call tool' : ''
|
|
136
|
-
modified = true
|
|
137
|
-
}
|
|
65
|
+
// ── Keep-alive agents (connection reuse → lower latency) ───────────────────
|
|
66
|
+
const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 64, maxFreeSockets: 32 })
|
|
67
|
+
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 64, maxFreeSockets: 32 })
|
|
138
68
|
|
|
139
|
-
|
|
140
|
-
// Check VALUE (not key existence), plugin sets "" so key always exists
|
|
141
|
-
if (!msg.reasoning_content) {
|
|
142
|
-
const cached = sessionCache.get(assistantIndex)
|
|
143
|
-
msg.reasoning_content = cached ?? ''
|
|
144
|
-
if (cached) {
|
|
145
|
-
console.log(`[Cache] session ${sessionId.slice(-8)}: replayed reasoning_content for turn ${assistantIndex} (${cached.length} chars)`)
|
|
146
|
-
}
|
|
147
|
-
modified = true
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// MiniMax-M3 reasoning_details array format (reasoning_split: true)
|
|
151
|
-
if (!msg.reasoning_details && !msg.reasoning_content) {
|
|
152
|
-
const cached = sessionCache.get(assistantIndex)
|
|
153
|
-
if (cached) {
|
|
154
|
-
msg.reasoning_details = [{ text: cached, type: 'thinking' }]
|
|
155
|
-
console.log(`[Cache] session ${sessionId.slice(-8)}: replayed reasoning_details for turn ${assistantIndex} (${cached.length} chars)`)
|
|
156
|
-
modified = true
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// OpenCode Go uses reasoning instead of reasoning_content
|
|
161
|
-
if (!msg.reasoning) {
|
|
162
|
-
const cached = sessionCache.get(assistantIndex)
|
|
163
|
-
msg.reasoning = cached ?? ''
|
|
164
|
-
if (cached) {
|
|
165
|
-
console.log(`[Cache] session ${sessionId.slice(-8)}: replayed reasoning for turn ${assistantIndex} (${cached.length} chars)`)
|
|
166
|
-
}
|
|
167
|
-
modified = true
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
assistantIndex++
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return modified ? JSON.stringify(parsed) : body
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── SSE stream parser ─────────────────────────────────────────────────────
|
|
177
|
-
// Extracts reasoning_content from streaming chunks using eventsource-parser
|
|
178
|
-
// (spec-compliant, 368k dependents, built-in maxBufferSize guard)
|
|
179
|
-
const MAX_BUFFER = 1024 * 1024 // 1MB, prevents OOM on malformed streams
|
|
180
|
-
|
|
181
|
-
function createStreamParser(sessionId, onComplete) {
|
|
182
|
-
let reasoningBuffer = ''
|
|
183
|
-
let assistantIndex = 0
|
|
184
|
-
const chunks = [] // forward buffers for re-serialization
|
|
185
|
-
|
|
186
|
-
const parser = createParser({
|
|
187
|
-
maxBufferSize: MAX_BUFFER,
|
|
188
|
-
onEvent(event) {
|
|
189
|
-
const line = event.event ? `event: ${event.event}\ndata: ${event.data}\n\n` : `data: ${event.data}\n\n`
|
|
190
|
-
chunks.push(line)
|
|
191
|
-
|
|
192
|
-
if (event.event === 'done' || JSON.parse(event.data || '{}')?.choices?.[0]?.finish_reason) {
|
|
193
|
-
if (reasoningBuffer) {
|
|
194
|
-
onComplete(assistantIndex, reasoningBuffer)
|
|
195
|
-
reasoningBuffer = ''
|
|
196
|
-
}
|
|
197
|
-
assistantIndex++
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (!event.data || event.data === '[DONE]') return
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
const parsed = JSON.parse(event.data)
|
|
205
|
-
const delta = parsed.choices?.[0]?.delta
|
|
206
|
-
if (!delta) return
|
|
207
|
-
|
|
208
|
-
if (typeof delta.reasoning_content === 'string') reasoningBuffer += delta.reasoning_content
|
|
209
|
-
if (typeof delta.reasoning === 'string') reasoningBuffer += delta.reasoning
|
|
210
|
-
if (Array.isArray(delta.reasoning_details)) {
|
|
211
|
-
for (const d of delta.reasoning_details) {
|
|
212
|
-
if (typeof d.text === 'string') reasoningBuffer += d.text
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
} catch { /* malformed SSE chunk, ignore */ }
|
|
216
|
-
},
|
|
217
|
-
onError(error) {
|
|
218
|
-
console.error(`[Proxy] SSE parser error in session ${sessionId.slice(-8)}:`, error.message)
|
|
219
|
-
}
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
return {
|
|
223
|
-
feed(chunk) {
|
|
224
|
-
chunks.length = 0
|
|
225
|
-
parser.feed(chunk)
|
|
226
|
-
return chunks.join('')
|
|
227
|
-
},
|
|
228
|
-
flush() {
|
|
229
|
-
if (reasoningBuffer) {
|
|
230
|
-
onComplete(assistantIndex, reasoningBuffer)
|
|
231
|
-
reasoningBuffer = ''
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── Main proxy handler ────────────────────────────────────────────────────
|
|
69
|
+
// ── Main proxy handler ─────────────────────────────────────────────────────
|
|
238
70
|
const server = http.createServer((req, res) => {
|
|
239
|
-
|
|
71
|
+
res.on('error', (err) => { if (DEBUG) console.error('[Proxy] client response error:', err.message) })
|
|
240
72
|
if (req.method === 'GET' && req.url === '/health') {
|
|
241
73
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
242
|
-
res.end(JSON.stringify({
|
|
74
|
+
res.end(JSON.stringify({
|
|
75
|
+
ok: true,
|
|
76
|
+
uptime: Math.floor(process.uptime()),
|
|
77
|
+
sessions: stats.sessions,
|
|
78
|
+
cacheHits: stats.hits,
|
|
79
|
+
cacheMisses: stats.misses,
|
|
80
|
+
stored: stats.stored,
|
|
81
|
+
requests: stats.requests,
|
|
82
|
+
passthrough: stats.passthrough,
|
|
83
|
+
}))
|
|
243
84
|
return
|
|
244
85
|
}
|
|
245
86
|
|
|
246
|
-
// Collect request body
|
|
247
87
|
const chunks = []
|
|
248
|
-
req.on('data', chunk => chunks.push(chunk))
|
|
88
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
249
89
|
req.on('end', () => {
|
|
90
|
+
stats.requests++
|
|
250
91
|
const rawBody = Buffer.concat(chunks)
|
|
251
|
-
const
|
|
92
|
+
const rawSessionId = req.headers['x-session-id']
|
|
252
93
|
const isPost = req.method === 'POST'
|
|
253
94
|
const isJson = (req.headers['content-type'] || '').includes('application/json')
|
|
254
|
-
|
|
255
|
-
// Determine upstream route
|
|
256
|
-
let modelName = ''
|
|
257
|
-
let routeTarget = DEFAULT_ROUTE
|
|
258
|
-
|
|
259
|
-
// Always parse model name from body so provider-specific patches work
|
|
260
|
-
// even in fixed-upstream mode (OpenCode Go)
|
|
95
|
+
let parsedBody = null
|
|
261
96
|
if (isPost && isJson && rawBody.length > 0) {
|
|
262
|
-
try {
|
|
263
|
-
const bodyJson = JSON.parse(rawBody.toString('utf8'))
|
|
264
|
-
modelName = bodyJson.model || ''
|
|
265
|
-
} catch { /* body not JSON yet */ }
|
|
97
|
+
try { parsedBody = JSON.parse(rawBody.toString('utf8')) } catch { /* ignore */ }
|
|
266
98
|
}
|
|
267
99
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
routeTarget = route(modelName)
|
|
274
|
-
}
|
|
100
|
+
// Resolve model + route (model parsed even in fixed-upstream mode)
|
|
101
|
+
const modelName = parsedBody?.model || ''
|
|
102
|
+
const routeTarget = UPSTREAM_URL
|
|
103
|
+
? { ...fixedUpstreamRoute(modelName, req.url), base: UPSTREAM_URL }
|
|
104
|
+
: route(modelName)
|
|
275
105
|
const upstream = url.parse(routeTarget.base)
|
|
276
|
-
|
|
106
|
+
let sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
|
|
107
|
+
if ((!sessionId || sessionId === 'unknown') && isPost && isJson && parsedBody) {
|
|
108
|
+
sessionId = deriveSessionId(modelName, parsedBody, req.headers['authorization']) || 'unknown'
|
|
109
|
+
}
|
|
110
|
+
const shouldPatch = isPost && isJson && !!routeTarget.reasoningKey && !!sessionId && sessionId !== 'unknown'
|
|
111
|
+
|
|
112
|
+
if (!shouldPatch) {
|
|
113
|
+
const reason = !isPost ? 'get'
|
|
114
|
+
: !isJson ? 'non_json'
|
|
115
|
+
: !sessionId ? 'no_session'
|
|
116
|
+
: sessionId === 'unknown' ? 'unknown_session'
|
|
117
|
+
: !modelName ? 'unknown_route'
|
|
118
|
+
: !routeTarget.reasoningKey ? 'model_not_patched'
|
|
119
|
+
: 'unknown_route'
|
|
120
|
+
writeLog({ event: 'passthrough', model: modelName, session: sessionId.slice(-8) || 'none', reason })
|
|
121
|
+
}
|
|
277
122
|
|
|
278
|
-
//
|
|
123
|
+
// ── Build outgoing request body ──
|
|
279
124
|
let bodyToSend = rawBody
|
|
280
|
-
|
|
281
|
-
|
|
125
|
+
let assistantCount = 0
|
|
126
|
+
let patchReport = null
|
|
127
|
+
if (shouldPatch && rawBody.length > 0 && parsedBody) {
|
|
282
128
|
try {
|
|
283
|
-
const bj =
|
|
129
|
+
const bj = parsedBody
|
|
284
130
|
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const isKimi = modelName.toLowerCase().startsWith('kimi') || modelName.toLowerCase().startsWith('moonshot')
|
|
131
|
+
// Kimi/Moonshot hardcode sampling params for thinking models.
|
|
132
|
+
const lower = modelName.toLowerCase()
|
|
133
|
+
const isKimi = lower.startsWith('kimi') || lower.startsWith('moonshot')
|
|
289
134
|
if (isKimi) {
|
|
290
|
-
delete bj
|
|
291
|
-
delete bj.
|
|
292
|
-
delete bj.top_k
|
|
293
|
-
delete bj.presence_penalty
|
|
294
|
-
delete bj.frequency_penalty
|
|
295
|
-
delete bj.n
|
|
296
|
-
// K2.7 thinking is always on; do not send thinking/reasoning_effort controls
|
|
297
|
-
if (modelName.toLowerCase().startsWith('kimi-k2.7')) {
|
|
298
|
-
delete bj.thinking
|
|
299
|
-
delete bj.reasoning_effort
|
|
300
|
-
}
|
|
135
|
+
for (const k of ['temperature', 'top_p', 'top_k', 'presence_penalty', 'frequency_penalty', 'n']) delete bj[k]
|
|
136
|
+
if (lower.startsWith('kimi-k2.7')) { delete bj.thinking; delete bj.reasoning_effort }
|
|
301
137
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
138
|
+
// MiniMax: keep thinking separate from content.
|
|
139
|
+
if (lower.startsWith('minimax')) bj.reasoning_split = true
|
|
140
|
+
|
|
141
|
+
const patched = patchRequestBody(JSON.stringify(bj), sessionId, routeTarget.reasoningKey)
|
|
142
|
+
bodyToSend = Buffer.from(patched.body, 'utf8')
|
|
143
|
+
assistantCount = patched.assistantCount
|
|
144
|
+
patchReport = patched.report
|
|
145
|
+
if (!patched.modified) stats.passthrough++
|
|
146
|
+
if (patchReport.assistantTurns > 0) {
|
|
147
|
+
writeLog({ event: 'inspect', model: modelName, session: sessionId.slice(-8), endpoint: req.url,
|
|
148
|
+
totalMessages: bj.messages.length, assistantTurns: patchReport.assistantTurns,
|
|
149
|
+
missingText: patchReport.missingText, missingReasoning: patchReport.missingReasoning })
|
|
307
150
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
151
|
+
if (patched.modified) {
|
|
152
|
+
writeLog({ event: 'patched', model: modelName, session: sessionId.slice(-8),
|
|
153
|
+
patchedFields: patchReport.turns.reduce((sum, turn) => sum + turn.fields.length, 0),
|
|
154
|
+
turns: patchReport.turns })
|
|
155
|
+
}
|
|
156
|
+
} catch { /* forward as-is */ }
|
|
313
157
|
}
|
|
314
158
|
|
|
315
|
-
//
|
|
316
|
-
// If base URL has a path prefix (e.g. /api/paas/v4), prepend it to req.url
|
|
159
|
+
// ── Upstream request options ──
|
|
317
160
|
let upstreamPath = req.url
|
|
318
161
|
const basePath = upstream.path || '/'
|
|
319
162
|
if (basePath !== '/' && basePath !== '/v1') {
|
|
320
163
|
upstreamPath = basePath + req.url.replace(/^\/v1/, '')
|
|
321
164
|
}
|
|
322
|
-
const
|
|
165
|
+
const transport = upstream.protocol === 'https:' ? https : http
|
|
166
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'proxy-'])
|
|
167
|
+
const safeHeaders = {}
|
|
168
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
169
|
+
const lowerKey = key.toLowerCase()
|
|
170
|
+
if (HOP_BY_HOP.has(lowerKey) || lowerKey.startsWith('proxy-')) continue
|
|
171
|
+
safeHeaders[key] = value
|
|
172
|
+
}
|
|
173
|
+
const proxyReq = transport.request({
|
|
323
174
|
hostname: upstream.hostname,
|
|
324
175
|
port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80),
|
|
325
176
|
path: upstreamPath,
|
|
326
177
|
method: req.method,
|
|
178
|
+
agent: upstream.protocol === 'https:' ? httpsAgent : httpAgent,
|
|
327
179
|
headers: {
|
|
328
|
-
...
|
|
180
|
+
...safeHeaders,
|
|
329
181
|
host: upstream.hostname,
|
|
330
182
|
'content-length': bodyToSend.length,
|
|
183
|
+
'accept-encoding': 'identity', // force uncompressed so we can stream raw
|
|
331
184
|
},
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
let responseClosed = false
|
|
188
|
+
const closeResponse = () => {
|
|
189
|
+
if (responseClosed) return
|
|
190
|
+
responseClosed = true
|
|
191
|
+
if (!res.destroyed) res.destroy()
|
|
192
|
+
}
|
|
193
|
+
const endError = (status, payload) => {
|
|
194
|
+
if (responseClosed) return
|
|
195
|
+
writeLog({ event: 'error', message: payload?.error || 'proxy error' })
|
|
196
|
+
responseClosed = true
|
|
197
|
+
if (!res.headersSent) {
|
|
198
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
199
|
+
res.end(JSON.stringify(payload))
|
|
200
|
+
} else if (!res.destroyed) {
|
|
201
|
+
res.destroy()
|
|
202
|
+
}
|
|
332
203
|
}
|
|
333
204
|
|
|
334
|
-
|
|
335
|
-
|
|
205
|
+
proxyReq.setTimeout(UPSTREAM_TIMEOUT, () => {
|
|
206
|
+
endError(504, { error: 'upstream timeout' })
|
|
207
|
+
proxyReq.destroy()
|
|
208
|
+
})
|
|
336
209
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
210
|
+
proxyReq.on('error', (err) => {
|
|
211
|
+
if (DEBUG) console.error('[Proxy] upstream error:', err.message)
|
|
212
|
+
writeLog({ event: 'error', message: err.message })
|
|
213
|
+
endError(502, { error: 'upstream error', detail: err.message })
|
|
214
|
+
})
|
|
340
215
|
|
|
216
|
+
proxyReq.on('response', (proxyRes) => {
|
|
341
217
|
const isStream = (proxyRes.headers['content-type'] || '').includes('text/event-stream')
|
|
342
218
|
const isOk = (proxyRes.statusCode || 500) < 400
|
|
219
|
+
const isCompressed = !!proxyRes.headers['content-encoding']
|
|
343
220
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const sc = getSessionCache(sessionId)
|
|
348
|
-
sc.set(index, reasoning)
|
|
349
|
-
console.log(`[Cache] session ${sessionId.slice(-8)}: stored reasoning turn ${index} (${reasoning.length} chars)`)
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
proxyRes.on('data', chunk => {
|
|
353
|
-
// parser.feed returns the (possibly modified) SSE text to forward
|
|
354
|
-
const forwardChunk = parser.feed(chunk)
|
|
355
|
-
res.write(forwardChunk)
|
|
356
|
-
})
|
|
221
|
+
// Forward response headers unchanged; client must decode compressed bytes.
|
|
222
|
+
const outHeaders = { ...proxyRes.headers }
|
|
223
|
+
res.writeHead(proxyRes.statusCode, outHeaders)
|
|
357
224
|
|
|
225
|
+
// Buffer ordinary JSON responses so reasoning can be cached too.
|
|
226
|
+
if (shouldPatch && !isStream && isOk && !isCompressed) {
|
|
227
|
+
const responseChunks = []
|
|
228
|
+
proxyRes.on('data', (chunk) => responseChunks.push(chunk))
|
|
358
229
|
proxyRes.on('end', () => {
|
|
359
|
-
|
|
360
|
-
|
|
230
|
+
if (responseClosed) return
|
|
231
|
+
const responseBody = Buffer.concat(responseChunks)
|
|
232
|
+
const reasoning = extractReasoningFromJson(responseBody.toString('utf8'))
|
|
233
|
+
if (reasoning) {
|
|
234
|
+
getSessionCache(sessionId).set(assistantCount, reasoning)
|
|
235
|
+
stats.stored++
|
|
236
|
+
writeLog({ event: 'cache_store', session: sessionId.slice(-8), turn: assistantCount, chars: reasoning.length })
|
|
237
|
+
}
|
|
238
|
+
responseClosed = true
|
|
239
|
+
res.end(responseBody)
|
|
361
240
|
})
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
241
|
+
proxyRes.on('error', closeResponse)
|
|
242
|
+
proxyRes.on('aborted', closeResponse)
|
|
243
|
+
return
|
|
365
244
|
}
|
|
366
|
-
})
|
|
367
245
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
246
|
+
if (!shouldPatch || !isStream || !isOk || isCompressed) {
|
|
247
|
+
proxyRes.pipe(res)
|
|
248
|
+
proxyRes.on('error', closeResponse)
|
|
249
|
+
proxyRes.on('aborted', closeResponse)
|
|
250
|
+
return
|
|
373
251
|
}
|
|
252
|
+
|
|
253
|
+
// Reasoning stream: forward RAW bytes immediately, parse only for cache.
|
|
254
|
+
const parser = createStreamParser(sessionId, assistantCount, (index, reasoning) => {
|
|
255
|
+
const sc = getSessionCache(sessionId)
|
|
256
|
+
sc.set(index, reasoning)
|
|
257
|
+
stats.stored++
|
|
258
|
+
writeLog({ event: 'cache_store', session: sessionId.slice(-8), turn: index, chars: reasoning.length })
|
|
259
|
+
if (DEBUG) console.log(`[Cache] ${sessionId.slice(-8)}: stored turn ${index} (${reasoning.length} chars)`)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
let parserDead = false
|
|
263
|
+
proxyRes.on('data', (chunk) => {
|
|
264
|
+
if (responseClosed) return
|
|
265
|
+
res.write(chunk) // raw forward — zero re-serialization
|
|
266
|
+
if (!parserDead) {
|
|
267
|
+
try { parser.feed(chunk) } catch { parserDead = true }
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
proxyRes.on('end', () => {
|
|
271
|
+
if (responseClosed) return
|
|
272
|
+
parser.flush(); responseClosed = true; res.end()
|
|
273
|
+
})
|
|
274
|
+
proxyRes.on('error', closeResponse)
|
|
275
|
+
proxyRes.on('aborted', closeResponse)
|
|
374
276
|
})
|
|
375
277
|
|
|
376
278
|
proxyReq.write(bodyToSend)
|
|
377
279
|
proxyReq.end()
|
|
378
280
|
})
|
|
379
281
|
|
|
380
|
-
req.on('error', (err) => {
|
|
381
|
-
console.error('[Proxy] request error:', err.message)
|
|
382
|
-
})
|
|
282
|
+
req.on('error', (err) => { if (DEBUG) console.error('[Proxy] request error:', err.message) })
|
|
383
283
|
})
|
|
384
284
|
|
|
385
285
|
server.listen(PORT, '127.0.0.1', () => {
|
|
286
|
+
writeLog({ event: 'proxy_started', port: PORT, version: '3.1.4', upstream: UPSTREAM_URL || 'model-routing', pid: process.pid })
|
|
386
287
|
if (UPSTREAM_URL) {
|
|
387
|
-
console.log(`[Proxy] fixed-upstream proxy on http://127.0.0.1:${PORT}`)
|
|
388
|
-
console.log(`[Proxy] upstream: ${UPSTREAM_URL}`)
|
|
288
|
+
console.log(`[Proxy] fixed-upstream proxy on http://127.0.0.1:${PORT} -> ${UPSTREAM_URL}`)
|
|
389
289
|
} else {
|
|
390
290
|
console.log(`[Proxy] universal model-routing proxy on http://127.0.0.1:${PORT}`)
|
|
391
291
|
console.log(`[Proxy] ${Object.keys(ROUTES).length} model prefixes loaded`)
|
|
@@ -393,30 +293,16 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
393
293
|
console.log(`[Proxy] in opencode.json set baseURL to http://127.0.0.1:${PORT}/v1`)
|
|
394
294
|
})
|
|
395
295
|
|
|
396
|
-
server.on('error', (err) => {
|
|
397
|
-
console.error('[Proxy] server error:', err.message)
|
|
398
|
-
process.exit(1)
|
|
399
|
-
})
|
|
296
|
+
server.on('error', (err) => { console.error('[Proxy] server error:', err.message); process.exit(1) })
|
|
400
297
|
|
|
401
298
|
// ── Graceful shutdown ──────────────────────────────────────────────────────
|
|
402
299
|
function shutdown(signal) {
|
|
403
300
|
console.log(`[Proxy] received ${signal}, shutting down gracefully...`)
|
|
404
|
-
// Log cache stats before clearing
|
|
405
301
|
let totalEntries = 0
|
|
406
|
-
for (const [, sessionMap] of cache.cache)
|
|
407
|
-
totalEntries += sessionMap.size
|
|
408
|
-
}
|
|
302
|
+
for (const [, sessionMap] of cache.cache) totalEntries += sessionMap.size
|
|
409
303
|
console.log(`[Proxy] ${cache.cache.size} session(s), ${totalEntries} cached turn(s)`)
|
|
410
|
-
server.close(() => {
|
|
411
|
-
|
|
412
|
-
process.exit(0)
|
|
413
|
-
})
|
|
414
|
-
// Force exit after 5 seconds if connections don't drain
|
|
415
|
-
setTimeout(() => {
|
|
416
|
-
console.error('[Proxy] forced exit after timeout')
|
|
417
|
-
process.exit(1)
|
|
418
|
-
}, 5000).unref()
|
|
304
|
+
server.close(() => { console.log('[Proxy] server closed'); process.exit(0) })
|
|
305
|
+
setTimeout(() => { console.error('[Proxy] forced exit after timeout'); process.exit(1) }, 5000).unref()
|
|
419
306
|
}
|
|
420
|
-
|
|
421
307
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
422
308
|
process.on('SIGINT', () => shutdown('SIGINT'))
|