fix(google): cover gemini pro zero thinking budget (#68607) (thanks @josmithiii)

This commit is contained in:
Peter Steinberger
2026-04-18 17:41:52 +01:00
parent 8c5a4eb866
commit f38727acd9
6 changed files with 108 additions and 20 deletions

View File

@@ -64,7 +64,7 @@ Docs: https://docs.openclaw.ai
- Agents/failover: avoid treating bare leading `402 ...` prose as billing errors while still recognizing proxy subscription failures. (#45827) Thanks @junyuc25.
- Config/$schema: preserve root-authored `$schema` during partial config rewrites without injecting include-only schema URLs into the root config. (#47322) Thanks @EfeDurmaz16.
- Agents/CLI delivery: run the same reply-media path normalizer the auto-reply flow uses before shipping `openclaw agent --deliver` payloads, so relative `MEDIA:./out/photo.png` tokens resolve against the agent workspace instead of being rejected downstream with `LocalMediaAccessError: Local media path is not under an allowed directory`. Thanks @frankekn.
- Agents/Google: strip `thinkingBudget=0` for the thinking-required `gemini-2.5-pro` model in the embedded runner Google sanitizer, so requests no longer fail with `Budget 0 is invalid. This model only works in thinking mode.` and the API uses its default thinking behavior instead. Thanks @josmithiii.
- Agents/Google: strip `thinkingBudget=0` for the thinking-required `gemini-2.5-pro` model in embedded-runner and native Google payloads, so requests no longer fail with `Budget 0 is invalid. This model only works in thinking mode.` and the API uses its default thinking behavior instead. (#68607) Thanks @josmithiii.
## 2026.4.15

View File

@@ -0,0 +1,22 @@
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
// Gemini 2.5 Pro only works in thinking mode and rejects thinkingBudget=0 with
// "Budget 0 is invalid. This model only works in thinking mode."
export function isGoogleThinkingRequiredModel(modelId: string): boolean {
return normalizeLowercaseStringOrEmpty(modelId).includes("gemini-2.5-pro");
}
export function stripInvalidGoogleThinkingBudget(params: {
thinkingConfig: Record<string, unknown>;
modelId?: string;
}): boolean {
if (
params.thinkingConfig.thinkingBudget !== 0 ||
typeof params.modelId !== "string" ||
!isGoogleThinkingRequiredModel(params.modelId)
) {
return false;
}
delete params.thinkingConfig.thinkingBudget;
return true;
}

View File

@@ -295,6 +295,45 @@ describe("google transport stream", () => {
});
});
it("omits disabled thinkingBudget=0 for Gemini 2.5 Pro direct payloads", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
maxTokens: 128,
} as never,
);
expect(params.generationConfig).toMatchObject({
maxOutputTokens: 128,
});
expect(params.generationConfig).not.toHaveProperty("thinkingConfig");
});
it("strips explicit thinkingBudget=0 but preserves includeThoughts for Gemini 2.5 Pro", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
thinking: {
enabled: true,
budgetTokens: 0,
},
} as never,
);
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingBudget: 0 },
});
});
it("includes cachedContent in direct Gemini payloads when requested", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),

View File

@@ -10,6 +10,7 @@ import {
import { parseGeminiAuth } from "../infra/gemini-auth.js";
import { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { stripInvalidGoogleThinkingBudget } from "./google-thinking-compat.js";
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js";
import { transformTransportMessages } from "./transport-message-transform.js";
@@ -213,14 +214,14 @@ function resolveThinkingLevel(level: ThinkingLevel, modelId: string): GoogleThin
throw new Error("Unsupported thinking level");
}
function getDisabledThinkingConfig(modelId: string): Record<string, unknown> {
function getDisabledThinkingConfig(modelId: string): Record<string, unknown> | undefined {
if (isGemini3ProModel(modelId)) {
return { thinkingLevel: "LOW" };
}
if (isGemini3FlashModel(modelId)) {
return { thinkingLevel: "MINIMAL" };
}
return { thinkingBudget: 0 };
return normalizeGoogleThinkingConfig(modelId, { thinkingBudget: 0 });
}
function getGoogleThinkingBudget(
@@ -258,7 +259,7 @@ function resolveGoogleThinkingConfig(
} else if (typeof options.thinking.budgetTokens === "number") {
config.thinkingBudget = options.thinking.budgetTokens;
}
return config;
return normalizeGoogleThinkingConfig(model.id, config);
}
if (!options?.reasoning) {
return getDisabledThinkingConfig(model.id);
@@ -270,10 +271,18 @@ function resolveGoogleThinkingConfig(
};
}
const budget = getGoogleThinkingBudget(model.id, options.reasoning, options.thinkingBudgets);
return {
return normalizeGoogleThinkingConfig(model.id, {
includeThoughts: true,
...(typeof budget === "number" ? { thinkingBudget: budget } : {}),
};
});
}
function normalizeGoogleThinkingConfig(
modelId: string,
thinkingConfig: Record<string, unknown>,
): Record<string, unknown> | undefined {
stripInvalidGoogleThinkingBudget({ thinkingConfig, modelId });
return Object.keys(thinkingConfig).length > 0 ? thinkingConfig : undefined;
}
function convertGoogleMessages(model: GoogleTransportModel, context: Context) {

View File

@@ -33,6 +33,17 @@ describe("sanitizeGoogleThinkingPayload — gemini-2.5-pro zero budget", () => {
expect(payload.config.thinkingConfig).toHaveProperty("includeThoughts", true);
});
it("removes thinkingBudget=0 from native Google generationConfig payloads", () => {
const payload = {
generationConfig: {
thinkingConfig: { thinkingBudget: 0, includeThoughts: true },
},
};
sanitizeGoogleThinkingPayload({ payload, modelId: "gemini-2.5-pro" });
expect(payload.generationConfig.thinkingConfig).not.toHaveProperty("thinkingBudget");
expect(payload.generationConfig.thinkingConfig).toHaveProperty("includeThoughts", true);
});
it("keeps thinkingBudget=0 for gemini-2.5-flash (not thinking-required)", () => {
const payload = {
config: {

View File

@@ -2,6 +2,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { stripInvalidGoogleThinkingBudget } from "../google-thinking-compat.js";
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
function isGemini31Model(modelId: string): boolean {
@@ -13,12 +14,6 @@ function isGemma4Model(modelId: string): boolean {
return normalizeLowercaseStringOrEmpty(modelId).startsWith("gemma-4");
}
// Gemini 2.5 Pro only works in thinking mode and rejects thinkingBudget=0 with
// "Budget 0 is invalid. This model only works in thinking mode."
function isThinkingRequiredModel(modelId: string): boolean {
return normalizeLowercaseStringOrEmpty(modelId).includes("gemini-2.5-pro");
}
function mapThinkLevelToGoogleThinkingLevel(
thinkingLevel: ThinkLevel,
): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" | undefined {
@@ -82,11 +77,27 @@ export function sanitizeGoogleThinkingPayload(params: {
return;
}
const payloadObj = params.payload as Record<string, unknown>;
const config = payloadObj.config;
if (!config || typeof config !== "object") {
sanitizeGoogleThinkingConfigContainer({
container: payloadObj.config,
modelId: params.modelId,
thinkingLevel: params.thinkingLevel,
});
sanitizeGoogleThinkingConfigContainer({
container: payloadObj.generationConfig,
modelId: params.modelId,
thinkingLevel: params.thinkingLevel,
});
}
function sanitizeGoogleThinkingConfigContainer(params: {
container: unknown;
modelId?: string;
thinkingLevel?: ThinkLevel;
}): void {
if (!params.container || typeof params.container !== "object") {
return;
}
const configObj = config as Record<string, unknown>;
const configObj = params.container as Record<string, unknown>;
const thinkingConfig = configObj.thinkingConfig;
if (!thinkingConfig || typeof thinkingConfig !== "object") {
return;
@@ -123,13 +134,9 @@ export function sanitizeGoogleThinkingPayload(params: {
const thinkingBudget = thinkingConfigObj.thinkingBudget;
// Gemini 2.5 Pro rejects thinkingBudget=0; remove it so the API uses its default.
if (
thinkingBudget === 0 &&
typeof params.modelId === "string" &&
isThinkingRequiredModel(params.modelId)
stripInvalidGoogleThinkingBudget({ thinkingConfig: thinkingConfigObj, modelId: params.modelId })
) {
delete thinkingConfigObj.thinkingBudget;
if (Object.keys(thinkingConfigObj).length === 0) {
delete configObj.thinkingConfig;
}