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/README.md +152 -59
- package/package.json +8 -11
- package/proxy/core.js +316 -0
- package/proxy/proxy.js +239 -333
- 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,311 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
-
// OpenCode Reasoning Cache Proxy
|
|
4
|
+
// OpenCode Reasoning Cache Proxy — v3.2 (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).
|
|
37
|
+
//
|
|
38
|
+
// Sentinel fix (v3.2):
|
|
39
|
+
// R1 (deepseek-r1 / deepseek-reasoner) now uses reasoningKey 'strip' — the
|
|
40
|
+
// request is admitted to patchRequestBody to actively REMOVE reasoning fields
|
|
41
|
+
// (shouldPatch), but excluded from response-side caching (shouldCache). All
|
|
42
|
+
// other null routes stay null (never fabricate, never strip). See v3.2.0.
|
|
17
43
|
//
|
|
18
44
|
// Deployment:
|
|
19
45
|
// node proxy.js
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// In opencode.json: set baseURL to http://127.0.0.1
|
|
46
|
+
// PORT=3457 (universal model-routing proxy)
|
|
47
|
+
// PORT=3458 UPSTREAM_URL=https://opencode.ai/zen/go/v1 (OpenCode Go proxy)
|
|
48
|
+
// In opencode.json: set baseURL to http://127.0.0.1:<PORT>/v1
|
|
49
|
+
//
|
|
50
|
+
// Optional env:
|
|
51
|
+
// DEBUG=1 verbose cache logging
|
|
52
|
+
// CACHE_CAPACITY=500 max sessions
|
|
53
|
+
// UPSTREAM_TIMEOUT=30000 ms request timeout
|
|
23
54
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
55
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
56
|
+
import http from 'node:http'
|
|
57
|
+
import https from 'node:https'
|
|
58
|
+
import url from 'node:url'
|
|
59
|
+
import {
|
|
60
|
+
ROUTES, cache, stats, route, fixedUpstreamRoute, getSessionCache, patchRequestBody,
|
|
61
|
+
extractReasoningFromJson, createStreamParser, writeLog, deriveSessionId,
|
|
62
|
+
} from './core.js'
|
|
29
63
|
|
|
30
|
-
const PORT
|
|
64
|
+
const PORT = parseInt(process.env.PORT || '3457', 10)
|
|
65
|
+
const DEBUG = process.env.DEBUG === '1'
|
|
66
|
+
const UPSTREAM_TIMEOUT = parseInt(process.env.UPSTREAM_TIMEOUT || '30000', 10)
|
|
31
67
|
|
|
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
|
|
68
|
+
// Fixed-upstream mode (OpenCode Go): every request goes to one endpoint.
|
|
35
69
|
const UPSTREAM_URL = process.env.UPSTREAM_URL || ''
|
|
36
70
|
|
|
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
|
-
}
|
|
138
|
-
|
|
139
|
-
// Inject real cached reasoning if available, otherwise ""
|
|
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
|
-
}
|
|
71
|
+
// ── Keep-alive agents (connection reuse → lower latency) ───────────────────
|
|
72
|
+
const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 64, maxFreeSockets: 32 })
|
|
73
|
+
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 64, maxFreeSockets: 32 })
|
|
236
74
|
|
|
237
|
-
// ── Main proxy handler
|
|
75
|
+
// ── Main proxy handler ─────────────────────────────────────────────────────
|
|
238
76
|
const server = http.createServer((req, res) => {
|
|
239
|
-
|
|
77
|
+
res.on('error', (err) => { if (DEBUG) console.error('[Proxy] client response error:', err.message) })
|
|
240
78
|
if (req.method === 'GET' && req.url === '/health') {
|
|
241
79
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
242
|
-
res.end(JSON.stringify({
|
|
80
|
+
res.end(JSON.stringify({
|
|
81
|
+
ok: true,
|
|
82
|
+
uptime: Math.floor(process.uptime()),
|
|
83
|
+
sessions: stats.sessions,
|
|
84
|
+
cacheHits: stats.hits,
|
|
85
|
+
cacheMisses: stats.misses,
|
|
86
|
+
stored: stats.stored,
|
|
87
|
+
requests: stats.requests,
|
|
88
|
+
passthrough: stats.passthrough,
|
|
89
|
+
}))
|
|
243
90
|
return
|
|
244
91
|
}
|
|
245
92
|
|
|
246
|
-
// Collect request body
|
|
247
93
|
const chunks = []
|
|
248
|
-
req.on('data', chunk => chunks.push(chunk))
|
|
94
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
249
95
|
req.on('end', () => {
|
|
96
|
+
stats.requests++
|
|
250
97
|
const rawBody = Buffer.concat(chunks)
|
|
251
|
-
const
|
|
98
|
+
const rawSessionId = req.headers['x-session-id']
|
|
252
99
|
const isPost = req.method === 'POST'
|
|
253
100
|
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)
|
|
101
|
+
let parsedBody = null
|
|
261
102
|
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 */ }
|
|
103
|
+
try { parsedBody = JSON.parse(rawBody.toString('utf8')) } catch { /* ignore */ }
|
|
266
104
|
}
|
|
267
105
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
routeTarget = route(modelName)
|
|
274
|
-
}
|
|
106
|
+
// Resolve model + route (model parsed even in fixed-upstream mode)
|
|
107
|
+
const modelName = parsedBody?.model || ''
|
|
108
|
+
const routeTarget = UPSTREAM_URL
|
|
109
|
+
? { ...fixedUpstreamRoute(modelName, req.url), base: UPSTREAM_URL }
|
|
110
|
+
: route(modelName)
|
|
275
111
|
const upstream = url.parse(routeTarget.base)
|
|
276
|
-
|
|
112
|
+
let sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
|
|
113
|
+
if ((!sessionId || sessionId === 'unknown') && isPost && isJson && parsedBody) {
|
|
114
|
+
sessionId = deriveSessionId(modelName, parsedBody, req.headers['authorization']) || 'unknown'
|
|
115
|
+
}
|
|
116
|
+
const shouldPatch = isPost && isJson && !!routeTarget.reasoningKey && !!sessionId && sessionId !== 'unknown'
|
|
117
|
+
// 'strip' routes (R1) are admitted to patchRequestBody (to REMOVE reasoning)
|
|
118
|
+
// but never cached for replay — reasoning must never be echoed back to R1.
|
|
119
|
+
const shouldCache = shouldPatch && routeTarget.reasoningKey !== 'strip'
|
|
120
|
+
|
|
121
|
+
if (!shouldPatch) {
|
|
122
|
+
const reason = !isPost ? 'get'
|
|
123
|
+
: !isJson ? 'non_json'
|
|
124
|
+
: !sessionId ? 'no_session'
|
|
125
|
+
: sessionId === 'unknown' ? 'unknown_session'
|
|
126
|
+
: !modelName ? 'unknown_route'
|
|
127
|
+
: !routeTarget.reasoningKey ? 'model_not_patched'
|
|
128
|
+
: 'unknown_route'
|
|
129
|
+
writeLog({ event: 'passthrough', model: modelName, session: sessionId.slice(-8) || 'none', reason })
|
|
130
|
+
}
|
|
277
131
|
|
|
278
|
-
//
|
|
132
|
+
// ── Build outgoing request body ──
|
|
279
133
|
let bodyToSend = rawBody
|
|
280
|
-
|
|
281
|
-
|
|
134
|
+
let assistantCount = 0
|
|
135
|
+
let patchReport = null
|
|
136
|
+
if (shouldPatch && rawBody.length > 0 && parsedBody) {
|
|
282
137
|
try {
|
|
283
|
-
const bj =
|
|
284
|
-
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
delete bj
|
|
293
|
-
delete bj.
|
|
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
|
-
}
|
|
138
|
+
const bj = parsedBody
|
|
139
|
+
|
|
140
|
+
// Kimi/Moonshot hardcode sampling params for thinking models.
|
|
141
|
+
const lower = modelName.toLowerCase()
|
|
142
|
+
const isKimi = lower.startsWith('kimi') || lower.startsWith('moonshot')
|
|
143
|
+
// F9: model-specific body tweaks are model-routing-mode only (3457).
|
|
144
|
+
// In fixed-upstream (OpenCode Go) mode the upstream owns per-model
|
|
145
|
+
// handling, so sampling params are left untouched.
|
|
146
|
+
if (!UPSTREAM_URL && isKimi) {
|
|
147
|
+
for (const k of ['temperature', 'top_p', 'top_k', 'presence_penalty', 'frequency_penalty', 'n']) delete bj[k]
|
|
148
|
+
if (lower.startsWith('kimi-k2.7')) { delete bj.thinking; delete bj.reasoning_effort }
|
|
301
149
|
}
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
150
|
+
// MiniMax: keep thinking separate from content.
|
|
151
|
+
// F10: same mode gate — only inject reasoning_split in routing mode.
|
|
152
|
+
if (!UPSTREAM_URL && lower.startsWith('minimax')) bj.reasoning_split = true
|
|
153
|
+
|
|
154
|
+
const patched = patchRequestBody(JSON.stringify(bj), sessionId, routeTarget.reasoningKey)
|
|
155
|
+
bodyToSend = Buffer.from(patched.body, 'utf8')
|
|
156
|
+
assistantCount = patched.assistantCount
|
|
157
|
+
patchReport = patched.report
|
|
158
|
+
if (!patched.modified) stats.passthrough++
|
|
159
|
+
if (patchReport.assistantTurns > 0) {
|
|
160
|
+
writeLog({ event: 'inspect', model: modelName, session: sessionId.slice(-8), endpoint: req.url,
|
|
161
|
+
totalMessages: bj.messages.length, assistantTurns: patchReport.assistantTurns,
|
|
162
|
+
missingText: patchReport.missingText, missingReasoning: patchReport.missingReasoning })
|
|
307
163
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
164
|
+
if (patched.modified) {
|
|
165
|
+
writeLog({ event: 'patched', model: modelName, session: sessionId.slice(-8),
|
|
166
|
+
patchedFields: patchReport.turns.reduce((sum, turn) => sum + turn.fields.length, 0),
|
|
167
|
+
turns: patchReport.turns })
|
|
168
|
+
}
|
|
169
|
+
} catch { /* forward as-is */ }
|
|
313
170
|
}
|
|
314
171
|
|
|
315
|
-
//
|
|
316
|
-
// If base URL has a path prefix (e.g. /api/paas/v4), prepend it to req.url
|
|
172
|
+
// ── Upstream request options ──
|
|
317
173
|
let upstreamPath = req.url
|
|
318
174
|
const basePath = upstream.path || '/'
|
|
319
175
|
if (basePath !== '/' && basePath !== '/v1') {
|
|
320
176
|
upstreamPath = basePath + req.url.replace(/^\/v1/, '')
|
|
321
177
|
}
|
|
322
|
-
const
|
|
178
|
+
const transport = upstream.protocol === 'https:' ? https : http
|
|
179
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'proxy-', 'x-session-id'])
|
|
180
|
+
const safeHeaders = {}
|
|
181
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
182
|
+
const lowerKey = key.toLowerCase()
|
|
183
|
+
if (HOP_BY_HOP.has(lowerKey) || lowerKey.startsWith('proxy-')) continue
|
|
184
|
+
safeHeaders[key] = value
|
|
185
|
+
}
|
|
186
|
+
const proxyReq = transport.request({
|
|
323
187
|
hostname: upstream.hostname,
|
|
324
188
|
port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80),
|
|
325
189
|
path: upstreamPath,
|
|
326
190
|
method: req.method,
|
|
191
|
+
agent: upstream.protocol === 'https:' ? httpsAgent : httpAgent,
|
|
327
192
|
headers: {
|
|
328
|
-
...
|
|
193
|
+
...safeHeaders,
|
|
329
194
|
host: upstream.hostname,
|
|
330
195
|
'content-length': bodyToSend.length,
|
|
196
|
+
'accept-encoding': 'identity', // force uncompressed so we can stream raw
|
|
331
197
|
},
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
let responseClosed = false
|
|
201
|
+
const closeResponse = () => {
|
|
202
|
+
if (responseClosed) return
|
|
203
|
+
responseClosed = true
|
|
204
|
+
if (!res.destroyed) res.destroy()
|
|
205
|
+
}
|
|
206
|
+
const endError = (status, payload) => {
|
|
207
|
+
if (responseClosed) return
|
|
208
|
+
writeLog({ event: 'error', message: payload?.error || 'proxy error' })
|
|
209
|
+
responseClosed = true
|
|
210
|
+
if (!res.headersSent) {
|
|
211
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
212
|
+
res.end(JSON.stringify(payload))
|
|
213
|
+
} else if (!res.destroyed) {
|
|
214
|
+
res.destroy()
|
|
215
|
+
}
|
|
332
216
|
}
|
|
333
217
|
|
|
334
|
-
|
|
335
|
-
|
|
218
|
+
proxyReq.setTimeout(UPSTREAM_TIMEOUT, () => {
|
|
219
|
+
endError(504, { error: 'upstream timeout' })
|
|
220
|
+
proxyReq.destroy()
|
|
221
|
+
})
|
|
336
222
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
223
|
+
proxyReq.on('error', (err) => {
|
|
224
|
+
if (DEBUG) console.error('[Proxy] upstream error:', err.message)
|
|
225
|
+
writeLog({ event: 'error', message: err.message })
|
|
226
|
+
endError(502, { error: 'upstream error', detail: err.message })
|
|
227
|
+
})
|
|
340
228
|
|
|
229
|
+
proxyReq.on('response', (proxyRes) => {
|
|
341
230
|
const isStream = (proxyRes.headers['content-type'] || '').includes('text/event-stream')
|
|
342
231
|
const isOk = (proxyRes.statusCode || 500) < 400
|
|
232
|
+
const isCompressed = !!proxyRes.headers['content-encoding']
|
|
233
|
+
|
|
234
|
+
// Forward response headers, stripping hop-by-hop headers first (F2).
|
|
235
|
+
// Node manages transfer-encoding/content-length on the client leg, so
|
|
236
|
+
// forwarding the upstream's chunking/connection headers would corrupt
|
|
237
|
+
// the re-streamed response. This single site covers all three response
|
|
238
|
+
// paths below (buffered JSON, passthrough pipe, reasoning stream).
|
|
239
|
+
const outHeaders = { ...proxyRes.headers }
|
|
240
|
+
for (const h of ['transfer-encoding', 'connection', 'keep-alive', 'te', 'trailer', 'upgrade', 'proxy-authenticate', 'proxy-authorization']) {
|
|
241
|
+
delete outHeaders[h]
|
|
242
|
+
}
|
|
243
|
+
res.writeHead(proxyRes.statusCode, outHeaders)
|
|
343
244
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
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
|
-
})
|
|
357
|
-
|
|
245
|
+
// Buffer ordinary JSON responses so reasoning can be cached too.
|
|
246
|
+
if (shouldCache && !isStream && isOk && !isCompressed) {
|
|
247
|
+
const responseChunks = []
|
|
248
|
+
proxyRes.on('data', (chunk) => responseChunks.push(chunk))
|
|
358
249
|
proxyRes.on('end', () => {
|
|
359
|
-
|
|
360
|
-
|
|
250
|
+
if (responseClosed) return
|
|
251
|
+
const responseBody = Buffer.concat(responseChunks)
|
|
252
|
+
const reasoning = extractReasoningFromJson(responseBody.toString('utf8'))
|
|
253
|
+
if (reasoning && getSessionCache(sessionId).set(assistantCount, reasoning)) {
|
|
254
|
+
stats.stored++
|
|
255
|
+
writeLog({ event: 'cache_store', session: sessionId.slice(-8), turn: assistantCount, chars: reasoning.length })
|
|
256
|
+
}
|
|
257
|
+
responseClosed = true
|
|
258
|
+
res.end(responseBody)
|
|
361
259
|
})
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
260
|
+
proxyRes.on('error', closeResponse)
|
|
261
|
+
proxyRes.on('aborted', closeResponse)
|
|
262
|
+
return
|
|
365
263
|
}
|
|
366
|
-
})
|
|
367
264
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
265
|
+
if (!shouldCache || !isStream || !isOk || isCompressed) {
|
|
266
|
+
proxyRes.pipe(res)
|
|
267
|
+
proxyRes.on('error', closeResponse)
|
|
268
|
+
proxyRes.on('aborted', closeResponse)
|
|
269
|
+
return
|
|
373
270
|
}
|
|
271
|
+
|
|
272
|
+
// Reasoning stream: forward RAW bytes immediately, parse only for cache.
|
|
273
|
+
const parser = createStreamParser(sessionId, assistantCount, (index, reasoning) => {
|
|
274
|
+
const sc = getSessionCache(sessionId)
|
|
275
|
+
if (sc.set(index, reasoning)) {
|
|
276
|
+
stats.stored++
|
|
277
|
+
writeLog({ event: 'cache_store', session: sessionId.slice(-8), turn: index, chars: reasoning.length })
|
|
278
|
+
if (DEBUG) console.log(`[Cache] ${sessionId.slice(-8)}: stored turn ${index} (${reasoning.length} chars)`)
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
let parserDead = false
|
|
283
|
+
proxyRes.on('data', (chunk) => {
|
|
284
|
+
if (responseClosed) return
|
|
285
|
+
res.write(chunk) // raw forward — zero re-serialization
|
|
286
|
+
if (!parserDead) {
|
|
287
|
+
try { parser.feed(chunk) } catch { parserDead = true }
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
proxyRes.on('end', () => {
|
|
291
|
+
if (responseClosed) return
|
|
292
|
+
parser.flush(); responseClosed = true; res.end()
|
|
293
|
+
})
|
|
294
|
+
proxyRes.on('error', closeResponse)
|
|
295
|
+
proxyRes.on('aborted', closeResponse)
|
|
374
296
|
})
|
|
375
297
|
|
|
376
298
|
proxyReq.write(bodyToSend)
|
|
377
299
|
proxyReq.end()
|
|
378
300
|
})
|
|
379
301
|
|
|
380
|
-
req.on('error', (err) => {
|
|
381
|
-
console.error('[Proxy] request error:', err.message)
|
|
382
|
-
})
|
|
302
|
+
req.on('error', (err) => { if (DEBUG) console.error('[Proxy] request error:', err.message) })
|
|
383
303
|
})
|
|
384
304
|
|
|
385
305
|
server.listen(PORT, '127.0.0.1', () => {
|
|
306
|
+
writeLog({ event: 'proxy_started', port: PORT, version: '3.2.0', upstream: UPSTREAM_URL || 'model-routing', pid: process.pid })
|
|
386
307
|
if (UPSTREAM_URL) {
|
|
387
|
-
console.log(`[Proxy] fixed-upstream proxy on http://127.0.0.1:${PORT}`)
|
|
388
|
-
console.log(`[Proxy] upstream: ${UPSTREAM_URL}`)
|
|
308
|
+
console.log(`[Proxy] fixed-upstream proxy on http://127.0.0.1:${PORT} -> ${UPSTREAM_URL}`)
|
|
389
309
|
} else {
|
|
390
310
|
console.log(`[Proxy] universal model-routing proxy on http://127.0.0.1:${PORT}`)
|
|
391
311
|
console.log(`[Proxy] ${Object.keys(ROUTES).length} model prefixes loaded`)
|
|
@@ -393,30 +313,16 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
393
313
|
console.log(`[Proxy] in opencode.json set baseURL to http://127.0.0.1:${PORT}/v1`)
|
|
394
314
|
})
|
|
395
315
|
|
|
396
|
-
server.on('error', (err) => {
|
|
397
|
-
console.error('[Proxy] server error:', err.message)
|
|
398
|
-
process.exit(1)
|
|
399
|
-
})
|
|
316
|
+
server.on('error', (err) => { console.error('[Proxy] server error:', err.message); process.exit(1) })
|
|
400
317
|
|
|
401
318
|
// ── Graceful shutdown ──────────────────────────────────────────────────────
|
|
402
319
|
function shutdown(signal) {
|
|
403
320
|
console.log(`[Proxy] received ${signal}, shutting down gracefully...`)
|
|
404
|
-
// Log cache stats before clearing
|
|
405
321
|
let totalEntries = 0
|
|
406
|
-
for (const [, sessionMap] of cache.cache)
|
|
407
|
-
totalEntries += sessionMap.size
|
|
408
|
-
}
|
|
322
|
+
for (const [, sessionMap] of cache.cache) totalEntries += sessionMap.size
|
|
409
323
|
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()
|
|
324
|
+
server.close(() => { console.log('[Proxy] server closed'); process.exit(0) })
|
|
325
|
+
setTimeout(() => { console.error('[Proxy] forced exit after timeout'); process.exit(1) }, 5000).unref()
|
|
419
326
|
}
|
|
420
|
-
|
|
421
327
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
422
328
|
process.on('SIGINT', () => shutdown('SIGINT'))
|