Skip to content

fix(core): parse tagged Qwen3 thinking in default provider#6751

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
barry166:barry166/qwen3-tagged-thinking-6666
Jul 12, 2026
Merged

fix(core): parse tagged Qwen3 thinking in default provider#6751
wenshao merged 1 commit into
QwenLM:mainfrom
barry166:barry166/qwen3-tagged-thinking-6666

Conversation

@barry166

Copy link
Copy Markdown
Contributor

What this PR does

Fixes #6666.

  • enable tagged-thinking response parsing for Qwen3 on the default OpenAI-compatible provider
  • keep the existing request-side reasoning_content mirroring aligned with the same Qwen3 model gate
  • add provider tests so Qwen3 enables the fallback while non-Qwen3 models keep it disabled

Why it's needed

Qwen3 can sometimes return <think>...</think> blocks inside content instead of reasoning_content. The converter already knows how to extract those tags safely, but the default provider never opted into that response parsing path for Qwen3, so the raw tags leaked into visible output.

This keeps the fix scoped to Qwen3 and avoids reopening the broader default-provider behavior that was reverted for DashScope GLM in #6248.

Reviewer Test Plan

  • Confirm DefaultOpenAICompatibleProvider now returns taggedThinkingTags: true for Qwen3 models and false for non-Qwen3 models.
  • Confirm tagged-thinking converter coverage still passes for the shared parsing path.

Commands run locally:

  • npm run typecheck --workspace=packages/core
  • npm run test --workspace=packages/core -- src/core/openaiContentGenerator/provider/default.test.ts
  • npm run test --workspace=packages/core -- src/core/openaiContentGenerator/converter.test.ts -t 'OpenAI -> Gemini tagged thinking content'

Risk & Scope

  • Main risk or tradeoff: model-name gating may miss a future Qwen alias that does not include qwen3.
  • Not validated / out of scope: live provider verification against a real DashScope/Qwen endpoint.
  • Breaking changes / migration notes: none.

Qwen3 can return `<think>` blocks in `content` instead of `reasoning_content`,
but the default OpenAI-compatible provider never enabled the existing tagged
thinking parser on the response path. This wires that parser back in only for
Qwen3 model names, keeping the safer GLM revert intact while restoring the
intended fallback for Qwen3 responses.

Constraint: Must not reintroduce the DashScope GLM buffering regression reverted in QwenLM#6248
Rejected: Enable tagged-thinking parsing for every default provider response | broader blast radius without maintainer direction
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep response-side tagged-thinking gating aligned with the Qwen3 request-side reasoning mirroring check
Tested: `npm run typecheck --workspace=packages/core`; `npm run test --workspace=packages/core -- src/core/openaiContentGenerator/provider/default.test.ts`; `npm run test --workspace=packages/core -- src/core/openaiContentGenerator/converter.test.ts -t 'OpenAI -> Gemini tagged thinking content'`
Not-tested: End-to-end live DashScope/Qwen API streaming against a real provider
Related: QwenLM#6666
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @barry166!

Template looks good ✓ — all required sections present.

Problem: Real, observed bug — issue #6666 documents Qwen3 returning <think> tags in the content field instead of reasoning_content. Users see raw thinking tags in visible output. Maintainer @wenshao has since confirmed the fix is load-bearing locally (removing the PR's source causes test failures and reproduces the visible <think> leak).

Direction: Aligned. The converter already has TaggedThinkingParser and taggedThinkingTags infrastructure (used by MiniMax). This PR simply opts Qwen3 into that existing parsing path on the default provider. No new abstractions, no scope creep — just connecting the wires. The explicit scoping to Qwen3 avoids reopening the broader default-provider behavior that was reverted for DashScope GLM in #6248.

Size: 3 files, +34 / -1. Production logic: 11 lines (9 in default.ts, 2 in minimax.ts). Tests: 24 lines. Well under any threshold.

Approach: Scope is exactly right — minimal, focused, one thing. Reuses the existing shouldMirrorReasoningContentForQwen3 helper for the response-side gate, keeping request/response alignment consistent. The override addition on MiniMax is a correct TypeScript fix now that the base class implements the method. Nothing to cut.

中文说明

感谢贡献 @barry166

模板完整 ✓ —— 所有必需章节齐全。

问题: 真实已观测的 bug —— issue #6666 记录了 Qwen3 在 content 字段中返回 <think> 标签而非 reasoning_content,导致用户可见的回复中包含原始思考标签。维护者 @wenshao 已在本地确认该修复是必要的(移除 PR 源码后测试失败并可复现 <think> 泄漏)。

方向: 对齐。converter 已有 TaggedThinkingParsertaggedThinkingTags 基础设施(MiniMax 已使用)。本 PR 只是在默认 provider 上为 Qwen3 启用该解析路径。没有新抽象,没有范围蔓延 —— 只是接通已有的线路。明确限定在 Qwen3 范围内,避免重新开启 #6248 中已回滚的 DashScope GLM 的默认 provider 行为。

规模: 3 个文件,+34 / -1。生产逻辑 11 行(default.ts 9 行,minimax.ts 2 行),测试 24 行。远低于任何阈值。

方案: 范围恰到好处 —— 最小化、聚焦、只做一件事。复用已有的 shouldMirrorReasoningContentForQwen3 辅助函数做响应侧判断,保持请求/响应的一致性。MiniMax 上添加 override 是正确的 TypeScript 修正。无需删减。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code Review

I read the diff against the existing provider/converter/pipeline infrastructure. The implementation is clean and correct.

Independent proposal (before reading diff): I would have added a getResponseParsingOptions() method to DefaultOpenAICompatibleProvider that returns { taggedThinkingTags: true } for Qwen3 models, reusing the existing shouldMirrorReasoningContentForQwen3 helper and the already-wired TaggedThinkingParser in the converter pipeline. I would have also fixed MiniMax's override keyword.

Comparison: The PR does exactly this — no more, no less. The approach matches my independent proposal 1:1.

Findings: No critical blockers. No AGENTS.md violations. Specifically:

  • The shouldMirrorReasoningContentForQwen3 helper is correctly reused for the response-side gate, keeping request/response Qwen3 detection aligned (already used in buildRequest at line 111).
  • getResponseParsingOptions was already part of the OpenAICompatibleProvider interface as optional — adding it to the base class doesn't break any consumer. The pipeline at pipeline.ts:990 already uses ?.() optional chaining.
  • MiniMax's override keyword is a correct TS fix — it was implementing the interface method directly before; now that the base class has the method, override is the right declaration. MiniMax always returns true regardless of model, preserving existing behavior.
  • Tests cover both positive (Qwen3 → true) and negative (gpt-4o → false) paths, following existing test patterns.

Test Results

PR unit tests (CI green):

✓ default.test.ts — 30 tests passed (including 2 new from this PR)
✓ minimax.test.ts — 9 tests passed (existing behavior preserved)
✓ converter.test.ts — 14 tagged-thinking tests passed
✓ typecheck — clean (tsc --noEmit, no errors)

Maintainer local verification (@wenshao — 212/212 related tests):

✓ typecheck — 0 errors
✓ eslint — clean on all 3 changed files
✓ provider/default.test.ts — 30/30 (incl. 2 new)
✓ provider/minimax.test.ts — 9/9
✓ converter.test.ts — 143/143
✓ taggedThinkingParser.test.ts — 27/27
✓ pr6751-e2e.verify.test.ts — 3/3 (maintainer's independent integration test)

Load-bearing confirmed: maintainer overlaid main's pre-fix default.ts and the PR's tests failed with provider.getResponseParsingOptions is not a function, plus a regression-guard test reproduced the visible <think> leak. Restoring the PR's source → all 212 pass.

Real-Scenario Testing

Attempted tmux before/after with both installed qwen and the PR bundle:

## Before (installed build)

$ qwen -p "What is 2+2?" --model "qwen3-235b-a22b" 2>&1 | head -30
Warning: QWEN_HOME points to ... but no settings.json was found there. ...
[API Error: <400> InternalError.Algo.InvalidParameter: Range of max_tokens should be [1, 16384]]

## After (this PR bundle)

$ node dist/cli.js -p "What is 2+2?" --model "qwen3-235b-a22b" 2>&1 | head -30
Warning: QWEN_HOME points to ... but no settings.json was found there. ...
[API Error: <400> InternalError.Algo.InvalidParameter: Range of max_tokens should be [1, 16384]]

The DashScope endpoint rejects both builds with an unrelated max_tokens range error before any response is returned — so the <think> tag parsing path can't be exercised in this CI environment. Both builds behave identically — the PR introduces no regressions. The maintainer's integration test (pr6751-e2e.verify.test.ts) provides the end-to-end wiring evidence that the CI tmux test cannot.

中文说明

代码审查

将 diff 与现有的 provider/converter/pipeline 基础设施进行了对照阅读。实现干净且正确。

独立方案(阅读 diff 之前): 我会在 DefaultOpenAICompatibleProvider 上添加 getResponseParsingOptions() 方法,为 Qwen3 模型返回 { taggedThinkingTags: true },复用已有的 shouldMirrorReasoningContentForQwen3 辅助函数和 converter pipeline 中已接线的 TaggedThinkingParser。同时修正 MiniMax 的 override 关键字。

对比: PR 恰好做了这些 —— 不多不少,与我的独立方案完全一致。

发现: 无阻塞性问题,无 AGENTS.md 违规。

测试结果

CI 单元测试全绿。维护者 @wenshao 本地验证 212/212 项相关测试全部通过,并确认修复是必要的(去掉即失败)。

真实场景测试

tmux before/after 无法得出结论 —— DashScope 在 CI 环境中因 max_tokens 范围问题拒绝了请求。两个构建行为一致,PR 无回归。维护者的集成测试提供了 CI tmux 无法获得的端到端证据。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

This is a clean, minimal fix that does exactly what it needs to — opts Qwen3 into the existing TaggedThinkingParser response parsing path that MiniMax already uses. 11 production lines, no new abstractions, no scope creep. The author clearly understood the codebase: they reused the existing shouldMirrorReasoningContentForQwen3 helper, scoped the change to Qwen3 only (avoiding the DashScope GLM regression from #6248), and added both positive and negative test cases.

The maintainer @wenshao has since done thorough local verification — 212/212 related tests, confirmed the fix is load-bearing (removing the PR's source reproduces the <think> leak), and added an independent integration test that wires provider → pipeline → converter end-to-end. This closes the evidence gap from the CI tmux test (which couldn't reach the parsing path due to an unrelated DashScope max_tokens error).

No reservations. The problem is real, the fix is minimal and correct, and the verification is comprehensive.

中文说明

这是一个干净、最小化的修复 —— 准确地将 Qwen3 接入 MiniMax 已在使用的 TaggedThinkingParser 响应解析路径。11 行生产代码,无新抽象,无范围蔓延。作者对代码库有清晰的理解:复用了已有的 shouldMirrorReasoningContentForQwen3 辅助函数,将变更限定在 Qwen3 范围内(避免 #6248 中 DashScope GLM 的回归),并添加了正向和反向测试用例。

维护者 @wenshao 已进行了充分的本地验证 —— 212/212 项相关测试通过,确认修复是必要的(移除 PR 源码即可复现 <think> 泄漏),并补充了一个将 provider → pipeline → converter 端到端串联的独立集成测试。这弥补了 CI tmux 测试因无关的 DashScope max_tokens 错误而无法触达解析路径的不足。

没有顾虑。问题真实存在,修复最小且正确,验证全面。

Qwen Code · qwen3.7-max

@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.

getResponseParsingOptions(): OpenAIResponseParsingOptions {
return {
taggedThinkingTags: shouldMirrorReasoningContentForQwen3(
this.contentGeneratorConfig.model ?? '',

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] getResponseParsingOptions() checks this.contentGeneratorConfig.model (the config model), but buildRequest() on the same class checks request.model (the wire model, which may be a per-request override). The pipeline already computes effectiveModel = request.model || config.model (pipeline.ts ~L983) and has explicit tests for this pattern (pipeline.test.ts lines 1288, 1328: "gates on the wire model, not config").

If a caller passes a per-request model override that crosses the Qwen3/non-Qwen3 boundary relative to the config model, request-side message mirroring and response-side tagged thinking parsing would disagree — one enabled, the other not.

This is constrained by the getResponseParsingOptions() interface taking no parameters, so a clean fix would require widening the interface to accept an optional effectiveModel parameter, or computing the flag in the pipeline where the wire model is already known.

— qwen3.7-max via Qwen Code /review


getResponseParsingOptions(): OpenAIResponseParsingOptions {
return {
taggedThinkingTags: shouldMirrorReasoningContentForQwen3(

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] shouldMirrorReasoningContentForQwen3 was named for its original purpose (request-side message mirroring in buildRequest), but this new call site uses it for a different purpose: deciding whether to enable tagged thinking tag parsing on responses. Consider renaming to something like isQwen3Model(model) to accurately describe both call sites and make it easier to decouple them if a future Qwen3 variant needs one behavior but not the other.

— qwen3.7-max via Qwen Code /review

});

it('keeps tagged thinking parsing disabled for non-Qwen3 models', () => {
mockContentGeneratorConfig.model = 'gpt-4o';

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 negative test uses 'gpt-4o' which doesn't contain "qwen" at all. A near-miss model name like 'Qwen/Qwen2.5-72B' (contains "qwen" but not "qwen3") would better exercise the boundary of the .includes('qwen3') substring check and catch a plausible regression where someone loosens the match to .includes('qwen').

Suggested change
mockContentGeneratorConfig.model = 'gpt-4o';
mockContentGeneratorConfig.model = 'Qwen/Qwen2.5-72B';

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification (maintainer) — merge reference

I built and ran the real test suite locally for this PR. Summary: all green, the fix is load-bearing, and behavior is correctly scoped to Qwen3.

Environment: macOS · Node v22.23.1 · npm 10.9.8 · vitest 3.2.4 · @qwen-code/[email protected] · PR head b8cf479 over main.

What I ran

Check Command Result
Typecheck tsc --noEmit (packages/core) ✅ 0 errors
Lint eslint on the 3 changed files ✅ exit 0
PR's provider tests provider/default.test.ts ✅ 30/30 (incl. 2 new)
MiniMax (touched — override) provider/minimax.test.ts ✅ 9/9
Shared parsing path converter.test.ts ✅ 143/143
Tagged-thinking parser taggedThinkingParser.test.ts ✅ 27/27
My extra e2e integration pr6751-e2e.verify.test.ts ✅ 3/3
Total related surface ✅ 212/212 passed

local verification — all green

Before / after — the change is load-bearing

To confirm the fix actually matters, I overlaid main's pre-fix default.ts (which has no getResponseParsingOptions) and re-ran the PR's tests. The 2 new provider tests and 2 of my e2e tests fail with provider.getResponseParsingOptions is not a function, and a regression-guard test reproduces the visible <think> leak. Restoring the PR's source → all 212 pass.

before/after — the fix is load-bearing

Independent end-to-end check

The PR's unit tests verify the two halves separately (the provider gate in default.test.ts; the converter parsing in converter.test.ts). Nothing tests them joined, so I added a small integration test that wires them exactly as pipeline.ts does — provider → responseParsingOptions → converter — and asserts the user-facing outcome for the real #6666 shape (<think>…</think> arriving inside content, not reasoning_content):

(Happy to contribute this test to the PR if useful — it is not part of the current diff.)

Correctness notes for merge

  • Scoped by model name, not provider. The gate is model.toLowerCase().includes('qwen3'), shared with the existing request-side reasoning_content mirroring. So the DashScope GLM behavior reverted in [codex] Revert GLM tagged thinking parsing for DashScope #6248 stays disabled (GLM ∌ qwen3); only Qwen3 opts in. ✔
  • DashScope / DeepSeek / ZAI / ModelScope / MiMo / Mistral inherit the default gate — all correctly off for non-Qwen3, on for Qwen3. MiniMax keeps its unconditional true (the PR only adds the override keyword — no behavior change). ✔
  • Case-insensitive matching verified (Qwen/Qwen3.7-Max, qwen3-coder-plus both match). Matches the author's flagged risk: a hypothetical future Qwen alias without qwen3 in its name would be missed — acceptable and trivially extensible later. ✔

Verdict: ✅ LGTM from local verification. Tests pass, typecheck/lint clean, the fix is minimal, correctly scoped, and demonstrably resolves #6666. CI on this PR is also already green (Test (ubuntu-latest, Node 22.x)).

🇨🇳 中文版本(点击展开)

✅ 本地验证(维护者)— 合并参考

我在本地构建并运行了这个 PR 的真实测试套件。结论:全部通过,该修复是必要的(去掉就会失败),且行为被正确地限定在 Qwen3 范围内。

环境: macOS · Node v22.23.1 · npm 10.9.8 · vitest 3.2.4 · @qwen-code/[email protected] · PR 头 b8cf479(基于 main)。

执行的检查

检查项 命令 结果
类型检查 tsc --noEmitpackages/core ✅ 0 错误
Lint 对 3 个改动文件执行 eslint ✅ 退出码 0
PR 新增的 provider 测试 provider/default.test.ts ✅ 30/30(含 2 个新增)
MiniMax(本 PR 改动 — override provider/minimax.test.ts ✅ 9/9
共享的解析路径 converter.test.ts ✅ 143/143
标签思考解析器 taggedThinkingParser.test.ts ✅ 27/27
我额外补充的端到端集成测试 pr6751-e2e.verify.test.ts ✅ 3/3
相关范围合计 ✅ 212/212 通过

(截图见上方英文部分。)

Before / After —证明该修复是必要的

为确认修复确实起作用,我把 main修复前default.ts(不含 getResponseParsingOptions)覆盖回去,再跑 PR 的测试:2 个新增 provider 测试以及我的 2 个端到端测试都会以 provider.getResponseParsingOptions is not a function 失败;同时有一个回归守卫测试复现了 <think> 标签泄漏到可见输出的问题。恢复 PR 源码后 → 212 项全部通过。

独立的端到端检查

PR 自带的单测分别验证了两个环节(default.test.ts 里的 provider 开关;converter.test.ts 里的转换解析),但没有测试把两者串起来的接缝。于是我补了一个小的集成测试,完全按 pipeline.ts 的方式把 provider → responseParsingOptions → converter 串联,并针对 #6666 的真实形态(<think>…</think> 出现在 content 而非 reasoning_content 中)断言最终面向用户的结果:

(如果有用,我很乐意把这个测试贡献到 PR 里 — 它目前不在改动中。)

合并前的正确性说明

  • 按模型名限定,而非按 provider 限定。 开关是 model.toLowerCase().includes('qwen3'),与已有的请求侧 reasoning_content 镜像逻辑共用。因此 [codex] Revert GLM tagged thinking parsing for DashScope #6248 中为 DashScope GLM 回退的行为仍然保持关闭(GLM 不含 qwen3),只有 Qwen3 会启用。✔
  • DashScope / DeepSeek / ZAI / ModelScope / MiMo / Mistral 继承默认开关 — 对非 Qwen3 一律关闭、对 Qwen3 开启;MiniMax 保持其无条件的 true(本 PR 仅补上 override 关键字,行为不变)。✔
  • 已验证大小写不敏感匹配(Qwen/Qwen3.7-Maxqwen3-coder-plus 均命中)。与作者标注的风险一致:未来若出现名字里不含 qwen3 的 Qwen 别名会漏判 — 可接受,且后续易于扩展。✔

结论: ✅ 从本地验证来看 LGTM。测试通过,类型/Lint 干净,修复最小化、限定范围正确,并可证明修复了 #6666。该 PR 的 CI 也已是绿色(Test (ubuntu-latest, Node 22.x))。

@wenshao

wenshao commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao added this pull request to the merge queue Jul 12, 2026
Merged via the queue into QwenLM:main with commit 220937e Jul 12, 2026
79 checks passed
@yiliang114

Copy link
Copy Markdown
Collaborator

Thanks for the contribution here.

After checking the real failing traces more closely, I think this fix is at the wrong layer. The issue is not that Qwen3 model names should opt into tagged-thinking parsing by default. In the observed failure, the response had already produced structured reasoning / thought content, but raw <think> / </think> fragments still leaked into visible content. That means the streamed response itself is malformed, rather than a normal <think>...</think> content fallback that should be parsed based on the model name.

I am going to revert this PR and handle the issue with response-shape based retry logic instead: when structured reasoning is already present but visible content contains raw thinking tags, we should drop that malformed attempt and retry, not enable parsing or cleanup based on a Qwen3 model-name match.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changelog Exclude from release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

qwen 3.7 max 模型在 content 字段中返回 <think> 标签而非 reasoning_content

4 participants