Skip to content

fix(core): preserve disabled reasoning effort - #7541

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
WladmirJunior:fix/preserve-disabled-reasoning-effort
Jul 23, 2026
Merged

fix(core): preserve disabled reasoning effort#7541
wenshao merged 2 commits into
QwenLM:mainfrom
WladmirJunior:fix/preserve-disabled-reasoning-effort

Conversation

@WladmirJunior

Copy link
Copy Markdown
Contributor

What this PR does

Preserves an explicitly configured reasoning_effort: "none" when a side query disables thinking. Other reasoning effort values and nested reasoning configuration continue to be removed as before.

Why it's needed

Structured side queries use a forced function call to produce schema-constrained output. Some OpenAI-compatible gateways reject function tools for GPT-5 models unless reasoning is explicitly disabled with reasoning_effort: "none". The current pipeline removes that explicit opt-out, causing Auto Mode classification and other structured side queries to fail with a 400 response.

Reviewer Test Plan

How to verify

Configure an OpenAI-compatible GPT-5 model with samplingParams.reasoning_effort set to none, then trigger a structured side query with thinking disabled. Confirm the outbound request retains reasoning_effort: "none". Also confirm non-disabled reasoning effort values are still removed when thinking is disabled.

Evidence (Before & After)

Before: the gateway rejected structured side queries because the explicit none value was removed before the request was sent.

After: the same Auto Mode classifier request succeeds, and the focused pipeline suite passes all 112 tests including the new regression case.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Node.js 26.5.0; focused core unit tests, formatting, lint, and core typecheck.

Risk & Scope

  • Main risk or tradeoff: providers that interpret the literal none value differently will now receive the user-configured value instead of having it removed, matching the explicit configuration.
  • Not validated / out of scope: Windows and Linux runtime validation; migration to the OpenAI Responses API.
  • Breaking changes / migration notes: none.

Linked Issues

N/A

中文说明

此 PR 的作用

当侧查询禁用思考时,保留显式配置的 reasoning_effort: "none"。其他推理强度值和嵌套推理配置仍会像以前一样被移除。

为什么需要它

结构化侧查询使用强制函数调用来生成符合 schema 的输出。某些 OpenAI 兼容网关会拒绝 GPT-5 模型的函数工具调用,除非通过 reasoning_effort: "none" 显式禁用推理。当前管道会删除这个显式关闭值,导致 Auto Mode 分类和其他结构化侧查询返回 400 错误。

Reviewer Test Plan

如何验证

配置一个 OpenAI 兼容的 GPT-5 模型,将 samplingParams.reasoning_effort 设置为 none,然后触发一个禁用思考的结构化侧查询。确认发出的请求仍包含 reasoning_effort: "none"。同时确认在禁用思考时,其他非禁用的推理强度值仍会被移除。

证据(修改前后)

修改前:由于显式的 none 值在发送请求前被删除,网关拒绝结构化侧查询。

修改后:同一个 Auto Mode 分类器请求成功,聚焦的管道测试套件全部 112 个测试通过,包括新的回归测试。

测试平台

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

Node.js 26.5.0;已运行聚焦的 core 单元测试、格式检查、lint 和 core 类型检查。

风险与范围

  • 主要风险或权衡:如果某个提供商对字面量 none 有不同解释,它现在会收到用户显式配置的值,而不是删除该值;这与用户配置一致。
  • 未验证或超出范围:Windows 和 Linux 运行时验证;迁移到 OpenAI Responses API。
  • 破坏性变更或迁移说明:无。

关联 Issue

@WladmirJunior
WladmirJunior force-pushed the fix/preserve-disabled-reasoning-effort branch from 6257299 to 572e823 Compare July 22, 2026 19:49
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@WladmirJunior
WladmirJunior force-pushed the fix/preserve-disabled-reasoning-effort branch 2 times, most recently from 3c75468 to 496dc8f Compare July 22, 2026 19:50
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with a clear failure mode — configuring samplingParams.reasoning_effort: "none" for an OpenAI-compatible GPT-5 model, then triggering a structured side query (e.g. the Auto Mode permission classifier) causes a 400 because the pipeline strips the explicit disable signal. The before/after is described in the PR body; no linked issue, but the scenario is concrete and reproducible.

Direction: aligned. OpenAI-compatible provider support is core to qwen-code, and reasoning_effort: "none" is semantically an opt-out (disable reasoning), not a reasoning configuration — stripping it when thinking is disabled is counterproductive. CHANGELOG has no direct reference to this specific case, but the area (reasoning effort handling) is actively maintained.

Size: 2 production lines changed in packages/core/src/core/openaiContentGenerator/pipeline.ts (+1 −1), 35 test lines added. Well within bounds.

Approach: the scope is exactly right — one condition guard that preserves "none" while still stripping all other reasoning effort values. No unrelated changes, no scope creep. The test covers the specific regression scenario.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,失败模式清晰——为 OpenAI 兼容的 GPT-5 模型配置 samplingParams.reasoning_effort: "none" 后,触发结构化侧查询(如 Auto Mode 权限分类器)会因管道剥离了显式禁用信号而返回 400。PR 正文描述了修改前后的行为;虽无关联 issue,但场景具体且可复现。

方向:对齐。OpenAI 兼容提供商支持是 qwen-code 的核心功能,而 reasoning_effort: "none" 语义上是关闭推理的开关,不是推理配置——在禁用思考时剥离它适得其反。

规模:packages/core/src/core/openaiContentGenerator/pipeline.ts 中修改了 2 行生产代码(+1 −1),新增 35 行测试代码。完全在范围内。

方案:范围恰好——一个条件守卫,保留 "none" 同时继续剥离其他推理强度值。无无关改动,无范围蔓延。测试覆盖了具体的回归场景。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at e959a853c3df58b365e3f43c5d968c1b024ae786 · re-run with @qwen-code /triage

@WladmirJunior
WladmirJunior force-pushed the fix/preserve-disabled-reasoning-effort branch from 496dc8f to e959a85 Compare July 22, 2026 19:52
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given the problem (stripping reasoning_effort: "none" breaks providers that use it as the disable signal), I would add a single condition guard at the reasoning-strip site in pipeline.ts to skip deletion when the value is "none", plus a regression test that exercises the side-query path with samplingParams: { reasoning_effort: 'none' }.

Comparison with the diff: the PR does exactly this. The one-line condition change at line 900 of pipeline.ts is the minimal correct fix — it preserves the explicit disable signal while still stripping all other reasoning effort values ("low", "medium", "high", etc.) that would contradict the thinking-disabled intent. The test is well-structured: it configures samplingParams: { reasoning_effort: 'none' }, executes with thinkingConfig: { includeThoughts: false } (the side-query path), and asserts the wire value survives.

No correctness bugs, security issues, or regressions found. The change follows the existing code style and the comment above the strip block already explains the intent. No over-abstraction, no duplication, no unrelated edits.

Testing

Unit tests: 112/112 pass on the PR branch (including the new regression test should preserve reasoning_effort none when thinking is disabled). Typecheck clean.

 ✓ src/core/openaiContentGenerator/pipeline.test.ts > ContentGenerationPipeline > execute > should preserve reasoning_effort none when thinking is disabled 0ms
 Test Files  1 passed (1)
      Tests  112 passed (112)

Real-scenario testing (mock OpenAI server):

Set up a mock OpenAI-compatible server on localhost:18923 that logs outbound request bodies, configured samplingParams.reasoning_effort: "none" via modelProviders, and ran both builds headless.

Before (installed qwen v0.20.1):

$ QWEN_HOME=/tmp/triage-7541 qwen -p "run echo hi" --model mock-model --approval-mode auto
Done! The command executed successfully.

$ cat /tmp/mock-openai-requests.jsonl
{"req":1,"reasoning_effort":"none","reasoning":null,"model":"mock-model","has_tools":true,"stream":true}
{"req":2,"reasoning_effort":"none","reasoning":null,"model":"mock-model","has_tools":true,"stream":true}

After (PR code via npm run dev):

$ QWEN_HOME=/tmp/triage-7541 npm run dev -- -p "run echo hi" --model mock-model --approval-mode auto
Done! The command executed successfully.

$ cat /tmp/mock-openai-requests.jsonl
{"req":1,"reasoning_effort":"none","reasoning":null,"model":"mock-model","has_tools":true,"stream":true}
{"req":2,"reasoning_effort":"none","reasoning":null,"model":"mock-model","has_tools":true,"stream":true}

Both builds preserve reasoning_effort: "none" on main queries (expected — thinking is not disabled for main queries). The fix targets side queries (thinking disabled), which are internal and not triggered in headless -p mode — the permission classifier only runs in interactive auto-mode. The unit test directly exercises this exact code path (pipeline.execute(request, 'side-query:permission-classifier') with thinkingConfig: { includeThoughts: false }) and confirms the wire value is preserved.

中文说明

代码审查

独立方案: 鉴于问题(剥离 reasoning_effort: "none" 会破坏将其用作禁用信号的提供商),我会在 pipeline.ts 的推理剥离位置添加一个条件守卫,当值为 "none" 时跳过删除,并添加一个回归测试来验证侧查询路径。

与 diff 的比较: PR 完全这样做了。pipeline.ts 第 900 行的一行条件更改是最小正确修复——保留显式禁用信号,同时继续剥离其他推理强度值。测试结构良好。

未发现正确性 bug、安全问题或回归。更改遵循现有代码风格。

测试

单元测试: PR 分支上 112/112 通过(包括新的回归测试)。类型检查通过。

真实场景测试(模拟 OpenAI 服务器): 搭建了模拟服务器,配置了 samplingParams.reasoning_effort: "none",分别运行了安装版本和 PR 代码。主查询行为一致(均保留 reasoning_effort: "none")。修复针对的是侧查询(思考禁用),这在无头 -p 模式下不会触发——权限分类器仅在交互式自动模式下运行。单元测试直接验证了此代码路径。

Qwen Code · qwen3.8-max-preview

Reviewed at e959a853c3df58b365e3f43c5d968c1b024ae786 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, minimal fix for a real provider-compatibility bug; would merge without hesitation.

This is exactly the kind of PR I like to see: one line of production code that fixes a concrete problem, backed by a focused regression test. The author correctly identified that reasoning_effort: "none" is semantically different from other effort values — it's the disable signal itself, so stripping it when thinking is disabled is self-defeating. The fix preserves the user's explicit configuration while keeping the strip behavior for all other values.

The test is well-placed alongside the existing reasoning-strip tests, uses the same mock patterns, and directly exercises the side-query code path. 112/112 tests pass, typecheck is clean. No unrelated changes, no scope creep, no over-engineering.

中文说明

置信度:5/5 — 干净、最小的修复,解决了真实的提供商兼容性问题;毫不犹豫地合并。

这是一个理想的 PR:一行生产代码修复了一个具体问题,配有聚焦的回归测试。作者正确识别了 reasoning_effort: "none" 与其他强度值的语义差异——它是禁用信号本身,在禁用思考时剥离它是自相矛盾的。修复保留了用户的显式配置,同时保持对其他值的剥离行为。

测试放置得当,使用了相同的 mock 模式,直接验证了侧查询代码路径。112/112 测试通过,类型检查通过。无无关改动,无范围蔓延,无过度工程。

Qwen Code · qwen3.8-max-preview

Reviewed at e959a853c3df58b365e3f43c5d968c1b024ae786 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

expect(apiCall.reasoning).toBeUndefined();
});

it('should preserve reasoning_effort none when thinking is disabled', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new condition typed['reasoning_effort'] !== 'none' has two branches (preserve when 'none', strip otherwise), but only the preserve branch is tested. No test verifies that reasoning_effort with a non-'none' value (e.g., 'high') is still stripped when thinking is disabled. — Failure scenario: a future change accidentally widens the preservation condition (e.g., removes the !== 'none' guard), causing non-none reasoning_effort values to leak through when thinking is disabled, contradicting the disable signal and adding unwanted reasoning latency/cost.

Suggested change
it('should preserve reasoning_effort none when thinking is disabled', async () => {
it('should preserve reasoning_effort none when thinking is disabled', async () => {
mockContentGeneratorConfig = {
...mockContentGeneratorConfig,
samplingParams: { reasoning_effort: 'none' },
} as ContentGeneratorConfig;
mockConfig = {
...mockConfig,
contentGeneratorConfig: mockContentGeneratorConfig,
};
pipeline = new ContentGenerationPipeline(mockConfig);
const request: GenerateContentParameters = {
model: 'gpt-5',
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
config: { thinkingConfig: { includeThoughts: false } },
};
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
{ role: 'user', content: 'Classify action' },
]);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
new GenerateContentResponse(),
);
(mockClient.chat.completions.create as Mock).mockResolvedValue({
id: 'response-id',
choices: [{ message: { content: 'safe' }, finish_reason: 'stop' }],
} as OpenAI.Chat.ChatCompletion);
await pipeline.execute(request, 'side-query:permission-classifier');
const apiCall = (mockClient.chat.completions.create as Mock).mock
.calls[0][0];
expect(apiCall.reasoning_effort).toBe('none');
});
it('should still strip non-none reasoning_effort when thinking is disabled', async () => {
mockContentGeneratorConfig = {
...mockContentGeneratorConfig,
samplingParams: { reasoning_effort: 'high' },
} as ContentGeneratorConfig;
mockConfig = {
...mockConfig,
contentGeneratorConfig: mockContentGeneratorConfig,
};
pipeline = new ContentGenerationPipeline(mockConfig);
const request: GenerateContentParameters = {
model: 'gpt-5',
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
config: { thinkingConfig: { includeThoughts: false } },
};
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
{ role: 'user', content: 'Classify action' },
]);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
new GenerateContentResponse(),
);
(mockClient.chat.completions.create as Mock).mockResolvedValue({
id: 'response-id',
choices: [{ message: { content: 'safe' }, finish_reason: 'stop' }],
} as OpenAI.Chat.ChatCompletion);
await pipeline.execute(request, 'side-query:permission-classifier');
const apiCall = (mockClient.chat.completions.create as Mock).mock
.calls[0][0];
expect(apiCall.reasoning_effort).toBeUndefined();
});

— qwen3.7-max via Qwen Code /review

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Minimal, well-scoped fix. Preserving only 'none' is the right granularity — other effort values ('low'/'medium'/'high') still get stripped as before. Test precisely covers the side-query + thinking-disabled scenario.

delete typed['reasoning'];
}
if ('reasoning_effort' in typed) {
if ('reasoning_effort' in typed && typed['reasoning_effort'] !== 'none') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the comparison is case-sensitive (!== 'none'). OpenAI only uses lowercase, but a user config with "None" would be silently stripped. Consider String(typed['reasoning_effort']).toLowerCase() !== 'none' for robustness. Non-blocking.

const apiCall = (mockClient.chat.completions.create as Mock).mock
.calls[0][0];
expect(apiCall.reasoning_effort).toBe('none');
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest adding a reverse test: reasoning_effort: 'high' with thinking disabled should still be stripped. The existing test at L829 only covers the nested reasoning object removal, not the top-level string. This would prevent a future regression where the guard is accidentally widened to preserve all values.

@gwinthis

Copy link
Copy Markdown
Collaborator

Review & Local Verification Report

代码审查

设计评价:精准最小修复。 本 PR 仅改动 1 行生产代码 + 1 个测试用例,解决了一个语义微妙的问题:

问题:当 thinkingConfig.includeThoughts === false 时,pipeline 会删除请求中的 reasoningreasoning_effort 字段。但 reasoning_effort: 'none' 是一个显式的"不要推理"信号——删除它后,provider 可能回退到默认推理级别(如 medium),反而产生不期望的推理开销。

修复

// Before: 无条件删除
if ('reasoning_effort' in typed) { delete typed['reasoning_effort']; }

// After: 保留 'none'(显式禁用信号)
if ('reasoning_effort' in typed && typed['reasoning_effort'] !== 'none') { delete typed['reasoning_effort']; }

语义分析:

  • reasoning_effort: 'none' → 保留(与 thinking disabled 语义一致)
  • reasoning_effort: 'low'|'medium'|'high' → 删除(与 thinking disabled 冲突)
  • reasoning 对象 → 仍然无条件删除(正确,因为 thinking disabled 时不应有 reasoning 配置)

测试覆盖: 新增测试验证 samplingParams: { reasoning_effort: 'none' } + includeThoughts: false 时,wire 请求保留 reasoning_effort: 'none'

本地验证

由于多 agent 并行工作目录冲突,未能在本地独立运行测试。但基于代码分析:

  • 改动仅涉及一个条件判断的边界条件
  • 不影响其他 reasoning_effort 值的处理逻辑
  • 测试用例覆盖了核心场景

结论

LGTM。 最小改动解决真实问题,语义正确,不引入副作用。典型的"less is more"修复。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two non-blocking follow-ups inline. Nothing here changes my approval.

delete typed['reasoning'];
}
if ('reasoning_effort' in typed) {
if ('reasoning_effort' in typed && typed['reasoning_effort'] !== 'none') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing worth noting: reasoningDisabled also fires on config-level reasoning: false, not just side queries. So a user who sets both reasoning: false and samplingParams.reasoning_effort: 'none' now ships the literal none on every request. On providers that reject that literal (DeepSeek's chat API only documents high/max), that combo goes from silently working to a 400. Sending the user's explicit value is still the right call — but the Risk section only mentions side queries, so might be worth calling this out there. Non-blocking.

delete typed['reasoning'];
}
if ('reasoning_effort' in typed) {
if ('reasoning_effort' in typed && typed['reasoning_effort'] !== 'none') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block comment above still says "we strip both shapes here", which is no longer accurate for the flat shape. And the 'none' exemption is exactly the kind of guard someone will simplify away later — the gateway constraint (function tools on GPT-5 rejected unless reasoning is explicitly disabled) isn't inferable from the code. Might be worth a one-line why next to the condition. Non-blocking.

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit afacef5 Jul 23, 2026
87 of 91 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants