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 +12 -10
- package/package.json +6 -5
- package/proxy/core.js +71 -10
- package/proxy/proxy.js +34 -14
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
npm install opencode-thinking-fix
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
>
|
|
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
|
|
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
|
|
93
|
+
## Plugin optionality and derived sessions
|
|
93
94
|
|
|
94
|
-
As of 3.0, plugin is
|
|
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.
|
|
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 —
|
|
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
|
|
447
|
+
opencode-thinking-fix-universal.ts # self-detection plugin
|
|
447
448
|
proxy/
|
|
448
|
-
core.js # pure logic: routes, cache, patching, parser
|
|
449
|
-
proxy.js # HTTP server + side effects (
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
253
|
-
flush() {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
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 {
|