diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index 00d83272fae..7683b6f5171 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -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' }], + } 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 diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 1debfc2f460..dfc7639063d 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -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 { + // Non-DashScope OpenAI-compatible servers (vLLM, SGLang, ...) render + // 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 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.