fix(google): preserve Gemma 4 thinking-off semantics (#62411) thanks @BunsDev

Co-authored-by: Nova <nova@openknot.ai>
This commit is contained in:
Val Alexander
2026-04-07 06:20:56 -05:00
committed by GitHub
parent 3493db46a4
commit 922459dda0
5 changed files with 197 additions and 4 deletions

View File

@@ -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.

View File

@@ -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,
});
});
});

View File

@@ -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;
}

View File

@@ -1104,6 +1104,109 @@ describe("applyExtraParamsToAgent", () => {
},
});
});
it("rewrites Gemma 4 thinkingBudget to a supported Google thinkingLevel", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
config: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 24576,
},
},
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
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<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
config: {
thinkingConfig: {
thinkingBudget: 0,
},
},
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
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<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
config: {
thinkingConfig: {
thinkingBudget: 0,
},
},
};
options?.onPayload?.(payload, _model);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
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 = {

View File

@@ -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<string, unknown>;
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;