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 +98 -22
- package/package.json +6 -5
- package/proxy/core.js +115 -10
- package/proxy/proxy.js +38 -20
package/README.md
CHANGED
|
@@ -10,23 +10,65 @@
|
|
|
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, OpenCode Go, and OpenRouter.
|
|
14
14
|
>
|
|
15
|
-
> **Zero config.** Install via `Ctrl+P`, restart OpenCode, done. The plugin
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
135
|
+
## Plugin optionality and derived sessions
|
|
93
136
|
|
|
94
|
-
As of 3.0, plugin is
|
|
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.
|
|
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
|
-
- [
|
|
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)
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
516
|
+
opencode-thinking-fix-universal.ts # self-detection plugin
|
|
447
517
|
proxy/
|
|
448
|
-
core.js # pure logic: routes, cache, patching, parser
|
|
449
|
-
proxy.js # HTTP server + side effects (
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
-
//
|
|
42
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
253
|
-
flush() {
|
|
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.
|
|
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
|
-
|
|
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')
|
|
@@ -157,13 +170,11 @@ const server = http.createServer((req, res) => {
|
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
// ── Upstream request options ──
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
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 {
|