From 922459dda03178f3aa479ecc97d06a76075ab4b5 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:20:56 -0500 Subject: [PATCH] fix(google): preserve Gemma 4 thinking-off semantics (#62411) thanks @BunsDev Co-authored-by: Nova --- CHANGELOG.md | 1 + extensions/google/provider-models.test.ts | 23 +++- extensions/google/provider-models.ts | 4 +- .../pi-embedded-runner-extraparams.test.ts | 103 ++++++++++++++++++ .../google-stream-wrappers.ts | 70 ++++++++++++ 5 files changed, 197 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b177ca602a..46f10c1b2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows. - Providers/Arcee AI: add a bundled Arcee AI provider plugin with Trinity catalog entries, OpenRouter support, and updated onboarding/auth guidance. (#62068) Thanks @arthurbr11. - Providers/Google: add Gemma 4 model support and keep Google fallback resolution on the requested provider path so native Google Gemma routes work again. (#61507) Thanks @eyjohn. +- Providers/Google: preserve explicit thinking-off semantics for Gemma 4 while still enabling Gemma reasoning support in the Google compatibility wrappers. (#62127) Thanks @romgenie, co-authored with BunsDev. - Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, doctor flows, and Docker Claude CLI live lanes again. - ACP/ACPX plugin: bump the bundled `acpx` pin to `0.5.1` so plugin-local installs and strict version checks pick up the latest published runtime release. (#62148) Thanks @onutc. - Tools/media generation: auto-fallback across auth-backed image, music, and video providers by default, and remap fallback size, aspect ratio, resolution, and duration hints to the closest supported option instead of dropping intent on provider switches. diff --git a/extensions/google/provider-models.test.ts b/extensions/google/provider-models.test.ts index 3aeceb06ec9..6dc07b4c733 100644 --- a/extensions/google/provider-models.test.ts +++ b/extensions/google/provider-models.test.ts @@ -222,20 +222,37 @@ describe("resolveGoogleGeminiForwardCompatModel", () => { expect(isModernGoogleModel("gemma-3-4b-it")).toBe(true); }); - it("resolves gemma model with reasoning forced off regardless of template", () => { + it("resolves Gemma 4 models with reasoning enabled regardless of template", () => { const model = resolveGoogleGeminiForwardCompatModel({ providerId: "google", ctx: createContext({ provider: "google", modelId: "gemma-4-26b-a4b-it", - models: [createTemplateModel("google", "gemini-3-flash-preview", { reasoning: true })], + models: [createTemplateModel("google", "gemini-3-flash-preview", { reasoning: false })], }), }); expect(model).toMatchObject({ provider: "google", id: "gemma-4-26b-a4b-it", - reasoning: false, // patch must override the template value + reasoning: true, + }); + }); + + it("preserves template reasoning for non-Gemma 4 gemma models", () => { + const model = resolveGoogleGeminiForwardCompatModel({ + providerId: "google", + ctx: createContext({ + provider: "google", + modelId: "gemma-3-4b-it", + models: [createTemplateModel("google", "gemini-3-flash-preview", { reasoning: false })], + }), + }); + + expect(model).toMatchObject({ + provider: "google", + id: "gemma-3-4b-it", + reasoning: false, }); }); }); diff --git a/extensions/google/provider-models.ts b/extensions/google/provider-models.ts index d5c6d313b87..cf23ba6fa57 100644 --- a/extensions/google/provider-models.ts +++ b/extensions/google/provider-models.ts @@ -151,7 +151,9 @@ export function resolveGoogleGeminiForwardCompatModel(params: { googleTemplateIds: GEMMA_TEMPLATE_IDS, cliTemplateIds: GEMMA_TEMPLATE_IDS, }; - patch = { reasoning: false }; + if (lower.startsWith("gemma-4")) { + patch = { reasoning: true }; + } } else { return undefined; } diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index d053a29e8ac..19b22f5a73c 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -1104,6 +1104,109 @@ describe("applyExtraParamsToAgent", () => { }, }); }); + + it("rewrites Gemma 4 thinkingBudget to a supported Google thinkingLevel", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + config: { + thinkingConfig: { + includeThoughts: true, + thinkingBudget: 24576, + }, + }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "google", "gemma-4-26b-a4b-it", undefined, "high"); + + const model = { + api: "google-generative-ai", + provider: "google", + id: "gemma-4-26b-a4b-it", + reasoning: true, + } as Model<"google-generative-ai">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.config).toEqual({ + thinkingConfig: { + includeThoughts: true, + thinkingLevel: "HIGH", + }, + }); + }); + + it("preserves Gemma 4 thinking off instead of rewriting thinkingBudget=0 to MINIMAL", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + config: { + thinkingConfig: { + thinkingBudget: 0, + }, + }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "google", "gemma-4-26b-a4b-it", undefined, "off"); + + const model = { + api: "google-generative-ai", + provider: "google", + id: "gemma-4-26b-a4b-it", + reasoning: true, + } as Model<"google-generative-ai">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.config).toEqual({}); + }); + + it("preserves explicit Gemma 4 thinking level when thinkingBudget=0", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + config: { + thinkingConfig: { + thinkingBudget: 0, + }, + }, + }; + options?.onPayload?.(payload, _model); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "google", "gemma-4-26b-a4b-it", undefined, "high"); + + const model = { + api: "google-generative-ai", + provider: "google", + id: "gemma-4-26b-a4b-it", + reasoning: true, + } as Model<"google-generative-ai">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.config).toEqual({ + thinkingConfig: { + thinkingLevel: "HIGH", + }, + }); + }); it("passes configured websocket transport through stream options", () => { const { calls, agent } = createOptionsCaptureAgent(); const cfg = { diff --git a/src/agents/pi-embedded-runner/google-stream-wrappers.ts b/src/agents/pi-embedded-runner/google-stream-wrappers.ts index 37a315128a8..735acd9816c 100644 --- a/src/agents/pi-embedded-runner/google-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/google-stream-wrappers.ts @@ -8,6 +8,10 @@ function isGemini31Model(modelId: string): boolean { return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash"); } +function isGemma4Model(modelId: string): boolean { + return modelId.trim().toLowerCase().startsWith("gemma-4"); +} + function mapThinkLevelToGoogleThinkingLevel( thinkingLevel: ThinkLevel, ): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" | undefined { @@ -27,6 +31,41 @@ function mapThinkLevelToGoogleThinkingLevel( } } +function mapThinkLevelToGemma4ThinkingLevel( + thinkingLevel?: ThinkLevel, +): "MINIMAL" | "HIGH" | undefined { + switch (thinkingLevel) { + case "off": + return undefined; + case "minimal": + case "low": + return "MINIMAL"; + case "medium": + case "adaptive": + case "high": + case "xhigh": + return "HIGH"; + default: + return undefined; + } +} + +function normalizeGemma4ThinkingLevel(value: unknown): "MINIMAL" | "HIGH" | undefined { + if (typeof value !== "string") { + return undefined; + } + switch (value.trim().toUpperCase()) { + case "MINIMAL": + case "LOW": + return "MINIMAL"; + case "MEDIUM": + case "HIGH": + return "HIGH"; + default: + return undefined; + } +} + export function sanitizeGoogleThinkingPayload(params: { payload: unknown; modelId?: string; @@ -46,6 +85,37 @@ export function sanitizeGoogleThinkingPayload(params: { return; } const thinkingConfigObj = thinkingConfig as Record; + + if (typeof params.modelId === "string" && isGemma4Model(params.modelId)) { + const normalizedThinkingLevel = normalizeGemma4ThinkingLevel(thinkingConfigObj.thinkingLevel); + const explicitMappedLevel = mapThinkLevelToGemma4ThinkingLevel(params.thinkingLevel); + const disabledViaBudget = + typeof thinkingConfigObj.thinkingBudget === "number" && thinkingConfigObj.thinkingBudget <= 0; + const hadThinkingBudget = thinkingConfigObj.thinkingBudget !== undefined; + delete thinkingConfigObj.thinkingBudget; + + if ( + params.thinkingLevel === "off" || + (disabledViaBudget && explicitMappedLevel === undefined && !normalizedThinkingLevel) + ) { + delete thinkingConfigObj.thinkingLevel; + if (Object.keys(thinkingConfigObj).length === 0) { + delete configObj.thinkingConfig; + } + return; + } + + const mappedLevel = + explicitMappedLevel ?? + normalizedThinkingLevel ?? + (hadThinkingBudget ? "MINIMAL" : undefined); + + if (mappedLevel) { + thinkingConfigObj.thinkingLevel = mappedLevel; + } + return; + } + const thinkingBudget = thinkingConfigObj.thinkingBudget; if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) { return;