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/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 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).
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
- // 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
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
- const http = require('http')
26
- const https = require('https')
27
- const url = require('url')
28
- const { createParser } = require('eventsource-parser')
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 = parseInt(process.env.PORT || '3457', 10)
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
- // 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
68
+ // Fixed-upstream mode (OpenCode Go): every request goes to one endpoint.
35
69
  const UPSTREAM_URL = process.env.UPSTREAM_URL || ''
36
70
 
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
- }
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
- // Health check, plugin uses this to detect running proxy
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({ ok: true, uptime: Math.floor(process.uptime()) }))
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 sessionId = req.headers['x-session-id'] || 'unknown'
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
- 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
- }
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
- const shouldPatch = isPost && isJson && routeTarget.reasoning
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
- // Patch request body if this is a reasoning provider
132
+ // ── Build outgoing request body ──
279
133
  let bodyToSend = rawBody
280
- if (shouldPatch && rawBody.length > 0) {
281
- let bodyStr = rawBody.toString('utf8')
134
+ let assistantCount = 0
135
+ let patchReport = null
136
+ if (shouldPatch && rawBody.length > 0 && parsedBody) {
282
137
  try {
283
- const bj = JSON.parse(bodyStr)
284
-
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')
289
- 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
- }
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
- // 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
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
- bodyStr = JSON.stringify(bj)
310
- } catch { /* pass */ }
311
- const patched = patchRequestBody(bodyStr, sessionId)
312
- bodyToSend = Buffer.from(patched, 'utf8')
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
- // Build upstream request options
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 upstreamOptions = {
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
- ...req.headers,
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
- // Use http or https depending on upstream
335
- const transport = upstream.protocol === 'https:' ? https : http
218
+ proxyReq.setTimeout(UPSTREAM_TIMEOUT, () => {
219
+ endError(504, { error: 'upstream timeout' })
220
+ proxyReq.destroy()
221
+ })
336
222
 
337
- const proxyReq = transport.request(upstreamOptions, (proxyRes) => {
338
- // Forward response headers
339
- res.writeHead(proxyRes.statusCode, proxyRes.headers)
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
- 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
- })
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
- parser.flush()
360
- res.end()
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
- } else {
363
- // Non-streaming or non-reasoning: pipe directly
364
- proxyRes.pipe(res)
260
+ proxyRes.on('error', closeResponse)
261
+ proxyRes.on('aborted', closeResponse)
262
+ return
365
263
  }
366
- })
367
264
 
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 }))
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
- 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()
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'))