opencode-thinking-fix 3.3.3 → 4.0.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 +63 -15
- 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
|
@@ -28,15 +28,27 @@ The drop happens in SDK serializers and client transforms (truthy checks, missin
|
|
|
28
28
|
|
|
29
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
30
|
|
|
31
|
-
## See it happen
|
|
31
|
+
## See it happen
|
|
32
32
|
|
|
33
|
-
You need
|
|
33
|
+
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.
|
|
34
34
|
|
|
35
|
-
1.
|
|
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.
|
|
35
|
+
### 1. The 4-step reproduction
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
1. Start a session with a reasoning model and give it a task that requires a tool call ("List the files in this directory, then read the largest one").
|
|
38
|
+
2. The model thinks, emits `reasoning_content`, and calls the tool.
|
|
39
|
+
3. Ask a follow-up that depends on the first answer ("Now summarize what you just read").
|
|
40
|
+
4. Capture the outgoing Turn-2 request. Every `reasoning_content` field from Turn 1 is missing or empty. The client dropped it.
|
|
41
|
+
|
|
42
|
+
### 2. The dataset (the actual proof)
|
|
43
|
+
|
|
44
|
+
If you don't want to run it yourself, the `proof/` directory contains the raw data from a week-long passive tap of real production traffic, byte-for-byte unmodified across DeepSeek, OpenCode Go, OpenCode Zen, and OpenRouter:
|
|
45
|
+
|
|
46
|
+
- **1,968 drops captured** (turns that carried reasoning earlier in the session arrived at the API without it).
|
|
47
|
+
- **2.29 million characters** of reasoning erased.
|
|
48
|
+
- **22 of those drops were on OpenRouter** — proving it is not a direct-provider quirk.
|
|
49
|
+
- **68% of drops happened with zero model change** (same model, same session, drop mid-conversation).
|
|
50
|
+
|
|
51
|
+
Every number is recomputable from `proof/reasoning-drop/drops.csv`. The A/B controls isolating the client as the sole point of failure live in `proof/free-models/`. The headline is the dataset; the recipe is a courtesy.
|
|
40
52
|
|
|
41
53
|
## Why you've probably never seen it
|
|
42
54
|
|
|
@@ -50,15 +62,10 @@ If you run long sessions, heavy tool calls, model switching, or subagents, you h
|
|
|
50
62
|
|
|
51
63
|
## The evidence
|
|
52
64
|
|
|
53
|
-
|
|
65
|
+
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/).
|
|
54
66
|
|
|
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
67
|
- **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).
|
|
68
|
+
- **Model-switch baseline** is in [`proof/reasoning-drop/switches.csv`](proof/reasoning-drop/switches.csv) so the 68% zero-model-change number is auditable independently of the drops file.
|
|
62
69
|
|
|
63
70
|
## For the skeptics
|
|
64
71
|
|
|
@@ -118,6 +125,17 @@ causing cross-provider poisoning. v3.1 fixes that:
|
|
|
118
125
|
(Qwen, GPT, Claude, Gemini, Llama, Mistral, Cerebras-hosted GLM) default to
|
|
119
126
|
`reasoningKey: null` — the body is forwarded untouched.
|
|
120
127
|
|
|
128
|
+
## v3.4 — Z.ai Preserved Thinking (F12, intel-driven)
|
|
129
|
+
|
|
130
|
+
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.
|
|
131
|
+
|
|
132
|
+
v3.4 fixes this on every Z.ai-reachable lane:
|
|
133
|
+
|
|
134
|
+
- **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.
|
|
135
|
+
- **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.
|
|
136
|
+
- **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.
|
|
137
|
+
- **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`).
|
|
138
|
+
|
|
121
139
|
### "Another way" — do you even need the plugin now?
|
|
122
140
|
|
|
123
141
|
The reasoning-drop is fixed at the wire layer by the proxy, so you have three options:
|
|
@@ -130,6 +148,22 @@ The reasoning-drop is fixed at the wire layer by the proxy, so you have three op
|
|
|
130
148
|
|
|
131
149
|
**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.
|
|
132
150
|
|
|
151
|
+
## v3.4.1 — InclusionAI Ling 3.0 Flash route (F19)
|
|
152
|
+
|
|
153
|
+
Three new entries in the port 3457 model-routing table cover InclusionAI's flagship flash reasoning model:
|
|
154
|
+
|
|
155
|
+
| Model id | Upstream | Why this route |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `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 |
|
|
158
|
+
| `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 |
|
|
159
|
+
| `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 |
|
|
160
|
+
|
|
161
|
+
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).
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
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).
|
|
166
|
+
|
|
133
167
|
## Plugin optionality and derived sessions
|
|
134
168
|
|
|
135
169
|
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.
|
|
@@ -195,7 +229,7 @@ See also: [OpenCode plugin docs](https://opencode.ai/docs/plugins)
|
|
|
195
229
|
---
|
|
196
230
|
|
|
197
231
|
- [The problem](#the-problem)
|
|
198
|
-
- [See it happen
|
|
232
|
+
- [See it happen](#see-it-happen)
|
|
199
233
|
- [Why you've probably never seen it](#why-youve-probably-never-seen-it)
|
|
200
234
|
- [The evidence](#the-evidence)
|
|
201
235
|
- [For the skeptics](#for-the-skeptics)
|
|
@@ -245,8 +279,20 @@ Drop the plugin file in your OpenCode plugins directory and restart:
|
|
|
245
279
|
```bash
|
|
246
280
|
mkdir -p ~/.config/opencode/plugins
|
|
247
281
|
cp plugins/opencode-thinking-fix-universal.ts ~/.config/opencode/plugins/
|
|
282
|
+
# Manual V1 copies also need the shared plugin package (npm installs and V2 hosts provide it):
|
|
283
|
+
cd ~/.config/opencode && npm i @opencode/plugin
|
|
248
284
|
```
|
|
249
285
|
|
|
286
|
+
For the V2 API, add the package to `plugins` instead of `plugin`:
|
|
287
|
+
|
|
288
|
+
```jsonc
|
|
289
|
+
{
|
|
290
|
+
"plugins": ["opencode-thinking-fix"]
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The same package supports both APIs from one default export. V2 reads `setup()`; V1 (>= 1.18.29) reads `server()`.
|
|
295
|
+
|
|
250
296
|
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.
|
|
251
297
|
|
|
252
298
|
It also handles `reasoning` for the OpenCode Go provider, and patches empty `content` fields that OpenAI-compatible SDKs sometimes omit.
|
|
@@ -418,6 +464,8 @@ The proxy auto-routes by model name prefix. All routes:
|
|
|
418
464
|
| `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes (`reasoning_content`) |
|
|
419
465
|
| `minimax` | `https://api.minimax.io/v1` | Yes (`reasoning_details`, `reasoning_split:true`) |
|
|
420
466
|
| `mimo` | `https://api.xiaomimimo.com/v1` | Yes (`reasoning_content`) |
|
|
467
|
+
| `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 |
|
|
468
|
+
| `opencode/ling-3.0-flash-fin-free` | `https://opencode.ai/zen/v1` | Yes (`reasoning_content`) |
|
|
421
469
|
| `gpt`, `o1` | `https://api.openai.com` | No |
|
|
422
470
|
| `claude`, `anthropic` | `https://api.anthropic.com` | No |
|
|
423
471
|
| `qwen` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | No |
|
|
@@ -555,7 +603,7 @@ proof/ # evidence: captures, controls, CSVs, re
|
|
|
555
603
|
| **macOS** | ✅ | ✅ | ✅ (bash) | ❌ (use launchd) |
|
|
556
604
|
| **Windows** | ✅ | ✅ | ❌ (bash) | ❌ |
|
|
557
605
|
|
|
558
|
-
OpenCode v1.17.9
|
|
606
|
+
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.
|
|
559
607
|
|
|
560
608
|
### Windows notes
|
|
561
609
|
|
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.0",
|
|
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
|