opencode-thinking-fix 3.1.4 → 3.3.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,23 +10,65 @@
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, OpenCode Go, and OpenRouter.
14
14
  >
15
- > **Zero config.** Install via `Ctrl+P`, restart OpenCode, done. The plugin auto-detects reasoning models and only patches when needed.
15
+ > **Zero config.** Install via `Ctrl+P`, restart OpenCode, done. The plugin detects reasoning models and only patches when needed.
16
16
  >
17
17
  > Docs: [OpenCode Plugins](https://opencode.ai/docs/plugins)
18
18
 
19
- Your AI has a secret notebook.
19
+ ## The problem
20
20
 
21
- When DeepSeek or Kimi answers you, it scribbles notes first. "Let me think... the user wants a login page... I should use React Hook Form... check the API docs..." These notes are `reasoning_content`. You never see them. But the AI needs them.
21
+ When DeepSeek or Kimi answers you, it scribbles notes first. "Let me think... the user wants a login page... I should use React Hook Form... check the API docs..." These notes are `reasoning_content`. You never see them. But the AI needs them — the provider docs require the notes to be sent back on every later turn.
22
22
 
23
- The client and its SDK throw the notebook away. Next turn, the AI reaches for it — but the request already went to the API without it. The request carries no notes, or a blank notebook. The AI doesn't crash, but it forgot everything it was thinking. That is why your AI seems dumber on turn 2. It's not dumber. It just lost its notes.
23
+ The client and its SDK throw the notebook away. Next turn, the AI reaches for it, but the request already went out without it. The AI doesn't crash. It just forgot everything it was thinking. That is why your AI seems dumber on turn 2. It's not dumber. It lost its notes.
24
24
 
25
- The drop happens in SDK serializers and client transforms (truthy checks, missing type members, stripped signatures), not in the providers — the providers are doing exactly what their docs say.
25
+ The drop happens in SDK serializers and client transforms (truthy checks, missing type members, stripped signatures), not in the providers. The providers do exactly what their docs say.
26
26
 
27
- **Without the plugin:** "Build me a login page." → AI builds it. "Now add password reset." → AI lost its notes, conversation degrades.
27
+ **Without the fix:** "Build me a login page." → AI builds it. "Now add password reset." → notes gone, the answer comes back generic.
28
28
 
29
- **With the plugin:** "Build me a login page." → AI builds it, notes saved. "Now add password reset." → AI reads its notes: "I used React Hook Form for login, I'll extend that for password reset." → Works.
29
+ **With the fix:** "Now add password reset." → AI reads its notes: "I used React Hook Form for login, I'll extend that." → works.
30
+
31
+ ## See it happen in 5 minutes
32
+
33
+ You need OpenCode and any reasoning model. No proxy, no plugin — this reproduces the drop on a stock install.
34
+
35
+ 1. Start a session with a reasoning model (free tier works, e.g. `opencode/hy3-free`) and give it a real task. It thinks, answers, and emits `reasoning_content`.
36
+ 2. Ask a follow-up that depends on the first answer, then capture the outgoing request. Every `reasoning_content` field from turn 1 arrives empty or missing.
37
+ 3. The same request through the SDK alone keeps the field. The client is the difference.
38
+
39
+ Captured evidence for exactly this sequence: [`proof/free-models/`](proof/free-models/). The A/B controls that isolate the field as the only variable: [`proof/curl-control-results.txt`](proof/curl-control-results.txt).
40
+
41
+ ## Why you've probably never seen it
42
+
43
+ This bug is conditional, and the conditions are "you work the tool hard":
44
+
45
+ - **Short sessions never hit it.** The drop happens on turn 2+, worst after tool calls. A two-message chat looks fine.
46
+ - **Most gateways don't enforce the contract.** On the free `opencode/zen` tier and OpenRouter, a missing field is accepted, so nothing errors. The model just quietly loses context. No 400, no warning, only worse answers.
47
+ - **Non-reasoning models are unaffected.** GPT, Claude, Qwen, Gemini never emit the field.
48
+
49
+ If you run long sessions, heavy tool calls, model switching, or subagents, you have probably been hit and blamed the model. That is who this fix is for. If you send three messages a day, you may genuinely never notice — and the plugin detects that and does nothing.
50
+
51
+ ## The evidence
52
+
53
+ We ran four passive taps (DeepSeek, OpenCode Go, OpenCode Zen, OpenRouter) that forwarded traffic byte-for-byte untouched and logged only metadata. They ran for a week of real work, then were retired. Methodology and raw data: [`proof/` directory](proof/).
54
+
55
+ - **1,968 drops captured** — a turn that carried reasoning earlier in the same session arrives at the API without it.
56
+ - **2.29 million characters** of reasoning erased over a week of real traffic.
57
+ - **68% of drops happened with zero model change** — same model, same session. Not a model-switch artifact.
58
+ - **OpenRouter drops it too** — 22 drops with confirmed session identity on models served through openrouter.ai, plus the same pattern on DeepSeek and OpenCode Go lanes.
59
+ - **The SDK is innocent.** `@ai-sdk/openai-compatible` round-trips the field correctly; the drop happens in the client's own transform. Captured side by side in [`proof/free-models/`](proof/free-models/).
60
+
61
+ Every number is recomputable from [`proof/reasoning-drop/drops.csv`](proof/reasoning-drop/drops.csv) and [`proof/reasoning-drop/switches.csv`](proof/reasoning-drop/switches.csv).
62
+
63
+ ## For the skeptics
64
+
65
+ Three objections come up every time this is posted. Fair questions, with the evidence:
66
+
67
+ **"Works on my side."** Probably true. The drop is silent on most gateways — no 400, just worse answers, so nothing looks broken unless you compare turn-3 quality with and without. The A/B controls hold everything constant except the field. And the 68% stat rules out model switching as the explanation.
68
+
69
+ **"Your config is broken."** The configs this was captured on are published, redacted, in [`proof/` directory](proof/). The root cause is in the client's message transform, not config — the SDK control shows the field surviving when only the client is removed from the path.
70
+
71
+ **"The model cards already say to pass reasoning back."** They do. That is the point. The provider requires the field; the client drops it. The model card is the spec this repo implements.
30
72
 
31
73
  ---
32
74
 
@@ -72,7 +114,8 @@ causing cross-provider poisoning. v3.1 fixes that:
72
114
  filled all three fields, so a GLM turn stored under `reasoning` could poison a later
73
115
  DeepSeek replay that expects `reasoning_content`.
74
116
  - **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`.
117
+ (400 if you do) while V4 must. R1 uses the v3.2 `reasoningKey: 'strip'` sentinel,
118
+ which actively removes reasoning fields before forwarding.
76
119
  - **Never fabricate reasoning.** Unknown models and providers that reject echo
77
120
  (Qwen, GPT, Claude, Gemini, Llama, Mistral, Cerebras-hosted GLM) default to
78
121
  `reasoningKey: null` — the body is forwarded untouched.
@@ -89,9 +132,9 @@ The reasoning-drop is fixed at the wire layer by the proxy, so you have three op
89
132
 
90
133
  **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
134
 
92
- ## Plugin deprecation and derived sessions
135
+ ## Plugin optionality and derived sessions
93
136
 
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.
137
+ 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
138
 
96
139
  ---
97
140
 
@@ -124,7 +167,7 @@ opencode plugin opencode-thinking-fix
124
167
  For a specific version:
125
168
 
126
169
  ```bash
127
- opencode plugin opencode-thinking-fix@3.1.4
170
+ opencode plugin opencode-thinking-fix@3.3.0
128
171
  ```
129
172
 
130
173
  Restart OpenCode after installing.
@@ -149,13 +192,17 @@ See also: [OpenCode plugin docs](https://opencode.ai/docs/plugins)
149
192
 
150
193
  ---
151
194
 
152
- - [What problem this fixes](#what-problem-this-fixes)
195
+ - [The problem](#the-problem)
196
+ - [See it happen in 5 minutes](#see-it-happen-in-5-minutes)
197
+ - [Why you've probably never seen it](#why-youve-probably-never-seen-it)
198
+ - [The evidence](#the-evidence)
199
+ - [For the skeptics](#for-the-skeptics)
153
200
  - [Option 1: Plugin (safety net)](#option-1-plugin-safety-net)
154
201
  - [Option 2: Proxy (replays real reasoning)](#option-2-proxy-replays-real-reasoning)
155
202
  - [Option 3: Watchdog (auto-recovery)](#option-3-watchdog-auto-recovery)
156
203
  - [How they work together](#how-they-work-together)
157
204
  - [Affected models](#affected-models)
158
- - [Model routing](#model-routing)
205
+ - [Model routing](#model-routing-proxy-port-3457)
159
206
  - [Is it working?](#is-it-working)
160
207
  - [This bug is everywhere](#this-bug-is-everywhere)
161
208
  - [Files in this repo](#files-in-this-repo)
@@ -170,7 +217,7 @@ The client/SDK dropped the field before it reached the API — the providers are
170
217
 
171
218
  > **Deprecated:** the historical symptom of this drop — `HTTP 400: The reasoning_content in the thinking mode must be passed back to the API` — is **resolved** by this fix. This repo is about the drop itself, not the error it used to cause.
172
219
 
173
- DeepSeek V4 (and Kimi K2.7, GLM 5.x, MiMo V2.5) require that `reasoning_content` from every prior assistant turn gets included in subsequent API requests. The [docs](https://api-docs.deepseek.com/guides/thinking_mode) say it clearly: if you do not pass back `reasoning_content` correctly, the API rejects the request. All five providers confirm this in their official documentation:
220
+ DeepSeek V4 (and Kimi K2.7, GLM 5.x, MiMo V2.5) require that `reasoning_content` from every prior assistant turn gets included in subsequent API requests. The [docs](https://api-docs.deepseek.com/guides/thinking_mode) describe the contract, and the strictness varies by provider and endpoint: some reject the request, some ignore the field, and the conversation still loses its context either way. All five providers confirm the passback requirement in their official documentation:
174
221
 
175
222
  - **DeepSeek**: "[The reasoning_content will be ignored by the API](https://api-docs.deepseek.com/guides/thinking_mode)", but the conversation history must contain the field.
176
223
  - **Z.AI / GLM**: "[Key: return reasoning_content to keep the reasoning coherent](https://docs.z.ai/en/api/thinking)."
@@ -348,6 +395,7 @@ The plugin is the safety net. If the proxy goes down, the plugin still injects e
348
395
  | MiMo V2.5 | Yes | Nice to have | `reasoning_content` on `api.xiaomimimo.com/v1` |
349
396
  | **MiniMax-M3** | Yes | **Recommended** | `reasoning_details[]` array; ~40% quality loss if stripped. Proxy injects `reasoning_split:true` to keep thinking separate from content. |
350
397
  | OpenCode Go | Yes | Recommended for chat/message routes | Chat uses `reasoning`; Anthropic messages are handled; unsupported response formats pass through |
398
+ | OpenRouter (reasoning models via openrouter.ai) | Yes | Recommended | Field depends on the upstream model; the fixed-upstream proxy replays `reasoning_content` and passes everything else through |
351
399
  | Qwen, GPT, Claude, Gemini, Llama, Mistral | No | No | No reasoning_content |
352
400
 
353
401
  ---
@@ -359,7 +407,7 @@ The proxy auto-routes by model name prefix. All routes:
359
407
  | Prefix | Upstream | Reasoning |
360
408
  |---|---|---|
361
409
  | `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) |
410
+ | `deepseek-reasoner` | `https://api.deepseek.com` | **No — actively stripped** (R1 contract: reasoning must NOT be echoed) |
363
411
  | `kimi`, `moonshot` | `https://api.moonshot.ai/v1` | Yes (`reasoning_content`) |
364
412
  | `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes (`reasoning_content`) |
365
413
  | `minimax` | `https://api.minimax.io/v1` | Yes (`reasoning_details`, `reasoning_split:true`) |
@@ -373,15 +421,37 @@ The proxy auto-routes by model name prefix. All routes:
373
421
 
374
422
  Unknown models fall back to `https://api.deepseek.com` with reasoning disabled.
375
423
 
424
+ ### OpenRouter (port 3462)
425
+
426
+ OpenRouter wraps many upstream models, and reasoning models served through it lose their thinking the same way. Run a third fixed-upstream proxy and point your OpenRouter provider at it:
427
+
428
+ ```bash
429
+ PORT=3462 UPSTREAM_URL=https://openrouter.ai/api/v1 REASONING_KEY=reasoning_content node proxy/proxy.js
430
+ ```
431
+
432
+ ```json
433
+ {
434
+ "provider": {
435
+ "openrouter": {
436
+ "options": {
437
+ "baseURL": "http://127.0.0.1:3462/v1"
438
+ }
439
+ }
440
+ }
441
+ }
442
+ ```
443
+
444
+ The passive-tap corpus includes an OpenRouter lane: 22 session-confirmed drops, concentrated on `minimax/minimax-m3:free` within one long session — enough to show the pattern exists on OpenRouter, with the full model-by-model matrix left to a follow-up corpus. See [The evidence](#the-evidence).
445
+
376
446
  ---
377
447
 
378
448
  ## Is it working?
379
449
 
380
450
  The plugin writes a structured JSON log: `~/.local/share/opencode/thinking-fix.log`.
381
451
 
382
- 628 unique sessions. 12,551 inspect events (the hook fires twice per message by design — first pass patches, second confirms clean). 297 reasoning model sessions patched, 331 non-reasoning correctly skipped. Zero false patches.
452
+ Every request logs an `inspect` event (field coverage, whether the model is a reasoning model, how many turns were patched), so the plugin is auditable even when it patches nothing. Non-reasoning models are skipped by design — zero false patches is a property of the self-detection, not a promise bolted on.
383
453
 
384
- Before and after, from a real session:
454
+ From a real 104-message session:
385
455
 
386
456
  ```
387
457
  Before: reasoning dropped on every turn → AI forgets its thinking
@@ -443,20 +513,26 @@ OpenCode is not the only tool that drops `reasoning_content`. Here is a partial
443
513
 
444
514
  ```
445
515
  plugins/
446
- opencode-thinking-fix-universal.ts # self-detection plugin (92 lines)
516
+ opencode-thinking-fix-universal.ts # self-detection plugin
447
517
  proxy/
448
- core.js # pure logic: routes, cache, patching, parser (138 lines)
449
- proxy.js # HTTP server + side effects (266 lines, 1 dep)
518
+ core.js # pure logic: routes, cache, patching, parser
519
+ proxy.js # HTTP server + side effects (1 dep)
450
520
  watchdog/
451
- watchdog.sh # auto-recovery watchdog (64 lines)
521
+ watchdog.sh # auto-recovery watchdog
452
522
  systemd/
453
523
  reasoning-cache.service # proxy systemd unit (port 3457)
454
524
  reasoning-cache-go.service # OpenCode Go proxy unit (port 3458)
455
525
  reasoning-proxy-watchdog.service # watchdog systemd unit
526
+ tests/
527
+ test-proxy.js # 127 proxy assertions
528
+ test-plugin.js # 12 plugin assertions
529
+ live-minimax-replay.mjs # self-contained replay gate (mock upstream)
530
+ proof/ # evidence: captures, controls, CSVs, repro scripts
456
531
  ```
457
532
 
458
533
  ## Release highlights
459
534
 
535
+ - **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 (live-verified across two turns), derived session keys, and `x-session-id` no longer forwarded upstream. See the changelog.
460
536
  - **v3.1.4:** proxy-owned structured JSONL logging with session identifiers truncated in logs.
461
537
  - **v3.1.3:** Anthropic-wire dialect detection for OpenCode Go routes.
462
538
  - **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.3.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,29 @@ 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
+ // Zen free-tier lane — BARE model ids as sent by the `opencode` provider
43
+ // section (port 3459, universal mode). MUST stay at the top: generic
44
+ // prefixes below ('deepseek-v4-flash', 'mimo', ...) would otherwise swallow
45
+ // these names and route free traffic to provider-native endpoints.
46
+ 'x-preview-f-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
47
+ 'hy3-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
48
+ 'mimo-v2.5-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
49
+ 'muse-spark-1.2-contributor-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
50
+ 'deepseek-v4-flash-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
51
+ 'ling-3.0-flash-fin-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
52
+ 'laguna-s-2.1-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
53
+ 'big-pickle': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
54
+ 'ox-alpha-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
55
+ 'nemotron-3-ultra-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
56
+ 'nemotron-3.5-lightning-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
57
+ // R1 aliases: reasoning must not be echoed back — the 'strip' sentinel
58
+ // admits the request to patchRequestBody (to REMOVE any reasoning fields a
59
+ // client/plugin attached) while excluding it from response-side caching.
60
+ 'deepseek-r1': { base: 'https://api.deepseek.com', reasoningKey: 'strip' },
43
61
  'deepseek-v4-pro': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
44
62
  'deepseek-v4-flash': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
45
63
  'deepseek-chat': { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
46
- 'deepseek-reasoner': { base: 'https://api.deepseek.com', reasoningKey: null },
64
+ 'deepseek-reasoner': { base: 'https://api.deepseek.com', reasoningKey: 'strip' },
47
65
  deepseek: { base: 'https://api.deepseek.com', reasoningKey: 'reasoning_content' },
48
66
  kimi: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
49
67
  moonshot: { base: 'https://api.moonshot.ai/v1', reasoningKey: 'reasoning_content' },
@@ -59,10 +77,30 @@ export const ROUTES = {
59
77
  gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', reasoningKey: null },
60
78
  llama: { base: 'https://api.together.xyz', reasoningKey: null },
61
79
  mistral: { base: 'https://api.mistral.ai', reasoningKey: null },
80
+ // opencode/ zen free-tier models — served by the OpenCode zen gateway at
81
+ // zen/v1 (chat/completions wire, reasoning_content carrier). Distinct from
82
+ // the opencode-go namespace (port 3458) handled by fixedUpstreamRoute.
83
+ // Explicit per-model prefixes only: no catch-all `opencode/` prefix, so paid
84
+ // opencode/* models keep their existing DEFAULT_ROUTE behavior.
85
+ 'opencode/hy3-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
86
+ 'opencode/mimo-v2.5-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
87
+ 'opencode/muse-spark-1.2-contributor-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
88
+ 'opencode/deepseek-v4-flash-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
89
+ 'opencode/nemotron-3-ultra-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
90
+ 'opencode/nemotron-3.5-lightning-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
62
91
  }
63
92
 
64
93
  export const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoningKey: null }
65
94
 
95
+ // F13 (2026-08-27): clients may speak either OpenAI-style relative paths
96
+ // (/v1/...) or the upstream's full official prefix (/api/v1/... — OpenRouter's
97
+ // documented base is https://openrouter.ai/api/v1). Never double the prefix.
98
+ export function upstreamPathFor(reqUrl, basePath) {
99
+ if (!basePath || basePath === '/' || basePath === '/v1') return reqUrl
100
+ if (reqUrl === basePath || reqUrl.startsWith(basePath + '/')) return reqUrl
101
+ return basePath + reqUrl.replace(/^\/v1/, '')
102
+ }
103
+
66
104
  export function route(modelName) {
67
105
  if (!modelName) return DEFAULT_ROUTE
68
106
  const lower = modelName.toLowerCase()
@@ -76,10 +114,27 @@ export function route(modelName) {
76
114
  const ANTHROPIC_WIRE_PREFIXES = ['minimax', 'qwen']
77
115
 
78
116
  export function fixedUpstreamRoute(modelName, requestUrl = '') {
79
- const stripped = (modelName || '').toLowerCase().replace(/^opencode-go\//, '')
117
+ const stripped = (modelName || '').toLowerCase().replace(/^opencode-go(?:-(?:messages|responses))?\//, '')
80
118
  if (String(requestUrl).includes('/messages') && ANTHROPIC_WIRE_PREFIXES.some((p) => stripped.startsWith(p))) {
81
119
  return { base: '', reasoningKey: 'anthropic' }
82
120
  }
121
+ // F11 (validated 2026-08-19): the opencode-go gateway rejects the `reasoning`
122
+ // field on glm-5.2 chat/completions echo (400 "Extra inputs are not
123
+ // permitted") but accepts `reasoning_content`. glm-5.2 was live-validated
124
+ // two-turn; other Go models were NOT changed by F11 and keep the existing
125
+ // `reasoning` behavior below (no exhaustive per-model validation claimed).
126
+ if (stripped === 'glm-5.2' && String(requestUrl).includes('/chat/completions')) {
127
+ return { base: '', reasoningKey: 'reasoning_content' }
128
+ }
129
+ // REASONING_KEY env override (F12, 2026-08-27): lanes fixed to upstreams whose
130
+ // wire is reasoning_content-based (zen/v1 free models, OpenRouter normalized
131
+ // per-message passback) set REASONING_KEY=reasoning_content in their unit.
132
+ // OpenRouter docs define top-level `reasoning` as a config OBJECT on requests,
133
+ // so a string injection there must use reasoning_content instead. Units without
134
+ // the env (validated go lane, 3458) keep the unconditional 'reasoning' behavior.
135
+ if (process.env.REASONING_KEY) {
136
+ return { base: '', reasoningKey: process.env.REASONING_KEY }
137
+ }
83
138
  // Unconditional 'reasoning' for go mode: matches current working behavior
84
139
  // for chat/completions models; /responses bodies have no `messages` array
85
140
  // and pass through untouched.
@@ -113,11 +168,28 @@ const MAX_TURNS_PER_SESSION = Number.isFinite(parsedMaxTurns) && parsedMaxTurns
113
168
  const parsedMaxReasoning = parseInt(process.env.MAX_REASONING_CHARS || '200000', 10)
114
169
  const MAX_REASONING_CHARS = Number.isFinite(parsedMaxReasoning) && parsedMaxReasoning > 0 ? parsedMaxReasoning : 200000
115
170
 
171
+ // MiniMax reasoning_details replay shape.
172
+ //
173
+ // NOTE (F6, 2026-08-19): in our opencode-go deployment (port 3458, Anthropic
174
+ // /v1/messages wire) every MiniMax request resolves to reasoningKey 'anthropic'
175
+ // via fixedUpstreamRoute(), so this constant and the reasoning_details branch
176
+ // in patchRequestBody() are UNUSED in production. Retained defensively for the
177
+ // port-3457 universal-mode / provider-native MiniMax fallback (ROUTES.minimax).
178
+ //
179
+ // 'full' emits the openai-responses-v1 item { type, format, index, text };
180
+ // 'minimal' emits { type, text }. Default 'minimal' (smallest documented
181
+ // outbound shape).
182
+ const MINIMAX_REASONING_DETAILS_SHAPE = process.env.MINIMAX_REASONING_DETAILS_SHAPE === 'full' ? 'full' : 'minimal'
183
+
116
184
  class BoundedSessionMap extends Map {
117
185
  set(key, value) {
118
- super.set(key, typeof value === 'string' ? value.slice(0, MAX_REASONING_CHARS) : value)
186
+ // Skip-not-truncate: an oversized reasoning value is dropped entirely
187
+ // rather than silently truncated (a truncated replay can be worse than a
188
+ // cache miss). Returns true when stored, false when skipped.
189
+ if (typeof value === 'string' && value.length > MAX_REASONING_CHARS) return false
190
+ super.set(key, value)
119
191
  while (this.size > MAX_TURNS_PER_SESSION) this.delete(this.keys().next().value)
120
- return this
192
+ return true
121
193
  }
122
194
  }
123
195
 
@@ -164,14 +236,37 @@ export function patchRequestBody(body, sessionId, reasoningKey) {
164
236
  if (missingReasoning) { stats.misses++; report.misses++ }
165
237
  }
166
238
  } else if (reasoningKey === 'reasoning_details') {
239
+ // F6 (defensive): provider-native MiniMax on port 3457 universal mode
240
+ // replays reasoning into `reasoning_details`. Never executed in the
241
+ // opencode-go deployment (MiniMax always routes as 'anthropic' there) —
242
+ // retained for the universal-mode / provider-native fallback.
167
243
  const hasDetails = Array.isArray(msg.reasoning_details) ? msg.reasoning_details.length > 0 : !!msg.reasoning_details
168
244
  missingReasoning = !hasDetails && !msg.reasoning_content
169
245
  if (missingReasoning) report.missingReasoning++
170
246
  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
247
+ msg.reasoning_details = MINIMAX_REASONING_DETAILS_SHAPE === 'full'
248
+ ? [{ type: 'reasoning.text', format: 'openai-responses-v1', index: assistantIndex, text: cached }]
249
+ : [{ type: 'reasoning.text', text: cached }]
250
+ stats.hits++; report.hits++; report.turns.push({ index: assistantIndex, fields: ['reasoning_details'], source: 'hit' }); modified = true
172
251
  } else if (missingReasoning) {
173
252
  stats.misses++; report.misses++
174
253
  }
254
+ } else if (reasoningKey === 'strip') {
255
+ // R1 sentinel: deepseek-r1 / deepseek-reasoner 400 when reasoning is
256
+ // echoed back. Actively remove any reasoning field a client/plugin may
257
+ // have attached rather than replay it.
258
+ const stripped = []
259
+ if (msg.reasoning_content !== undefined) { delete msg.reasoning_content; stripped.push('reasoning_content') }
260
+ if (msg.reasoning !== undefined) { delete msg.reasoning; stripped.push('reasoning') }
261
+ if (msg.reasoning_details !== undefined) { delete msg.reasoning_details; stripped.push('reasoning_details') }
262
+ if (Array.isArray(msg.content)) {
263
+ const kept = msg.content.filter((b) => !(b && (b.type === 'thinking' || b.type === 'redacted_thinking')))
264
+ if (kept.length !== msg.content.length) { msg.content = kept; stripped.push('content') }
265
+ }
266
+ if (stripped.length > 0) {
267
+ modified = true
268
+ report.turns.push({ index: assistantIndex, fields: stripped, source: 'strip' })
269
+ }
175
270
  } else if (!msg[reasoningKey] || msg[reasoningKey] === '') {
176
271
  missingReasoning = true
177
272
  report.missingReasoning++
@@ -192,7 +287,10 @@ export function extractReasoningFromJson(text) {
192
287
  // Anthropic buffered format: content[] with thinking blocks.
193
288
  if (Array.isArray(parsed?.content)) {
194
289
  const thinking = parsed.content
195
- .filter((b) => b && b.type === 'thinking' && typeof b.thinking === 'string')
290
+ // Explicit redacted_thinking guard (redundant with the
291
+ // b.type === 'thinking' equality below, but kept for contract
292
+ // clarity: redacted blocks must never be replayed as reasoning).
293
+ .filter((b) => b && b.type === 'thinking' && b.type !== 'redacted_thinking' && typeof b.thinking === 'string')
196
294
  .map((b) => b.thinking)
197
295
  .join('\n')
198
296
  if (thinking) return thinking
@@ -213,6 +311,9 @@ export function extractReasoningFromJson(text) {
213
311
 
214
312
  export function createStreamParser(sessionId, baseIndex, onComplete) {
215
313
  let reasoningBuffer = ''
314
+ // StringDecoder buffers partial multibyte UTF-8 sequences across feed()
315
+ // calls, so a character split between two chunks is not corrupted.
316
+ const decoder = new StringDecoder('utf8')
216
317
  const parser = createParser({
217
318
  maxBufferSize: 1024 * 1024,
218
319
  onEvent(event) {
@@ -249,7 +350,11 @@ export function createStreamParser(sessionId, baseIndex, onComplete) {
249
350
  onError(error) { if (DEBUG) console.error(`[Proxy] SSE parse error (${sessionId.slice(-8)}):`, error.message) },
250
351
  })
251
352
  return {
252
- feed(chunk) { parser.feed(chunk.toString('utf8')) },
253
- flush() { if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' } },
353
+ feed(chunk) { parser.feed(decoder.write(chunk)) },
354
+ flush() {
355
+ const tail = decoder.end()
356
+ if (tail) parser.feed(tail)
357
+ if (reasoningBuffer) { onComplete(baseIndex, reasoningBuffer); reasoningBuffer = '' }
358
+ },
254
359
  }
255
360
  }
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)
@@ -52,7 +58,7 @@ import https from 'node:https'
52
58
  import url from 'node:url'
53
59
  import {
54
60
  ROUTES, cache, stats, route, fixedUpstreamRoute, getSessionCache, patchRequestBody,
55
- extractReasoningFromJson, createStreamParser, writeLog, deriveSessionId,
61
+ extractReasoningFromJson, createStreamParser, writeLog, deriveSessionId, upstreamPathFor,
56
62
  } from './core.js'
57
63
 
58
64
  const PORT = parseInt(process.env.PORT || '3457', 10)
@@ -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')
@@ -157,13 +170,11 @@ const server = http.createServer((req, res) => {
157
170
  }
158
171
 
159
172
  // ── Upstream request options ──
160
- let upstreamPath = req.url
161
- const basePath = upstream.path || '/'
162
- if (basePath !== '/' && basePath !== '/v1') {
163
- upstreamPath = basePath + req.url.replace(/^\/v1/, '')
164
- }
173
+ // F13: path normalization lives in core.js (upstreamPathFor) — never double
174
+ // the upstream's official prefix.
175
+ const upstreamPath = upstreamPathFor(req.url, upstream.path || '/')
165
176
  const transport = upstream.protocol === 'https:' ? https : http
166
- const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'proxy-'])
177
+ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'proxy-', 'x-session-id'])
167
178
  const safeHeaders = {}
168
179
  for (const [key, value] of Object.entries(req.headers)) {
169
180
  const lowerKey = key.toLowerCase()
@@ -218,20 +229,26 @@ const server = http.createServer((req, res) => {
218
229
  const isOk = (proxyRes.statusCode || 500) < 400
219
230
  const isCompressed = !!proxyRes.headers['content-encoding']
220
231
 
221
- // Forward response headers unchanged; client must decode compressed bytes.
232
+ // Forward response headers, stripping hop-by-hop headers first (F2).
233
+ // Node manages transfer-encoding/content-length on the client leg, so
234
+ // forwarding the upstream's chunking/connection headers would corrupt
235
+ // the re-streamed response. This single site covers all three response
236
+ // paths below (buffered JSON, passthrough pipe, reasoning stream).
222
237
  const outHeaders = { ...proxyRes.headers }
238
+ for (const h of ['transfer-encoding', 'connection', 'keep-alive', 'te', 'trailer', 'upgrade', 'proxy-authenticate', 'proxy-authorization']) {
239
+ delete outHeaders[h]
240
+ }
223
241
  res.writeHead(proxyRes.statusCode, outHeaders)
224
242
 
225
243
  // Buffer ordinary JSON responses so reasoning can be cached too.
226
- if (shouldPatch && !isStream && isOk && !isCompressed) {
244
+ if (shouldCache && !isStream && isOk && !isCompressed) {
227
245
  const responseChunks = []
228
246
  proxyRes.on('data', (chunk) => responseChunks.push(chunk))
229
247
  proxyRes.on('end', () => {
230
248
  if (responseClosed) return
231
249
  const responseBody = Buffer.concat(responseChunks)
232
250
  const reasoning = extractReasoningFromJson(responseBody.toString('utf8'))
233
- if (reasoning) {
234
- getSessionCache(sessionId).set(assistantCount, reasoning)
251
+ if (reasoning && getSessionCache(sessionId).set(assistantCount, reasoning)) {
235
252
  stats.stored++
236
253
  writeLog({ event: 'cache_store', session: sessionId.slice(-8), turn: assistantCount, chars: reasoning.length })
237
254
  }
@@ -243,7 +260,7 @@ const server = http.createServer((req, res) => {
243
260
  return
244
261
  }
245
262
 
246
- if (!shouldPatch || !isStream || !isOk || isCompressed) {
263
+ if (!shouldCache || !isStream || !isOk || isCompressed) {
247
264
  proxyRes.pipe(res)
248
265
  proxyRes.on('error', closeResponse)
249
266
  proxyRes.on('aborted', closeResponse)
@@ -253,10 +270,11 @@ const server = http.createServer((req, res) => {
253
270
  // Reasoning stream: forward RAW bytes immediately, parse only for cache.
254
271
  const parser = createStreamParser(sessionId, assistantCount, (index, reasoning) => {
255
272
  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)`)
273
+ if (sc.set(index, reasoning)) {
274
+ stats.stored++
275
+ writeLog({ event: 'cache_store', session: sessionId.slice(-8), turn: index, chars: reasoning.length })
276
+ if (DEBUG) console.log(`[Cache] ${sessionId.slice(-8)}: stored turn ${index} (${reasoning.length} chars)`)
277
+ }
260
278
  })
261
279
 
262
280
  let parserDead = false
@@ -283,7 +301,7 @@ const server = http.createServer((req, res) => {
283
301
  })
284
302
 
285
303
  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 })
304
+ writeLog({ event: 'proxy_started', port: PORT, version: '3.2.0', upstream: UPSTREAM_URL || 'model-routing', pid: process.pid })
287
305
  if (UPSTREAM_URL) {
288
306
  console.log(`[Proxy] fixed-upstream proxy on http://127.0.0.1:${PORT} -> ${UPSTREAM_URL}`)
289
307
  } else {