opencode-thinking-fix 3.2.0 → 3.3.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 CHANGED
@@ -10,23 +10,65 @@
10
10
  npm install opencode-thinking-fix
11
11
  ```
12
12
 
13
- > Restores reasoning content that clients and SDKs drop from multi-turn conversations — DeepSeek, Kimi, GLM, MiMo, MiniMax, and OpenCode Go.
13
+ > Restores reasoning content that clients and SDKs drop from multi-turn conversations — DeepSeek, Kimi, GLM, MiMo, MiniMax, OpenCode Go, and OpenRouter.
14
14
  >
15
- > **Zero config.** Install via `Ctrl+P`, restart OpenCode, done. The plugin auto-detects reasoning models and only patches when needed.
15
+ > **Zero config.** Install via `Ctrl+P`, restart OpenCode, done. The plugin detects reasoning models and only patches when needed.
16
16
  >
17
17
  > Docs: [OpenCode Plugins](https://opencode.ai/docs/plugins)
18
18
 
19
- Your AI has a secret notebook.
19
+ ## The problem
20
20
 
21
- When DeepSeek or Kimi answers you, it scribbles notes first. "Let me think... the user wants a login page... I should use React Hook Form... check the API docs..." These notes are `reasoning_content`. You never see them. But the AI needs them.
21
+ When DeepSeek or Kimi answers you, it scribbles notes first. "Let me think... the user wants a login page... I should use React Hook Form... check the API docs..." These notes are `reasoning_content`. You never see them. But the AI needs them — the provider docs require the notes to be sent back on every later turn.
22
22
 
23
- The client and its SDK throw the notebook away. Next turn, the AI reaches for it — but the request already went to the API without it. The request carries no notes, or a blank notebook. The AI doesn't crash, but it forgot everything it was thinking. That is why your AI seems dumber on turn 2. It's not dumber. It just lost its notes.
23
+ The client and its SDK throw the notebook away. Next turn, the AI reaches for it, but the request already went out without it. The AI doesn't crash. It just forgot everything it was thinking. That is why your AI seems dumber on turn 2. It's not dumber. It lost its notes.
24
24
 
25
- The drop happens in SDK serializers and client transforms (truthy checks, missing type members, stripped signatures), not in the providers — the providers are doing exactly what their docs say.
25
+ The drop happens in SDK serializers and client transforms (truthy checks, missing type members, stripped signatures), not in the providers. The providers do exactly what their docs say.
26
26
 
27
- **Without the plugin:** "Build me a login page." → AI builds it. "Now add password reset." → AI lost its notes, conversation degrades.
27
+ **Without the fix:** "Build me a login page." → AI builds it. "Now add password reset." → notes gone, the answer comes back generic.
28
28
 
29
- **With the plugin:** "Build me a login page." → AI builds it, notes saved. "Now add password reset." → AI reads its notes: "I used React Hook Form for login, I'll extend that for password reset." → Works.
29
+ **With the fix:** "Now add password reset." → AI reads its notes: "I used React Hook Form for login, I'll extend that." → works.
30
+
31
+ ## See it happen in 5 minutes
32
+
33
+ You need OpenCode and any reasoning model. No proxy, no plugin — this reproduces the drop on a stock install.
34
+
35
+ 1. Start a session with a reasoning model (free tier works, e.g. `opencode/hy3-free`) and give it a real task. It thinks, answers, and emits `reasoning_content`.
36
+ 2. Ask a follow-up that depends on the first answer, then capture the outgoing request. Every `reasoning_content` field from turn 1 arrives empty or missing.
37
+ 3. The same request through the SDK alone keeps the field. The client is the difference.
38
+
39
+ Captured evidence for exactly this sequence: [`proof/free-models/`](proof/free-models/). The A/B controls that isolate the field as the only variable: [`proof/curl-control-results.txt`](proof/curl-control-results.txt).
40
+
41
+ ## Why you've probably never seen it
42
+
43
+ This bug is conditional, and the conditions are "you work the tool hard":
44
+
45
+ - **Short sessions never hit it.** The drop happens on turn 2+, worst after tool calls. A two-message chat looks fine.
46
+ - **Most gateways don't enforce the contract.** On the free `opencode/zen` tier and OpenRouter, a missing field is accepted, so nothing errors. The model just quietly loses context. No 400, no warning, only worse answers.
47
+ - **Non-reasoning models are unaffected.** GPT, Claude, Qwen, Gemini never emit the field.
48
+
49
+ If you run long sessions, heavy tool calls, model switching, or subagents, you have probably been hit and blamed the model. That is who this fix is for. If you send three messages a day, you may genuinely never notice — and the plugin detects that and does nothing.
50
+
51
+ ## The evidence
52
+
53
+ We ran four passive taps (DeepSeek, OpenCode Go, OpenCode Zen, OpenRouter) that forwarded traffic byte-for-byte untouched and logged only metadata. They ran for a week of real work, then were retired. Methodology and raw data: [`proof/` directory](proof/).
54
+
55
+ - **1,968 drops captured** — a turn that carried reasoning earlier in the same session arrives at the API without it.
56
+ - **2.29 million characters** of reasoning erased over a week of real traffic.
57
+ - **68% of drops happened with zero model change** — same model, same session. Not a model-switch artifact.
58
+ - **OpenRouter drops it too** — 22 drops with confirmed session identity on models served through openrouter.ai, plus the same pattern on DeepSeek and OpenCode Go lanes.
59
+ - **The SDK is innocent.** `@ai-sdk/openai-compatible` round-trips the field correctly; the drop happens in the client's own transform. Captured side by side in [`proof/free-models/`](proof/free-models/).
60
+
61
+ Every number is recomputable from [`proof/reasoning-drop/drops.csv`](proof/reasoning-drop/drops.csv) and [`proof/reasoning-drop/switches.csv`](proof/reasoning-drop/switches.csv).
62
+
63
+ ## For the skeptics
64
+
65
+ Three objections come up every time this is posted. Fair questions, with the evidence:
66
+
67
+ **"Works on my side."** Probably true. The drop is silent on most gateways — no 400, just worse answers, so nothing looks broken unless you compare turn-3 quality with and without. The A/B controls hold everything constant except the field. And the 68% stat rules out model switching as the explanation.
68
+
69
+ **"Your config is broken."** The configs this was captured on are published, redacted, in [`proof/` directory](proof/). The root cause is in the client's message transform, not config — the SDK control shows the field surviving when only the client is removed from the path.
70
+
71
+ **"The model cards already say to pass reasoning back."** They do. That is the point. The provider requires the field; the client drops it. The model card is the spec this repo implements.
30
72
 
31
73
  ---
32
74
 
@@ -71,9 +113,7 @@ causing cross-provider poisoning. v3.1 fixes that:
71
113
  (MiniMax split mode), `reasoning` (OpenCode Go / fixed-upstream). Previously the proxy
72
114
  filled all three fields, so a GLM turn stored under `reasoning` could poison a later
73
115
  DeepSeek replay that expects `reasoning_content`.
74
- - **R1 vs V4 split.** `deepseek-reasoner` (R1) must **not** receive reasoning echoed back
75
- (400 if you do) while V4 must. R1 uses the v3.2 `reasoningKey: 'strip'` sentinel,
76
- which actively removes reasoning fields before forwarding.
116
+ - **Legacy DeepSeek aliases vs V4.** DeepSeek retired the `deepseek-chat` / `deepseek-reasoner` aliases on 2026-07-24; the current catalog is `deepseek-v4-flash`, `deepseek-v4-pro`, and the experimental `deepseek-v4-flash-vision-exp`. While the R1 alias was live it had the inverted contract (echoed reasoning → 400), which is why the proxy carries a `strip` route: it actively removes reasoning fields before forwarding, kept today only as a compatibility guard for clients that still send the old name. V4 thinking mode uses the normal passback contract instead.
77
117
  - **Never fabricate reasoning.** Unknown models and providers that reject echo
78
118
  (Qwen, GPT, Claude, Gemini, Llama, Mistral, Cerebras-hosted GLM) default to
79
119
  `reasoningKey: null` — the body is forwarded untouched.
@@ -125,7 +165,7 @@ opencode plugin opencode-thinking-fix
125
165
  For a specific version:
126
166
 
127
167
  ```bash
128
- opencode plugin opencode-thinking-fix@3.2.0
168
+ opencode plugin opencode-thinking-fix@3.3.0
129
169
  ```
130
170
 
131
171
  Restart OpenCode after installing.
@@ -150,13 +190,17 @@ See also: [OpenCode plugin docs](https://opencode.ai/docs/plugins)
150
190
 
151
191
  ---
152
192
 
153
- - [What problem this fixes](#what-problem-this-fixes)
193
+ - [The problem](#the-problem)
194
+ - [See it happen in 5 minutes](#see-it-happen-in-5-minutes)
195
+ - [Why you've probably never seen it](#why-youve-probably-never-seen-it)
196
+ - [The evidence](#the-evidence)
197
+ - [For the skeptics](#for-the-skeptics)
154
198
  - [Option 1: Plugin (safety net)](#option-1-plugin-safety-net)
155
199
  - [Option 2: Proxy (replays real reasoning)](#option-2-proxy-replays-real-reasoning)
156
200
  - [Option 3: Watchdog (auto-recovery)](#option-3-watchdog-auto-recovery)
157
201
  - [How they work together](#how-they-work-together)
158
202
  - [Affected models](#affected-models)
159
- - [Model routing](#model-routing)
203
+ - [Model routing](#model-routing-proxy-port-3457)
160
204
  - [Is it working?](#is-it-working)
161
205
  - [This bug is everywhere](#this-bug-is-everywhere)
162
206
  - [Files in this repo](#files-in-this-repo)
@@ -171,7 +215,7 @@ The client/SDK dropped the field before it reached the API — the providers are
171
215
 
172
216
  > **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.
173
217
 
174
- DeepSeek V4 (and Kimi K2.7, GLM 5.x, MiMo V2.5) require that `reasoning_content` from every prior assistant turn gets included in subsequent API requests. The [docs](https://api-docs.deepseek.com/guides/thinking_mode) say it clearly: if you do not pass back `reasoning_content` correctly, the API rejects the request. All five providers confirm this in their official documentation:
218
+ 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:
175
219
 
176
220
  - **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.
177
221
  - **Z.AI / GLM**: "[Key: return reasoning_content to keep the reasoning coherent](https://docs.z.ai/en/api/thinking)."
@@ -343,12 +387,14 @@ The plugin is the safety net. If the proxy goes down, the plugin still injects e
343
387
  | Model | Plugin helps | Proxy helps | What it needs |
344
388
  |---|---|---|---|
345
389
  | DeepSeek V4 Pro / Flash | Yes | Nice to have | Accepts `""` (tool-call turns need real text) |
390
+ | DeepSeek V4 Flash Vision (exp) | Yes | Nice to have | Text + image input on a separate model id; same passback contract |
346
391
  | Kimi K2.5 / K2.6 | Yes | Nice to have | Accepts `""` |
347
392
  | **Kimi K2.7 Code** | **Not enough alone** | **Required** | Field must be *present*; real text keeps it coherent |
348
393
  | GLM-5.x / Zhipu | Yes | Nice to have | Accepts `""` |
349
394
  | MiMo V2.5 | Yes | Nice to have | `reasoning_content` on `api.xiaomimimo.com/v1` |
350
395
  | **MiniMax-M3** | Yes | **Recommended** | `reasoning_details[]` array; ~40% quality loss if stripped. Proxy injects `reasoning_split:true` to keep thinking separate from content. |
351
396
  | OpenCode Go | Yes | Recommended for chat/message routes | Chat uses `reasoning`; Anthropic messages are handled; unsupported response formats pass through |
397
+ | OpenRouter (reasoning models via openrouter.ai) | Yes | Recommended | Field depends on the upstream model; the fixed-upstream proxy replays `reasoning_content` and passes everything else through |
352
398
  | Qwen, GPT, Claude, Gemini, Llama, Mistral | No | No | No reasoning_content |
353
399
 
354
400
  ---
@@ -359,8 +405,9 @@ The proxy auto-routes by model name prefix. All routes:
359
405
 
360
406
  | Prefix | Upstream | Reasoning |
361
407
  |---|---|---|
362
- | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-chat` | `https://api.deepseek.com` | Yes (`reasoning_content`) |
363
- | `deepseek-reasoner` | `https://api.deepseek.com` | **No — actively stripped** (R1 contract: reasoning must NOT be echoed) |
408
+ | `deepseek-v4-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-vision-exp` | `https://api.deepseek.com` | Yes (`reasoning_content`) |
409
+ | `deepseek-chat` | `https://api.deepseek.com` | Non-thinking alias (no reasoning emitted; passback is a harmless no-op) |
410
+ | `deepseek-r1`, `deepseek-reasoner` | `https://api.deepseek.com` | Legacy — retired by DeepSeek 2026-07-24; kept as a compatibility guard with active stripping |
364
411
  | `kimi`, `moonshot` | `https://api.moonshot.ai/v1` | Yes (`reasoning_content`) |
365
412
  | `glm`, `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | Yes (`reasoning_content`) |
366
413
  | `minimax` | `https://api.minimax.io/v1` | Yes (`reasoning_details`, `reasoning_split:true`) |
@@ -374,15 +421,37 @@ The proxy auto-routes by model name prefix. All routes:
374
421
 
375
422
  Unknown models fall back to `https://api.deepseek.com` with reasoning disabled.
376
423
 
424
+ ### OpenRouter (port 3462)
425
+
426
+ OpenRouter wraps many upstream models, and reasoning models served through it lose their thinking the same way. Run a third fixed-upstream proxy and point your OpenRouter provider at it:
427
+
428
+ ```bash
429
+ PORT=3462 UPSTREAM_URL=https://openrouter.ai/api/v1 REASONING_KEY=reasoning_content node proxy/proxy.js
430
+ ```
431
+
432
+ ```json
433
+ {
434
+ "provider": {
435
+ "openrouter": {
436
+ "options": {
437
+ "baseURL": "http://127.0.0.1:3462/v1"
438
+ }
439
+ }
440
+ }
441
+ }
442
+ ```
443
+
444
+ The passive-tap corpus includes an OpenRouter lane: 22 session-confirmed drops, concentrated on `minimax/minimax-m3:free` within one long session — enough to show the pattern exists on OpenRouter, with the full model-by-model matrix left to a follow-up corpus. See [The evidence](#the-evidence).
445
+
377
446
  ---
378
447
 
379
448
  ## Is it working?
380
449
 
381
450
  The plugin writes a structured JSON log: `~/.local/share/opencode/thinking-fix.log`.
382
451
 
383
- 628 unique sessions. 12,551 inspect events (the hook fires twice per message by design — first pass patches, second confirms clean). 297 reasoning model sessions patched, 331 non-reasoning correctly skipped. Zero false patches.
452
+ Every request logs an `inspect` event (field coverage, whether the model is a reasoning model, how many turns were patched), so the plugin is auditable even when it patches nothing. Non-reasoning models are skipped by design — zero false patches is a property of the self-detection, not a promise bolted on.
384
453
 
385
- Before and after, from a real session:
454
+ From a real 104-message session:
386
455
 
387
456
  ```
388
457
  Before: reasoning dropped on every turn → AI forgets its thinking
@@ -454,11 +523,16 @@ systemd/
454
523
  reasoning-cache.service # proxy systemd unit (port 3457)
455
524
  reasoning-cache-go.service # OpenCode Go proxy unit (port 3458)
456
525
  reasoning-proxy-watchdog.service # watchdog systemd unit
526
+ tests/
527
+ test-proxy.js # 127 proxy assertions
528
+ test-plugin.js # 12 plugin assertions
529
+ live-minimax-replay.mjs # self-contained replay gate (mock upstream)
530
+ proof/ # evidence: captures, controls, CSVs, repro scripts
457
531
  ```
458
532
 
459
533
  ## Release highlights
460
534
 
461
- - **v3.2.0:** R1 `strip` sentinel (actively remove reasoning for `deepseek-r1`/`deepseek-reasoner`), multibyte-safe SSE decoding (StringDecoder), response hop-by-hop header stripping, oversized-reasoning skip-not-truncate, MiniMax replay shape behind `MINIMAX_REASONING_DETAILS_SHAPE` (default `minimal`), a real-core test suite (95 assertions), glm-5.2 go-mode `reasoning_content` key (F11, live-verified), derived session keys (Option C), and `x-session-id` no longer forwarded upstream. See the changelog.
535
+ - **v3.2.0:** R1 `strip` sentinel (actively remove reasoning for `deepseek-r1`/`deepseek-reasoner`), multibyte-safe SSE decoding (StringDecoder), response hop-by-hop header stripping, oversized-reasoning skip-not-truncate, MiniMax replay shape behind `MINIMAX_REASONING_DETAILS_SHAPE` (default `minimal`), a real-core test suite (95 assertions), glm-5.2 go-mode `reasoning_content` key (live-verified across two turns), derived session keys, and `x-session-id` no longer forwarded upstream. See the changelog.
462
536
  - **v3.1.4:** proxy-owned structured JSONL logging with session identifiers truncated in logs.
463
537
  - **v3.1.3:** Anthropic-wire dialect detection for OpenCode Go routes.
464
538
  - **v3.1.2:** bounded cache/session memory, raw-stream preservation, hop-by-hop header removal, and safer parser failure handling.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-thinking-fix",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "description": "Restores reasoning content dropped from multi-turn OpenCode conversations (DeepSeek, Kimi, GLM, MiMo, MiniMax, OpenCode Go). Plugin + proxy + watchdog.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/proxy/core.js CHANGED
@@ -39,6 +39,21 @@ export function writeLog(entry) {
39
39
  const DEBUG = process.env.DEBUG === '1'
40
40
 
41
41
  export const ROUTES = {
42
+ // Zen free-tier lane — BARE model ids as sent by the `opencode` provider
43
+ // section (port 3459, universal mode). MUST stay at the top: generic
44
+ // prefixes below ('deepseek-v4-flash', 'mimo', ...) would otherwise swallow
45
+ // these names and route free traffic to provider-native endpoints.
46
+ 'x-preview-f-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
47
+ 'hy3-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
48
+ 'mimo-v2.5-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
49
+ 'muse-spark-1.2-contributor-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
50
+ 'deepseek-v4-flash-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
51
+ 'ling-3.0-flash-fin-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
52
+ 'laguna-s-2.1-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
53
+ 'big-pickle': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
54
+ 'ox-alpha-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
55
+ 'nemotron-3-ultra-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
56
+ 'nemotron-3.5-lightning-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
42
57
  // R1 aliases: reasoning must not be echoed back — the 'strip' sentinel
43
58
  // admits the request to patchRequestBody (to REMOVE any reasoning fields a
44
59
  // client/plugin attached) while excluding it from response-side caching.
@@ -62,10 +77,30 @@ export const ROUTES = {
62
77
  gemini: { base: 'https://generativelanguage.googleapis.com/v1beta/openai', reasoningKey: null },
63
78
  llama: { base: 'https://api.together.xyz', reasoningKey: null },
64
79
  mistral: { base: 'https://api.mistral.ai', reasoningKey: null },
80
+ // opencode/ zen free-tier models — served by the OpenCode zen gateway at
81
+ // zen/v1 (chat/completions wire, reasoning_content carrier). Distinct from
82
+ // the opencode-go namespace (port 3458) handled by fixedUpstreamRoute.
83
+ // Explicit per-model prefixes only: no catch-all `opencode/` prefix, so paid
84
+ // opencode/* models keep their existing DEFAULT_ROUTE behavior.
85
+ 'opencode/hy3-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
86
+ 'opencode/mimo-v2.5-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
87
+ 'opencode/muse-spark-1.2-contributor-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
88
+ 'opencode/deepseek-v4-flash-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
89
+ 'opencode/nemotron-3-ultra-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
90
+ 'opencode/nemotron-3.5-lightning-free': { base: 'https://opencode.ai/zen/v1', reasoningKey: 'reasoning_content' },
65
91
  }
66
92
 
67
93
  export const DEFAULT_ROUTE = { base: 'https://api.deepseek.com', reasoningKey: null }
68
94
 
95
+ // F13 (2026-08-27): clients may speak either OpenAI-style relative paths
96
+ // (/v1/...) or the upstream's full official prefix (/api/v1/... — OpenRouter's
97
+ // documented base is https://openrouter.ai/api/v1). Never double the prefix.
98
+ export function upstreamPathFor(reqUrl, basePath) {
99
+ if (!basePath || basePath === '/' || basePath === '/v1') return reqUrl
100
+ if (reqUrl === basePath || reqUrl.startsWith(basePath + '/')) return reqUrl
101
+ return basePath + reqUrl.replace(/^\/v1/, '')
102
+ }
103
+
69
104
  export function route(modelName) {
70
105
  if (!modelName) return DEFAULT_ROUTE
71
106
  const lower = modelName.toLowerCase()
@@ -91,6 +126,15 @@ export function fixedUpstreamRoute(modelName, requestUrl = '') {
91
126
  if (stripped === 'glm-5.2' && String(requestUrl).includes('/chat/completions')) {
92
127
  return { base: '', reasoningKey: 'reasoning_content' }
93
128
  }
129
+ // REASONING_KEY env override (F12, 2026-08-27): lanes fixed to upstreams whose
130
+ // wire is reasoning_content-based (zen/v1 free models, OpenRouter normalized
131
+ // per-message passback) set REASONING_KEY=reasoning_content in their unit.
132
+ // OpenRouter docs define top-level `reasoning` as a config OBJECT on requests,
133
+ // so a string injection there must use reasoning_content instead. Units without
134
+ // the env (validated go lane, 3458) keep the unconditional 'reasoning' behavior.
135
+ if (process.env.REASONING_KEY) {
136
+ return { base: '', reasoningKey: process.env.REASONING_KEY }
137
+ }
94
138
  // Unconditional 'reasoning' for go mode: matches current working behavior
95
139
  // for chat/completions models; /responses bodies have no `messages` array
96
140
  // and pass through untouched.
package/proxy/proxy.js CHANGED
@@ -58,7 +58,7 @@ import https from 'node:https'
58
58
  import url from 'node:url'
59
59
  import {
60
60
  ROUTES, cache, stats, route, fixedUpstreamRoute, getSessionCache, patchRequestBody,
61
- extractReasoningFromJson, createStreamParser, writeLog, deriveSessionId,
61
+ extractReasoningFromJson, createStreamParser, writeLog, deriveSessionId, upstreamPathFor,
62
62
  } from './core.js'
63
63
 
64
64
  const PORT = parseInt(process.env.PORT || '3457', 10)
@@ -170,11 +170,9 @@ const server = http.createServer((req, res) => {
170
170
  }
171
171
 
172
172
  // ── Upstream request options ──
173
- let upstreamPath = req.url
174
- const basePath = upstream.path || '/'
175
- if (basePath !== '/' && basePath !== '/v1') {
176
- upstreamPath = basePath + req.url.replace(/^\/v1/, '')
177
- }
173
+ // F13: path normalization lives in core.js (upstreamPathFor) — never double
174
+ // the upstream's official prefix.
175
+ const upstreamPath = upstreamPathFor(req.url, upstream.path || '/')
178
176
  const transport = upstream.protocol === 'https:' ? https : http
179
177
  const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'proxy-', 'x-session-id'])
180
178
  const safeHeaders = {}