From 7b816c49a7c1089c13dd300c88be6ee8bf134c9d Mon Sep 17 00:00:00 2001 From: Isaac Hernandez Date: Thu, 13 Aug 2026 08:26:02 -0400 Subject: [PATCH 1/4] fix(core): send thinkingBudget, not thinkingLevel, to Gemini 2.5 Gemini 2.5 rejects thinkingLevel outright, so every request from the Gemini/Vertex generator 400s on that family: with no effort set the default sent THINKING_LEVEL_UNSPECIFIED, and /effort sent LOW/MEDIUM/HIGH. Only reasoning: false escaped. Map the effort tier to thinkingConfig.thinkingBudget for 2.5 and keep thinkingLevel for Gemini 3, which is the split the unified-effort design doc already specifies. Drop THINKING_LEVEL_UNSPECIFIED from the no-effort default: it means the model decides, which is what omitting the field does. Fixes #9019 --- .../geminiContentGenerator.test.ts | 122 +++++++++++--- .../geminiContentGenerator.ts | 153 ++++++++++++------ 2 files changed, 204 insertions(+), 71 deletions(-) diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts index 0e3ef8a3280..aff7f579b1b 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts @@ -98,10 +98,7 @@ describe('GeminiContentGenerator', () => { config: expect.objectContaining({ temperature: 1, topP: 0.95, - thinkingConfig: { - includeThoughts: true, - thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED', - }, + thinkingConfig: { includeThoughts: true }, }), }), ); @@ -158,10 +155,7 @@ describe('GeminiContentGenerator', () => { config: expect.objectContaining({ temperature: 1, topP: 0.95, - thinkingConfig: { - includeThoughts: true, - thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED', - }, + thinkingConfig: { includeThoughts: true }, }), }), ); @@ -268,7 +262,7 @@ describe('GeminiContentGenerator', () => { const generatorWithReasoning = new GeminiContentGenerator( { apiKey: 'test' }, { - model: 'gemini-2.5-pro', + model: 'gemini-3-pro-preview', reasoning: { effort: 'high', }, @@ -277,7 +271,7 @@ describe('GeminiContentGenerator', () => { ); const request = { - model: 'gemini-2.5-pro', + model: 'gemini-3-pro-preview', contents: [], }; @@ -296,16 +290,16 @@ describe('GeminiContentGenerator', () => { }); it("maps reasoning effort 'max' to HIGH (Gemini has no higher tier)", async () => { - // 'max' is a DeepSeek-specific extension. Gemini caps at HIGH, so the - // converter must clamp instead of falling through to UNSPECIFIED. + // 'max' is a DeepSeek-specific extension. The Gemini 3 ladder caps at + // HIGH, so the converter must clamp instead of dropping the tier. const generatorWithMax = new GeminiContentGenerator({ apiKey: 'test' }, { - model: 'gemini-2.5-pro', + model: 'gemini-3-pro-preview', reasoning: { effort: 'max' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); await generatorWithMax.generateContent( - { model: 'gemini-2.5-pro', contents: [] }, + { model: 'gemini-3-pro-preview', contents: [] }, 'prompt-id', ); @@ -323,13 +317,13 @@ describe('GeminiContentGenerator', () => { it("maps reasoning effort 'medium' to MEDIUM", async () => { const generatorWithMedium = new GeminiContentGenerator({ apiKey: 'test' }, { - model: 'gemini-2.5-pro', + model: 'gemini-3-pro-preview', reasoning: { effort: 'medium' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); await generatorWithMedium.generateContent( - { model: 'gemini-2.5-pro', contents: [] }, + { model: 'gemini-3-pro-preview', contents: [] }, 'prompt-id', ); @@ -347,13 +341,13 @@ describe('GeminiContentGenerator', () => { it("clamps reasoning effort 'xhigh' to HIGH (Gemini has no xhigh tier)", async () => { const generatorWithXhigh = new GeminiContentGenerator({ apiKey: 'test' }, { - model: 'gemini-2.5-pro', + model: 'gemini-3-pro-preview', reasoning: { effort: 'xhigh' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); await generatorWithXhigh.generateContent( - { model: 'gemini-2.5-pro', contents: [] }, + { model: 'gemini-3-pro-preview', contents: [] }, 'prompt-id', ); @@ -369,6 +363,98 @@ describe('GeminiContentGenerator', () => { ); }); + describe('Gemini 2.5 thinking budget', () => { + const generatorFor = (model: string, reasoning?: unknown) => + new GeminiContentGenerator({ apiKey: 'test' }, { + model, + reasoning, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const sentThinkingConfig = () => + mockGoogleGenAI.models.generateContent.mock.calls[0][0].config + .thinkingConfig; + + it.each([ + ['low', 2048], + ['medium', 8192], + ['high', 16384], + ['xhigh', 32768], + ['max', 32768], + ])( + 'maps effort %s to a thinkingBudget on 2.5 Pro', + async (effort, budget) => { + const gen = generatorFor('gemini-2.5-pro', { effort }); + + await gen.generateContent( + { model: 'gemini-2.5-pro', contents: [] }, + 'prompt-id', + ); + + expect(sentThinkingConfig()).toEqual({ + includeThoughts: true, + thinkingBudget: budget, + }); + }, + ); + + it('caps the top tiers at the Flash ceiling', async () => { + const gen = generatorFor('gemini-2.5-flash', { effort: 'max' }); + + await gen.generateContent( + { model: 'gemini-2.5-flash', contents: [] }, + 'prompt-id', + ); + + expect(sentThinkingConfig()).toEqual({ + includeThoughts: true, + thinkingBudget: 24576, + }); + }); + + it('never sends thinkingLevel to a 2.5 model', async () => { + const gen = generatorFor('gemini-2.5-flash', { effort: 'high' }); + + await gen.generateContent( + { model: 'gemini-2.5-flash', contents: [] }, + 'prompt-id', + ); + + expect(sentThinkingConfig()).not.toHaveProperty('thinkingLevel'); + }); + + it('omits both knobs when no effort is configured', async () => { + const gen = generatorFor('gemini-2.5-pro'); + + await gen.generateContent( + { model: 'gemini-2.5-pro', contents: [] }, + 'prompt-id', + ); + + expect(sentThinkingConfig()).toEqual({ includeThoughts: true }); + }); + + it('leaves an explicit request-level thinkingConfig alone', async () => { + const gen = generatorFor('gemini-2.5-pro'); + + await gen.generateContent( + { + model: 'gemini-2.5-pro', + contents: [], + config: { + thinkingConfig: { thinkingBudget: 0, includeThoughts: false }, + }, + }, + 'prompt-id', + ); + + expect(sentThinkingConfig()).toEqual({ + thinkingBudget: 0, + includeThoughts: false, + }); + }); + }); + it('should strip displayName from inlineData and fileData before sending to API', async () => { const request = { model: 'gemini-1.5-flash', diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts index b17cb6edbfc..e58b270caaf 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -21,6 +21,7 @@ import type { ContentGenerator, ContentGeneratorConfig, } from '../contentGenerator.js'; +import type { ReasoningEffort } from '../reasoning-effort.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { reportGeminiChunk, @@ -31,6 +32,43 @@ import { const debugLogger = createDebugLogger('GEMINI'); +// Gemini 2.5 has no `thinkingLevel`; the request is rejected outright when the +// field is present. That family takes a `thinkingBudget` instead, which is the +// split the unified-effort design already calls for +// (docs/design/2026-06-30-unified-reasoning-effort-cli.md). +const BUDGET_STYLE_MODEL = /gemini-2\.5/; +const BUDGET_STYLE_PRO = /gemini-2\.5-pro/; + +// Buckets invert the budget -> level thresholds in that same design doc. The +// top tiers take the model's documented ceiling, which is the one place where +// 2.5 can go beyond what the Gemini 3 ladder expresses. +const THINKING_BUDGET_CEILING_PRO = 32768; +const THINKING_BUDGET_CEILING = 24576; + +function thinkingBudgetForEffort( + effort: ReasoningEffort, + model: string, +): number { + switch (effort) { + case 'low': + return 2048; + case 'medium': + return 8192; + case 'high': + return 16384; + case 'xhigh': + case 'max': + return BUDGET_STYLE_PRO.test(model) + ? THINKING_BUDGET_CEILING_PRO + : THINKING_BUDGET_CEILING; + default: { + const _exhaustive: never = effort; + void _exhaustive; + return 16384; + } + } +} + function observeGeminiStream( stream: AsyncIterable, telemetryAttempt: GenAiAttemptHandle | undefined, @@ -143,18 +181,19 @@ export class GeminiContentGenerator implements ContentGenerator { 'frequencyPenalty', ), thinkingConfig: getParameterValue( - this.buildThinkingConfig(), + this.buildThinkingConfig(request.model), 'thinkingConfig', - { - includeThoughts: true, - thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel, - }, + { includeThoughts: true }, ), }; } - private buildThinkingConfig(): - | { includeThoughts: boolean; thinkingLevel?: ThinkingLevel } + private buildThinkingConfig(model: string): + | { + includeThoughts: boolean; + thinkingLevel?: ThinkingLevel; + thinkingBudget?: number; + } | undefined { const reasoning = this.contentGeneratorConfig?.reasoning; @@ -162,58 +201,66 @@ export class GeminiContentGenerator implements ContentGenerator { return { includeThoughts: false }; } - if (reasoning) { - // Gemini's thinkingLevel ladder is MINIMAL / LOW / MEDIUM / HIGH — there - // is no xhigh/max, so the extra-strong tiers are capped at HIGH. An unset - // effort stays UNSPECIFIED so the model picks its own default. - let thinkingLevel: ThinkingLevel; - switch (reasoning.effort) { - case 'low': - thinkingLevel = 'LOW' as ThinkingLevel; - break; - case 'medium': - thinkingLevel = 'MEDIUM' as ThinkingLevel; - break; - case 'high': - thinkingLevel = 'HIGH' as ThinkingLevel; - break; - case 'xhigh': - case 'max': - // Gemini has no tier above HIGH; log the clamp once (mirroring the - // Anthropic generator's one-time clamp warning) so a /effort xhigh|max - // that silently runs at HIGH leaves a trace in debug logs. - if (!this.effortClampWarned) { - debugLogger.warn( - `reasoning.effort='${reasoning.effort}' is not supported by Gemini; clamping to 'HIGH'.`, - ); - this.effortClampWarned = true; - } - thinkingLevel = 'HIGH' as ThinkingLevel; - break; - case undefined: - // No effort set — let the model pick its own default. - thinkingLevel = 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel; - break; - default: { - // Exhaustiveness guard: every ReasoningEffort tier (and undefined) is - // handled above, so this is unreachable. Adding a new tier without a - // matching case makes this a TypeScript compile error rather than a - // silent fall-through to UNSPECIFIED. (A `default` is required here by - // the eslint default-case rule.) - const _exhaustive: never = reasoning.effort; - void _exhaustive; - thinkingLevel = 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel; - break; - } - } + if (!reasoning) { + return undefined; + } + // No effort set: send neither knob. THINKING_LEVEL_UNSPECIFIED means "the + // model decides", which is exactly what omitting the field does, and the + // 2.5 family rejects the field itself. + if (reasoning.effort === undefined) { + return { includeThoughts: true }; + } + + if (BUDGET_STYLE_MODEL.test(model)) { return { includeThoughts: true, - thinkingLevel, + thinkingBudget: thinkingBudgetForEffort(reasoning.effort, model), }; } - return undefined; + // Gemini's thinkingLevel ladder is MINIMAL / LOW / MEDIUM / HIGH — there + // is no xhigh/max, so the extra-strong tiers are capped at HIGH. + let thinkingLevel: ThinkingLevel; + switch (reasoning.effort) { + case 'low': + thinkingLevel = 'LOW' as ThinkingLevel; + break; + case 'medium': + thinkingLevel = 'MEDIUM' as ThinkingLevel; + break; + case 'high': + thinkingLevel = 'HIGH' as ThinkingLevel; + break; + case 'xhigh': + case 'max': + // Gemini has no tier above HIGH; log the clamp once (mirroring the + // Anthropic generator's one-time clamp warning) so a /effort xhigh|max + // that silently runs at HIGH leaves a trace in debug logs. + if (!this.effortClampWarned) { + debugLogger.warn( + `reasoning.effort='${reasoning.effort}' is not supported by Gemini; clamping to 'HIGH'.`, + ); + this.effortClampWarned = true; + } + thinkingLevel = 'HIGH' as ThinkingLevel; + break; + default: { + // Exhaustiveness guard: every ReasoningEffort tier is handled above, so + // this is unreachable. Adding a new tier without a matching case makes + // this a TypeScript compile error rather than a silent fall-through. + // (A `default` is required here by the eslint default-case rule.) + const _exhaustive: never = reasoning.effort; + void _exhaustive; + thinkingLevel = 'HIGH' as ThinkingLevel; + break; + } + } + + return { + includeThoughts: true, + thinkingLevel, + }; } async generateContent( From b516805f52b7dfca9ac2a3a668462bec0bd3b559 Mon Sep 17 00:00:00 2001 From: Isaac Hernandez Date: Thu, 13 Aug 2026 15:51:33 -0400 Subject: [PATCH 2/4] refactor(core): use shared clampReasoningEffort in Gemini generator The hand-rolled xhigh/max clamp duplicated the Anthropic generator's use of the same helper. Switching to it keeps clamp policy in one place so a ladder change doesn't need re-deriving per adapter. --- .../geminiContentGenerator.ts | 60 ++++++++----------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts index e58b270caaf..c60e24114a3 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -21,7 +21,10 @@ import type { ContentGenerator, ContentGeneratorConfig, } from '../contentGenerator.js'; -import type { ReasoningEffort } from '../reasoning-effort.js'; +import { + clampReasoningEffort, + type ReasoningEffort, +} from '../reasoning-effort.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { reportGeminiChunk, @@ -220,42 +223,27 @@ export class GeminiContentGenerator implements ContentGenerator { } // Gemini's thinkingLevel ladder is MINIMAL / LOW / MEDIUM / HIGH — there - // is no xhigh/max, so the extra-strong tiers are capped at HIGH. - let thinkingLevel: ThinkingLevel; - switch (reasoning.effort) { - case 'low': - thinkingLevel = 'LOW' as ThinkingLevel; - break; - case 'medium': - thinkingLevel = 'MEDIUM' as ThinkingLevel; - break; - case 'high': - thinkingLevel = 'HIGH' as ThinkingLevel; - break; - case 'xhigh': - case 'max': - // Gemini has no tier above HIGH; log the clamp once (mirroring the - // Anthropic generator's one-time clamp warning) so a /effort xhigh|max - // that silently runs at HIGH leaves a trace in debug logs. - if (!this.effortClampWarned) { - debugLogger.warn( - `reasoning.effort='${reasoning.effort}' is not supported by Gemini; clamping to 'HIGH'.`, - ); - this.effortClampWarned = true; - } - thinkingLevel = 'HIGH' as ThinkingLevel; - break; - default: { - // Exhaustiveness guard: every ReasoningEffort tier is handled above, so - // this is unreachable. Adding a new tier without a matching case makes - // this a TypeScript compile error rather than a silent fall-through. - // (A `default` is required here by the eslint default-case rule.) - const _exhaustive: never = reasoning.effort; - void _exhaustive; - thinkingLevel = 'HIGH' as ThinkingLevel; - break; - } + // is no xhigh/max, so the extra-strong tiers clamp down via the shared + // rank-based clamp (the Anthropic generator uses the same helper for its + // own per-model ceilings). + const clamped = clampReasoningEffort(reasoning.effort, [ + 'low', + 'medium', + 'high', + ]); + if (clamped !== reasoning.effort && !this.effortClampWarned) { + debugLogger.warn( + `reasoning.effort='${reasoning.effort}' is not supported by Gemini; clamping to '${clamped.toUpperCase()}'.`, + ); + this.effortClampWarned = true; } + const thinkingLevel = ( + { + low: 'LOW', + medium: 'MEDIUM', + high: 'HIGH', + } as Record<'low' | 'medium' | 'high', ThinkingLevel> + )[clamped as 'low' | 'medium' | 'high']; return { includeThoughts: true, From 2d07a2bbd8497cf32d447d94de0560eb6c6fa45b Mon Sep 17 00:00:00 2001 From: Isaac Hernandez Date: Thu, 13 Aug 2026 15:51:48 -0400 Subject: [PATCH 3/4] test(core): cover reasoning set without an effort in Gemini generator The existing "no effort configured" test passed reasoning: undefined, which exercises the earlier !reasoning branch, not the new reasoning.effort === undefined branch added for a reasoning object present without an effort (e.g. {} or { budget_tokens }). --- .../geminiContentGenerator.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts index aff7f579b1b..123ae983217 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts @@ -434,6 +434,17 @@ describe('GeminiContentGenerator', () => { expect(sentThinkingConfig()).toEqual({ includeThoughts: true }); }); + it('omits both knobs when reasoning is set without an effort', async () => { + const gen = generatorFor('gemini-2.5-pro', {}); + + await gen.generateContent( + { model: 'gemini-2.5-pro', contents: [] }, + 'prompt-id', + ); + + expect(sentThinkingConfig()).toEqual({ includeThoughts: true }); + }); + it('leaves an explicit request-level thinkingConfig alone', async () => { const gen = generatorFor('gemini-2.5-pro'); From d730c5c7844e886f51f69e7fb9b6ef25bc989f4b Mon Sep 17 00:00:00 2001 From: Isaac Hernandez Date: Thu, 10 Sep 2026 16:15:57 -0300 Subject: [PATCH 4/4] fix(core): clamp Gemini 3 Pro thinking levels, warn on budget_tokens, and update docs --- docs/users/configuration/model-providers.md | 20 +-- docs/users/configuration/settings.md | 2 +- packages/cli/src/config/settingsSchema.ts | 2 +- packages/core/src/core/contentGenerator.ts | 3 +- .../llm-content-generator.test.ts | 137 +++++++++++++++++- .../llm-content-generator.ts | 29 ++-- .../schemas/settings.schema.json | 2 +- 7 files changed, 171 insertions(+), 24 deletions(-) diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index 3b70c3ee3ba..54604a61c60 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -730,16 +730,16 @@ 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 `/effort` tiers are passed through for any model id starting with `qwen3.8-max` (including dated snapshots and `-latest` aliases); DashScope applies any model-specific mapping. This family's ladder stops at `xhigh`, so a configured `max` is clamped to `xhigh` (logged once) rather than sent and rejected. An explicit `reasoning_effort` in `samplingParams` or `extra_body` is a verbatim override and is not clamped. When `reasoning_effort` and `thinking_budget` conflict, the normal `extra_body` > `samplingParams` > `reasoning` precedence keeps only the higher-priority field; an explicit same-layer pair keeps `reasoning_effort`, matching the provider's behavior before cross-layer resolution. If a static field wins, `/effort` reports that field instead of implying the requested tier is effective. When an effort tier wins, a conflicting `enable_thinking` is also dropped. 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. `max` is accepted only on a real DeepSeek hostname; a `deepseek`-named model on another host keeps the generic `xhigh` ceiling, matching the hostname gate on the reshape itself. | -| **OpenAI / Z.ai** (`z.ai`, `bigmodel.cn`) | Flat `reasoning_effort: ` body parameter | GLM-5.2+ on a Z.ai host takes the full ladder, `max` included, and the nested `reasoning.effort` is rewritten to the flat field. Older GLM ids, and a `glm-*` model reached on any other host, keep the generic `xhigh` ceiling: the model name alone says nothing about what that endpoint accepts. | -| **OpenAI** (other compatible servers) | Known GPT-5 / GPT-6 Astra: flat `reasoning_effort`; other models: nested `reasoning` | GPT effort is clamped in both directions to the supported subset for the known model. GPT-5.6 and GPT-6 Astra allow `max`, while earlier models have lower ceilings. Tiers below the model floor are raised: GPT-5 Pro accepts only `high`; GPT-5.2 Pro, GPT-5.4 Pro and GPT-5.5 Pro raise `low` to `medium`. OpenRouter retains nested `reasoning`. Unknown model names keep the generic `xhigh` ceiling and nested shape. Explicit reasoning values in `samplingParams` / `extra_body` bypass the configured-tier clamp. | -| **OpenAI Responses** (`openai-responses`) | `reasoning: { effort, summary: "auto" }` plus `include: ["reasoning.encrypted_content"]` | Every tier passes through verbatim with no clamping. `extra_body.enable_thinking: true` is translated to `reasoning: { effort: "medium" }` when no explicit `reasoning` is set (and is never itself forwarded — it has no meaning on this wire); prefer setting `reasoning.effort` directly. | -| **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 `/effort` tiers are passed through for any model id starting with `qwen3.8-max` (including dated snapshots and `-latest` aliases); DashScope applies any model-specific mapping. This family's ladder stops at `xhigh`, so a configured `max` is clamped to `xhigh` (logged once) rather than sent and rejected. An explicit `reasoning_effort` in `samplingParams` or `extra_body` is a verbatim override and is not clamped. When `reasoning_effort` and `thinking_budget` conflict, the normal `extra_body` > `samplingParams` > `reasoning` precedence keeps only the higher-priority field; an explicit same-layer pair keeps `reasoning_effort`, matching the provider's behavior before cross-layer resolution. If a static field wins, `/effort` reports that field instead of implying the requested tier is effective. When an effort tier wins, a conflicting `enable_thinking` is also dropped. 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. `max` is accepted only on a real DeepSeek hostname; a `deepseek`-named model on another host keeps the generic `xhigh` ceiling, matching the hostname gate on the reshape itself. | +| **OpenAI / Z.ai** (`z.ai`, `bigmodel.cn`) | Flat `reasoning_effort: ` body parameter | GLM-5.2+ on a Z.ai host takes the full ladder, `max` included, and the nested `reasoning.effort` is rewritten to the flat field. Older GLM ids, and a `glm-*` model reached on any other host, keep the generic `xhigh` ceiling: the model name alone says nothing about what that endpoint accepts. | +| **OpenAI** (other compatible servers) | Known GPT-5 / GPT-6 Astra: flat `reasoning_effort`; other models: nested `reasoning` | GPT effort is clamped in both directions to the supported subset for the known model. GPT-5.6 and GPT-6 Astra allow `max`, while earlier models have lower ceilings. Tiers below the model floor are raised: GPT-5 Pro accepts only `high`; GPT-5.2 Pro, GPT-5.4 Pro and GPT-5.5 Pro raise `low` to `medium`. OpenRouter retains nested `reasoning`. Unknown model names keep the generic `xhigh` ceiling and nested shape. Explicit reasoning values in `samplingParams` / `extra_body` bypass the configured-tier clamp. | +| **OpenAI Responses** (`openai-responses`) | `reasoning: { effort, summary: "auto" }` plus `include: ["reasoning.encrypted_content"]` | Every tier passes through verbatim with no clamping. `extra_body.enable_thinking: true` is translated to `reasoning: { effort: "medium" }` when no explicit `reasoning` is set (and is never itself forwarded — it has no meaning on this wire); prefer setting `reasoning.effort` directly. | +| **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 }` (Gemini 3) / `thinkingConfig: { includeThoughts: true, thinkingBudget }` (Gemini 2.5) | Gemini 3 sends `thinkingLevel` with `low` → `LOW`, `medium` → `MEDIUM` (clamped to `HIGH` on Pro), `high`/`xhigh`/`max` → `HIGH` (one-time clamp warning). Gemini 2.5 sends `thinkingBudget` buckets (`low` → 2048, `medium` → 8192, `high` → 16384, `xhigh`/`max` → 32768 Pro / 24576 Flash); when effort is unset, neither knob is sent. | ### `reasoning: false` diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index f31b3c6f027..9ed204626d5 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -177,7 +177,7 @@ These settings are read from operator scopes only (User, System, and SystemDefau | Setting | Type | Description | Default | | -------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | `model.name` | string | The Qwen model to use for conversations. | `undefined` | -| `model.reasoningEffort` | enum | How hard reasoning-capable models think, applied across all providers. Set with the [`/effort`](../features/commands) command (`low`, `medium`, `high`, `xhigh`, `max`). Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at `high`; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default. | `undefined` | +| `model.reasoningEffort` | enum | How hard reasoning-capable models think, applied across all providers. Set with the [`/effort`](../features/commands) command (`low`, `medium`, `high`, `xhigh`, `max`). Each provider maps and clamps this to what the active model supports (e.g. Gemini 3 caps at `high`; Gemini 2.5 maps xhigh/max to its thinking-budget ceiling; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default. | `undefined` | | `model.baseUrl` | string | Persisted automatically by the model picker to disambiguate when multiple `modelProviders` entries share the same model id. Not intended to be set by hand — use the `/model` picker or a `modelProviders` entry instead; a stale hand-edited value can silently route requests to a different same-id provider. | `undefined` | | `model.sessionTokenLimit` | number | Maximum recorded prompt token count allowed before sending the next message. `-1` means unlimited; `0` is also treated as unlimited (unlike `model.maxToolCalls`, where `0` disallows all calls). When the recorded prompt count exceeds the limit, the next send is dropped (the session is not aborted). | `-1` | | `model.maxSessionTurns` | integer | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 9e0a10468b4..f6386d57556 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1629,7 +1629,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: undefined as string | undefined, description: - 'How hard reasoning-capable models think, applied across all providers. Set with /effort. Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at "high"; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default.', + 'How hard reasoning-capable models think, applied across all providers. Set with /effort. Each provider maps and clamps this to what the active model supports (e.g. Gemini 3 caps at "high"; Gemini 2.5 maps xhigh/max to its thinking-budget ceiling; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default.', showInDialog: true, options: [ { value: 'low', label: 'Low' }, diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 190dabc5687..84d65f66a07 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -140,7 +140,8 @@ export type ContentGeneratorConfig = { // Anthropic `output_config.effort` on Opus 4.7+, OpenAI `xhigh`). // - Generic OpenAI-compatible endpoints and the DashScope // qwen3.8-max family cap at 'xhigh' ('max' is a vendor extension, - // not part of the generic ladder); Gemini caps at 'high'. + // not part of the generic ladder); Gemini 3 caps at 'high' (Gemini + // 2.5 maps 'xhigh'/'max' to its thinking-budget ceiling). // - Real Anthropic clamps each tier to the active model's supported // set (Opus 4.7+/5.x accept 'xhigh'/'max'; Opus/Sonnet 4.6 accept // 'max'; older models cap at 'high'), logged once per generator via diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 655015c95c2..7d33806424c 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -12,6 +12,17 @@ import type { Config } from '../../config/config.js'; const mockReportLlmRequest = vi.hoisted(() => vi.fn()); const mockReportLlmResponse = vi.hoisted(() => vi.fn()); const mockReportLlmChunk = vi.hoisted(() => vi.fn()); +const mockDebugLogger = vi.hoisted(() => ({ + isEnabled: vi.fn().mockReturnValue(true), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../utils/debugLogger.js', () => ({ + createDebugLogger: vi.fn().mockReturnValue(mockDebugLogger), +})); vi.mock('@google/genai', () => { const mockGenerateContent = vi.fn(); @@ -581,7 +592,7 @@ describe('LlmContentGenerator', () => { ); }); - it("maps reasoning effort 'medium' to MEDIUM", async () => { + it("clamps reasoning effort 'medium' to HIGH on Gemini 3 Pro (supports low/high only)", async () => { const generatorWithMedium = new LlmContentGenerator({ apiKey: 'test' }, { model: 'gemini-3-pro-preview', reasoning: { effort: 'medium' }, @@ -593,6 +604,30 @@ describe('LlmContentGenerator', () => { 'prompt-id', ); + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'HIGH', + }, + }), + }), + ); + }); + + it("maps reasoning effort 'medium' to MEDIUM on Gemini 3 Flash", async () => { + const generatorWithMedium = new LlmContentGenerator({ apiKey: 'test' }, { + model: 'gemini-3-flash-preview', + reasoning: { effort: 'medium' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await generatorWithMedium.generateContent( + { model: 'gemini-3-flash-preview', contents: [] }, + 'prompt-id', + ); + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ @@ -605,6 +640,30 @@ describe('LlmContentGenerator', () => { ); }); + it("maps reasoning effort 'low' to LOW on Gemini 3", async () => { + const generatorWithLow = new LlmContentGenerator({ apiKey: 'test' }, { + model: 'gemini-3-pro-preview', + reasoning: { effort: 'low' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await generatorWithLow.generateContent( + { model: 'gemini-3-pro-preview', contents: [] }, + 'prompt-id', + ); + + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'LOW', + }, + }), + }), + ); + }); + it("clamps reasoning effort 'xhigh' to HIGH (Gemini has no xhigh tier)", async () => { const generatorWithXhigh = new LlmContentGenerator({ apiKey: 'test' }, { model: 'gemini-3-pro-preview', @@ -732,6 +791,82 @@ describe('LlmContentGenerator', () => { }); }); + describe('warning latches', () => { + it('warns once when effort is clamped and latches for subsequent requests', async () => { + const gen = new LlmContentGenerator({ apiKey: 'test' }, { + model: 'gemini-3-pro-preview', + reasoning: { effort: 'medium' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await gen.generateContent( + { model: 'gemini-3-pro-preview', contents: [] }, + 'prompt-1', + ); + expect(mockDebugLogger.warn).toHaveBeenCalledTimes(1); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + "reasoning.effort='medium' is not supported by Gemini; clamping to 'HIGH'.", + ); + + await gen.generateContent( + { model: 'gemini-3-pro-preview', contents: [] }, + 'prompt-2', + ); + expect(mockDebugLogger.warn).toHaveBeenCalledTimes(1); + }); + + it('does not warn when effort is supported by the model', async () => { + const gen = new LlmContentGenerator({ apiKey: 'test' }, { + model: 'gemini-3-pro-preview', + reasoning: { effort: 'low' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await gen.generateContent( + { model: 'gemini-3-pro-preview', contents: [] }, + 'prompt-1', + ); + expect(mockDebugLogger.warn).not.toHaveBeenCalled(); + }); + + it('warns once when budget_tokens is set and latches for subsequent requests', async () => { + const gen = new LlmContentGenerator({ apiKey: 'test' }, { + model: 'gemini-2.5-pro', + reasoning: { effort: 'high', budget_tokens: 4096 }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await gen.generateContent( + { model: 'gemini-2.5-pro', contents: [] }, + 'prompt-1', + ); + expect(mockDebugLogger.warn).toHaveBeenCalledTimes(1); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + 'reasoning.budget_tokens=4096 is not supported by Gemini; ignoring budget_tokens.', + ); + + await gen.generateContent( + { model: 'gemini-2.5-pro', contents: [] }, + 'prompt-2', + ); + expect(mockDebugLogger.warn).toHaveBeenCalledTimes(1); + }); + + it('does not warn when budget_tokens is omitted', async () => { + const gen = new LlmContentGenerator({ apiKey: 'test' }, { + model: 'gemini-2.5-pro', + reasoning: { effort: 'high' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await gen.generateContent( + { model: 'gemini-2.5-pro', contents: [] }, + 'prompt-1', + ); + expect(mockDebugLogger.warn).not.toHaveBeenCalled(); + }); + }); + it('should strip displayName from inlineData and fileData before sending to API', async () => { const request = { model: 'gemini-1.5-flash', diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index 2667d1a986a..f3624fade07 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -47,6 +47,7 @@ const debugLogger = createDebugLogger('GEMINI'); // (docs/design/2026-06-30-unified-reasoning-effort-cli.md). const BUDGET_STYLE_MODEL = /gemini-2\.5/; const BUDGET_STYLE_PRO = /gemini-2\.5-pro/; +const LEVEL_STYLE_PRO = /gemini-3.*pro/; // Buckets invert the budget -> level thresholds in that same design doc. The // top tiers take the model's documented ceiling, which is the one place where @@ -115,6 +116,8 @@ export class LlmContentGenerator implements ContentGenerator { // Latch so the effort-clamp warning fires once per generator lifetime // instead of on every request that needs the downgrade. private effortClampWarned = false; + // Latch so the unsupported budget_tokens warning fires once per generator lifetime. + private budgetTokensWarned = false; constructor( options: { @@ -246,6 +249,9 @@ export class LlmContentGenerator implements ContentGenerator { }; } + // Config-level reasoning takes precedence over request-level thinkingConfig via + // getParameterValue (consistent with other parameters). Request-level overrides + // like thinkingBudget: 0 are respected when config-level reasoning is unset. private buildThinkingConfig(model: string): | { includeThoughts: boolean; @@ -263,6 +269,13 @@ export class LlmContentGenerator implements ContentGenerator { return undefined; } + if (reasoning.budget_tokens !== undefined && !this.budgetTokensWarned) { + debugLogger.warn( + `reasoning.budget_tokens=${reasoning.budget_tokens} is not supported by Gemini; ignoring budget_tokens.`, + ); + this.budgetTokensWarned = true; + } + // No effort set: send neither knob. THINKING_LEVEL_UNSPECIFIED means "the // model decides", which is exactly what omitting the field does, and the // 2.5 family rejects the field itself. @@ -277,15 +290,13 @@ export class LlmContentGenerator implements ContentGenerator { }; } - // Gemini's thinkingLevel ladder is MINIMAL / LOW / MEDIUM / HIGH — there - // is no xhigh/max, so the extra-strong tiers clamp down via the shared - // rank-based clamp (the Anthropic generator uses the same helper for its - // own per-model ceilings). - const clamped = clampReasoningEffort(reasoning.effort, [ - 'low', - 'medium', - 'high', - ]); + // Gemini 3 Pro models support low and high only; Flash models support + // minimal, low, medium, high. Extra-strong tiers (xhigh/max) and unsupported + // intermediate tiers (medium on Pro) clamp via the shared rank-based clamp. + const supported = LEVEL_STYLE_PRO.test(model) + ? (['low', 'high'] as const) + : (['low', 'medium', 'high'] as const); + const clamped = clampReasoningEffort(reasoning.effort, supported); if (clamped !== reasoning.effort && !this.effortClampWarned) { debugLogger.warn( `reasoning.effort='${reasoning.effort}' is not supported by Gemini; clamping to '${clamped.toUpperCase()}'.`, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 4a37a355ed6..7e6f346ec4e 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -727,7 +727,7 @@ "xhigh", "max" ], - "description": "How hard reasoning-capable models think, applied across all providers. Set with /effort. Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at \"high\"; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default." + "description": "How hard reasoning-capable models think, applied across all providers. Set with /effort. Each provider maps and clamps this to what the active model supports (e.g. Gemini 3 caps at \"high\"; Gemini 2.5 maps xhigh/max to its thinking-budget ceiling; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default." }, "maxSessionTurns": { "description": "Maximum number of user/model/tool turns to keep in a session. Must be an integer; -1 means unlimited.",