opencode-thinking-fix 3.3.4 → 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 CHANGED
@@ -125,6 +125,17 @@ causing cross-provider poisoning. v3.1 fixes that:
125
125
  (Qwen, GPT, Claude, Gemini, Llama, Mistral, Cerebras-hosted GLM) default to
126
126
  `reasoningKey: null` — the body is forwarded untouched.
127
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
+
128
139
  ### "Another way" — do you even need the plugin now?
129
140
 
130
141
  The reasoning-drop is fixed at the wire layer by the proxy, so you have three options:
@@ -137,6 +148,22 @@ The reasoning-drop is fixed at the wire layer by the proxy, so you have three op
137
148
 
138
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.
139
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
+
140
167
  ## Plugin optionality and derived sessions
141
168
 
142
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.
@@ -252,8 +279,20 @@ Drop the plugin file in your OpenCode plugins directory and restart:
252
279
  ```bash
253
280
  mkdir -p ~/.config/opencode/plugins
254
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
284
+ ```
285
+
286
+ For the V2 API, add the package to `plugins` instead of `plugin`:
287
+
288
+ ```jsonc
289
+ {
290
+ "plugins": ["opencode-thinking-fix"]
291
+ }
255
292
  ```
256
293
 
294
+ The same package supports both APIs from one default export. V2 reads `setup()`; V1 (>= 1.18.29) reads `server()`.
295
+
257
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.
258
297
 
259
298
  It also handles `reasoning` for the OpenCode Go provider, and patches empty `content` fields that OpenAI-compatible SDKs sometimes omit.
@@ -425,6 +464,8 @@ The proxy auto-routes by model name prefix. All routes:
425
464
  | `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes (`reasoning_content`) |
426
465
  | `minimax` | `https://api.minimax.io/v1` | Yes (`reasoning_details`, `reasoning_split:true`) |
427
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`) |
428
469
  | `gpt`, `o1` | `https://api.openai.com` | No |
429
470
  | `claude`, `anthropic` | `https://api.anthropic.com` | No |
430
471
  | `qwen` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | No |
@@ -562,7 +603,7 @@ proof/ # evidence: captures, controls, CSVs, re
562
603
  | **macOS** | ✅ | ✅ | ✅ (bash) | ❌ (use launchd) |
563
604
  | **Windows** | ✅ | ✅ | ❌ (bash) | ❌ |
564
605
 
565
- OpenCode v1.17.9+, DeepSeek V4 Pro, Kimi K2.5/K2.6/K2.7, GLM-5.x, MiMo V2.5, MiniMax-M3, OpenCode Go.
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.
566
607
 
567
608
  ### Windows notes
568
609
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-thinking-fix",
3
- "version": "3.3.4",
4
- "description": "Restores reasoning content dropped from multi-turn OpenCode conversations (DeepSeek, Kimi, GLM, MiMo, MiniMax, OpenCode Go, OpenRouter). Plugin + proxy + watchdog.",
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
- "peerDependencies": {
79
- "@opencode-ai/plugin": ">=1.3.0"
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 type { Plugin } from '@opencode-ai/plugin'
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
- // Plugin
96
+ // V1 plugin (OpenCode 1.x)
27
97
  // ─────────────────────────────────────────────────────────────────────────────
28
- export const ThinkingFixPlugin: Plugin = async ({ client }) => {
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.app.log({ body: { service: 'thinking-fix', level: 'info', message: `Plugin loaded, logging to ${LOG_FILE}` } })
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
- // Self-detect: if any assistant turn has reasoning_content → reasoning model
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
- if (!hasReasoning) {
54
- writeLog({ event: 'inspect', isReasoningModel: false, totalMessages: messages.length })
55
- return
56
- }
57
-
58
- let patched = 0
59
- const turns: Array<{ index: number; fields: string[] }> = []
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
- writeLog({ event: 'inspect', isReasoningModel: true, totalMessages: messages.length, patchedFields: patched, turns })
80
-
81
- if (patched > 0) {
82
- await client.app.log({ body: { service: 'thinking-fix', level: 'info', message: `patched ${patched} field(s) across ${messages.length} message(s)` } })
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.app.log({ body: { service: 'thinking-fix', level: 'error', message: `Error, passing through unmodified: ${err?.message ?? err}` } })
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