opencode-thinking-fix 2.0.0 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/proxy/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 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
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
- // 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
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
- // 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
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
- const http = require('http')
26
- const https = require('https')
27
- const url = require('url')
28
- const { createParser } = require('eventsource-parser')
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 = parseInt(process.env.PORT || '3457', 10)
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
- // Optional fixed upstream override. When set, ALL requests are routed here
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
- // ── Model routing table ────────────────────────────────────────────────────
38
- // Each entry: { base: upstream API base URL, reasoning: does this model emit reasoning_content? }
39
- // Prefix-based matching: "deepseek-v4-pro" matches the "deepseek" prefix
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
- // 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
- }
236
-
237
- // ── Main proxy handler ────────────────────────────────────────────────────
69
+ // ── Main proxy handler ─────────────────────────────────────────────────────
238
70
  const server = http.createServer((req, res) => {
239
- // Health check, plugin uses this to detect running proxy
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({ ok: true, uptime: Math.floor(process.uptime()) }))
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 sessionId = req.headers['x-session-id'] || 'unknown'
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
- if (UPSTREAM_URL) {
269
- // Fixed upstream mode (e.g. OpenCode Go)
270
- routeTarget = { base: UPSTREAM_URL, reasoning: true }
271
- } else {
272
- // Model-based routing mode
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
- const shouldPatch = isPost && isJson && routeTarget.reasoning
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
- // Patch request body if this is a reasoning provider
123
+ // ── Build outgoing request body ──
279
124
  let bodyToSend = rawBody
280
- if (shouldPatch && rawBody.length > 0) {
281
- let bodyStr = rawBody.toString('utf8')
125
+ let assistantCount = 0
126
+ let patchReport = null
127
+ if (shouldPatch && rawBody.length > 0 && parsedBody) {
282
128
  try {
283
- const bj = JSON.parse(bodyStr)
129
+ const bj = parsedBody
284
130
 
285
- // Strip unsupported params for Kimi/Moonshot thinking mode
286
- // Moonshot hardcodes sampling params for thinking models and rejects
287
- // explicit values. Docs: https://platform.kimi.ai/docs/api/models-overview
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.temperature
291
- delete bj.top_p
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
- // Inject reasoning_split for MiniMax models (avoids <think> tags in content)
304
- const isMiniMax = modelName.toLowerCase().startsWith('minimax') || modelName.toLowerCase().startsWith('mimo')
305
- if (isMiniMax) {
306
- bj.reasoning_split = true
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
- bodyStr = JSON.stringify(bj)
310
- } catch { /* pass */ }
311
- const patched = patchRequestBody(bodyStr, sessionId)
312
- bodyToSend = Buffer.from(patched, 'utf8')
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
- // Build upstream request options
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 upstreamOptions = {
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
- ...req.headers,
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
- // Use http or https depending on upstream
335
- const transport = upstream.protocol === 'https:' ? https : http
205
+ proxyReq.setTimeout(UPSTREAM_TIMEOUT, () => {
206
+ endError(504, { error: 'upstream timeout' })
207
+ proxyReq.destroy()
208
+ })
336
209
 
337
- const proxyReq = transport.request(upstreamOptions, (proxyRes) => {
338
- // Forward response headers
339
- res.writeHead(proxyRes.statusCode, proxyRes.headers)
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
- if (shouldPatch && isStream && isOk) {
345
- // Parse SSE stream to extract and cache reasoning_content
346
- const parser = createStreamParser(sessionId, (index, reasoning) => {
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
- parser.flush()
360
- res.end()
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
- } else {
363
- // Non-streaming or non-reasoning: pipe directly
364
- proxyRes.pipe(res)
241
+ proxyRes.on('error', closeResponse)
242
+ proxyRes.on('aborted', closeResponse)
243
+ return
365
244
  }
366
- })
367
245
 
368
- proxyReq.on('error', (err) => {
369
- console.error('[Proxy] upstream error:', err.message)
370
- if (!res.headersSent) {
371
- res.writeHead(502)
372
- res.end(JSON.stringify({ error: 'upstream error', detail: err.message }))
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
- console.log('[Proxy] server closed')
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'))