opencode-thinking-fix 3.3.4 → 4.0.1
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 +128 -61
- package/package.json +8 -9
- package/plugins/opencode-thinking-fix-universal.ts +164 -40
- package/proxy/core.js +93 -0
- package/proxy/proxy.js +16 -0
package/README.md
CHANGED
|
@@ -12,11 +12,20 @@ npm install opencode-thinking-fix
|
|
|
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
|
-
> **
|
|
15
|
+
> **Start with the plugin.** It is a zero-config seatbelt that stops the 400 error. Add the proxy when you need the model to remember what it was thinking. [Choose your setup](#choose-your-setup).
|
|
16
16
|
>
|
|
17
17
|
> Docs: [OpenCode Plugins](https://opencode.ai/docs/plugins)
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Table of Contents
|
|
20
|
+
|
|
21
|
+
- [The Problem](#the-problem)
|
|
22
|
+
- [The Evidence/Proof](#the-evidence--proof)
|
|
23
|
+
- [Quick Install](#quick-install)
|
|
24
|
+
- [Choose Your Setup](#choose-your-setup)
|
|
25
|
+
- [Affected Models](#affected-models)
|
|
26
|
+
- [Troubleshooting](#troubleshooting)
|
|
27
|
+
|
|
28
|
+
## The Problem
|
|
20
29
|
|
|
21
30
|
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
31
|
|
|
@@ -24,10 +33,25 @@ The client and its SDK throw the notebook away. Next turn, the AI reaches for it
|
|
|
24
33
|
|
|
25
34
|
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
35
|
|
|
36
|
+
<!-- TODO for Developer: Insert a terminal screenshot or GIF here showing the 400 error on the left and a successful turn-2 on the right -->
|
|
37
|
+
|
|
27
38
|
**Without the fix:** "Build me a login page." → AI builds it. "Now add password reset." → notes gone, the answer comes back generic.
|
|
28
39
|
|
|
29
40
|
**With the fix:** "Now add password reset." → AI reads its notes: "I used React Hook Form for login, I'll extend that." → works.
|
|
30
41
|
|
|
42
|
+
> **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.
|
|
43
|
+
|
|
44
|
+
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:
|
|
45
|
+
|
|
46
|
+
- **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.
|
|
47
|
+
- **Z.AI / GLM**: "[Key: return reasoning_content to keep the reasoning coherent](https://docs.z.ai/en/api/thinking)."
|
|
48
|
+
- **Kimi / Moonshot**: "[You must keep the reasoning_content in the multi-round conversation... otherwise an error will be thrown](https://platform.moonshot.ai/docs/guide/thinking-mode)."
|
|
49
|
+
- **MiniMax**: "[The complete model response must be append to the conversation history](https://platform.minimaxi.com/document/ChatCompletion%20v2)."
|
|
50
|
+
- **Xiaomi MiMo**: "[Any assistant message with tool calls... must preserve its full reasoning_content field, otherwise the API will return a 400 error](https://dev.mi.com/doc/llm-api). Affected frameworks include TRAE, Cursor, Roo Code, Codex, GitHub Copilot CLI, Zed, AutoGen."
|
|
51
|
+
|
|
52
|
+
The field is non-standard per OpenAI, so SDKs and clients ignore it — the drop is
|
|
53
|
+
systemic, not a single tool's bug. This repo fixes it. Three layers, pick what you need.
|
|
54
|
+
|
|
31
55
|
## See it happen
|
|
32
56
|
|
|
33
57
|
You don't need a specific free-tier model. Use any reasoning model on your own API key (DeepSeek V4, Kimi K2.7, GLM-5, MiMo) or any reasoning-capable model from the routing table below.
|
|
@@ -60,7 +84,7 @@ This bug is conditional, and the conditions are "you work the tool hard":
|
|
|
60
84
|
|
|
61
85
|
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.
|
|
62
86
|
|
|
63
|
-
## The
|
|
87
|
+
## The Evidence / Proof
|
|
64
88
|
|
|
65
89
|
The numbers above come from 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/).
|
|
66
90
|
|
|
@@ -125,6 +149,17 @@ causing cross-provider poisoning. v3.1 fixes that:
|
|
|
125
149
|
(Qwen, GPT, Claude, Gemini, Llama, Mistral, Cerebras-hosted GLM) default to
|
|
126
150
|
`reasoningKey: null` — the body is forwarded untouched.
|
|
127
151
|
|
|
152
|
+
## v3.4 — Z.ai Preserved Thinking (F12, intel-driven)
|
|
153
|
+
|
|
154
|
+
The proxy injects the field, but some providers strip it *server-side* before it reaches the model. Z.ai is the most common case: the standard Chat Completions endpoint defaults `thinking.clear_thinking` to `true`, so prior `reasoning_content` is removed from context even when the client passes it back correctly. The result is a GLM session that re-derives from scratch every turn, loops, and burns tokens — with the proxy fully active.
|
|
155
|
+
|
|
156
|
+
v3.4 fixes this on every Z.ai-reachable lane:
|
|
157
|
+
|
|
158
|
+
- **What is injected.** `thinking.clear_thinking: false` on the request body, which opts the session into Z.ai Preserved Thinking. The field is idempotently merged with any client-supplied `thinking` (e.g. `thinking.type: "enabled"`), so client choices are preserved.
|
|
159
|
+
- **Where it fires.** Any lane that ends at a Z.ai-reachable host: port 3457 universal (BigModel direct), port 3458 (OpenCode Go gateway), port 3462 (OpenRouter → Z.ai under the hood). The host allowlist is conservative — unknown upstreams are skipped.
|
|
160
|
+
- **What is skipped.** The injection is skipped when the client explicitly disabled thinking (`thinking: false`) — forcing preserved thinking on top of disabled thinking would be incoherent. The Coding Plan endpoint already defaults `clear_thinking: false`, so the merge is a no-op there.
|
|
161
|
+
- **Source of truth.** [docs.z.ai/guides/capabilities/thinking-mode](https://docs.z.ai/guides/capabilities/thinking-mode) (Preserved Thinking section) and the [chat completion API reference](https://docs.z.ai/api-reference/llm/chat-completion) (the `thinking.clear_thinking` parameter, type `boolean`, default `true`).
|
|
162
|
+
|
|
128
163
|
### "Another way" — do you even need the plugin now?
|
|
129
164
|
|
|
130
165
|
The reasoning-drop is fixed at the wire layer by the proxy, so you have three options:
|
|
@@ -137,6 +172,22 @@ The reasoning-drop is fixed at the wire layer by the proxy, so you have three op
|
|
|
137
172
|
|
|
138
173
|
**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.
|
|
139
174
|
|
|
175
|
+
## v3.4.1 — InclusionAI Ling 3.0 Flash route (F19)
|
|
176
|
+
|
|
177
|
+
Three new entries in the port 3457 model-routing table cover InclusionAI's flagship flash reasoning model:
|
|
178
|
+
|
|
179
|
+
| Model id | Upstream | Why this route |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `inclusionai/ling-3.0-flash` (paid) | `https://openrouter.ai/api/v1` | Primary distribution; OR exposes `delta.reasoning` + `delta.reasoning_details` on the response side and normalizes `reasoning_content` on the input side |
|
|
182
|
+
| `inclusionai/ling-3.0-flash:free` (free tier) | `https://openrouter.ai/api/v1` | Same model on the same upstream; rate-limited per upstream (Novita/DeepInfra) but otherwise identical |
|
|
183
|
+
| `opencode/ling-3.0-flash-fin-free` | `https://opencode.ai/zen/v1` | OpenCode Zen free-tier lane, same carrier as the other Zen free models; previously fell through to the DeepSeek default and 400'd |
|
|
184
|
+
|
|
185
|
+
No code change to the proxy logic itself — only the route table. Reasoning replay works the same way as for the other Chinese reasoning models: the SSE parser reads `delta.reasoning` (and the array form from `delta.reasoning_details`), the proxy caches it keyed by `x-session-id` + assistant index, and the next turn echoes it back as `reasoning_content` (which OpenRouter normalizes on outbound to the upstream).
|
|
186
|
+
|
|
187
|
+
Live-verified with a two-turn reasoning-continuity test on port 3457. Turn 1 emitted 271 chars of reasoning on a 7×6 step-by-step; turn 2 (with the cached `reasoning_content` echoed back) referenced the prior step ("The user previously asked for 7 times 6, and I was in the process of showing step-by-step work. The result of 7 × 6 is 42.") and continued to 42+5=47. Upstream was Novita.
|
|
188
|
+
|
|
189
|
+
Sources: [openrouter.ai/inclusionai/ling-3.0-flash](https://openrouter.ai/inclusionai/ling-3.0-flash) (model page), [ant-ling.com/en/docs/api-reference/openai/](https://ant-ling.com/en/docs/api-reference/openai/) (native ant-ling OpenAI-compatible API), [huggingface.co/inclusionAI/Ling-3.0-flash](https://huggingface.co/inclusionAI/Ling-3.0-flash) (model card), [github.com/inclusionAI](https://github.com/inclusionAI) (org), [arxiv.org/abs/2606.15079](https://arxiv.org/abs/2606.15079) (Ling/Ring 2.6 technical report).
|
|
190
|
+
|
|
140
191
|
## Plugin optionality and derived sessions
|
|
141
192
|
|
|
142
193
|
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.
|
|
@@ -145,7 +196,23 @@ As of 3.2.0, the plugin is optional. The shipped npm plugin still contains the t
|
|
|
145
196
|
|
|
146
197
|
## Quick Install
|
|
147
198
|
|
|
148
|
-
|
|
199
|
+
### Choose Your Setup
|
|
200
|
+
|
|
201
|
+
| If you want to... | What you need to install | Go to section |
|
|
202
|
+
| :--- | :--- | :--- |
|
|
203
|
+
| Just stop the 400 error | Plugin only (The Seatbelt) | [Quick Install: Plugin](#quick-install-plugin) |
|
|
204
|
+
| Make the AI remember its thinking | Plugin + Proxy (The Real Fix) | [Full Setup: Proxy](#full-setup-proxy) |
|
|
205
|
+
| Run this automatically in background | Add the Watchdog | [Auto-Recovery](#auto-recovery) |
|
|
206
|
+
|
|
207
|
+
### Quick Install: Plugin
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npm install opencode-thinking-fix
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Then add `"plugin": ["opencode-thinking-fix"]` to `opencode.json` and restart OpenCode.
|
|
214
|
+
|
|
215
|
+
> **The plugin is a seatbelt, not the real fix.** It keeps the field present so requests don't 400, but it does not replay the model's actual prior reasoning. To make the model remember its earlier thinking, see [Full Setup: Proxy](#full-setup-proxy). For the trade-offs, see [Plugin details](#plugin-details-the-seatbelt).
|
|
149
216
|
|
|
150
217
|
| Method | Command | Best for |
|
|
151
218
|
|--------|---------|----------|
|
|
@@ -153,11 +220,11 @@ As of 3.2.0, the plugin is optional. The shipped npm plugin still contains the t
|
|
|
153
220
|
| **CLI** | `opencode plugin opencode-thinking-fix` | Scripting |
|
|
154
221
|
| **Manual** | Add `"plugin": ["opencode-thinking-fix"]` to `opencode.json` | Version pinning |
|
|
155
222
|
|
|
156
|
-
|
|
223
|
+
#### Already installed? Update
|
|
157
224
|
|
|
158
225
|
Versions before 3.3 do not cover OpenRouter or the OpenCode Zen free-tier lane. Update inside OpenCode (`Ctrl+P` → install plugin → `opencode-thinking-fix`) or with `opencode plugin opencode-thinking-fix`, then restart. The plugin self-detects reasoning models, so updating is safe on non-reasoning setups too.
|
|
159
226
|
|
|
160
|
-
|
|
227
|
+
#### Method 1: TUI (press `Ctrl+P` while OpenCode is running)
|
|
161
228
|
|
|
162
229
|
1. Press `Ctrl+P` to open the command palette.
|
|
163
230
|
2. Type `install plugin` and press `Enter`.
|
|
@@ -167,7 +234,7 @@ Versions before 3.3 do not cover OpenRouter or the OpenCode Zen free-tier lane.
|
|
|
167
234
|
|
|
168
235
|
Check `~/.local/share/opencode/thinking-fix.log` for `plugin_loaded`. See [Is it working?](#is-it-working).
|
|
169
236
|
|
|
170
|
-
|
|
237
|
+
#### Method 2: CLI (shell command)
|
|
171
238
|
|
|
172
239
|
```bash
|
|
173
240
|
opencode plugin opencode-thinking-fix
|
|
@@ -181,7 +248,7 @@ opencode plugin opencode-thinking-fix@3.3.0
|
|
|
181
248
|
|
|
182
249
|
Restart OpenCode after installing.
|
|
183
250
|
|
|
184
|
-
|
|
251
|
+
#### Method 3: Manual config (add to `opencode.json`)
|
|
185
252
|
|
|
186
253
|
```json
|
|
187
254
|
{
|
|
@@ -201,59 +268,35 @@ See also: [OpenCode plugin docs](https://opencode.ai/docs/plugins)
|
|
|
201
268
|
|
|
202
269
|
---
|
|
203
270
|
|
|
204
|
-
|
|
205
|
-
- [See it happen](#see-it-happen)
|
|
206
|
-
- [Why you've probably never seen it](#why-youve-probably-never-seen-it)
|
|
207
|
-
- [The evidence](#the-evidence)
|
|
208
|
-
- [For the skeptics](#for-the-skeptics)
|
|
209
|
-
- [Option 1: Plugin (partial safety net)](#option-1-plugin-partial-safety-net)
|
|
210
|
-
- [Option 2: Proxy (replays real reasoning)](#option-2-proxy-replays-real-reasoning)
|
|
211
|
-
- [Option 3: Watchdog (auto-recovery)](#option-3-watchdog-auto-recovery)
|
|
212
|
-
- [How they work together](#how-they-work-together)
|
|
213
|
-
- [Affected models](#affected-models)
|
|
214
|
-
- [Model routing](#model-routing-proxy-port-3457)
|
|
215
|
-
- [Is it working?](#is-it-working)
|
|
216
|
-
- [This bug is everywhere](#this-bug-is-everywhere)
|
|
217
|
-
- [Files in this repo](#files-in-this-repo)
|
|
218
|
-
|
|
219
|
-
---
|
|
220
|
-
|
|
221
|
-
## What problem this fixes
|
|
271
|
+
### Plugin details (the seatbelt)
|
|
222
272
|
|
|
223
|
-
|
|
273
|
+
#### Install via npm (recommended)
|
|
224
274
|
|
|
225
|
-
|
|
275
|
+
See [Quick Install: Plugin](#quick-install-plugin) above, use OpenCode TUI (`Ctrl+P`) or CLI (`opencode plugin opencode-thinking-fix`).
|
|
226
276
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
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:
|
|
230
|
-
|
|
231
|
-
- **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.
|
|
232
|
-
- **Z.AI / GLM**: "[Key: return reasoning_content to keep the reasoning coherent](https://docs.z.ai/en/api/thinking)."
|
|
233
|
-
- **Kimi / Moonshot**: "[You must keep the reasoning_content in the multi-round conversation... otherwise an error will be thrown](https://platform.moonshot.ai/docs/guide/thinking-mode)."
|
|
234
|
-
- **MiniMax**: "[The complete model response must be append to the conversation history](https://platform.minimaxi.com/document/ChatCompletion%20v2)."
|
|
235
|
-
- **Xiaomi MiMo**: "[Any assistant message with tool calls... must preserve its full reasoning_content field, otherwise the API will return a 400 error](https://dev.mi.com/doc/llm-api). Affected frameworks include TRAE, Cursor, Roo Code, Codex, GitHub Copilot CLI, Zed, AutoGen."
|
|
236
|
-
|
|
237
|
-
The field is non-standard per OpenAI, so SDKs and clients ignore it — the drop is
|
|
238
|
-
systemic, not a single tool's bug. This repo fixes it. Three layers, pick what you need.
|
|
239
|
-
|
|
240
|
-
---
|
|
241
|
-
|
|
242
|
-
## Option 1: plugin (partial safety net)
|
|
243
|
-
|
|
244
|
-
### Install via npm (recommended)
|
|
245
|
-
|
|
246
|
-
See [Quick Install](#quick-install) above, use OpenCode TUI (`Ctrl+P`) or CLI (`opencode plugin opencode-thinking-fix`).
|
|
247
|
-
|
|
248
|
-
### Manual install (for local development)
|
|
277
|
+
#### Manual install (for local development)
|
|
249
278
|
|
|
250
279
|
Drop the plugin file in your OpenCode plugins directory and restart:
|
|
251
280
|
|
|
252
281
|
```bash
|
|
253
282
|
mkdir -p ~/.config/opencode/plugins
|
|
254
283
|
cp plugins/opencode-thinking-fix-universal.ts ~/.config/opencode/plugins/
|
|
284
|
+
# Manual V1 copies also need the shared plugin package (npm installs and V2 hosts provide it):
|
|
285
|
+
cd ~/.config/opencode && npm i @opencode/plugin
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
For the V2 API, add the package to `plugins` instead of `plugin`:
|
|
289
|
+
|
|
290
|
+
```jsonc
|
|
291
|
+
{
|
|
292
|
+
"plugins": ["opencode-thinking-fix"]
|
|
293
|
+
}
|
|
255
294
|
```
|
|
256
295
|
|
|
296
|
+
The same package supports both APIs from one default export. V2 reads `setup()`; V1 (>= 1.18.29) reads `server()`.
|
|
297
|
+
|
|
298
|
+
#### What the plugin does
|
|
299
|
+
|
|
257
300
|
It scans outgoing messages for any assistant turn that already has `reasoning_content`. If it finds one (meaning you are using a reasoning model), it adds `reasoning_content: ""` to every assistant turn missing it. If it finds nothing (Qwen, GPT, Claude, they never produce this field), it does nothing.
|
|
258
301
|
|
|
259
302
|
It also handles `reasoning` for the OpenCode Go provider, and patches empty `content` fields that OpenAI-compatible SDKs sometimes omit.
|
|
@@ -266,13 +309,15 @@ No config file changes. No build step. OpenCode compiles `.ts` plugins when it s
|
|
|
266
309
|
|
|
267
310
|
---
|
|
268
311
|
|
|
269
|
-
|
|
312
|
+
### Full Setup: Proxy
|
|
313
|
+
|
|
314
|
+
If you have not installed the plugin yet, start with [Quick Install: Plugin](#quick-install-plugin). The proxy is the part that puts the model's real reasoning back into the conversation.
|
|
270
315
|
|
|
271
316
|
A Node.js proxy that catches API responses as they come back, pulls out the actual `reasoning_content` text, and caches it in memory. On the next request, it injects that real text back into the conversation history instead of empty strings.
|
|
272
317
|
|
|
273
318
|
Your model sees its full chain-of-thought from turn 1 on every subsequent turn. The difference is noticeable on complex multi-turn coding sessions.
|
|
274
319
|
|
|
275
|
-
|
|
320
|
+
#### Two-proxy architecture
|
|
276
321
|
|
|
277
322
|
The proxy runs on **two ports**:
|
|
278
323
|
|
|
@@ -293,7 +338,7 @@ PORT=3458 UPSTREAM_URL=https://opencode.ai/zen/go/v1 node proxy/proxy.js
|
|
|
293
338
|
|
|
294
339
|
> **Windows PowerShell:** use `$env:PORT=3457; node proxy/proxy.js` (PowerShell) or `set PORT=3457 && node proxy/proxy.js` (CMD).
|
|
295
340
|
|
|
296
|
-
|
|
341
|
+
#### OpenCode Go setup (explicit and credentials-safe)
|
|
297
342
|
|
|
298
343
|
The npm package intentionally does **not** run a `postinstall` script. It never
|
|
299
344
|
reads `auth.json`, edits your OpenCode configuration, starts a process, or
|
|
@@ -331,7 +376,7 @@ configured upstream and does not persist the credential. Stop the proxy when
|
|
|
331
376
|
it is not needed. The systemd and watchdog options below are deliberately
|
|
332
377
|
opt-in alternatives for operators who want automatic restarts.
|
|
333
378
|
|
|
334
|
-
|
|
379
|
+
#### Install as systemd services (auto-start at boot)
|
|
335
380
|
|
|
336
381
|
```bash
|
|
337
382
|
mkdir -p ~/.config/systemd/user
|
|
@@ -369,9 +414,9 @@ One runtime dependency (`eventsource-parser`). The proxy uses Node.js built-in `
|
|
|
369
414
|
|
|
370
415
|
---
|
|
371
416
|
|
|
372
|
-
|
|
417
|
+
### Auto-Recovery
|
|
373
418
|
|
|
374
|
-
The watchdog script checks both proxy instances every 4 minutes and restarts any that are down:
|
|
419
|
+
The watchdog only keeps the proxy alive, so start the proxy first. The script checks both proxy instances every 4 minutes and restarts any that are down:
|
|
375
420
|
|
|
376
421
|
```bash
|
|
377
422
|
cp watchdog/watchdog.sh ~/reasoning-cache-proxy/
|
|
@@ -380,9 +425,21 @@ systemctl --user daemon-reload
|
|
|
380
425
|
systemctl --user enable --now reasoning-proxy-watchdog.service
|
|
381
426
|
```
|
|
382
427
|
|
|
428
|
+
#### Windows: NSSM or Task Scheduler
|
|
429
|
+
|
|
430
|
+
On Windows, NSSM (Non-Sucking Service Manager) or Task Scheduler can run the proxy or a PowerShell watchdog script in the background. A quick NSSM setup:
|
|
431
|
+
|
|
432
|
+
```powershell
|
|
433
|
+
nssm install ReasoningCacheProxy node.exe proxy\proxy.js
|
|
434
|
+
nssm set ReasoningCacheProxy AppEnvironmentExtra PORT=3457
|
|
435
|
+
nssm start ReasoningCacheProxy
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
See [Windows notes](#windows-notes) for the full setup.
|
|
439
|
+
|
|
383
440
|
---
|
|
384
441
|
|
|
385
|
-
|
|
442
|
+
### How They Work Together
|
|
386
443
|
|
|
387
444
|
```
|
|
388
445
|
OpenCode → [plugin patches missing reasoning_content/reasoning]
|
|
@@ -395,7 +452,7 @@ The plugin is partial insurance. The hard 400 is mostly gone from modern gateway
|
|
|
395
452
|
|
|
396
453
|
---
|
|
397
454
|
|
|
398
|
-
## Affected
|
|
455
|
+
## Affected Models
|
|
399
456
|
|
|
400
457
|
| Model | Plugin helps | Proxy helps | What it needs |
|
|
401
458
|
|---|---|---|---|
|
|
@@ -416,6 +473,10 @@ The plugin is partial insurance. The hard 400 is mostly gone from modern gateway
|
|
|
416
473
|
|
|
417
474
|
The proxy auto-routes by model name prefix. All routes:
|
|
418
475
|
|
|
476
|
+
<details>
|
|
477
|
+
<summary><b>Click to expand the full model routing table</b></summary>
|
|
478
|
+
<br>
|
|
479
|
+
|
|
419
480
|
| Prefix | Upstream | Reasoning |
|
|
420
481
|
|---|---|---|
|
|
421
482
|
| `deepseek-v4-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-vision-exp` | `https://api.deepseek.com` | Yes (`reasoning_content`) |
|
|
@@ -425,6 +486,8 @@ The proxy auto-routes by model name prefix. All routes:
|
|
|
425
486
|
| `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes (`reasoning_content`) |
|
|
426
487
|
| `minimax` | `https://api.minimax.io/v1` | Yes (`reasoning_details`, `reasoning_split:true`) |
|
|
427
488
|
| `mimo` | `https://api.xiaomimimo.com/v1` | Yes (`reasoning_content`) |
|
|
489
|
+
| `inclusionai/ling-3.0-flash` (and `:free`) | `https://openrouter.ai/api/v1` | Yes — OR normalizes to `delta.reasoning` + `delta.reasoning_details`; passback as `reasoning_content` is honored |
|
|
490
|
+
| `opencode/ling-3.0-flash-fin-free` | `https://opencode.ai/zen/v1` | Yes (`reasoning_content`) |
|
|
428
491
|
| `gpt`, `o1` | `https://api.openai.com` | No |
|
|
429
492
|
| `claude`, `anthropic` | `https://api.anthropic.com` | No |
|
|
430
493
|
| `qwen` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | No |
|
|
@@ -432,6 +495,8 @@ The proxy auto-routes by model name prefix. All routes:
|
|
|
432
495
|
| `llama` | `https://api.together.xyz` | No |
|
|
433
496
|
| `mistral` | `https://api.mistral.ai` | No |
|
|
434
497
|
|
|
498
|
+
</details>
|
|
499
|
+
|
|
435
500
|
Unknown models fall back to `https://api.deepseek.com` with reasoning disabled.
|
|
436
501
|
|
|
437
502
|
### OpenRouter (port 3462)
|
|
@@ -454,11 +519,13 @@ PORT=3462 UPSTREAM_URL=https://openrouter.ai/api/v1 REASONING_KEY=reasoning_cont
|
|
|
454
519
|
}
|
|
455
520
|
```
|
|
456
521
|
|
|
457
|
-
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
|
|
522
|
+
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/Proof](#the-evidence--proof).
|
|
458
523
|
|
|
459
524
|
---
|
|
460
525
|
|
|
461
|
-
##
|
|
526
|
+
## Troubleshooting
|
|
527
|
+
|
|
528
|
+
### Is it working?
|
|
462
529
|
|
|
463
530
|
The plugin writes a structured JSON log: `~/.local/share/opencode/thinking-fix.log`.
|
|
464
531
|
|
|
@@ -562,7 +629,7 @@ proof/ # evidence: captures, controls, CSVs, re
|
|
|
562
629
|
| **macOS** | ✅ | ✅ | ✅ (bash) | ❌ (use launchd) |
|
|
563
630
|
| **Windows** | ✅ | ✅ | ❌ (bash) | ❌ |
|
|
564
631
|
|
|
565
|
-
OpenCode v1.17.9
|
|
632
|
+
OpenCode v1.17.9+ (V1 plugin), OpenCode v2.0+ (V2 plugin), DeepSeek V4 Pro, Kimi K2.5/K2.6/K2.7, GLM-5.x, MiMo V2.5, MiniMax-M3, OpenCode Go.
|
|
566
633
|
|
|
567
634
|
### Windows notes
|
|
568
635
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-thinking-fix",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Restores reasoning content dropped from multi-turn OpenCode conversations (DeepSeek, Kimi, GLM, MiMo, MiniMax, OpenCode Go, OpenRouter).
|
|
3
|
+
"version": "4.0.1",
|
|
4
|
+
"description": "Restores reasoning content dropped from multi-turn OpenCode conversations (DeepSeek, Kimi, GLM, MiMo, MiniMax, InclusionAI/Ling, OpenCode Go, OpenRouter). V1 + V2 plugin, proxy, and watchdog.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"oc-plugin": [
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"zhipu",
|
|
21
21
|
"mimo",
|
|
22
22
|
"minimax",
|
|
23
|
+
"inclusionai",
|
|
24
|
+
"ling-3.0-flash",
|
|
23
25
|
"openrouter",
|
|
24
26
|
"xiaomi-mimo",
|
|
25
27
|
"reasoning-content",
|
|
@@ -73,15 +75,12 @@
|
|
|
73
75
|
"LICENSE.md"
|
|
74
76
|
],
|
|
75
77
|
"dependencies": {
|
|
78
|
+
"@opencode/plugin": "^2.0.32",
|
|
76
79
|
"eventsource-parser": "^3.1.0"
|
|
77
80
|
},
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
"peerDependenciesMeta": {
|
|
82
|
-
"@opencode-ai/plugin": {
|
|
83
|
-
"optional": false
|
|
84
|
-
}
|
|
81
|
+
"scripts": {
|
|
82
|
+
"test": "node tests/test-plugin.js && node tests/test-plugin-v2.js && node tests/test-proxy.js",
|
|
83
|
+
"typecheck": "tsc --noEmit"
|
|
85
84
|
},
|
|
86
85
|
"engines": {
|
|
87
86
|
"node": ">=18"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Plugin } from '@opencode/plugin'
|
|
2
2
|
import { appendFileSync, mkdirSync } from 'fs'
|
|
3
3
|
import { homedir } from 'os'
|
|
4
4
|
import { join } from 'path'
|
|
@@ -21,16 +21,98 @@ function writeLog(entry: Record<string, unknown>): void {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// V1 message patching (OpenCode 1.x)
|
|
26
|
+
// Assistant turns must carry reasoning_content / reasoning so providers that
|
|
27
|
+
// require the passback do not reject the next request.
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function patchV1Messages(messages: any[]): { patched: number; reasoningModel: boolean; turns: Array<{ index: number; fields: string[] }> } {
|
|
31
|
+
// Self-detect: if any assistant turn has reasoning_content → reasoning model
|
|
32
|
+
const reasoningModel = messages.some((wrapper: any) => {
|
|
33
|
+
const msg = wrapper?.info ?? wrapper
|
|
34
|
+
return msg?.role === 'assistant' && ('reasoning_content' in msg || 'reasoning' in msg)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
if (!reasoningModel) return { patched: 0, reasoningModel: false, turns: [] }
|
|
38
|
+
|
|
39
|
+
let patched = 0
|
|
40
|
+
const turns: Array<{ index: number; fields: string[] }> = []
|
|
41
|
+
|
|
42
|
+
messages.forEach((wrapper: any, index: number) => {
|
|
43
|
+
const msg = wrapper?.info ?? wrapper
|
|
44
|
+
if (!msg || msg.role !== 'assistant') return
|
|
45
|
+
const fields: string[] = []
|
|
46
|
+
|
|
47
|
+
if (!msg.content && msg.content !== '') {
|
|
48
|
+
msg.content = msg.tool_calls?.length ? 'call tool' : ''
|
|
49
|
+
fields.push('content')
|
|
50
|
+
}
|
|
51
|
+
if (!('reasoning_content' in msg)) { msg.reasoning_content = ''; fields.push('reasoning_content') }
|
|
52
|
+
if (!('reasoning' in msg)) { msg.reasoning = ''; fields.push('reasoning') }
|
|
53
|
+
|
|
54
|
+
if (fields.length > 0) {
|
|
55
|
+
patched += fields.length
|
|
56
|
+
turns.push({ index, fields })
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return { patched, reasoningModel: true, turns }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
// V2 message patching (OpenCode 2.x)
|
|
65
|
+
// V2 normalizes assistant turns into `content` parts, so the passback field
|
|
66
|
+
// is carried on `native.openaiCompatible.reasoning_content`. The OpenAI Chat
|
|
67
|
+
// protocol reads it when it builds the outbound body.
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function patchV2Messages(messages: any[]): { patched: number; reasoningModel: boolean } {
|
|
71
|
+
const reasoningModel = messages.some((msg: any) => {
|
|
72
|
+
if (!msg || msg.role !== 'assistant') return false
|
|
73
|
+
if (Array.isArray(msg.content) && msg.content.some((part: any) => part?.type === 'reasoning')) return true
|
|
74
|
+
const native = msg.native?.openaiCompatible
|
|
75
|
+
return !!native && (typeof native.reasoning_content === 'string' || typeof native.reasoning === 'string')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (!reasoningModel) return { patched: 0, reasoningModel: false }
|
|
79
|
+
|
|
80
|
+
let patched = 0
|
|
81
|
+
for (const msg of messages) {
|
|
82
|
+
if (!msg || msg.role !== 'assistant') continue
|
|
83
|
+
if (msg.native === undefined) msg.native = {}
|
|
84
|
+
if (msg.native.openaiCompatible === undefined) msg.native.openaiCompatible = {}
|
|
85
|
+
const native = msg.native.openaiCompatible
|
|
86
|
+
if (typeof native.reasoning_content !== 'string') {
|
|
87
|
+
native.reasoning_content = ''
|
|
88
|
+
patched++
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { patched, reasoningModel: true }
|
|
93
|
+
}
|
|
24
94
|
|
|
25
95
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
-
//
|
|
96
|
+
// V1 plugin (OpenCode 1.x)
|
|
27
97
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
98
|
+
|
|
99
|
+
type V1Input = {
|
|
100
|
+
client?: {
|
|
101
|
+
app?: {
|
|
102
|
+
log?: (args: { body: { service: string; level: string; message: string } }) => Promise<unknown>
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type V1Hooks = Record<string, (input: any, output: any) => Promise<void>>
|
|
108
|
+
|
|
109
|
+
const ThinkingFixPluginV1 = async (input: V1Input = {}): Promise<V1Hooks> => {
|
|
110
|
+
const { client } = input
|
|
29
111
|
mkdirSync(LOG_DIR, { recursive: true })
|
|
30
112
|
|
|
31
|
-
writeLog({ event: 'plugin_loaded', logFile: LOG_FILE })
|
|
113
|
+
writeLog({ event: 'plugin_loaded', runtime: 'v1', logFile: LOG_FILE })
|
|
32
114
|
|
|
33
|
-
await client
|
|
115
|
+
await client?.app?.log?.({ body: { service: 'thinking-fix', level: 'info', message: `Plugin loaded, logging to ${LOG_FILE}` } })
|
|
34
116
|
|
|
35
117
|
return {
|
|
36
118
|
'chat.headers': async (input: any, output: any) => {
|
|
@@ -44,49 +126,91 @@ export const ThinkingFixPlugin: Plugin = async ({ client }) => {
|
|
|
44
126
|
const messages = output?.messages
|
|
45
127
|
if (!Array.isArray(messages) || messages.length === 0) return
|
|
46
128
|
|
|
47
|
-
|
|
48
|
-
const hasReasoning = messages.some((w: any) => {
|
|
49
|
-
const m = w?.info ?? w
|
|
50
|
-
return m?.role === 'assistant' && ('reasoning_content' in m || 'reasoning' in m)
|
|
51
|
-
})
|
|
129
|
+
const result = patchV1Messages(messages)
|
|
52
130
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
messages.forEach((wrapper: any, index: number) => {
|
|
62
|
-
const msg = wrapper?.info ?? wrapper
|
|
63
|
-
if (!msg || msg.role !== 'assistant') return
|
|
64
|
-
const fields: string[] = []
|
|
65
|
-
|
|
66
|
-
if (!msg.content && msg.content !== '') {
|
|
67
|
-
msg.content = msg.tool_calls?.length ? 'call tool' : ''
|
|
68
|
-
fields.push('content')
|
|
69
|
-
}
|
|
70
|
-
if (!('reasoning_content' in msg)) { msg.reasoning_content = ''; fields.push('reasoning_content') }
|
|
71
|
-
if (!('reasoning' in msg)) { msg.reasoning = ''; fields.push('reasoning') }
|
|
72
|
-
|
|
73
|
-
if (fields.length > 0) {
|
|
74
|
-
patched += fields.length
|
|
75
|
-
turns.push({ index, fields })
|
|
76
|
-
}
|
|
131
|
+
writeLog({
|
|
132
|
+
event: 'inspect',
|
|
133
|
+
runtime: 'v1',
|
|
134
|
+
isReasoningModel: result.reasoningModel,
|
|
135
|
+
totalMessages: messages.length,
|
|
136
|
+
patchedFields: result.patched,
|
|
137
|
+
turns: result.turns,
|
|
77
138
|
})
|
|
78
139
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
140
|
+
if (result.patched > 0) {
|
|
141
|
+
await client?.app?.log?.({
|
|
142
|
+
body: {
|
|
143
|
+
service: 'thinking-fix',
|
|
144
|
+
level: 'info',
|
|
145
|
+
message: `patched ${result.patched} field(s) across ${messages.length} message(s)`,
|
|
146
|
+
},
|
|
147
|
+
})
|
|
83
148
|
}
|
|
84
149
|
} catch (err: any) {
|
|
85
|
-
writeLog({ event: 'error', message: err?.message ?? String(err) })
|
|
86
|
-
await client
|
|
150
|
+
writeLog({ event: 'error', runtime: 'v1', message: err?.message ?? String(err) })
|
|
151
|
+
await client?.app?.log?.({
|
|
152
|
+
body: {
|
|
153
|
+
service: 'thinking-fix',
|
|
154
|
+
level: 'error',
|
|
155
|
+
message: `Error, passing through unmodified: ${err?.message ?? err}`,
|
|
156
|
+
},
|
|
157
|
+
})
|
|
87
158
|
}
|
|
88
159
|
},
|
|
89
160
|
}
|
|
90
161
|
}
|
|
91
162
|
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
// V2 plugin (OpenCode 2.x)
|
|
165
|
+
// chat.headers → ctx.session.hook('model.request', ...)
|
|
166
|
+
// chat.messages.transform → ctx.session.hook('context', ...)
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
const ThinkingFixPluginV2 = Plugin.define({
|
|
170
|
+
id: 'opencode-thinking-fix',
|
|
171
|
+
async setup(ctx) {
|
|
172
|
+
mkdirSync(LOG_DIR, { recursive: true })
|
|
173
|
+
writeLog({ event: 'plugin_loaded', runtime: 'v2', directory: ctx.location.directory, logFile: LOG_FILE })
|
|
174
|
+
|
|
175
|
+
await ctx.session.hook('model.request', (event) => {
|
|
176
|
+
if (event.sessionID && !event.headers['x-session-id']) {
|
|
177
|
+
event.headers['x-session-id'] = event.sessionID
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const inspect = (messages: any[]) => {
|
|
182
|
+
try {
|
|
183
|
+
if (!Array.isArray(messages) || messages.length === 0) return
|
|
184
|
+
|
|
185
|
+
const result = patchV2Messages(messages)
|
|
186
|
+
|
|
187
|
+
writeLog({
|
|
188
|
+
event: 'inspect',
|
|
189
|
+
runtime: 'v2',
|
|
190
|
+
isReasoningModel: result.reasoningModel,
|
|
191
|
+
totalMessages: messages.length,
|
|
192
|
+
patchedFields: result.patched,
|
|
193
|
+
})
|
|
194
|
+
} catch (err: any) {
|
|
195
|
+
writeLog({ event: 'error', runtime: 'v2', message: err?.message ?? String(err) })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// `context` covers the agent loop; `compaction` covers checkpoint summaries.
|
|
200
|
+
await ctx.session.hook('context', (event) => inspect(event.messages as any[]))
|
|
201
|
+
await ctx.session.hook('compaction', (event) => inspect(event.messages as any[]))
|
|
202
|
+
},
|
|
203
|
+
})
|
|
92
204
|
|
|
205
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
206
|
+
// Entrypoint
|
|
207
|
+
// V2 reads the default export's id/setup. V1 (>=1.18.29) reads server().
|
|
208
|
+
// Keeping one default object avoids loading the plugin twice on V1.
|
|
209
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
export default {
|
|
212
|
+
...ThinkingFixPluginV2,
|
|
213
|
+
async server(input: V1Input) {
|
|
214
|
+
return ThinkingFixPluginV1(input)
|
|
215
|
+
},
|
|
216
|
+
}
|
package/proxy/core.js
CHANGED
|
@@ -88,6 +88,24 @@ export const ROUTES = {
|
|
|
88
88
|
'opencode/deepseek-v4-flash-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
|
|
89
89
|
'opencode/nemotron-3-ultra-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
|
|
90
90
|
'opencode/nemotron-3.5-lightning-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
|
|
91
|
+
// F19 (2026-09-03): InclusionAI Ling 3.0 Flash — Chinese MoE reasoning model
|
|
92
|
+
// (124B/5.1B active, 262K ctx). Primary distribution is OpenRouter
|
|
93
|
+
// (`inclusionai/ling-3.0-flash` paid, `:free` free tier). On OR the response
|
|
94
|
+
// wire uses `delta.reasoning` (string) + `delta.reasoning_details` (array
|
|
95
|
+
// of `reasoning.text` items with `format: "unknown"`); OR also accepts
|
|
96
|
+
// `reasoning_content` as an input alias and normalizes on outbound to the
|
|
97
|
+
// upstream (Novita, DeepInfra). Native ant-ling.com uses
|
|
98
|
+
// `thinking.type: enabled|disabled`; not routed here (no client key).
|
|
99
|
+
'inclusionai/ling-3.0-flash': { base: 'https://openrouter.ai/api/v1', reasoningKey: 'reasoning_content' },
|
|
100
|
+
'inclusionai/ling-3.0-flash:free': { base: 'https://openrouter.ai/api/v1', reasoningKey: 'reasoning_content' },
|
|
101
|
+
// Zen free-tier lane: `opencode/ling-3.0-flash-fin-free` is served by the
|
|
102
|
+
// OpenCode Zen gateway at zen/v1 (chat/completions wire, DeepSeek-style
|
|
103
|
+
// `reasoning_content` carrier). Add the prefixed form here so 3457 model-
|
|
104
|
+
// routing sends it to Zen, not the default DeepSeek fallback.
|
|
105
|
+
'opencode/ling-3.0-flash-fin-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
|
|
106
|
+
// Catch-all for future InclusionAI variants (Ling-2.x, Ring-2.6-1T, etc.)
|
|
107
|
+
// that may be added to OpenRouter before this proxy gets a per-model entry.
|
|
108
|
+
'inclusionai': { base: 'https://openrouter.ai/api/v1', reasoningKey: 'reasoning_content' },
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
export const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoningKey: null }
|
|
@@ -358,3 +376,78 @@ export function createStreamParser(sessionId, baseIndex, onComplete) {
|
|
|
358
376
|
},
|
|
359
377
|
}
|
|
360
378
|
}
|
|
379
|
+
|
|
380
|
+
// F12 (2026-09-01): provider-specific request-body injections applied before
|
|
381
|
+
// forwarding. Currently a single rule: Z.ai preserved-thinking opt-in. Future
|
|
382
|
+
// F-tags (per-provider `thinking.keep`, sampling-constraint strips, etc.) can
|
|
383
|
+
// be added here without growing the patchRequestBody signature.
|
|
384
|
+
//
|
|
385
|
+
// Z.ai `clear_thinking` is the server-side opt-in for Preserved Thinking on
|
|
386
|
+
// the standard Z.ai Chat Completions endpoint. Default is `true` on the
|
|
387
|
+
// standard API, `false` on the Coding Plan endpoint. With `true`, the server
|
|
388
|
+
// strips prior reasoning_content from context even when the client passes it
|
|
389
|
+
// back — which silently degrades multi-turn GLM sessions to "re-derive from
|
|
390
|
+
// scratch every turn, loop, burn tokens". The OpenCode Go gateway and
|
|
391
|
+
// OpenRouter do NOT change this default on the user's behalf; the client must
|
|
392
|
+
// opt in.
|
|
393
|
+
//
|
|
394
|
+
// Docs:
|
|
395
|
+
// https://docs.z.ai/guides/capabilities/thinking-mode
|
|
396
|
+
// "This capability is enabled by default on the Coding Plan endpoint and
|
|
397
|
+
// disabled by default on the standard API endpoint. If you want to enable
|
|
398
|
+
// Preserved Thinking in your product … you can turn it on for the API
|
|
399
|
+
// endpoint by setting clear_thinking: false, and you must return the
|
|
400
|
+
// complete, unmodified reasoning_content back to the API."
|
|
401
|
+
// https://docs.z.ai/api-reference/llm/chat-completion
|
|
402
|
+
// thinking.clear_thinking — type boolean, default true.
|
|
403
|
+
//
|
|
404
|
+
// Z.ai-reachable hosts (allowlist, conservative):
|
|
405
|
+
// - open.bigmodel.cn (BigModel direct, port 3457 universal mode)
|
|
406
|
+
// - api.z.ai (Z.ai international, future-proofing)
|
|
407
|
+
// - opencode.ai/zen/go (OpenCode Go gateway, port 3458)
|
|
408
|
+
// - openrouter.ai (OpenRouter, port 3462; routes GLM to Z.ai under the hood)
|
|
409
|
+
function isZAiReachableBase(base) {
|
|
410
|
+
if (!base || typeof base !== 'string') return false
|
|
411
|
+
let host, path
|
|
412
|
+
try { const u = new URL(base); host = u.hostname.toLowerCase(); path = u.pathname.toLowerCase() } catch { return false }
|
|
413
|
+
if (host === 'open.bigmodel.cn') return true
|
|
414
|
+
if (host === 'api.z.ai') return true
|
|
415
|
+
if (host === 'opencode.ai' && path.startsWith('/zen/go')) return true
|
|
416
|
+
if (host === 'openrouter.ai') return true
|
|
417
|
+
return false
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function applyProviderBodyInjections(modelName, body, routeTarget) {
|
|
421
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { body, modified: false, injections: [] }
|
|
422
|
+
const injections = []
|
|
423
|
+
let modified = false
|
|
424
|
+
const lower = (modelName || '').toLowerCase()
|
|
425
|
+
const base = (routeTarget && routeTarget.base) || ''
|
|
426
|
+
|
|
427
|
+
// F12: GLM / Zhipu on any Z.ai-reachable lane — opt in to Preserved Thinking
|
|
428
|
+
// so the server keeps prior reasoning_content across turns instead of
|
|
429
|
+
// silently stripping it. Skipped when the client explicitly disabled
|
|
430
|
+
// thinking (forcing preserved thinking on top of disabled thinking would be
|
|
431
|
+
// incoherent). The merge is idempotent: a client-supplied thinking.type is
|
|
432
|
+
// preserved alongside the injected clear_thinking:false.
|
|
433
|
+
//
|
|
434
|
+
// Model-name detection accepts both bare names (glm-5.2, zhipu-glm-5) and
|
|
435
|
+
// gateway-prefixed slugs (z-ai/glm-5.2, zhipu/glm-4.5, opencode-go/glm-5.2).
|
|
436
|
+
// The "/glm" or "/zhipu" path-segment check catches the prefixed slugs that
|
|
437
|
+
// don't start with the bare name.
|
|
438
|
+
const isZAiModel = lower.startsWith('glm')
|
|
439
|
+
|| lower.startsWith('zhipu')
|
|
440
|
+
|| lower.includes('/glm')
|
|
441
|
+
|| lower.includes('/zhipu')
|
|
442
|
+
if (isZAiModel && isZAiReachableBase(base)) {
|
|
443
|
+
if (body.thinking !== false) {
|
|
444
|
+
const before = body.thinking
|
|
445
|
+
const preserved = before && typeof before === 'object' && !Array.isArray(before) ? before : {}
|
|
446
|
+
body.thinking = { ...preserved, clear_thinking: false }
|
|
447
|
+
if (before !== body.thinking) modified = true
|
|
448
|
+
injections.push({ tag: 'F12', field: 'thinking.clear_thinking', value: false })
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { body, modified, injections }
|
|
453
|
+
}
|
package/proxy/proxy.js
CHANGED
|
@@ -59,6 +59,7 @@ import url from 'node:url'
|
|
|
59
59
|
import {
|
|
60
60
|
ROUTES, cache, stats, route, fixedUpstreamRoute, getSessionCache, patchRequestBody,
|
|
61
61
|
extractReasoningFromJson, createStreamParser, writeLog, deriveSessionId, upstreamPathFor,
|
|
62
|
+
applyProviderBodyInjections,
|
|
62
63
|
} from './core.js'
|
|
63
64
|
|
|
64
65
|
const PORT = parseInt(process.env.PORT || '3457', 10)
|
|
@@ -151,6 +152,21 @@ const server = http.createServer((req, res) => {
|
|
|
151
152
|
// F10: same mode gate — only inject reasoning_split in routing mode.
|
|
152
153
|
if (!UPSTREAM_URL && lower.startsWith('minimax')) bj.reasoning_split = true
|
|
153
154
|
|
|
155
|
+
// F12 (2026-09-01): provider-specific request-body injections.
|
|
156
|
+
// Currently a single rule: Z.ai clear_thinking:false on any Z.ai-reachable
|
|
157
|
+
// lane (3457 direct, 3458 Go, 3462 OpenRouter). Unlike F9/F10 this is NOT
|
|
158
|
+
// gated by !UPSTREAM_URL — Preserved Thinking is the Z.ai contract, not a
|
|
159
|
+
// port-3457-specific behavior. The host allowlist inside
|
|
160
|
+
// applyProviderBodyInjections() keeps the rule conservative.
|
|
161
|
+
const providerInjections = applyProviderBodyInjections(modelName, bj, routeTarget)
|
|
162
|
+
for (const inj of providerInjections.injections) {
|
|
163
|
+
if (DEBUG) console.log(`[F12] ${modelName} -> ${inj.field}=${JSON.stringify(inj.value)} (host=${routeTarget.base || 'unknown'})`)
|
|
164
|
+
}
|
|
165
|
+
if (providerInjections.injections.length > 0) {
|
|
166
|
+
writeLog({ event: 'provider_injection', model: modelName, session: sessionId.slice(-8),
|
|
167
|
+
injections: providerInjections.injections, host: routeTarget.base || '' })
|
|
168
|
+
}
|
|
169
|
+
|
|
154
170
|
const patched = patchRequestBody(JSON.stringify(bj), sessionId, routeTarget.reasoningKey)
|
|
155
171
|
bodyToSend = Buffer.from(patched.body, 'utf8')
|
|
156
172
|
assistantCount = patched.assistantCount
|