diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index dbc8d55cdc5..edc3eed577e 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -583,14 +583,14 @@ The optional `reasoning` field under `generationConfig` controls how aggressivel ### Per-provider behavior -| Protocol / provider | Wire shape | Notes | -| --------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **OpenAI / DashScope** (`qwen3.8-max` family) | Flat `reasoning_effort: ` body parameter | The five `/effort` tiers (`low`, `medium`, `high`, `xhigh`, `max`) are passed through verbatim for any model id starting with `qwen3.8-max` (including dated snapshots and `-latest` aliases); DashScope applies any model-specific mapping. For this family, a configured `enable_thinking` or `thinking_budget` is dropped (with a debug log) whenever an effort tier ships, because DashScope rejects requests carrying `reasoning_effort` together with either field. Other Qwen models continue to map a selected effort to `enable_thinking: true`; a `reasoning_effort` override passes through there without dropping `enable_thinking`, and only a conflicting `thinking_budget` is dropped. | -| **OpenAI / DeepSeek** (`api.deepseek.com`) | Flat `reasoning_effort: ` body parameter | When `reasoning.effort` is set in the nested config shape, it's rewritten to flat `reasoning_effort` and `'low'`/`'medium'` are normalized to `'high'`, `'xhigh'` to `'max'` — mirroring DeepSeek's [server-side back-compat](https://api-docs.deepseek.com/zh-cn/api/create-chat-completion). Top-level `samplingParams.reasoning_effort` or `extra_body.reasoning_effort` overrides skip this normalization and ship verbatim. | -| **OpenAI** (other compatible servers) | `reasoning: { effort, ... }` passed through verbatim | Set via `samplingParams` (e.g. `samplingParams.reasoning_effort` for GPT-5/o-series) when the provider expects a different shape. | -| **Anthropic** (real `api.anthropic.com`) | `output_config: { effort }` plus the `effort-2025-11-24` beta header | Real Anthropic accepts `'low'`/`'medium'`/`'high'` only. `'max'` is **clamped to `'high'`** with a `debugLogger.warn` line (once per generator); if you want max effort, switch the baseURL to a DeepSeek-compatible endpoint that supports it. | -| **Anthropic** (`api.deepseek.com/anthropic`) | Same `output_config: { effort }` + beta header | `'max'` is passed through unchanged. | -| **Gemini** (`@google/genai`) | `thinkingConfig: { includeThoughts: true, thinkingLevel }` | `'low'` → `LOW`, `'high'`/`'max'` → `HIGH`, others → `THINKING_LEVEL_UNSPECIFIED` (Gemini has no `MAX` tier). | +| Protocol / provider | Wire shape | Notes | +| --------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **OpenAI / DashScope** (`qwen3.8-max` family) | Flat `reasoning_effort: ` body parameter | The five `/effort` tiers (`low`, `medium`, `high`, `xhigh`, `max`) are passed through verbatim for any model id starting with `qwen3.8-max` (including dated snapshots and `-latest` aliases); DashScope applies any model-specific mapping. For this family the tier ships alone: a conflicting `enable_thinking` or `thinking_budget` is dropped (warn-logged, once per generator) — DashScope rejects requests combining `reasoning_effort` with `thinking_budget`, and two thinking controls should not ship together. An explicit `enable_thinking: false` in `extra_body` is honoured rather than dropped: it overrides the configured tier as `reasoning_effort: 'none'`, one of the few places `extra_body` does not win verbatim. Other Qwen models continue to map a selected effort to `enable_thinking: true`; a `reasoning_effort` override passes through there unless it conflicts with a `thinking_budget` (a pair DashScope rejects), in which case the inert `reasoning_effort` is dropped and both `enable_thinking` and `thinking_budget` survive. | +| **OpenAI / DeepSeek** (`api.deepseek.com`) | Flat `reasoning_effort: ` body parameter | When `reasoning.effort` is set in the nested config shape, it's rewritten to flat `reasoning_effort` and `'low'`/`'medium'` are normalized to `'high'`, `'xhigh'` to `'max'` — mirroring DeepSeek's [server-side back-compat](https://api-docs.deepseek.com/zh-cn/api/create-chat-completion). Top-level `samplingParams.reasoning_effort` or `extra_body.reasoning_effort` overrides skip this normalization and ship verbatim. | +| **OpenAI** (other compatible servers) | `reasoning: { effort, ... }` passed through verbatim | Set via `samplingParams` (e.g. `samplingParams.reasoning_effort` for GPT-5/o-series) when the provider expects a different shape. | +| **Anthropic** (real `api.anthropic.com`) | `output_config: { effort }` plus the `effort-2025-11-24` beta header | Real Anthropic accepts `'low'`/`'medium'`/`'high'` only. `'max'` is **clamped to `'high'`** with a `debugLogger.warn` line (once per generator); if you want max effort, switch the baseURL to a DeepSeek-compatible endpoint that supports it. | +| **Anthropic** (`api.deepseek.com/anthropic`) | Same `output_config: { effort }` + beta header | `'max'` is passed through unchanged. | +| **Gemini** (`@google/genai`) | `thinkingConfig: { includeThoughts: true, thinkingLevel }` | `'low'` → `LOW`, `'high'`/`'max'` → `HIGH`, others → `THINKING_LEVEL_UNSPECIFIED` (Gemini has no `MAX` tier). | ### `reasoning: false` diff --git a/packages/core/src/core/modalityDefaults.test.ts b/packages/core/src/core/modalityDefaults.test.ts index d84d45f44c5..4faa2150f61 100644 --- a/packages/core/src/core/modalityDefaults.test.ts +++ b/packages/core/src/core/modalityDefaults.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { defaultModalities } from './modalityDefaults.js'; +import { + defaultModalities, + isQwenFamilyWireModel, + isTieredEffortWireModel, +} from './modalityDefaults.js'; describe('defaultModalities', () => { describe('Google Gemini', () => { @@ -295,3 +299,39 @@ describe('defaultModalities', () => { }); }); }); + +describe('isQwenFamilyWireModel', () => { + it('matches qwen* ids case-insensitively', () => { + expect(isQwenFamilyWireModel('qwen3.8-max')).toBe(true); + expect(isQwenFamilyWireModel('Qwen3.7-Max')).toBe(true); + expect(isQwenFamilyWireModel('qwen-vl-max')).toBe(true); + }); + + it('matches the coder-model QWEN_OAUTH default', () => { + expect(isQwenFamilyWireModel('coder-model')).toBe(true); + }); + + it('rejects non-qwen ids and empty input', () => { + expect(isQwenFamilyWireModel('glm-5.2')).toBe(false); + expect(isQwenFamilyWireModel('kimi-k2.6')).toBe(false); + expect(isQwenFamilyWireModel('')).toBe(false); + expect(isQwenFamilyWireModel(undefined)).toBe(false); + }); +}); + +describe('isTieredEffortWireModel', () => { + it('matches the qwen3.8-max family including snapshots and aliases', () => { + expect(isTieredEffortWireModel('qwen3.8-max')).toBe(true); + expect(isTieredEffortWireModel('qwen3.8-max-preview')).toBe(true); + expect(isTieredEffortWireModel('qwen3.8-max-2026-01-15')).toBe(true); + expect(isTieredEffortWireModel('qwen3.8-max-latest')).toBe(true); + expect(isTieredEffortWireModel('Qwen3.8-Max')).toBe(true); + }); + + it('rejects other qwen models and non-qwen ids', () => { + expect(isTieredEffortWireModel('qwen3.7-max')).toBe(false); + expect(isTieredEffortWireModel('coder-model')).toBe(false); + expect(isTieredEffortWireModel('glm-5.2')).toBe(false); + expect(isTieredEffortWireModel(undefined)).toBe(false); + }); +}); diff --git a/packages/core/src/core/modalityDefaults.ts b/packages/core/src/core/modalityDefaults.ts index e043dd757c1..05a990620c9 100644 --- a/packages/core/src/core/modalityDefaults.ts +++ b/packages/core/src/core/modalityDefaults.ts @@ -112,3 +112,34 @@ export function defaultModalities(model: string): InputModalities { } return {}; } + +/** + * True for wire model ids in the qwen family: any `qwen*` id plus + * `coder-model`, the QWEN_OAUTH default (DEFAULT_QWEN_MODEL in + * config/models.ts, aliased to a Qwen 3.6 Plus hybrid), which doesn't + * start with `qwen` but is the most common hybrid-thinking model for + * first-time users. Shared by the pipeline's disable/tool-choice gates + * and the DashScope provider's effort mapping so the family fact lives + * in one place. + */ +export function isQwenFamilyWireModel(model: string | undefined): boolean { + if (!model) { + return false; + } + const normalized = model.toLowerCase(); + return normalized.startsWith('qwen') || normalized === 'coder-model'; +} + +/** + * True for the qwen3.8-max wire model family — the only family that + * reads the tiered `reasoning_effort` field directly. Prefix-matched so + * dated snapshots and `-latest` aliases are covered, consistent with the + * family pattern in MODALITY_PATTERNS above. Older qwen hybrids expose + * only the on/off `enable_thinking` switch instead. + */ +export function isTieredEffortWireModel(model: string | undefined): boolean { + if (!model) { + return false; + } + return model.toLowerCase().startsWith('qwen3.8-max'); +} diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index db2c0cdc1e9..c1a27eba616 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -780,25 +780,27 @@ describe('ContentGenerationPipeline', () => { expectedToolChoice: 'required', }, { - name: 'strip the effort tier under the config-level reasoning opt-out', + name: 'emit the tier-native disable shape under the config-level reasoning opt-out', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen3.8-max', extraBody: { reasoning_effort: 'high' }, thinkingMandatory: undefined, reasoning: false, includeThoughts: true, - expectedThinking: false, + expectedThinking: undefined, + expectedReasoningEffort: 'none', expectedToolChoice: 'required', }, { - name: 'strip the effort tier under the per-request thinking opt-out', + name: 'emit the tier-native disable shape under the per-request thinking opt-out', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen3.8-max', extraBody: { reasoning_effort: 'high' }, thinkingMandatory: undefined, reasoning: undefined, includeThoughts: false, - expectedThinking: false, + expectedThinking: undefined, + expectedReasoningEffort: 'none', expectedToolChoice: 'required', }, { @@ -926,14 +928,19 @@ describe('ContentGenerationPipeline', () => { .calls[0][0]; expect(apiCall.enable_thinking).toBe(testCase.expectedThinking); expect(apiCall.tool_choice).toBe(testCase.expectedToolChoice); + if ('expectedReasoningEffort' in testCase) { + expect(apiCall.reasoning_effort).toBe(testCase.expectedReasoningEffort); + } }); - it('feeds the real provider knob drop into the pipeline gate for a non-qwen preset shape', async () => { + it('keeps forced tool selection for a non-qwen preset shape end to end', async () => { // The table above mocks buildRequest as a plain extra_body merge, so - // the real provider drop never executes there. Run the actual - // DashScope provider instead: its family-gated drop must keep the glm - // preset's enable_thinking, which then trips the pipeline's - // enable_thinking === true clause and strips tool_choice. + // the real provider never executes there. Run the actual DashScope + // provider instead: its family-gated drop keeps the glm preset's + // enable_thinking, and the pipeline's enable_thinking clause is + // family-gated too — on glm the field is an opaque no-op (GLM reads + // thinking.enabled), not a thinking switch, so tool_choice=required + // must survive for its forced-tool side queries. mockContentGeneratorConfig = { ...mockContentGeneratorConfig, baseUrl: @@ -999,7 +1006,7 @@ describe('ContentGenerationPipeline', () => { .calls[0][0]; expect(apiCall.enable_thinking).toBe(true); expect(apiCall.reasoning_effort).toBe('high'); - expect(apiCall.tool_choice).toBeUndefined(); + expect(apiCall.tool_choice).toBe('required'); }); it('learns required thinking from a provider error and retries once', async () => { @@ -1069,10 +1076,14 @@ describe('ContentGenerationPipeline', () => { const calls = (mockClient.chat.completions.create as Mock).mock.calls; expect(calls).toHaveLength(3); + // The tier-native disable shape is reasoning_effort: 'none' (the + // boolean is not a knob this family reads), and the retry trigger + // must recognise it. expect(calls[0][0]).toMatchObject({ - enable_thinking: false, + reasoning_effort: 'none', tool_choice: 'required', }); + expect(calls[0][0].enable_thinking).toBeUndefined(); expect(calls[1][0].enable_thinking).toBe(true); expect(calls[1][0].tool_choice).toBeUndefined(); expect(calls[2][0].enable_thinking).toBe(true); @@ -2247,7 +2258,8 @@ describe('ContentGenerationPipeline', () => { const calls = (mockClient.chat.completions.create as Mock).mock.calls; expect(calls).toHaveLength(2); - expect(calls[0][0].enable_thinking).toBe(false); + expect(calls[0][0].reasoning_effort).toBe('none'); + expect(calls[0][0].enable_thinking).toBeUndefined(); expect(calls[1][0].enable_thinking).toBe(true); expect(mockReportOpenAiRequest).toHaveBeenNthCalledWith(1, calls[0][0]); expect(mockReportOpenAiRequest).toHaveBeenNthCalledWith(2, calls[1][0]); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 37b9dc977c7..547843544b0 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -21,6 +21,10 @@ import { redactProxyError } from '../../utils/runtimeFetchOptions.js'; import { runtimeDiagnostics } from '../../utils/runtimeDiagnostics.js'; import { createChildAbortController } from '../../utils/abortController.js'; import { reconcileMaxTokens } from '../tokenLimits.js'; +import { + isQwenFamilyWireModel, + isTieredEffortWireModel, +} from '../modalityDefaults.js'; import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, MAX_STREAM_IDLE_TIMEOUT_MS, @@ -877,12 +881,20 @@ export class ContentGenerationPipeline { // 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 regression). - if ( - !thinkingMandatory && - DashScopeOpenAICompatibleProvider.isQwenFamilyWireModel(model) - ) { + if (!thinkingMandatory && isQwenFamilyWireModel(model)) { if (isDashScope) { - typed['enable_thinking'] = false; + if (isTieredEffortWireModel(model)) { + // The tier-native family reads reasoning_effort, not the + // boolean: emit the canonical disable in the knob it reads + // (the strip below preserves 'none'). Drop a user-supplied + // thinking_budget too — DashScope rejects it alongside + // reasoning_effort. + delete typed['enable_thinking']; + delete typed['thinking_budget']; + typed['reasoning_effort'] = 'none'; + } else { + 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 @@ -954,19 +966,24 @@ export class ContentGenerationPipeline { const typed = providerRequest as unknown as Record; const reasoningEffort = typed['reasoning_effort']; - // DashScope rejects forced tool selection while thinking is enabled. The - // `reasoning_effort` clause is family-gated like the disable path above: - // on non-qwen models sharing the DashScope endpoint it is an opaque - // sampling override, not a thinking switch, and dropping `required` - // there would degrade their forced-tool side queries. + // DashScope rejects forced tool selection while thinking is enabled + // ("The tool_choice parameter does not support being set to required or + // object in thinking mode"). Both field clauses are family-gated like + // the disable path above: `enable_thinking` and `reasoning_effort` are + // qwen thinking switches, but on non-qwen models sharing the endpoint + // they are opaque parameters that do not put the request in thinking + // mode (GLM reads `thinking.enabled`, DeepSeek `thinking.type`), and + // dropping `required` there only degrades their forced-tool side + // queries. `thinkingMandatory` stays ungated: it is explicit + // "thinking is on" knowledge, model-agnostic by design. if ( isDashScope && typed['tool_choice'] === 'required' && (thinkingMandatory || - typed['enable_thinking'] === true || - (DashScopeOpenAICompatibleProvider.isQwenFamilyWireModel(model) && - typeof reasoningEffort === 'string' && - reasoningEffort !== 'none')) + (isQwenFamilyWireModel(model) && + (typed['enable_thinking'] === true || + (typeof reasoningEffort === 'string' && + reasoningEffort !== 'none')))) ) { debugLogger.debug( 'DashScope: dropping tool_choice=required while thinking is enabled', @@ -1159,7 +1176,11 @@ export class ContentGenerationPipeline { | undefined; if ( (wireRequest?.['enable_thinking'] === false || - chatTemplateKwargs?.['enable_thinking'] === false) && + chatTemplateKwargs?.['enable_thinking'] === false || + // The tier-native family's disable shape (reasoning_effort: + // 'none') replaces enable_thinking: false on the wire; recognise + // it so runtime learning still fires there. + wireRequest?.['reasoning_effort'] === 'none') && request.config?.abortSignal?.aborted !== true && isRequiredThinkingError(error) ) { diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index d73c26dad1c..36225d88e6a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -724,14 +724,26 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(result['reasoning_effort']).toBe('high'); expect(result['enable_thinking']).toBeUndefined(); expect(result['reasoning']).toBeUndefined(); - expect(mockDebugLogger.debug).toHaveBeenCalledWith( - 'DashScope: dropping thinking knobs that conflict with reasoning_effort', + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + 'DashScope: dropped extra_body thinking knobs that conflict with reasoning_effort', { model: 'qwen3.8-max-preview', reasoningEffort: 'high', dropped: ['enable_thinking'], }, ); + + // The conflict is persistent for this generator; the warn fires once, + // not on every request. + generator.buildRequest( + { + ...baseRequest, + model: 'qwen3.8-max-preview', + reasoning: { effort: 'high' }, + } as unknown as Parameters[0], + 'test-prompt-id-2', + ); + expect(mockDebugLogger.warn).toHaveBeenCalledTimes(1); }); it('keeps enable_thinking when a request-level reasoning_effort override ships on a legacy qwen model', () => { @@ -760,9 +772,11 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(result['enable_thinking']).toBe(true); }); - it('drops only thinking_budget when a reasoning_effort override ships on a legacy qwen model', () => { - // DashScope rejects the reasoning_effort + thinking_budget pair on - // qwen models, but the legacy thinking switch must survive. + it('drops the inert reasoning_effort when it conflicts with thinking_budget on a legacy qwen model', () => { + // DashScope rejects the reasoning_effort + thinking_budget pair. + // Legacy hybrids read enable_thinking/thinking_budget, not + // reasoning_effort, so the inert field goes and the knobs the model + // reads survive. const generator = new DashScopeOpenAICompatibleProvider( { ...mockContentGeneratorConfig, @@ -782,9 +796,31 @@ describe('DashScopeOpenAICompatibleProvider', () => { 'test-prompt-id', ) as unknown as Record; - expect(result['reasoning_effort']).toBe('max'); + expect(result['reasoning_effort']).toBeUndefined(); expect(result['enable_thinking']).toBe(true); - expect(result['thinking_budget']).toBeUndefined(); + expect(result['thinking_budget']).toBe(1024); + }); + + it('drops the inert reasoning_effort for a legacy qwen model with only a user thinking_budget', () => { + // No config tier: the wire would otherwise carry a single ignored + // parameter (reasoning_effort) with the meaningful thinking_budget + // deleted. The user's budget must survive. + const generator = new DashScopeOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + model: 'qwen3.7-max', + extra_body: { thinking_budget: 4096, reasoning_effort: 'max' }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + const result = generator.buildRequest( + { ...baseRequest, model: 'qwen3.7-max' }, + 'test-prompt-id', + ) as unknown as Record; + + expect(result['reasoning_effort']).toBeUndefined(); + expect(result['thinking_budget']).toBe(4096); + expect(result['enable_thinking']).toBeUndefined(); }); it('keeps every knob for a non-qwen model with an extra_body enable_thinking and reasoning_effort', () => { @@ -806,10 +842,30 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(result['enable_thinking']).toBe(true); expect(result['reasoning_effort']).toBe('high'); - expect(mockDebugLogger.debug).not.toHaveBeenCalledWith( - 'DashScope: dropping thinking knobs that conflict with reasoning_effort', - expect.anything(), + expect(mockDebugLogger.warn).not.toHaveBeenCalled(); + }); + + it('keeps every knob for a non-qwen model with an extra_body thinking_budget and reasoning_effort', () => { + // The family gate's observable effect for non-qwen models: a user + // thinking_budget survives alongside an opaque reasoning_effort + // override (mutation check: deleting the gate's early return drops + // the budget here). + const generator = new DashScopeOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + model: 'glm-5.2', + extra_body: { reasoning_effort: 'high', thinking_budget: 1024 }, + } as ContentGeneratorConfig, + mockCliConfig, ); + const result = generator.buildRequest( + { ...baseRequest, model: 'glm-5.2' }, + 'test-prompt-id', + ) as unknown as Record; + + expect(result['reasoning_effort']).toBe('high'); + expect(result['thinking_budget']).toBe(1024); + expect(mockDebugLogger.warn).not.toHaveBeenCalled(); }); it('keeps the thinking knobs when reasoning_effort is the none disable value', () => { @@ -833,14 +889,59 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(result['enable_thinking']).toBe(true); }); + it('honours an explicit extra_body enable_thinking: false over the tier on qwen3.8-max', () => { + // The off-switch arrives through the documented extra_body escape + // hatch; deleting it would silently turn thinking back on. Translate + // it into the family's canonical disable instead. + const generator = new DashScopeOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + model: 'qwen3.8-max', + reasoning: { effort: 'high' }, + extra_body: { enable_thinking: false }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + const result = generator.buildRequest( + { ...baseRequest, model: 'qwen3.8-max' }, + 'test-prompt-id', + ) as unknown as Record; + + expect(result['reasoning_effort']).toBe('none'); + expect(result['enable_thinking']).toBeUndefined(); + }); + + it('drops both conflicting knobs when a tier ships alongside enable_thinking and thinking_budget', () => { + // Multi-knob shape: the delete loop must clear every conflicting + // field, not just the first one. + const generator = new DashScopeOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + model: 'qwen3.8-max', + reasoning: { effort: 'high' }, + extra_body: { enable_thinking: true, thinking_budget: 1024 }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + const result = generator.buildRequest( + { ...baseRequest, model: 'qwen3.8-max' }, + 'test-prompt-id', + ) as unknown as Record; + + expect(result['reasoning_effort']).toBe('high'); + expect(result['enable_thinking']).toBeUndefined(); + expect(result['thinking_budget']).toBeUndefined(); + }); + it.each(['qwen3.8-max-2026-01-15', 'qwen3.8-max-latest'])( - 'passes effort through for the %s snapshot/alias id', + 'passes effort through and drops the preset enable_thinking for the %s snapshot/alias id', (model) => { const generator = new DashScopeOpenAICompatibleProvider( { ...mockContentGeneratorConfig, model, reasoning: { effort: 'xhigh' }, + extra_body: { enable_thinking: true }, } as ContentGeneratorConfig, mockCliConfig, ); @@ -919,7 +1020,10 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(result['vl_high_resolution_images']).toBe(true); }); - it('vision model: drops a conflicting thinking_budget on the vision branch too', () => { + it('vision model: drops the inert reasoning_effort against a thinking_budget on the vision branch too', () => { + // qwen-vl-max is a legacy hybrid; the vision branch resolves the + // budget conflict the same way as the text path — the inert + // reasoning_effort goes, the knobs the model reads survive. const generator = new DashScopeOpenAICompatibleProvider( { ...mockContentGeneratorConfig, @@ -938,9 +1042,9 @@ describe('DashScopeOpenAICompatibleProvider', () => { 'test-prompt-id', ) as unknown as Record; - expect(result['reasoning_effort']).toBe('max'); + expect(result['reasoning_effort']).toBeUndefined(); expect(result['enable_thinking']).toBe(true); - expect(result['thinking_budget']).toBeUndefined(); + expect(result['thinking_budget']).toBe(2048); expect(result['vl_high_resolution_images']).toBe(true); }); diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 9704af8442c..fb59aa182f9 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -18,6 +18,10 @@ import type { import type { OpenAIResponseParsingOptions } from '../responseParsingOptions.js'; import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js'; import { createDebugLogger } from '../../../utils/debugLogger.js'; +import { + isQwenFamilyWireModel, + isTieredEffortWireModel, +} from '../../modalityDefaults.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; const debugLogger = createDebugLogger('DashScopeOpenAICompatibleProvider'); @@ -131,22 +135,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr ); } - /** - * True for wire model ids in the qwen family: any `qwen*` id plus - * `coder-model`, the QWEN_OAUTH default (DEFAULT_QWEN_MODEL in - * config/models.ts, aliased to a Qwen 3.6 Plus hybrid), which doesn't - * start with `qwen` but is the most common hybrid-thinking model for - * first-time users. Shared by the pipeline's disable/tool-choice gates - * and this provider's effort mapping so the predicate lives in one place. - */ - static isQwenFamilyWireModel(model: string | undefined): boolean { - if (!model) { - return false; - } - const normalized = model.toLowerCase(); - return normalized.startsWith('qwen') || normalized === 'coder-model'; - } - override buildHeaders(): Record { const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; @@ -284,12 +272,11 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr if (hasQwenEffortConfig && 'reasoning' in visionResult) { delete visionResult['reasoning']; } - const visionMerged: Record = { - ...visionResult, - ...(extraBody ? extraBody : {}), - }; - this.dropConflictingThinkingKnobs(request.model, visionMerged); - return visionMerged as unknown as OpenAI.Chat.ChatCompletionCreateParams; + return this.mergeExtraBodyAndResolveKnobs( + visionResult, + extraBody, + request.model, + ); } // DashScope-exclusive fields not present in the OpenAI SDK types; user @@ -311,23 +298,38 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr if (hasQwenEffortConfig && 'reasoning' in result) { delete result['reasoning']; } + return this.mergeExtraBodyAndResolveKnobs(result, extraBody, request.model); + } + + /** + * Shared tail for the vision and text branches: merge user extra_body + * last, then resolve thinking-knob conflicts against the wire model. + */ + private mergeExtraBodyAndResolveKnobs( + result: Record, + extraBody: Record | undefined, + model: string | undefined, + ): OpenAI.Chat.ChatCompletionCreateParams { const merged: Record = { ...result, ...(extraBody ? extraBody : {}), }; - this.dropConflictingThinkingKnobs(request.model, merged); + this.dropConflictingThinkingKnobs(model, merged); return merged as unknown as OpenAI.Chat.ChatCompletionCreateParams; } + private resolveWireModel(model: string | undefined): string { + return (model ?? this.contentGeneratorConfig.model ?? '').toLowerCase(); + } + /** * Translate the unified reasoning effort into the wire shape the model * accepts. The qwen3.8-max family takes the tiered `reasoning_effort` - * directly — prefix-matched, so dated snapshots and `-latest` aliases are - * covered like the family match in modalityDefaults.ts. Older qwen hybrid - * models expose only the on/off `enable_thinking` switch, so the effort - * ladder collapses to on/off there. Gated to qwen-family wire models - * (mirroring the pipeline's disable gate) so the qwen-specific fields - * never leak to a non-qwen model sharing the DashScope endpoint. + * directly; older qwen hybrid models expose only the on/off + * `enable_thinking` switch, so the effort ladder collapses to on/off + * there. Gated to qwen-family wire models (mirroring the pipeline's + * disable gate) so the qwen-specific fields never leak to a non-qwen + * model sharing the DashScope endpoint. */ private buildQwenEffortConfig( model: string | undefined, @@ -336,35 +338,34 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr if (!reasoning || reasoning.effort === undefined) { return {}; } - const wireModel = ( - model ?? - this.contentGeneratorConfig.model ?? - '' - ).toLowerCase(); - if (wireModel.startsWith('qwen3.8-max')) { + const wireModel = this.resolveWireModel(model); + if (isTieredEffortWireModel(wireModel)) { return { reasoning_effort: reasoning.effort }; } - if (DashScopeOpenAICompatibleProvider.isQwenFamilyWireModel(wireModel)) { + if (isQwenFamilyWireModel(wireModel)) { return { enable_thinking: true }; } return {}; } /** - * Drop thinking knobs that conflict with a shipping `reasoning_effort`. + * Resolve thinking knobs that conflict with a shipping `reasoning_effort`. * Preset extra_body injects `enable_thinking` for models declared with - * enableThinking (provider-config.ts). Only qwen-family models read these - * qwen-specific knobs, and only the qwen3.8-max family reads - * `reasoning_effort` itself — there both `enable_thinking` (the "two - * competing knobs" shape the nested-`reasoning` strip in buildRequest - * exists to prevent) and `thinking_budget` (a pair DashScope rejects - * alongside `reasoning_effort`) go, so the tier ships alone. Older qwen - * hybrids read `enable_thinking`, not `reasoning_effort`, so they keep - * the switch and lose only a conflicting `thinking_budget`; dropping the - * switch there would ship no thinking signal at all. Non-qwen models on - * the endpoint treat `reasoning_effort` as an opaque sampling override - * and keep every knob. Runs after the extra_body merge so user-supplied - * knobs are covered too. + * enableThinking (provider-config.ts), and user extra_body merges last. + * Only the qwen3.8-max family reads `reasoning_effort` itself — there the + * tier ships alone: an `enable_thinking: true` alongside it is a second + * competing knob (the shape the nested-`reasoning` strip in buildRequest + * exists to prevent), and DashScope rejects `reasoning_effort` combined + * with `thinking_budget`. An explicit `enable_thinking: false` is the + * documented extra_body escape hatch winning over the config tier, so it + * is honoured as the family's canonical disable (`reasoning_effort: + * 'none'`, preserved by the pipeline's disable strip) rather than + * silently deleted. Older qwen hybrids read `enable_thinking` / + * `thinking_budget`, not `reasoning_effort`, so when an opaque + * reasoning_effort override conflicts with a meaningful thinking_budget + * the inert field goes and the knobs the model reads survive. Non-qwen + * models treat `reasoning_effort` as an opaque sampling override and + * keep every knob. */ private dropConflictingThinkingKnobs( model: string | undefined, @@ -377,20 +378,23 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr if (typeof effort !== 'string' || effort === 'none') { return; } - const wireModel = ( - model ?? - this.contentGeneratorConfig.model ?? - '' - ).toLowerCase(); - if (!DashScopeOpenAICompatibleProvider.isQwenFamilyWireModel(wireModel)) { + const wireModel = this.resolveWireModel(model); + if (!isQwenFamilyWireModel(wireModel)) { return; } const dropped: string[] = []; - if ('thinking_budget' in merged) { - dropped.push('thinking_budget'); - } - if (wireModel.startsWith('qwen3.8-max') && 'enable_thinking' in merged) { - dropped.push('enable_thinking'); + if (isTieredEffortWireModel(wireModel)) { + if ('enable_thinking' in merged) { + if (merged['enable_thinking'] === false) { + merged['reasoning_effort'] = 'none'; + } + dropped.push('enable_thinking'); + } + if ('thinking_budget' in merged) { + dropped.push('thinking_budget'); + } + } else if ('thinking_budget' in merged) { + dropped.push('reasoning_effort'); } if (dropped.length === 0) { return; @@ -398,12 +402,24 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr for (const key of dropped) { delete merged[key]; } - debugLogger.debug( - 'DashScope: dropping thinking knobs that conflict with reasoning_effort', - { model: wireModel, reasoningEffort: effort, dropped }, - ); + // Warn (not debug): this discards keys the user supplied through + // extra_body, the documented escape hatch. Once per generator so a + // persistent conflict doesn't spam every request. + if (!this.conflictingKnobDropWarned) { + this.conflictingKnobDropWarned = true; + debugLogger.warn( + 'DashScope: dropped extra_body thinking knobs that conflict with reasoning_effort', + { + model: wireModel, + reasoningEffort: merged['reasoning_effort'], + dropped, + }, + ); + } } + private conflictingKnobDropWarned = false; + buildMetadata(userPromptId: string): DashScopeRequestMetadata { const channel = this.cliConfig.getChannel?.();