Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,7 @@ describe('LlmContentGenerator', () => {
config: expect.objectContaining({
temperature: 1,
topP: 0.95,
thinkingConfig: {
includeThoughts: true,
thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED',
},
thinkingConfig: { includeThoughts: true },
}),
}),
);
Expand Down Expand Up @@ -156,10 +153,7 @@ describe('LlmContentGenerator', () => {
config: expect.objectContaining({
temperature: 1,
topP: 0.95,
thinkingConfig: {
includeThoughts: true,
thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED',
},
thinkingConfig: { includeThoughts: true },
}),
}),
);
Expand Down Expand Up @@ -253,15 +247,15 @@ describe('LlmContentGenerator', () => {

it('should map reasoning effort to thinkingConfig', async () => {
const generatorWithReasoning = new LlmContentGenerator({ apiKey: 'test' }, {
model: 'gemini-2.5-pro',
model: 'gemini-3-pro-preview',
reasoning: {
effort: 'high',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);

const request = {
model: 'gemini-2.5-pro',
model: 'gemini-3-pro-preview',
contents: [],
};

Expand All @@ -280,16 +274,16 @@ describe('LlmContentGenerator', () => {
});

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 LlmContentGenerator({ 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',
);

Expand All @@ -307,13 +301,13 @@ describe('LlmContentGenerator', () => {

it("maps reasoning effort 'medium' to MEDIUM", async () => {
const generatorWithMedium = new LlmContentGenerator({ 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',
);

Expand All @@ -331,13 +325,13 @@ describe('LlmContentGenerator', () => {

it("clamps reasoning effort 'xhigh' to HIGH (Gemini has no xhigh tier)", async () => {
const generatorWithXhigh = new LlmContentGenerator({ 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',
);

Expand All @@ -353,6 +347,109 @@ describe('LlmContentGenerator', () => {
);
});

describe('Gemini 2.5 thinking budget', () => {
const generatorFor = (model: string, reasoning?: unknown) =>
new LlmContentGenerator({ 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');
Comment on lines +751 to +752

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. Addressed in 2d07a2b


await gen.generateContent(
{ model: 'gemini-2.5-pro', contents: [] },
'prompt-id',
);

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');

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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import type {
ContentGenerator,
ContentGeneratorConfig,
} from '../contentGenerator.js';
import {
clampReasoningEffort,
type ReasoningEffort,
} from '../reasoning-effort.js';
import { createDebugLogger } from '../../utils/debugLogger.js';
import {
reportLlmChunk,
Expand All @@ -29,6 +33,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 observeLlmStream(
stream: AsyncIterable<GenerateContentResponse>,
telemetryAttempt: GenAiAttemptHandle | undefined,
Expand Down Expand Up @@ -141,77 +182,71 @@ export class LlmContentGenerator 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;

if (reasoning === false) {
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 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,
thinkingLevel,
};
}

async generateContent(
Expand Down
Loading