diff --git a/CHANGELOG.md b/CHANGELOG.md index 701e5ebd39f..263cd9b73d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/agents/google-thinking-compat.ts b/src/agents/google-thinking-compat.ts new file mode 100644 index 00000000000..1e8cddd06d9 --- /dev/null +++ b/src/agents/google-thinking-compat.ts @@ -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; + modelId?: string; +}): boolean { + if ( + params.thinkingConfig.thinkingBudget !== 0 || + typeof params.modelId !== "string" || + !isGoogleThinkingRequiredModel(params.modelId) + ) { + return false; + } + delete params.thinkingConfig.thinkingBudget; + return true; +} diff --git a/src/agents/google-transport-stream.test.ts b/src/agents/google-transport-stream.test.ts index 9bd597a95b2..750a0b8dcb7 100644 --- a/src/agents/google-transport-stream.test.ts +++ b/src/agents/google-transport-stream.test.ts @@ -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(), diff --git a/src/agents/google-transport-stream.ts b/src/agents/google-transport-stream.ts index 7dc472d5b0f..56bd5864821 100644 --- a/src/agents/google-transport-stream.ts +++ b/src/agents/google-transport-stream.ts @@ -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 { +function getDisabledThinkingConfig(modelId: string): Record | 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, +): Record | undefined { + stripInvalidGoogleThinkingBudget({ thinkingConfig, modelId }); + return Object.keys(thinkingConfig).length > 0 ? thinkingConfig : undefined; } function convertGoogleMessages(model: GoogleTransportModel, context: Context) { diff --git a/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts b/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts index 31ca20a23e7..4965e3a8816 100644 --- a/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts +++ b/src/agents/pi-embedded-runner/google-stream-wrappers.test.ts @@ -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: { diff --git a/src/agents/pi-embedded-runner/google-stream-wrappers.ts b/src/agents/pi-embedded-runner/google-stream-wrappers.ts index 5d781cfa7b3..9f7b479402a 100644 --- a/src/agents/pi-embedded-runner/google-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/google-stream-wrappers.ts @@ -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; - 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; + const configObj = params.container as Record; 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; }