fix(core): emit OpenRouter's reasoning disable when thinking is off - #9758
Conversation
The AUTO-mode permission classifier's stage-1 side query forces a respond_in_schema tool call (tool_choice: 'required') with a 256-token budget and includeThoughts: false. On OpenRouter endpoints the thinking-disable rendered only into shapes the gateway ignores (chat_template_kwargs.enable_thinking for qwen-family models), and the pipeline's unconditional strip then removed the `reasoning` object — OpenRouter's native thinking knob. Thinking stayed on, the model spent the whole budget on reasoning, never emitted the tool call, and the classifier fail-closed with "Classifier stage 1 unavailable" (QwenLM#9757). Mirror the isDeepSeekHostname precedent: hostname-gated detection (openrouter.ai / *.openrouter.ai) and emit `reasoning: { enabled: false }` in the reasoningDisabled branch after the strip — the provider buildRequest hook runs before the strip, so emitting earlier would be removed again. Applied endpoint-wide rather than qwen-family-gated: `reasoning` is an OpenRouter provider-level parameter, unlike `enable_thinking`, which is a qwen wire field that leaks upstream on non-qwen routings. thinkingMandatory models stay exempt; DashScope (both shapes), vLLM/SGLang, DeepSeek hostname, and the official OpenAI endpoint are untouched. Repro + regression coverage added in pipeline.test.ts (red before the fix, green after).
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓ Problem: observed bug, not theory — #9757 is open with a concrete reproduction (OpenRouter + AUTO mode → Direction: aligned. This completes the per-provider thinking-disable matrix — DashScope, vLLM/SGLang, and DeepSeek hostnames each already emit their own native disable shape; OpenRouter was the missing one. The placement problem is real too: the unconditional Size: 65 production lines ( Approach: minimal and on-pattern — a hostname detector mirroring the existing Risk: Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:已观测到的 bug,不是理论问题——#9757 仍 open,有具体复现(OpenRouter + AUTO 模式 → 方向:对齐。这补全了各供应商的思考禁用矩阵——DashScope、vLLM/SGLang、DeepSeek hostname 各自已有原生禁用形状,缺的就是 OpenRouter。放置位置的问题也是真实的:管道里无条件的 规模:65 行生产代码( 方案:最小且符合既有模式——镜像现有 风险: 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewThe implementation matches what I'd have proposed before reading the diff — a hostname detector mirroring
Request-build flow, since after-the-strip placement is the subtle invariant here: sequenceDiagram
participant P1 as pipeline.buildRequest
participant P2 as provider.buildRequest hook
participant P3 as reasoningDisabled branch
participant P4 as chat.completions.create
P1->>P2: build base request
P2-->>P1: providerRequest with hook fields
P1->>P3: emit per-provider disable shapes
Note over P3: qwen branches first, then strip reasoning and reasoning_effort
P3->>P3: emit reasoning enabled=false after the strip (new)
P3->>P4: final wire body
CI evidenceThe PR's own CI on the reviewed commit: security checks, precheck, and desktop shell builds are green; the Linux unit suite — this repo's PR test gate — is still running. The macOS/Windows Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Sandboxed verification would settle the remaining behavioural gap: 中文说明代码审查实现与我读 diff 前独立提出的方案一致——镜像
CI 证据被审提交上 PR 自己的 CI:安全检查、precheck、桌面壳构建均绿;Linux 单元测试(本仓库 PR 的测试门禁)仍在运行。macOS/Windows 的 沙箱验证可以补上剩余的行为缺口: — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — clean, minimal fix that closes a real per-provider gap with regression-pinning tests; the missing point is untested live-gateway behaviour, not anything in the diff. Stepping back: the problem is real and user-visible — AUTO mode is effectively unusable on OpenRouter — and the fix is exactly the missing piece, not a new mechanism but one more branch in an established pattern. Every line in the diff serves the stated goal; no drive-by edits. My independent proposal before reading the code was essentially what landed, minus the config-level and non-qwen coverage, which are genuine improvements. The one subtle invariant — emit after the strip, not in the provider hook — is the kind of thing that breaks silently if gotten wrong, and the tests would catch exactly that breakage, which is what I want to see from a change in this file. Reservations, none blocking: live OpenRouter behaviour is unverified for lack of an API key (OpenRouter's documented Approval deferred until CI lands green on 中文说明置信度:4/5 —— 干净、最小的修复,用带回归保护的测试补上了真实的供应商缺口;扣掉的一分是未经真实网关验证的行为,而不是 diff 本身有任何问题。 退一步看:问题真实且用户可感——AUTO 模式在 OpenRouter 上实际不可用——而修复恰好是缺的那一块,不是新机制,只是既有模式里再加一个分支。diff 里每一行都服务于既定目标,没有顺手改动。我在读代码前独立提出的方案基本就是落地的方案,只是少了配置级和非 qwen 的覆盖,而那两点是真正的改进。唯一微妙的不变量——在剥离之后补发、而不是在 provider hook 里——正是那种搞错了就会静默坏掉的点,而测试恰好能捕获那种坏法,这是我对这个文件的改动想看到的。 保留意见,均不阻塞:因缺少 API key,OpenRouter 真实行为未验证(目前的证据是 OpenRouter 文档化的 — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
Test Plan (not a blocker): 167 passed — this review observed 20918, 1685, 23284, 1653, 495, 4125, 595 passed.
中文说明
已审查——无阻断问题。 建议见行内评论。
Test Plan(非阻断):167 passed — this review observed 20918, 1685, 23284, 1653, 495, 4125, 595 passed。
— qwen3.8-max via Qwen Code /review (v0.22.0)
|
Closeout update: added colocated OpenRouter hostname coverage, pinned that the OpenRouter disable shape is only emitted on the disabled-thinking path, and documented the OpenRouter native disable shape. Verified with |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
中文说明
— qwen3.8-max via Qwen Code /review (v0.22.0)
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
Not explored to full depth (tool budget reached): "agent 1a": running the 8 new pipeline.test.ts tests end-to-end — blocked because this shared worktree lacks a complete built dist/ (vitest globalSetup prerequisite), and….
Test Plan (not a blocker): 167 passed — this review observed 20925, 1685, 23284, 1653, 495, 4125, 595 passed.
中文说明
未发现问题。LGTM!✅
未探索到全部深度(达到工具调用预算):"agent 1a":running the 8 new pipeline.test.ts tests end-to-end — blocked because this shared worktree lacks a complete built dist/ (vitest globalSetup prerequisite), and…。
Test Plan(非阻断):167 passed — this review observed 20925, 1685, 23284, 1653, 495, 4125, 595 passed。
— qwen3.8-max via Qwen Code /review (v0.22.0)
|
Local runtime verification: PASS (wire-level request-body evidence)
Captured request #1 ( {
"model": "or-verify",
"temperature": 0,
"stream": false,
"max_tokens": 32000,
"reasoning": { "enabled": false }
}Captured control #2 (same { "model": "or-verify", "reasoning": { "effort": "medium" } }So on OpenRouter, thinking-off requests now ship the provider's canonical disable switch while thinking-on requests keep their configured effort — exactly the behavior the fix targets (#9757). Runtime evidence only; the PR's unit tests already run in CI. Visual evidence (terminal screenshot of the captured wire bodies at PR head |
1 similar comment
|
Local runtime verification: PASS (wire-level request-body evidence)
Captured request #1 ( {
"model": "or-verify",
"temperature": 0,
"stream": false,
"max_tokens": 32000,
"reasoning": { "enabled": false }
}Captured control #2 (same { "model": "or-verify", "reasoning": { "effort": "medium" } }So on OpenRouter, thinking-off requests now ship the provider's canonical disable switch while thinking-on requests keep their configured effort — exactly the behavior the fix targets (#9757). Runtime evidence only; the PR's unit tests already run in CI. Visual evidence (terminal screenshot of the captured wire bodies at PR head |
chiga0
left a comment
There was a problem hiding this comment.
No blocking findings.
Scope: Standard tier — 5 files, 456 lines diff (mostly new tests).
Checked:
openrouter.ts:isOpenRouterHostnameparsesbaseUrlwithnew URL(), matcheshostname === "openrouter.ai" || hostname.endsWith(".openrouter.ai"). Correct — no substring false-positives (evil-hostname test confirms). MirrorsisDeepSeekHostname.pipeline.ts: The newreasoning: { enabled: false }block fires after the existing DeepSeek block, inside theincludeThoughts === falsebranch. Placement is after any provider-hookreasoningstrip, so the OpenRouter shape is not immediately clobbered.thinkingMandatoryguard is preserved.- Test matrix: 8 tests cover —
includeThoughts:falseon OpenRouter (emits), thinking enabled (does NOT emit), configreasoning:false(emits), non-qwen model on OpenRouter (emits),thinkingMandatory=true(does NOT emit), non-OpenRouter endpoint (does NOT emit), evil hostname (does NOT emit), officialapi.openai.com(does NOT emit). Full branch coverage. openrouter.test.ts: 5it.eachcases includingopenrouter.ai.evil.comfalse-positive guard.
CI: precheck-pr SKIPPED — fork PR security model. Test plan ran 8 new cases locally per prior review (167 passed).
Cross-check: Prior suggestion about missing openrouter.test.ts is addressed — the file is in this diff. No prior criticals.
Reviewed with AI assistance.
Local runtime verification — PASS (socket-level wire capture + user-visible A/B)Maintainer-side verification of head Harness (how the capture is made honest)
1. User-visible A/B — the #9757 symptom, reproduced and fixedSame prompt, same fake gateway, same AUTO mode; only the build differs. merge-base PR head Side-effect oracle (a marker file the shell call writes): base = never created, head = created. Stage-1 bodies captured in those two TUI sessions:
2. Captured request body (bytes off the socket)The two bodies are byte-identical apart from the added field: 3. Hostname / config matrix (all rows on the head build)
Notes on the matrix:
4. Static checks (merge-base counterfactual)
5. Non-blocking notes
VerdictLGTM — recommend merge. The failure in #9757 reproduces deterministically on merge-base and disappears at head; the added wire field lands exactly where it should and nowhere else; existing per-provider disable shapes are untouched; no new lint/type/test debt. The only thing local verification cannot settle is whether OpenRouter's gateway honours 中文说明本地运行时验证 —— 通过(socket 级线上抓包 + 用户可见 A/B)以维护者身份对 head 验证环境(抓包为什么可信)
1. 用户可见 A/B —— #9757 的现象复现并被修复同样的 prompt、同样的假网关、同样的 AUTO 模式,只有构建不同。 merge-base
PR head
副作用判据(shell 调用写出的 marker 文件):base 从未生成,head 已生成。这两次 TUI 会话抓到的 stage-1 请求体:
2. 抓到的请求体(socket 上的字节)
两个请求体除了新增字段之外逐字节相同: 3. Hostname / 配置矩阵(全部在 head 构建上跑)
矩阵要点:
4. 静态检查(含 merge-base 反事实)
5. 非阻塞说明
结论LGTM —— 建议合入。 #9757 的故障在 merge-base 上可确定性复现,在 head 上消失;新增的线上字段落在该落的地方、且没有外溢;既有的各供应商关闭形状未受影响;没有新增 lint/类型/测试债务。本地验证唯一无法定论的,是 OpenRouter 网关端到端是否认 |
|
Released in v0.22.2. |




What this PR does
When reasoning is disabled (
includeThoughts: falseon a request, e.g. the AUTO-mode classifier's stage-1 side query, or config-levelreasoning: false) and the endpoint is OpenRouter (openrouter.aior a subdomain), the OpenAI-compatible pipeline now emits OpenRouter's native thinking-disable parameterreasoning: { enabled: false }on the wire request. The detection is a hostname-only helper (isOpenRouterHostname) mirroring the existingisDeepSeekHostnameprecedent, and the emission sits in thereasoningDisabledbranch after the unconditionalreasoning/reasoning_effortstrip, because the providerbuildRequesthook runs before that strip and anything emitted there would be removed again.Why it's needed
Fixes the failure chain in #9757: the classifier's stage 1 is a structured side query that forces a
respond_in_schematool call (tool_choice: 'required', forced-tool mapping from #6929) with a 256-token budget andthinkingConfig: { includeThoughts: false }. On OpenRouter the thinking-disable only rendered into shapes the gateway ignores —chat_template_kwargs.enable_thinking(the vLLM/SGLang shape) for qwen-family models — and the pipeline then stripped thereasoningobject, which is exactly OpenRouter's native thinking knob. Thinking stayed enabled, the model spent the whole 256-token budget on reasoning and never emitted the forced tool call,generateJsonreturned{}, schema validation failed, and the classifier fail-closed withClassifier stage 1 unavailable, forcing manual approval for every AUTO-mode action. This is a residual gap next to #6929 (which added the forced tool call + budget), not a duplicate: #6929 works wherever the thinking-disable actually lands, but OpenRouter was missing from the disable-rendering logic. It is also disjoint from #9590 (still open), which covers DeepSeek/GLM/Kimi thinking controls on the WebShell UI and does not touch the OpenRouter classifier path.Reviewer Test Plan
How to verify
Detector-level reproduction and regression coverage live in
packages/core/src/core/openaiContentGenerator/pipeline.test.ts(searchOpenRouter): withbaseUrl: https://openrouter.ai/api/v1andthinkingConfig: { includeThoughts: false }, the capturedchat.completions.createbody is asserted to carryreasoning: { enabled: false }. On unpatched main these tests fail withreasoning: undefined; with this PR they pass. Negative guards: noreasoningfield leaks to the official OpenAI endpoint, to non-OpenRouter OpenAI-compatible gateways (vLLM/SGLang), or to lookalike hostnames (openrouter.ai.evil.com), andthinkingMandatorymodels on OpenRouter stay exempt. Existing behavior preserved: DashScope tiered (reasoning_effort: 'none') and boolean (enable_thinking: false) branches, vLLM/SGLangchat_template_kwargs, and DeepSeek hostnamethinking: { type: 'disabled' }are untouched and covered by their existing tests. Run:cd packages/core && npx vitest run src/core/openaiContentGenerator/pipeline.test.ts— 167 passed locally, plusnpm run typecheck(clean).Evidence (Before & After)
N/A — non-user-visible wire-shape fix; evidence is the unit-test red/green pair described above (3 emission tests failed before the fix, all 167 pass after).
Tested on
Environment (optional)
Unit tests only (
vitest+tsc --noEmitinpackages/core); no live OpenRouter account was used.Risk & Scope
reasoning: { enabled: false }is now sent on every reasoning-disabled request to OpenRouter hostnames regardless of model family. That is intentional —reasoningis an OpenRouter provider-level parameter the gateway routes to whatever model supports it (unlikeenable_thinking, a qwen-specific wire field that the existing code deliberately family-gates to avoid leaking upstream) — but it means OpenRouter-side handling of the parameter is the only validation so far.chat_template_kwargs.enable_thinking=falsefield the gateway ignores — left in place to keep the diff minimal; no changes to classifier budgets or stage-2 behavior.Linked Issues
Fixes #9757
Related: #6929 (forced
respond_in_schematool call + 256-token budget for side queries — this PR fixes the OpenRouter gap its failure comment describes), #6791 (same failure family), #9590 (open; DeepSeek/GLM/Kimi WebShell thinking controls, disjoint scope)中文说明
本 PR 做了什么
当推理被禁用时(请求级
includeThoughts: false,例如 AUTO 模式分类器的 stage-1 side query;或配置级reasoning: false),如果端点是 OpenRouter(openrouter.ai或其子域名),OpenAI 兼容管道现在会在出站请求上带上 OpenRouter 原生的思考关闭参数reasoning: { enabled: false }。检测是一个仅看 hostname 的辅助函数(isOpenRouterHostname),对齐现有isDeepSeekHostname先例;补发逻辑放在reasoningDisabled分支里对reasoning/reasoning_effort的无条件剥离之后,因为 provider 的buildRequesthook 先于剥离运行,在那里发出的字段会被再次删掉。为什么需要
修复 #9757 的失败链:分类器 stage 1 是一个结构化 side query,强制
respond_in_schema工具调用(tool_choice: 'required',来自 #6929 的强制工具映射),预算 256 token,并带thinkingConfig: { includeThoughts: false }。在 OpenRouter 上,思考关闭只渲染成网关忽略的格式——qwen family 模型拿到chat_template_kwargs.enable_thinking(vLLM/SGLang 形状)——随后管道又把reasoning对象剥掉,而这恰恰是 OpenRouter 原生的思考开关。于是思考仍然开启,模型把 256 token 预算全花在推理上,永远发不出强制工具调用,generateJson返回{},schema 校验失败,分类器 fail-closed 报Classifier stage 1 unavailable,AUTO 模式每个操作都转手动审批。这是 #6929 之后的残留缺口而非重复:#6929 在思考关闭能真正生效的端点上是有效的,只是关闭逻辑的渲染里漏了 OpenRouter。与仍 open 的 #9590 也不重叠——那个 PR 覆盖 WebShell UI 上 DeepSeek/GLM/Kimi 的思考控件,不涉及 OpenRouter 分类器路径。审阅者测试计划
如何验证
检测器级复现与回归保护在
packages/core/src/core/openaiContentGenerator/pipeline.test.ts(搜OpenRouter):baseUrl: https://openrouter.ai/api/v1+thinkingConfig: { includeThoughts: false }时,断言捕获到的chat.completions.create请求体带reasoning: { enabled: false }。在未修复的 main 上这些测试以reasoning: undefined失败;本 PR 下通过。负向保护:官方 OpenAI 端点、非 OpenRouter 的 OpenAI 兼容网关(vLLM/SGLang)、仿冒域名(openrouter.ai.evil.com)都不会多收reasoning字段;OpenRouter 上的thinkingMandatory模型保持豁免。既有行为保留:DashScope 分层(reasoning_effort: 'none')与布尔(enable_thinking: false)分支、vLLM/SGLang 的chat_template_kwargs、DeepSeek hostname 的thinking: { type: 'disabled' }均未改动且由既有测试覆盖。运行:cd packages/core && npx vitest run src/core/openaiContentGenerator/pipeline.test.ts——本地 167 个全部通过,另有npm run typecheck无错误。前后证据
N/A——非用户可见的线上请求形状修复;证据是上述单元测试的修复前红/修复后绿(3 个补发断言修复前失败,修复后 167 个全过)。
测试环境
环境(可选)
仅单元测试(
packages/core里的vitest+tsc --noEmit);未使用真实 OpenRouter 账号。风险与范围
reasoning: { enabled: false },不限模型族。这是有意为之——reasoning是 OpenRouter 的 provider 级参数,网关会把它路由给支持的模型(不同于enable_thinking,那是 qwen 专属线上字段,现有代码刻意按模型族门控以防泄漏到上游)——但这也意味着目前对reasoning参数的验证只到 OpenRouter 文档层面。chat_template_kwargs.enable_thinking=false字段,为保持最小 diff 保留;分类器预算与 stage-2 行为均未改动。关联 Issue
Fixes #9757
相关:#6929(side query 的强制
respond_in_schema工具调用 + 256 token 预算——本 PR 修复的正是其失败评论描述的 OpenRouter 缺口)、#6791(同族问题)、#9590(open;DeepSeek/GLM/Kimi WebShell 思考控件,范围不重叠)