Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/users/configuration/model-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `qwen3.8-max-preview`) | Flat `reasoning_effort: <effort>` body parameter | The five `/effort` tiers (`low`, `medium`, `high`, `xhigh`, `max`) are passed through verbatim; DashScope applies any model-specific mapping. Do not also configure `thinking_budget`, because DashScope rejects requests containing both fields. Other Qwen models continue to map a selected effort to `enable_thinking: true`. |
| **OpenAI / DeepSeek** (`api.deepseek.com`) | Flat `reasoning_effort: <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: <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. |
Comment thread
wenshao marked this conversation as resolved.
| **OpenAI / DeepSeek** (`api.deepseek.com`) | Flat `reasoning_effort: <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`

Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/core/openaiContentGenerator/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { Config } from '../../config/config.js';
import { AuthType, type ContentGeneratorConfig } from '../contentGenerator.js';
import type { OpenAICompatibleProvider } from './provider/index.js';
import { DefaultOpenAICompatibleProvider } from './provider/default.js';
import { DashScopeOpenAICompatibleProvider } from './provider/dashscope.js';
import {
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
MAX_STREAM_IDLE_TIMEOUT_MS,
Expand Down Expand Up @@ -755,6 +756,18 @@ describe('ContentGenerationPipeline', () => {
expectedThinking: undefined,
expectedToolChoice: 'required',
},
{
name: 'preserve required tool selection for a non-qwen model with a user reasoning_effort',
baseUrl:
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
model: 'glm-5.2',
extraBody: { reasoning_effort: 'high' },
thinkingMandatory: undefined,
reasoning: undefined,
includeThoughts: true,
expectedThinking: undefined,
expectedToolChoice: 'required',
},
{
name: 'preserve required tool selection when thinking is not enabled',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
Expand All @@ -766,6 +779,28 @@ describe('ContentGenerationPipeline', () => {
expectedThinking: undefined,
expectedToolChoice: 'required',
},
{
name: 'strip the effort tier 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,
expectedToolChoice: 'required',
},
{
name: 'strip the effort tier 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,
expectedToolChoice: 'required',
},
{
name: 'never emit the disable even under the reasoning opt-out',
baseUrl:
Expand Down Expand Up @@ -893,6 +928,80 @@ describe('ContentGenerationPipeline', () => {
expect(apiCall.tool_choice).toBe(testCase.expectedToolChoice);
});

it('feeds the real provider knob drop into the pipeline gate for a non-qwen preset shape', 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.
mockContentGeneratorConfig = {
...mockContentGeneratorConfig,
baseUrl:
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
model: 'glm-5.2',
authType: AuthType.QWEN_OAUTH,
extra_body: { enable_thinking: true, reasoning_effort: 'high' },
} as ContentGeneratorConfig;
mockConfig = {
...mockConfig,
contentGeneratorConfig: mockContentGeneratorConfig,
};
pipeline = new ContentGenerationPipeline(mockConfig);

const realProvider = new DashScopeOpenAICompatibleProvider(
mockContentGeneratorConfig,
{
getContentGeneratorConfig: () => ({ enableCacheControl: false }),
} as unknown as Config,
);
(mockProvider.buildRequest as Mock).mockImplementation((req) =>
realProvider.buildRequest(req, 'side-query:combined-shape'),
);

const request: GenerateContentParameters = {
model: 'glm-5.2',
contents: [{ parts: [{ text: 'Summarize' }], role: 'user' }],
config: {
thinkingConfig: { includeThoughts: true },
tools: [
{
functionDeclarations: [
{
name: 'respond_in_schema',
parameters: { type: Type.OBJECT, properties: {} },
},
],
},
],
toolConfig: {
functionCallingConfig: { mode: FunctionCallingConfigMode.ANY },
},
},
};

(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
{ role: 'user', content: 'Summarize' },
]);
(mockConverter.convertGeminiToolsToOpenAI as Mock).mockResolvedValue([
{ type: 'function', function: { name: 'respond_in_schema' } },
]);
(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, 'side-query:combined-shape');

const apiCall = (mockClient.chat.completions.create as Mock).mock
.calls[0][0];
expect(apiCall.enable_thinking).toBe(true);
expect(apiCall.reasoning_effort).toBe('high');
expect(apiCall.tool_choice).toBeUndefined();
});

it('learns required thinking from a provider error and retries once', async () => {
mockContentGeneratorConfig = {
...mockContentGeneratorConfig,
Expand Down
21 changes: 13 additions & 8 deletions packages/core/src/core/openaiContentGenerator/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,14 +877,9 @@ 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).
//
// `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.
if (
!thinkingMandatory &&
(model.startsWith('qwen') || model === 'coder-model')
DashScopeOpenAICompatibleProvider.isQwenFamilyWireModel(model)
) {
if (isDashScope) {
typed['enable_thinking'] = false;
Expand Down Expand Up @@ -959,14 +954,24 @@ export class ContentGenerationPipeline {

const typed = providerRequest as unknown as Record<string, unknown>;
const reasoningEffort = typed['reasoning_effort'];
// DashScope rejects forced tool selection while thinking is enabled.
// 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.
if (
isDashScope &&
typed['tool_choice'] === 'required' &&
(thinkingMandatory ||
typed['enable_thinking'] === true ||
(typeof reasoningEffort === 'string' && reasoningEffort !== 'none'))
(DashScopeOpenAICompatibleProvider.isQwenFamilyWireModel(model) &&
typeof reasoningEffort === 'string' &&
reasoningEffort !== 'none'))
) {
debugLogger.debug(
'DashScope: dropping tool_choice=required while thinking is enabled',
{ model, reasoningEffort, thinkingMandatory },
);
delete typed['tool_choice'];
}

Expand Down
Loading
Loading