-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(core): disable qwen thinking via chat_template_kwargs on non-DashScope servers #6271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1108,6 +1108,141 @@ describe('ContentGenerationPipeline', () => { | |
| expect(apiCall.enable_thinking).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('disables qwen thinking via chat_template_kwargs on a non-DashScope endpoint (vLLM/SGLang)', async () => { | ||
| // Self-hosted OpenAI-compatible servers render the chat template | ||
| // server-side and read the thinking switch from `chat_template_kwargs`, | ||
| // silently ignoring a top-level `enable_thinking`. A qwen model on such | ||
| // an endpoint must therefore get the switch nested, not top-level — and | ||
| // any top-level `enable_thinking: true` a provider preset injected via | ||
| // extra_body must be stripped so it can't contradict the opt-out. | ||
| mockContentGeneratorConfig = { | ||
| ...mockContentGeneratorConfig, | ||
| baseUrl: 'https://llm.example.com/v1', | ||
| model: 'Qwen3.6-27B', | ||
| } as ContentGeneratorConfig; | ||
| mockConfig = { | ||
| ...mockConfig, | ||
| contentGeneratorConfig: mockContentGeneratorConfig, | ||
| }; | ||
| pipeline = new ContentGenerationPipeline(mockConfig); | ||
|
|
||
| (mockProvider.buildRequest as Mock).mockImplementation((req) => ({ | ||
| ...req, | ||
| enable_thinking: true, // Simulates extra_body injection | ||
| })); | ||
|
|
||
| const request: GenerateContentParameters = { | ||
| model: 'Qwen3.6-27B', | ||
| 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' }], | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The test now verifies that a top-level The production code at expect(apiCall.chat_template_kwargs).toEqual({ apply_chat_template: true, enable_thinking: false });This guards against a future refactor that drops the — qwen3.7-max via Qwen Code /review |
||
| } as OpenAI.Chat.ChatCompletion); | ||
|
|
||
| await pipeline.execute(request, 'forked_query'); | ||
|
|
||
| const apiCall = (mockClient.chat.completions.create as Mock).mock | ||
| .calls[0][0]; | ||
| expect(apiCall.chat_template_kwargs).toEqual({ enable_thinking: false }); | ||
| expect(apiCall.enable_thinking).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('disables coder-model thinking via chat_template_kwargs on a non-DashScope endpoint', async () => { | ||
| // `coder-model` is the QWEN_OAUTH default, but a user can point it at a | ||
| // self-hosted endpoint. The `model === 'coder-model'` arm must reach the | ||
| // non-DashScope chat_template_kwargs path just like a `qwen*` model. | ||
| mockContentGeneratorConfig = { | ||
| ...mockContentGeneratorConfig, | ||
| baseUrl: 'https://llm.example.com/v1', | ||
| model: 'coder-model', | ||
| } as ContentGeneratorConfig; | ||
| mockConfig = { | ||
| ...mockConfig, | ||
| contentGeneratorConfig: mockContentGeneratorConfig, | ||
| }; | ||
| pipeline = new ContentGenerationPipeline(mockConfig); | ||
|
|
||
| const request: GenerateContentParameters = { | ||
| model: 'coder-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.chat_template_kwargs).toEqual({ enable_thinking: false }); | ||
| expect(apiCall.enable_thinking).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('merges enable_thinking into pre-existing chat_template_kwargs on a non-DashScope endpoint', async () => { | ||
| // The non-DashScope path spreads any existing `chat_template_kwargs` | ||
| // before appending `enable_thinking: false`. Guard the merge so a future | ||
| // refactor can't silently drop user-configured kwargs. | ||
| mockContentGeneratorConfig = { | ||
| ...mockContentGeneratorConfig, | ||
| baseUrl: 'https://llm.example.com/v1', | ||
| model: 'Qwen3.6-27B', | ||
| } as ContentGeneratorConfig; | ||
| mockConfig = { | ||
| ...mockConfig, | ||
| contentGeneratorConfig: mockContentGeneratorConfig, | ||
| }; | ||
| pipeline = new ContentGenerationPipeline(mockConfig); | ||
|
|
||
| (mockProvider.buildRequest as Mock).mockImplementation((req) => ({ | ||
| ...req, | ||
| chat_template_kwargs: { apply_chat_template: true }, | ||
| })); | ||
|
|
||
| const request: GenerateContentParameters = { | ||
| model: 'Qwen3.6-27B', | ||
| 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.chat_template_kwargs).toEqual({ | ||
| apply_chat_template: true, | ||
| enable_thinking: false, | ||
| }); | ||
| }); | ||
|
|
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -584,13 +584,39 @@ export class ContentGenerationPipeline { | |||||||||||||||||||||||||||||||||||||||||||||
| // 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 === 'coder-model') | ||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||
| typed['enable_thinking'] = false; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (model.startsWith('qwen') || model === 'coder-model') { | ||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||
| DashScopeOpenAICompatibleProvider.isDashScopeProvider( | ||||||||||||||||||||||||||||||||||||||||||||||
| this.contentGeneratorConfig, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||
| typed['enable_thinking'] = false; | ||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] The non-DashScope branch sets When a provider preset (e.g. ModelScope's While vLLM/SGLang may ignore the top-level field (as the comment notes), other OpenAI-compatible servers (ModelScope, LiteLLM proxies) that read both could keep thinking enabled despite the explicit opt-out. The new test doesn't catch this because its mock doesn't inject
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||
| // Non-DashScope OpenAI-compatible servers (vLLM, SGLang, ...) render | ||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Consider documenting this assumption in the comment, or gating the field on a known server list. — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||
| // the model's chat template server-side and read the thinking switch | ||||||||||||||||||||||||||||||||||||||||||||||
| // from `chat_template_kwargs`, not a top-level `enable_thinking` | ||||||||||||||||||||||||||||||||||||||||||||||
| // (which they silently ignore). Send it there so hybrid qwen models | ||||||||||||||||||||||||||||||||||||||||||||||
| // actually stop emitting <think> when reasoning is disabled — e.g. | ||||||||||||||||||||||||||||||||||||||||||||||
| // the auto-mode permission classifier's short structured-output | ||||||||||||||||||||||||||||||||||||||||||||||
| // calls, which otherwise spend their small token budget on thinking | ||||||||||||||||||||||||||||||||||||||||||||||
| // and fail closed. Servers that don't recognise `chat_template_kwargs` | ||||||||||||||||||||||||||||||||||||||||||||||
| // ignore the unknown field, so the switch is a harmless no-op there. | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // Drop any top-level `enable_thinking` a provider preset injected via | ||||||||||||||||||||||||||||||||||||||||||||||
| // extra_body (provider-config.ts emits it for models configured with | ||||||||||||||||||||||||||||||||||||||||||||||
| // `enableThinking: true`): leaving it would contradict the | ||||||||||||||||||||||||||||||||||||||||||||||
| // `chat_template_kwargs` opt-out on servers that honour both, and | ||||||||||||||||||||||||||||||||||||||||||||||
| // keeps this path from leaking the qwen-specific field top-level. | ||||||||||||||||||||||||||||||||||||||||||||||
| delete typed['enable_thinking']; | ||||||||||||||||||||||||||||||||||||||||||||||
| const existing = (typed['chat_template_kwargs'] ?? {}) as Record< | ||||||||||||||||||||||||||||||||||||||||||||||
| string, | ||||||||||||||||||||||||||||||||||||||||||||||
| unknown | ||||||||||||||||||||||||||||||||||||||||||||||
| >; | ||||||||||||||||||||||||||||||||||||||||||||||
| typed['chat_template_kwargs'] = { | ||||||||||||||||||||||||||||||||||||||||||||||
| ...existing, | ||||||||||||||||||||||||||||||||||||||||||||||
| enable_thinking: false, | ||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| // Strip reasoning config — extra_body could inject it, overriding | ||||||||||||||||||||||||||||||||||||||||||||||
| // buildReasoningConfig's decision to return {} for disabled thinking. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The only
coder-modeltest (line ~986) usesAuthType.QWEN_OAUTH, which causesisDashScopeProviderto returntrue. The|| model === 'coder-model'branch inside the non-DashScopeelsearm is therefore never exercised in tests.Consider adding a test with
model: 'coder-model',baseUrl: 'https://llm.example.com/v1'(non-DashScope),includeThoughts: false, assertingchat_template_kwargsequals{ enable_thinking: false }.— qwen3.7-max via Qwen Code /review