opencode-thinking-fix 3.1.4 → 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 CHANGED
@@ -10,7 +10,7 @@
10
10
  npm install opencode-thinking-fix
11
11
  ```
12
12
 
13
- > Fix for reasoning being dropped from multi-turn conversations with DeepSeek, Kimi, GLM, MiMo, and MiniMax-M3.
13
+ > Restores reasoning content that clients and SDKs drop from multi-turn conversations — DeepSeek, Kimi, GLM, MiMo, MiniMax, and OpenCode Go.
14
14
  >
15
15
  > **Zero config.** Install via `Ctrl+P`, restart OpenCode, done. The plugin auto-detects reasoning models and only patches when needed.
16
16
  >
@@ -72,7 +72,8 @@ causing cross-provider poisoning. v3.1 fixes that:
72
72
  filled all three fields, so a GLM turn stored under `reasoning` could poison a later
73
73
  DeepSeek replay that expects `reasoning_content`.
74
74
  - **R1 vs V4 split.** `deepseek-reasoner` (R1) must **not** receive reasoning echoed back
75
- (400 if you do) while V4 must. R1 now routes with `reasoningKey: null`.
75
+ (400 if you do) while V4 must. R1 uses the v3.2 `reasoningKey: 'strip'` sentinel,
76
+ which actively removes reasoning fields before forwarding.
76
77
  - **Never fabricate reasoning.** Unknown models and providers that reject echo
77
78
  (Qwen, GPT, Claude, Gemini, Llama, Mistral, Cerebras-hosted GLM) default to
78
79
  `reasoningKey: null` — the body is forwarded untouched.
@@ -89,9 +90,9 @@ The reasoning-drop is fixed at the wire layer by the proxy, so you have three op
89
90
 
90
91
  **Recommendation:** run the proxy, and slim the plugin down to *just* the `x-session-id` header injection (delete its message-transform hook). That gives you real reasoning replay with the least overhead.
91
92
 
92
- ## Plugin deprecation and derived sessions
93
+ ## Plugin optionality and derived sessions
93
94
 
94
- As of 3.0, plugin is deprecated. Proxy derives session identity from request when `x-session-id` is absent. Formula: `sha256(authHeader || '' || modelName || firstUserMessageText)`, prefixed with `derived:` and truncated to 32 hex characters; header wins when present. Rare bounded collision: conversations sharing same first user message, model, and auth key share cache key, causing minor reasoning cross-talk that self-heals as turns diverge.
95
+ As of 3.2.0, the plugin is optional. The shipped npm plugin still contains the transform hook; without it, derived keys keep caching functional; with it, you add exact session IDs + schema padding. The proxy derives session identity from the request when `x-session-id` is absent. Formula: `sha256(authHeader + '||' + modelName + '||' + firstUserMessageText)`, prefixed with `derived:` and truncated to 32 hex characters; header wins when present. The key is fixed at the first user message, so cross-talk is bounded to sessions sharing the same auth header, model, and first user prompt; replayed text is still valid reasoning for that model, so the impact is quality-level, never a 400.
95
96
 
96
97
  ---
97
98
 
@@ -124,7 +125,7 @@ opencode plugin opencode-thinking-fix
124
125
  For a specific version:
125
126
 
126
127
  ```bash
127
- opencode plugin opencode-thinking-fix@3.1.4
128
+ opencode plugin opencode-thinking-fix@3.2.0
128
129
  ```
129
130
 
130
131
  Restart OpenCode after installing.
@@ -359,7 +360,7 @@ The proxy auto-routes by model name prefix. All routes:
359
360
  | Prefix | Upstream | Reasoning |
360
361
  |---|---|---|
361
362
  | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-chat` | `https://api.deepseek.com` | Yes (`reasoning_content`) |
362
- | `deepseek-reasoner` | `https://api.deepseek.com` | **No — must NOT echo** (R1 contract) |
363
+ | `deepseek-reasoner` | `https://api.deepseek.com` | **No — actively stripped** (R1 contract: reasoning must NOT be echoed) |
363
364
  | `kimi`, `moonshot` | `https://api.moonshot.ai/v1` | Yes (`reasoning_content`) |
364
365
  | `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes (`reasoning_content`) |
365
366
  | `minimax` | `https://api.minimax.io/v1` | Yes (`reasoning_details`, `reasoning_split:true`) |
@@ -443,12 +444,12 @@ OpenCode is not the only tool that drops `reasoning_content`. Here is a partial
443
444
 
444
445
  ```
445
446
  plugins/
446
- opencode-thinking-fix-universal.ts # self-detection plugin (92 lines)
447
+ opencode-thinking-fix-universal.ts # self-detection plugin
447
448
  proxy/
448
- core.js # pure logic: routes, cache, patching, parser (138 lines)
449
- proxy.js # HTTP server + side effects (266 lines, 1 dep)
449
+ core.js # pure logic: routes, cache, patching, parser
450
+ proxy.js # HTTP server + side effects (1 dep)
450
451
  watchdog/
451
- watchdog.sh # auto-recovery watchdog (64 lines)
452
+ watchdog.sh # auto-recovery watchdog
452
453
  systemd/
453
454
  reasoning-cache.service # proxy systemd unit (port 3457)
454
455
  reasoning-cache-go.service # OpenCode Go proxy unit (port 3458)
@@ -457,6 +458,7 @@ systemd/
457
458
 
458
459
  ## Release highlights
459
460
 
461
+ - **v3.2.0:** R1 `strip` sentinel (actively remove reasoning for `deepseek-r1`/`deepseek-reasoner`), multibyte-safe SSE decoding (StringDecoder), response hop-by-hop header stripping, oversized-reasoning skip-not-truncate, MiniMax replay shape behind `MINIMAX_REASONING_DETAILS_SHAPE` (default `minimal`), a real-core test suite (95 assertions), glm-5.2 go-mode `reasoning_content` key (F11, live-verified), derived session keys (Option C), and `x-session-id` no longer forwarded upstream. See the changelog.
460
462
  - **v3.1.4:** proxy-owned structured JSONL logging with session identifiers truncated in logs.
461
463
  - **v3.1.3:** Anthropic-wire dialect detection for OpenCode Go routes.
462
464
  - **v3.1.2:** bounded cache/session memory, raw-stream preservation, hop-by-hop header removal, and safer parser failure handling.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-thinking-fix",
3
- "version": "3.1.4",
4
- "description": "Fix reasoning_content 400 errors for DeepSeek/Kimi/GLM/MiMo/MiniMax in OpenCode",
3
+ "version": "3.2.0",
4
+ "description": "Restores reasoning content dropped from multi-turn OpenCode conversations (DeepSeek, Kimi, GLM, MiMo, MiniMax, OpenCode Go). Plugin + proxy + watchdog.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "oc-plugin": [
@@ -26,10 +26,9 @@
26
26
  "thinking-mode",
27
27
  "tool-calls",
28
28
  "multi-turn",
29
- "400-error",
30
- "http-400",
31
29
  "proxy",
32
30
  "plugin",
31
+ "cli",
33
32
  "cursor",
34
33
  "cline",
35
34
  "aider",
@@ -44,7 +43,9 @@
44
43
  "chinese-llm",
45
44
  "agent",
46
45
  "mcp",
47
- "api-fix"
46
+ "reasoning-preservation",
47
+ "thinking-blocks",
48
+ "context-restoration"
48
49
  ],
49
50
  "repository": {
50
51
  "type": "git",
package/proxy/core.js CHANGED
@@ -5,6 +5,7 @@ import crypto from 'node:crypto'
5
5
  import { homedir } from 'node:os'
6
6
  import { join, dirname } from 'node:path'
7
7
  import { appendFileSync, mkdirSync } from 'node:fs'
8
+ import { StringDecoder } from 'node:string_decoder'
8
9
 
9
10
  export function deriveSessionId(modelName, parsedBody, authHeader) {
10
11
  const messages = parsedBody?.messages
@@ -38,12 +39,14 @@ export function writeLog(entry) {
38
39
  const DEBUG = process.env.DEBUG === '1'
39
40
 
40
41
  export const ROUTES = {
41
- // R1 aliases: reasoning must not be echoed.
42
- 'deepseek-r1': { base: 'https://api.deepseek.com', reasoningKey: null },
42
+ // R1 aliases: reasoning must not be echoed back — the 'strip' sentinel
43
+ // admits the request to patchRequestBody (to REMOVE any reasoning fields a
44
+ // client/plugin attached) while excluding it from response-side caching.
45
+ 'deepseek-r1': { base: 'https://api.deepseek.com', reasoningKey: 'strip' },
43
46
  'deepseek-v4-pro': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
44
47
  'deepseek-v4-flash': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
45
48
  'deepseek-chat': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
46
- 'deepseek-reasoner': { base: 'https://api.deepseek.com', reasoningKey: null },
49
+ 'deepseek-reasoner': { base: 'https://api.deepseek.com', reasoningKey: 'strip' },
47
50
  deepseek: { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
48
51
  kimi: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
49
52
  moonshot: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
@@ -76,10 +79,18 @@ export function route(modelName) {
76
79
  const ANTHROPIC_WIRE_PREFIXES = ['minimax', 'qwen']
77
80
 
78
81
  export function fixedUpstreamRoute(modelName, requestUrl = '') {
79
- const stripped = (modelName || '').toLowerCase().replace(/^opencode-go\//, '')
82
+ const stripped = (modelName || '').toLowerCase().replace(/^opencode-go(?:-(?:messages|responses))?\//, '')
80
83
  if (String(requestUrl).includes('/messages') && ANTHROPIC_WIRE_PREFIXES.some((p) => stripped.startsWith(p))) {
81
84
  return { base: '', reasoningKey: 'anthropic' }
82
85
  }
86
+ // F11 (validated 2026-08-19): the opencode-go gateway rejects the `reasoning`
87
+ // field on glm-5.2 chat/completions echo (400 "Extra inputs are not
88
+ // permitted") but accepts `reasoning_content`. glm-5.2 was live-validated
89
+ // two-turn; other Go models were NOT changed by F11 and keep the existing
90
+ // `reasoning` behavior below (no exhaustive per-model validation claimed).
91
+ if (stripped === 'glm-5.2' && String(requestUrl).includes('/chat/completions')) {
92
+ return { base: '', reasoningKey: 'reasoning_content' }
93
+ }
83
94
  // Unconditional 'reasoning' for go mode: matches current working behavior
84
95
  // for chat/completions models; /responses bodies have no `messages` array
85
96
  // and pass through untouched.
@@ -113,11 +124,28 @@ const MAX_TURNS_PER_SESSION = Number.isFinite(parsedMaxTurns) && parsedMaxTurns
113
124
  const parsedMaxReasoning = parseInt(process.env.MAX_REASONING_CHARS || '200000', 10)
114
125
  const MAX_REASONING_CHARS = Number.isFinite(parsedMaxReasoning) && parsedMaxReasoning > 0 ? parsedMaxReasoning : 200000
115
126
 
127
+ // MiniMax reasoning_details replay shape.
128
+ //
129
+ // NOTE (F6, 2026-08-19): in our opencode-go deployment (port 3458, Anthropic
130
+ // /v1/messages wire) every MiniMax request resolves to reasoningKey 'anthropic'
131
+ // via fixedUpstreamRoute(), so this constant and the reasoning_details branch
132
+ // in patchRequestBody() are UNUSED in production. Retained defensively for the
133
+ // port-3457 universal-mode / provider-native MiniMax fallback (ROUTES.minimax).
134
+ //
135
+ // 'full' emits the openai-responses-v1 item { type, format, index, text };
136
+ // 'minimal' emits { type, text }. Default 'minimal' (smallest documented
137
+ // outbound shape).
138
+ const MINIMAX_REASONING_DETAILS_SHAPE = process.env.MINIMAX_REASONING_DETAILS_SHAPE === 'full' ? 'full' : 'minimal'
139
+
116
140
  class BoundedSessionMap extends Map {
117
141
  set(key, value) {
118
- super.set(key, typeof value === 'string' ? value.slice(0, MAX_REASONING_CHARS) : value)
142
+ // Skip-not-truncate: an oversized reasoning value is dropped entirely
143
+ // rather than silently truncated (a truncated replay can be worse than a
144
+ // cache miss). Returns true when stored, false when skipped.
145
+ if (typeof value === 'string' && value.length > MAX_REASONING_CHARS) return false
146
+ super.set(key, value)
119
147
  while (this.size > MAX_TURNS_PER_SESSION) this.delete(this.keys().next().value)
120
- return this
148
+ return true
121
149
  }
122
150
  }
123
151
 
@@ -164,14 +192,37 @@ export function patchRequestBody(body, sessionId, reasoningKey) {
164
192
  if (missingReasoning) { stats.misses++; report.misses++ }
165
193
  }
166
194
  } else if (reasoningKey === 'reasoning_details') {
195
+ // F6 (defensive): provider-native MiniMax on port 3457 universal mode
196
+ // replays reasoning into `reasoning_details`. Never executed in the
197
+ // opencode-go deployment (MiniMax always routes as 'anthropic' there) —
198
+ // retained for the universal-mode / provider-native fallback.
167
199
  const hasDetails = Array.isArray(msg.reasoning_details) ? msg.reasoning_details.length > 0 : !!msg.reasoning_details
168
200
  missingReasoning = !hasDetails && !msg.reasoning_content
169
201
  if (missingReasoning) report.missingReasoning++
170
202
  if (!hasDetails && !msg.reasoning_content && cached) {
171
- msg.reasoning_details = [{ text: cached, type: 'thinking' }]; stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['reasoning_details'], source: 'hit' }); modified = true
203
+ msg.reasoning_details = MINIMAX_REASONING_DETAILS_SHAPE === 'full'
204
+ ? [{ type: 'reasoning.text', format: 'openai-responses-v1', index: assistantIndex, text: cached }]
205
+ : [{ type: 'reasoning.text', text: cached }]
206
+ stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['reasoning_details'], source: 'hit' }); modified = true
172
207
  } else if (missingReasoning) {
173
208
  stats.misses++; report.misses++
174
209
  }
210
+ } else if (reasoningKey === 'strip') {
211
+ // R1 sentinel: deepseek-r1 / deepseek-reasoner 400 when reasoning is
212
+ // echoed back. Actively remove any reasoning field a client/plugin may
213
+ // have attached rather than replay it.
214
+ const stripped = []
215
+ if (msg.reasoning_content !== undefined) { delete msg.reasoning_content; stripped.push('reasoning_content') }
216
+ if (msg.reasoning !== undefined) { delete msg.reasoning; stripped.push('reasoning') }
217
+ if (msg.reasoning_details !== undefined) { delete msg.reasoning_details; stripped.push('reasoning_details') }
218
+ if (Array.isArray(msg.content)) {
219
+ const kept = msg.content.filter((b) => !(b && (b.type === 'thinking' || b.type === 'redacted_thinking')))
220
+ if (kept.length !== msg.content.length) { msg.content = kept; stripped.push('content') }
221
+ }
222
+ if (stripped.length > 0) {
223
+ modified = true
224
+ report.turns.push({ index: assistantIndex, fields: stripped, source: 'strip' })
225
+ }
175
226
  } else if (!msg[reasoningKey] || msg[reasoningKey] === '') {
176
227
  missingReasoning = true
177
228
  report.missingReasoning++
@@ -192,7 +243,10 @@ export function extractReasoningFromJson(text) {
192
243
  // Anthropic buffered format: content[] with thinking blocks.
193
244
  if (Array.isArray(parsed?.content)) {
194
245
  const thinking = parsed.content
195
- .filter((b) => b && b.type === 'thinking' && typeof b.thinking === 'string')
246
+ // Explicit redacted_thinking guard (redundant with the
247
+ // b.type === 'thinking' equality below, but kept for contract
248
+ // clarity: redacted blocks must never be replayed as reasoning).
249
+ .filter((b) => b && b.type === 'thinking' && b.type !== 'redacted_thinking' && typeof b.thinking === 'string')
196
250
  .map((b) => b.thinking)
197
251
  .join('\n')
198
252
  if (thinking) return thinking
@@ -213,6 +267,9 @@ export function extractReasoningFromJson(text) {
213
267
 
214
268
  export function createStreamParser(sessionId, baseIndex, onComplete) {
215
269
  let reasoningBuffer = ''
270
+ // StringDecoder buffers partial multibyte UTF-8 sequences across feed()
271
+ // calls, so a character split between two chunks is not corrupted.
272
+ const decoder = new StringDecoder('utf8')
216
273
  const parser = createParser({
217
274
  maxBufferSize: 1024 * 1024,
218
275
  onEvent(event) {
@@ -249,7 +306,11 @@ export function createStreamParser(sessionId, baseIndex, onComplete) {
249
306
  onError(error) { if (DEBUG) console.error(`[Proxy] SSE parse error (${sessionId.slice(-8)}):`, error.message) },
250
307
  })
251
308
  return {
252
- feed(chunk) { parser.feed(chunk.toString('utf8')) },
253
- flush() { if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' } },
309
+ feed(chunk) { parser.feed(decoder.write(chunk)) },
310
+ flush() {
311
+ const tail = decoder.end()
312
+ if (tail) parser.feed(tail)
313
+ if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
314
+ },
254
315
  }
255
316
  }
package/proxy/proxy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  // ─────────────────────────────────────────────────────────────────────────────
4
- // OpenCode Reasoning Cache Proxy — v3.1 (performance + correctness + dialect)
4
+ // OpenCode Reasoning Cache Proxy — v3.2 (performance + correctness + dialect)
5
5
  //
6
6
  // What it does:
7
7
  // - Sits between OpenCode and DeepSeek/Kimi/GLM/MiMo/MiniMax on localhost:3457
@@ -35,6 +35,12 @@
35
35
  // deepseek-reasoner (R1) is null: it must NOT receive reasoning echoed back.
36
36
  // Unknown models default to null (never fabricate reasoning).
37
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.
43
+ //
38
44
  // Deployment:
39
45
  // node proxy.js
40
46
  // PORT=3457 (universal model-routing proxy)
@@ -108,6 +114,9 @@ const server = http.createServer((req, res) => {
108
114
  sessionId = deriveSessionId(modelName, parsedBody, req.headers['authorization']) || 'unknown'
109
115
  }
110
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'
111
120
 
112
121
  if (!shouldPatch) {
113
122
  const reason = !isPost ? 'get'
@@ -131,12 +140,16 @@ const server = http.createServer((req, res) => {
131
140
  // Kimi/Moonshot hardcode sampling params for thinking models.
132
141
  const lower = modelName.toLowerCase()
133
142
  const isKimi = lower.startsWith('kimi') || lower.startsWith('moonshot')
134
- if (isKimi) {
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) {
135
147
  for (const k of ['temperature', 'top_p', 'top_k', 'presence_penalty', 'frequency_penalty', 'n']) delete bj[k]
136
148
  if (lower.startsWith('kimi-k2.7')) { delete bj.thinking; delete bj.reasoning_effort }
137
149
  }
138
150
  // MiniMax: keep thinking separate from content.
139
- if (lower.startsWith('minimax')) bj.reasoning_split = true
151
+ // F10: same mode gate — only inject reasoning_split in routing mode.
152
+ if (!UPSTREAM_URL && lower.startsWith('minimax')) bj.reasoning_split = true
140
153
 
141
154
  const patched = patchRequestBody(JSON.stringify(bj), sessionId, routeTarget.reasoningKey)
142
155
  bodyToSend = Buffer.from(patched.body, 'utf8')
@@ -163,7 +176,7 @@ const server = http.createServer((req, res) => {
163
176
  upstreamPath = basePath + req.url.replace(/^\/v1/, '')
164
177
  }
165
178
  const transport = upstream.protocol === 'https:' ? https : http
166
- const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'proxy-'])
179
+ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'proxy-', 'x-session-id'])
167
180
  const safeHeaders = {}
168
181
  for (const [key, value] of Object.entries(req.headers)) {
169
182
  const lowerKey = key.toLowerCase()
@@ -218,20 +231,26 @@ const server = http.createServer((req, res) => {
218
231
  const isOk = (proxyRes.statusCode || 500) < 400
219
232
  const isCompressed = !!proxyRes.headers['content-encoding']
220
233
 
221
- // Forward response headers unchanged; client must decode compressed bytes.
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).
222
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
+ }
223
243
  res.writeHead(proxyRes.statusCode, outHeaders)
224
244
 
225
245
  // Buffer ordinary JSON responses so reasoning can be cached too.
226
- if (shouldPatch && !isStream && isOk && !isCompressed) {
246
+ if (shouldCache && !isStream && isOk && !isCompressed) {
227
247
  const responseChunks = []
228
248
  proxyRes.on('data', (chunk) => responseChunks.push(chunk))
229
249
  proxyRes.on('end', () => {
230
250
  if (responseClosed) return
231
251
  const responseBody = Buffer.concat(responseChunks)
232
252
  const reasoning = extractReasoningFromJson(responseBody.toString('utf8'))
233
- if (reasoning) {
234
- getSessionCache(sessionId).set(assistantCount, reasoning)
253
+ if (reasoning && getSessionCache(sessionId).set(assistantCount, reasoning)) {
235
254
  stats.stored++
236
255
  writeLog({ event: 'cache_store', session: sessionId.slice(-8), turn: assistantCount, chars: reasoning.length })
237
256
  }
@@ -243,7 +262,7 @@ const server = http.createServer((req, res) => {
243
262
  return
244
263
  }
245
264
 
246
- if (!shouldPatch || !isStream || !isOk || isCompressed) {
265
+ if (!shouldCache || !isStream || !isOk || isCompressed) {
247
266
  proxyRes.pipe(res)
248
267
  proxyRes.on('error', closeResponse)
249
268
  proxyRes.on('aborted', closeResponse)
@@ -253,10 +272,11 @@ const server = http.createServer((req, res) => {
253
272
  // Reasoning stream: forward RAW bytes immediately, parse only for cache.
254
273
  const parser = createStreamParser(sessionId, assistantCount, (index, reasoning) => {
255
274
  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)`)
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
+ }
260
280
  })
261
281
 
262
282
  let parserDead = false
@@ -283,7 +303,7 @@ const server = http.createServer((req, res) => {
283
303
  })
284
304
 
285
305
  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 })
306
+ writeLog({ event: 'proxy_started', port: PORT, version: '3.2.0', upstream: UPSTREAM_URL || 'model-routing', pid: process.pid })
287
307
  if (UPSTREAM_URL) {
288
308
  console.log(`[Proxy] fixed-upstream proxy on http://127.0.0.1:${PORT} -> ${UPSTREAM_URL}`)
289
309
  } else {