From a21ce7d62d15421e667f5617e5423ec738478199 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 25 May 2026 18:59:32 +0800 Subject: [PATCH 1/6] fix(core): emit enable_thinking on DashScope when reasoning is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `'enable_thinking' in typed` guard only flipped the field when pre-populated, but provider buildRequest never auto-injects this qwen3-specific extension. So QWEN_OAUTH defaults and any qwen3 model without `extra_body.enable_thinking` configured (e.g. qwen3.5-flash) silently burned reasoning tokens on every side-query despite includeThoughts: false. Hostname-gated unconditional set mirrors the existing DeepSeek branch. Closes #4501 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- .../openaiContentGenerator/pipeline.test.ts | 226 +++++++++++++++++- .../core/openaiContentGenerator/pipeline.ts | 26 +- 2 files changed, 247 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index 3504071ae3c..d8b6c44c09c 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -15,7 +15,7 @@ import { OpenAIContentConverter } from './converter.js'; import { openaiRequestCaptureContext } from './requestCaptureContext.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; import type { Config } from '../../config/config.js'; -import type { ContentGeneratorConfig, AuthType } from '../contentGenerator.js'; +import { AuthType, type ContentGeneratorConfig } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; // Mock dependencies @@ -463,8 +463,22 @@ describe('ContentGenerationPipeline', () => { }); it('should override enable_thinking when thinkingConfig disables it', async () => { - // Arrange — provider injects enable_thinking: true via extra_body, - // but request explicitly disables thinking + // Arrange — provider injects enable_thinking: true via extra_body + // (e.g. user configured `enableThinking: true` via setup wizard, + // see provider-config.ts), but request explicitly disables thinking. + // DashScope hostname is required because the override is gated on it + // (otherwise the qwen-specific `enable_thinking` field would leak to + // non-qwen providers). + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + (mockProvider.buildRequest as Mock).mockImplementation((req) => ({ ...req, enable_thinking: true, // Simulates extra_body injection @@ -747,6 +761,212 @@ describe('ContentGenerationPipeline', () => { expect(apiCall.thinking).toBeUndefined(); }); + it('emits enable_thinking:false on DashScope hostname when includeThoughts is false', async () => { + // Regression for #4501: qwen3 hybrid models (e.g. qwen3.5-flash) + // default to thinking-on. Provider buildRequest never auto-injects + // `enable_thinking`, so a previous guarded `'enable_thinking' in typed` + // check never fired and side-queries burned reasoning tokens (24-95x + // output bloat in production). The disable must be emitted explicitly. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.5-flash', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + // Provider passes the request through unchanged — simulates the + // common case where the user has not configured + // `extra_body.enable_thinking` (so the field never appears on the + // wire body unless we add it here). + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Summarize' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBe(false); + }); + + it('emits enable_thinking:false on DashScope hostname when reasoning is configured to false', async () => { + // Config-level opt-out (`reasoning: false`) should also disable + // qwen3 thinking, mirroring the DeepSeek pair above. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.5-flash', + reasoning: false, + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Hello' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'main'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBe(false); + }); + + it('emits enable_thinking:false on QWEN_OAUTH regardless of baseUrl', async () => { + // QWEN_OAUTH activates the DashScope provider regardless of baseUrl + // (see DashScopeOpenAICompatibleProvider.isDashScopeProvider line 47). + // Verify the gate fires through that path too — important because + // QWEN_OAUTH is the default flow for first-time users and does not + // go through the wizard's `extra_body` setup. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + authType: AuthType.QWEN_OAUTH, + baseUrl: 'https://some-oauth-issued-endpoint.example/v1', + model: 'qwen3-coder-flash', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hi' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Hi' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBe(false); + }); + + it('emits enable_thinking:false on internal alibaba-inc.com hostname', async () => { + // Internal Alibaba domains proxy to DashScope-compatible APIs and + // are treated as DashScope by design (provider/dashscope.ts:75-78). + // Cover the internal-origin path explicitly so a future tightening + // of the hostname rules does not silently drop coverage for + // internal users. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://gateway.alibaba-inc.com/v1', + model: 'qwen3.5-flash', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hi' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Hi' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBe(false); + }); + + it('does NOT emit enable_thinking on a non-DashScope hostname', async () => { + // `enable_thinking` is a qwen-specific extension. Pushing it at a + // strict OpenAI-compatible backend could trip an unknown-key 400 + // and would also pollute logs with a meaningless field. Mirror of + // the DeepSeek negative test above. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Suggest' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Suggest' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBeUndefined(); + }); + it('should handle errors and log them', async () => { // Arrange const request: GenerateContentParameters = { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index e08751ea8d2..73fc803f86d 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -12,6 +12,7 @@ import { } from '@google/genai'; import type { ContentGeneratorConfig } from '../contentGenerator.js'; import { OpenAIContentConverter } from './converter.js'; +import { DashScopeOpenAICompatibleProvider } from './provider/dashscope.js'; import { isDeepSeekHostname } from './provider/deepseek.js'; import { openaiRequestCaptureContext } from './requestCaptureContext.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; @@ -361,7 +362,24 @@ export class ContentGenerationPipeline { this.contentGeneratorConfig.reasoning === false; if (reasoningDisabled) { const typed = providerRequest as unknown as Record; - if ('enable_thinking' in typed) { + // qwen3 hybrid-thinking models (e.g. qwen3.5-flash) default to + // thinking-on at the server. Provider buildRequest never auto- + // injects `enable_thinking`, so a vanilla wire body lacks the + // field and the disable signal would not reach the server. + // Hostname-gated unconditional set mirrors the DeepSeek branch + // below. Overrides any user-supplied `extra_body.enable_thinking: + // true` because per-request `includeThoughts: false` is the + // stronger intent (set by every side-query via sideQuery.ts). + // + // Scope: targets qwen3 hybrid via DashScope's compatible-mode + // contract. GLM (extra_body.thinking.enabled) and DeepSeek-on- + // DashScope (thinking: { type: 'disabled' }) need different + // disable shapes — pre-existing gap, not closed here. + if ( + DashScopeOpenAICompatibleProvider.isDashScopeProvider( + this.contentGeneratorConfig, + ) + ) { typed['enable_thinking'] = false; } // Strip reasoning config — extra_body could inject it, overriding @@ -471,7 +489,11 @@ export class ContentGenerationPipeline { // - glm-4.7 — thinking is enabled by default; can be disabled via `extra_body.thinking.enabled` // - kimi-k2-thinking — thinking is enabled by default and cannot be disabled // - gpt-5.x series — thinking is enabled by default; can be disabled via `reasoning.effort` - // - qwen3 series — model-dependent; can be manually disabled via `extra_body.enable_thinking` + // - qwen3 series — model-dependent; emitted as `enable_thinking: false` + // by buildRequest above when reasoning is disabled + // on DashScope endpoints (provider never auto- + // injects this field, so the wire body would + // otherwise lack the disable signal) // // Given this inconsistency, we avoid mapping values and only pass through the // configured reasoning object when explicitly enabled. This keeps provider- and From 74bdd17ad33e86fdc4261681b5fa3294bdaa6f85 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 25 May 2026 19:45:22 +0800 Subject: [PATCH 2/6] fix(core): trim DashScope enable_thinking comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous comment block restated mechanics already visible in the code (overrides, mirrors DeepSeek, set-by-side-query) and pinned "includeThoughts: false" as the trigger when reasoning: false also qualifies. Keep only the two non-obvious whys (provider doesn't auto-inject; gate prevents leaking the qwen-specific field) plus a one-line scope note. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- .../core/openaiContentGenerator/pipeline.ts | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 73fc803f86d..d0d689aa1fc 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -362,19 +362,11 @@ export class ContentGenerationPipeline { this.contentGeneratorConfig.reasoning === false; if (reasoningDisabled) { const typed = providerRequest as unknown as Record; - // qwen3 hybrid-thinking models (e.g. qwen3.5-flash) default to - // thinking-on at the server. Provider buildRequest never auto- - // injects `enable_thinking`, so a vanilla wire body lacks the - // field and the disable signal would not reach the server. - // Hostname-gated unconditional set mirrors the DeepSeek branch - // below. Overrides any user-supplied `extra_body.enable_thinking: - // true` because per-request `includeThoughts: false` is the - // stronger intent (set by every side-query via sideQuery.ts). - // - // Scope: targets qwen3 hybrid via DashScope's compatible-mode - // contract. GLM (extra_body.thinking.enabled) and DeepSeek-on- - // DashScope (thinking: { type: 'disabled' }) need different - // disable shapes — pre-existing gap, not closed here. + // Provider buildRequest doesn't auto-inject `enable_thinking`, so a + // guarded `in typed` check would never fire for default qwen3 configs. + // Hostname gate avoids leaking this qwen-specific field elsewhere. + // Scope: qwen3 hybrid only — GLM/DeepSeek-on-DashScope use different + // disable shapes and aren't handled. if ( DashScopeOpenAICompatibleProvider.isDashScopeProvider( this.contentGeneratorConfig, @@ -490,10 +482,7 @@ export class ContentGenerationPipeline { // - kimi-k2-thinking — thinking is enabled by default and cannot be disabled // - gpt-5.x series — thinking is enabled by default; can be disabled via `reasoning.effort` // - qwen3 series — model-dependent; emitted as `enable_thinking: false` - // by buildRequest above when reasoning is disabled - // on DashScope endpoints (provider never auto- - // injects this field, so the wire body would - // otherwise lack the disable signal) + // on DashScope endpoints when reasoning is disabled // // Given this inconsistency, we avoid mapping values and only pass through the // configured reasoning object when explicitly enabled. This keeps provider- and From 7a7da7c53ea831ef8621af2fb4ab9c9f7d9565ee Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 26 May 2026 14:36:25 +0800 Subject: [PATCH 3/6] fix(core): restrict enable_thinking emission to qwen models on DashScope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashScope's compatible-mode endpoint routes multiple model families (qwen3, GLM, DeepSeek). The hostname-only gate would send `enable_thinking: false` to all of them when reasoning is disabled — fine for qwen, but GLM uses `extra_body.thinking.enabled` and DeepSeek-on-DashScope uses `thinking: { type: 'disabled' }`. Sending the qwen-specific field to those is at best a no-op and at worst forwarded upstream and rejected. AND the gate with a `qwen` model-name prefix to restore conservative posture for non-qwen routings. Per review feedback from @LaZzyMan and @wenshao. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- .../openaiContentGenerator/pipeline.test.ts | 49 +++++++++++++++++-- .../core/openaiContentGenerator/pipeline.ts | 13 +++-- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index d8b6c44c09c..d3878a99b94 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -466,12 +466,14 @@ describe('ContentGenerationPipeline', () => { // Arrange — provider injects enable_thinking: true via extra_body // (e.g. user configured `enableThinking: true` via setup wizard, // see provider-config.ts), but request explicitly disables thinking. - // DashScope hostname is required because the override is gated on it - // (otherwise the qwen-specific `enable_thinking` field would leak to - // non-qwen providers). + // DashScope hostname + qwen model name are both required: the gate + // is hostname + model-name to avoid leaking the qwen-specific + // `enable_thinking` field to non-qwen routings (off-DashScope, or + // GLM/DeepSeek on the same DashScope hostname). mockContentGeneratorConfig = { ...mockContentGeneratorConfig, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.5-flash', } as ContentGeneratorConfig; mockConfig = { ...mockConfig, @@ -967,6 +969,47 @@ describe('ContentGenerationPipeline', () => { expect(apiCall.enable_thinking).toBeUndefined(); }); + it('does NOT emit enable_thinking on a non-qwen model routed through DashScope', async () => { + // DashScope's compatible-mode endpoint routes multiple model families + // (qwen3, GLM, DeepSeek). Hostname alone is not enough — GLM uses + // `extra_body.thinking.enabled` and DeepSeek-on-DashScope uses + // `thinking: { type: 'disabled' }`, so sending `enable_thinking` is + // at best a no-op and at worst forwarded upstream and rejected. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'glm-5', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Summarize' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBeUndefined(); + }); + it('should handle errors and log them', async () => { // Arrange const request: GenerateContentParameters = { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index d0d689aa1fc..7119d78b16b 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -364,13 +364,18 @@ export class ContentGenerationPipeline { const typed = providerRequest as unknown as Record; // Provider buildRequest doesn't auto-inject `enable_thinking`, so a // guarded `in typed` check would never fire for default qwen3 configs. - // Hostname gate avoids leaking this qwen-specific field elsewhere. - // Scope: qwen3 hybrid only — GLM/DeepSeek-on-DashScope use different - // disable shapes and aren't handled. + // Hostname + model-name gate avoids leaking this qwen-specific field + // to non-qwen routings on the same DashScope hostname (GLM uses + // `extra_body.thinking.enabled`, DeepSeek-on-DashScope uses + // `thinking: { type: 'disabled' }`; sending `enable_thinking` to them + // is at best a no-op, at worst forwarded upstream and rejected). if ( DashScopeOpenAICompatibleProvider.isDashScopeProvider( this.contentGeneratorConfig, - ) + ) && + (this.contentGeneratorConfig.model ?? '') + .toLowerCase() + .startsWith('qwen') ) { typed['enable_thinking'] = false; } From 4491a70c7cd895fb6d0a787b6edd74aaf59b7187 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 26 May 2026 22:10:49 +0800 Subject: [PATCH 4/6] fix(core): cover coder-model in DashScope enable_thinking gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `coder-model` is `DEFAULT_QWEN_MODEL` (config/models.ts:7) — the QWEN_OAUTH default model, aliased to Qwen 3.6 Plus hybrid. The previous `startsWith('qwen')` gate didn't match it (string starts with `c`), so QWEN_OAUTH default-flow users — the exact scenario #4501 targets — continued burning reasoning tokens on side-queries. The original QWEN_OAUTH test masked this by using `qwen3-coder-flash`; switch it to the real default `coder-model` so the regression is pinned. Also add a `!baseUrl` regression test to cover the implicit-DashScope default branch in `isDashScopeProvider` (dashscope.ts:49) — all other positive tests explicitly set baseUrl. Per @wenshao's [Critical] review. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- .../openaiContentGenerator/pipeline.test.ts | 57 ++++++++++++++++--- .../core/openaiContentGenerator/pipeline.ts | 10 +++- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index d3878a99b94..dea87269d63 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -846,17 +846,18 @@ describe('ContentGenerationPipeline', () => { expect(apiCall.enable_thinking).toBe(false); }); - it('emits enable_thinking:false on QWEN_OAUTH regardless of baseUrl', async () => { - // QWEN_OAUTH activates the DashScope provider regardless of baseUrl - // (see DashScopeOpenAICompatibleProvider.isDashScopeProvider line 47). - // Verify the gate fires through that path too — important because - // QWEN_OAUTH is the default flow for first-time users and does not - // go through the wizard's `extra_body` setup. + it('emits enable_thinking:false on QWEN_OAUTH with the default coder-model', async () => { + // QWEN_OAUTH is the default auth flow for first-time users and + // ships with `model: 'coder-model'` (DEFAULT_QWEN_MODEL in + // config/models.ts — aliased to Qwen 3.6 Plus hybrid). The string + // doesn't start with `qwen`, so the gate must special-case it; + // otherwise the exact regression that #4501 fixes (side-queries + // burning reasoning tokens on the default flow) remains live. mockContentGeneratorConfig = { ...mockContentGeneratorConfig, authType: AuthType.QWEN_OAUTH, baseUrl: 'https://some-oauth-issued-endpoint.example/v1', - model: 'qwen3-coder-flash', + model: 'coder-model', } as ContentGeneratorConfig; mockConfig = { ...mockConfig, @@ -1010,6 +1011,48 @@ describe('ContentGenerationPipeline', () => { expect(apiCall.enable_thinking).toBeUndefined(); }); + it('emits enable_thinking:false when baseUrl is unset (DashScope default)', async () => { + // `isDashScopeProvider` treats a missing baseUrl as DashScope + // (`dashscope.ts:49` returns true for `!baseUrl`). A fresh install + // that hasn't run the setup wizard hits this path. All other + // positive tests above explicitly set baseUrl, so pin this + // implicit-default branch separately to detect future tightening + // of the `!baseUrl` early-return. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + model: 'qwen3.5-flash', + } as ContentGeneratorConfig; + delete (mockContentGeneratorConfig as { baseUrl?: string }).baseUrl; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Summarize' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBe(false); + }); + it('should handle errors and log them', async () => { // Arrange const request: GenerateContentParameters = { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 7119d78b16b..d5d2c55358d 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -369,13 +369,17 @@ export class ContentGenerationPipeline { // `extra_body.thinking.enabled`, DeepSeek-on-DashScope uses // `thinking: { type: 'disabled' }`; sending `enable_thinking` to them // is at best a no-op, at worst forwarded upstream and rejected). + // + // `coder-model` is the QWEN_OAUTH default (DEFAULT_QWEN_MODEL in + // config/models.ts, aliased to Qwen 3.6 Plus hybrid) — it doesn't + // start with `qwen` but is the most common hybrid-thinking model + // for first-time users, so it must be covered. + const model = (this.contentGeneratorConfig.model ?? '').toLowerCase(); if ( DashScopeOpenAICompatibleProvider.isDashScopeProvider( this.contentGeneratorConfig, ) && - (this.contentGeneratorConfig.model ?? '') - .toLowerCase() - .startsWith('qwen') + (model.startsWith('qwen') || model === 'coder-model') ) { typed['enable_thinking'] = false; } From 420a13138c401fb7cb297a85fba1303dad80e857 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sun, 31 May 2026 01:21:00 +0800 Subject: [PATCH 5/6] fix(core): gate DashScope enable_thinking on the wire model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate keyed off `contentGeneratorConfig.model`, but buildRequest ships `context.model` (= `request.model || contentGeneratorConfig.model`, the value baseRequest.model is built from). A request-level model override desynced the gate from what actually goes on the wire: - qwen config + non-qwen request model still emitted `enable_thinking`, leaking the qwen-only field to the non-qwen routing on the wire - non-qwen config + qwen request model missed the disable signal, regressing #4501 Gate on `context.model` instead. Also swap the hardcoded `'coder-model'` for the `DEFAULT_QWEN_MODEL` constant so a rename can't silently break the match. Add regression tests for both override directions; the existing positive tests set `request.model: 'test-model'`, which — now that the gate reads the wire model — was overriding the qwen config they meant to exercise, so point them at the intended wire model. Per @yiliang114's review. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- .../openaiContentGenerator/pipeline.test.ts | 93 +++++++++++++++++-- .../core/openaiContentGenerator/pipeline.ts | 21 +++-- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index dea87269d63..58607362301 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -487,7 +487,7 @@ describe('ContentGenerationPipeline', () => { })); const request: GenerateContentParameters = { - model: 'test-model', + model: 'qwen3.5-flash', contents: [{ parts: [{ text: 'Suggest next' }], role: 'user' }], config: { thinkingConfig: { includeThoughts: false } }, }; @@ -785,7 +785,7 @@ describe('ContentGenerationPipeline', () => { // `extra_body.enable_thinking` (so the field never appears on the // wire body unless we add it here). const request: GenerateContentParameters = { - model: 'test-model', + model: 'qwen3.5-flash', contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], config: { thinkingConfig: { includeThoughts: false } }, }; @@ -824,7 +824,7 @@ describe('ContentGenerationPipeline', () => { pipeline = new ContentGenerationPipeline(mockConfig); const request: GenerateContentParameters = { - model: 'test-model', + model: 'qwen3.5-flash', contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], }; @@ -866,7 +866,7 @@ describe('ContentGenerationPipeline', () => { pipeline = new ContentGenerationPipeline(mockConfig); const request: GenerateContentParameters = { - model: 'test-model', + model: 'coder-model', contents: [{ parts: [{ text: 'Hi' }], role: 'user' }], config: { thinkingConfig: { includeThoughts: false } }, }; @@ -907,7 +907,7 @@ describe('ContentGenerationPipeline', () => { pipeline = new ContentGenerationPipeline(mockConfig); const request: GenerateContentParameters = { - model: 'test-model', + model: 'qwen3.5-flash', contents: [{ parts: [{ text: 'Hi' }], role: 'user' }], config: { thinkingConfig: { includeThoughts: false } }, }; @@ -988,7 +988,47 @@ describe('ContentGenerationPipeline', () => { pipeline = new ContentGenerationPipeline(mockConfig); const request: GenerateContentParameters = { - model: 'test-model', + model: 'glm-5', + contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Summarize' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBeUndefined(); + }); + + it('gates on the wire model, not config: qwen config + non-qwen request.model does NOT emit', async () => { + // buildRequest ships `context.model` (= request.model || config.model). + // A qwen *config* with a non-qwen *request* model must gate on the + // request model — otherwise the qwen-only field leaks to the non-qwen + // routing that is actually on the wire (e.g. GLM rejecting it upstream). + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.5-flash', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'glm-5', // request-level override to a non-qwen wire model contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], config: { thinkingConfig: { includeThoughts: false } }, }; @@ -1011,6 +1051,45 @@ describe('ContentGenerationPipeline', () => { expect(apiCall.enable_thinking).toBeUndefined(); }); + it('gates on the wire model, not config: non-qwen config + qwen request.model emits false', async () => { + // The mirror direction: a non-qwen *config* with a qwen *request* model + // must still emit the disable signal, since the wire model is qwen and + // would otherwise keep thinking-on (the #4501 regression). + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'glm-5', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'qwen3.5-flash', // request-level override to a qwen wire model + contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Summarize' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBe(false); + }); + it('emits enable_thinking:false when baseUrl is unset (DashScope default)', async () => { // `isDashScopeProvider` treats a missing baseUrl as DashScope // (`dashscope.ts:49` returns true for `!baseUrl`). A fresh install @@ -1030,7 +1109,7 @@ describe('ContentGenerationPipeline', () => { pipeline = new ContentGenerationPipeline(mockConfig); const request: GenerateContentParameters = { - model: 'test-model', + model: 'qwen3.5-flash', contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }], config: { thinkingConfig: { includeThoughts: false } }, }; diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index d5d2c55358d..8dfdeb18416 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -18,6 +18,7 @@ import { openaiRequestCaptureContext } from './requestCaptureContext.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; import { TaggedThinkingParser } from './taggedThinkingParser.js'; import type { PipelineConfig, RequestContext } from './types.js'; +import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { redactProxyError } from '../../utils/runtimeFetchOptions.js'; import { runtimeDiagnostics } from '../../utils/runtimeDiagnostics.js'; @@ -370,16 +371,24 @@ export class ContentGenerationPipeline { // `thinking: { type: 'disabled' }`; sending `enable_thinking` to them // is at best a no-op, at worst forwarded upstream and rejected). // - // `coder-model` is the QWEN_OAUTH default (DEFAULT_QWEN_MODEL in - // config/models.ts, aliased to Qwen 3.6 Plus hybrid) — it doesn't - // start with `qwen` but is the most common hybrid-thinking model - // for first-time users, so it must be covered. - const model = (this.contentGeneratorConfig.model ?? '').toLowerCase(); + // Gate on the *wire* model (`context.model`, i.e. + // `request.model || contentGeneratorConfig.model` — the same value + // baseRequest.model is built from above), not on the config model. A + // request-level model override would otherwise desync the gate from + // what actually ships: a qwen config with a non-qwen request model + // would leak the field, and a non-qwen config with a qwen request + // model would miss the disable signal (the #4501 regression). + // + // DEFAULT_QWEN_MODEL (currently `coder-model`) is the QWEN_OAUTH + // default — it doesn't start with `qwen` but is the most common + // hybrid-thinking model for first-time users, so it must be covered. + // Importing the constant keeps this gate in sync if the alias moves. + const model = (context.model ?? '').toLowerCase(); if ( DashScopeOpenAICompatibleProvider.isDashScopeProvider( this.contentGeneratorConfig, ) && - (model.startsWith('qwen') || model === 'coder-model') + (model.startsWith('qwen') || model === DEFAULT_QWEN_MODEL) ) { typed['enable_thinking'] = false; } From 52984e43abe90c030e6d7fa2e016ed540a5d099f Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Sun, 31 May 2026 09:37:46 +0800 Subject: [PATCH 6/6] fix(core): keep coder-model inline, drop DEFAULT_QWEN_MODEL import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the `DEFAULT_QWEN_MODEL` import added in 420a13138. Importing the constant was already raised in review (@yiliang114) and declined: `coder-model` is a public DashScope routing alias, effectively frozen — not an internal constant that might drift silently. The inline comment already documents the relationship, and the import adds a cross-module dependency not worth it for this bugfix. The wire-model gate fix (gating on `context.model` rather than `contentGeneratorConfig.model`) is unaffected and stays. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- .../core/src/core/openaiContentGenerator/pipeline.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 8dfdeb18416..63a1260d753 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -18,7 +18,6 @@ import { openaiRequestCaptureContext } from './requestCaptureContext.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; import { TaggedThinkingParser } from './taggedThinkingParser.js'; import type { PipelineConfig, RequestContext } from './types.js'; -import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { redactProxyError } from '../../utils/runtimeFetchOptions.js'; import { runtimeDiagnostics } from '../../utils/runtimeDiagnostics.js'; @@ -379,16 +378,16 @@ export class ContentGenerationPipeline { // would leak the field, and a non-qwen config with a qwen request // model would miss the disable signal (the #4501 regression). // - // DEFAULT_QWEN_MODEL (currently `coder-model`) is the QWEN_OAUTH - // default — it doesn't start with `qwen` but is the most common - // hybrid-thinking model for first-time users, so it must be covered. - // Importing the constant keeps this gate in sync if the alias moves. + // `coder-model` is the QWEN_OAUTH default (DEFAULT_QWEN_MODEL in + // config/models.ts, aliased to Qwen 3.6 Plus hybrid) — it doesn't + // start with `qwen` but is the most common hybrid-thinking model + // for first-time users, so it must be covered. const model = (context.model ?? '').toLowerCase(); if ( DashScopeOpenAICompatibleProvider.isDashScopeProvider( this.contentGeneratorConfig, ) && - (model.startsWith('qwen') || model === DEFAULT_QWEN_MODEL) + (model.startsWith('qwen') || model === 'coder-model') ) { typed['enable_thinking'] = false; }