fix: map google adaptive thinking dynamically

This commit is contained in:
Peter Steinberger
2026-04-25 02:03:57 +01:00
parent f3330f5db6
commit cc0f3067a0
11 changed files with 213 additions and 9 deletions

View File

@@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai
- Agents/sessions: persist the runtime-resolved context budget from embedded agent runs, so Codex GPT-5.5 sessions keep the catalog/runtime context cap instead of falling back to the generic 200k status value. Fixes #71294. Thanks @tud0r.
- Agents/tools: fail runs before model submission when explicit tool allowlists resolve to no callable tools, preventing text-only hallucinated tool results for missing tools such as plugin commands that were not registered. Fixes #71292. Thanks @steipete.
- Agents/embedded: skip provider submission when an embedded run has no prompt, replay history, or prompt-local images, preventing empty OpenAI Responses requests from surfacing provider errors into user channels. Fixes #71130. Thanks @steipete.
- Providers/Google: map `/think adaptive` to Gemini dynamic thinking instead of a fixed medium/high budget, using Gemini 3's provider default and Gemini 2.5's `thinkingBudget: -1`. Fixes #71316. Thanks @steipete.
- Providers/MiniMax: keep M2.7 chat model metadata text-only so image tool requests route through `MiniMax-VL-01` instead of the Anthropic-compatible chat endpoint. Fixes #71296. Thanks @ilker-cevikkaya.
- Discord/replies: run `message_sending` plugin hooks for Discord reply delivery, including DM targets, so plugins can transform or cancel outbound Discord replies consistently with other channels. Fixes #59350. (#71094) Thanks @wei840222.
- Control UI/commands: carry provider-owned thinking option ids/labels in session rows and defaults so fresh sessions show and accept dynamic modes such as `adaptive`, `xhigh`, and `max`. Fixes #71269. Thanks @Young-Khalil.

View File

@@ -14,7 +14,7 @@ title: "Thinking levels"
- medium → “think harder”
- high → “ultrathink” (max budget)
- xhigh → “ultrathink+” (GPT-5.2+ and Codex models, plus Anthropic Claude Opus 4.7 effort)
- adaptive → provider-managed adaptive thinking (supported for Claude 4.6 on Anthropic/Bedrock and Anthropic Claude Opus 4.7)
- adaptive → provider-managed adaptive thinking (supported for Claude 4.6 on Anthropic/Bedrock, Anthropic Claude Opus 4.7, and Google Gemini dynamic thinking)
- max → provider max reasoning (currently Anthropic Claude Opus 4.7)
- `x-high`, `x_high`, `extra-high`, `extra high`, and `extra_high` map to `xhigh`.
- `highest` maps to `high`.
@@ -27,6 +27,7 @@ title: "Thinking levels"
- Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting.
- Anthropic Claude Opus 4.7 also exposes `/think max`; it maps to the same provider-owned max effort path.
- OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value.
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
- MiniMax (`minimax/*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from MiniMax's non-native Anthropic stream format.
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
- Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.

View File

@@ -180,6 +180,40 @@ describe("google provider plugin hooks", () => {
runCase(cliProvider, "google-gemini-cli");
});
it("advertises adaptive thinking for Gemini dynamic thinking", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google");
expect(provider.resolveThinkingProfile).toBeDefined();
const resolveThinkingProfile = provider.resolveThinkingProfile!;
const gemini3Profile = resolveThinkingProfile({
provider: "google",
modelId: "gemini-3.1-pro-preview",
} as never);
const gemini25Profile = resolveThinkingProfile({
provider: "google",
modelId: "gemini-2.5-flash",
} as never);
expect(gemini3Profile?.levels).toEqual([
{ id: "off" },
{ id: "low" },
{ id: "adaptive" },
{ id: "high" },
]);
expect(gemini25Profile?.levels).toEqual([
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "adaptive" },
{ id: "high" },
]);
});
it("shares Gemini replay and stream hooks across Google provider variants", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,

View File

@@ -12,8 +12,15 @@ export const GOOGLE_GEMINI_PROVIDER_HOOKS = {
resolveThinkingProfile: ({ modelId }: ProviderDefaultThinkingPolicyContext) =>
({
levels: isGoogleGemini3ProModel(modelId)
? [{ id: "off" }, { id: "low" }, { id: "high" }]
: [{ id: "off" }, { id: "minimal" }, { id: "low" }, { id: "medium" }, { id: "high" }],
? [{ id: "off" }, { id: "low" }, { id: "adaptive" }, { id: "high" }]
: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "adaptive" },
{ id: "high" },
],
}) satisfies ProviderThinkingProfile,
wrapStreamFn: createGoogleThinkingStreamWrapper,
};

View File

@@ -1,6 +1,7 @@
export {
createGoogleThinkingPayloadWrapper,
createGoogleThinkingStreamWrapper,
isGoogleGemini25ThinkingBudgetModel,
isGoogleGemini3FlashModel,
isGoogleGemini3ProModel,
isGoogleGemini3ThinkingLevelModel,

View File

@@ -10,7 +10,7 @@ describe("google thinking policy", () => {
["minimal", "LOW"],
["low", "LOW"],
["medium", "HIGH"],
["adaptive", "HIGH"],
["adaptive", undefined],
["high", "HIGH"],
["xhigh", "HIGH"],
] as const)("maps Gemini 3 Pro thinking level %s to %s", (thinkingLevel, expected) => {
@@ -40,7 +40,7 @@ describe("google thinking policy", () => {
["minimal", "MINIMAL"],
["low", "LOW"],
["medium", "MEDIUM"],
["adaptive", "MEDIUM"],
["adaptive", undefined],
["high", "HIGH"],
["xhigh", "HIGH"],
] as const)("maps Gemini 3 Flash thinking level %s to %s", (thinkingLevel, expected) => {
@@ -53,7 +53,7 @@ describe("google thinking policy", () => {
});
it.each([
[-1, "MINIMAL"],
[-1, undefined],
[0, "MINIMAL"],
[2048, "LOW"],
[8192, "MEDIUM"],
@@ -98,6 +98,43 @@ describe("google thinking policy", () => {
});
});
it("keeps Gemini 3 adaptive thinking provider-dynamic instead of forcing a fixed level", () => {
const payload = {
generationConfig: {
thinkingConfig: { thinkingBudget: 8192, includeThoughts: true },
},
};
sanitizeGoogleThinkingPayload({
payload,
modelId: "gemini-3-flash-preview",
thinkingLevel: "adaptive",
});
expect(payload.generationConfig.thinkingConfig).toEqual({
includeThoughts: true,
});
});
it("maps Gemini 2.5 adaptive thinking to dynamic thinkingBudget", () => {
const payload = {
config: {
thinkingConfig: { thinkingBudget: 8192, includeThoughts: true },
},
};
sanitizeGoogleThinkingPayload({
payload,
modelId: "gemini-2.5-flash",
thinkingLevel: "adaptive",
});
expect(payload.config.thinkingConfig).toEqual({
includeThoughts: true,
thinkingBudget: -1,
});
});
it("maps Gemma 4 thinking mode without sending thinkingBudget", () => {
const payload = {
config: {

View File

@@ -1,6 +1,7 @@
export {
createGoogleThinkingPayloadWrapper,
createGoogleThinkingStreamWrapper,
isGoogleGemini25ThinkingBudgetModel,
isGoogleGemini3FlashModel,
isGoogleGemini3ProModel,
isGoogleGemini3ThinkingLevelModel,

View File

@@ -444,6 +444,44 @@ describe("google transport stream", () => {
});
});
it("keeps adaptive Gemini 3 thinking on provider dynamic defaults", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel({ id: "gemini-3-flash-preview" }),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
reasoning: "adaptive",
} as never,
);
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingLevel: expect.any(String) },
});
expect(params.generationConfig).not.toMatchObject({
thinkingConfig: { thinkingBudget: expect.any(Number) },
});
});
it("maps adaptive Gemini 2.5 thinking to dynamic thinkingBudget", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel({ id: "gemini-2.5-flash" }),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
reasoning: "adaptive",
} as never,
);
expect(params.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true, thinkingBudget: -1 },
});
});
it("normalizes explicit Gemini 3 Pro thinking levels", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel({ id: "gemini-3.1-pro-preview" }),

View File

@@ -25,6 +25,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim
import { parseGeminiAuth } from "./gemini-auth.js";
import { normalizeGoogleApiBaseUrl } from "./provider-policy.js";
import {
isGoogleGemini25ThinkingBudgetModel,
isGoogleGemini3FlashModel,
isGoogleGemini3ProModel,
resolveGoogleGemini3ThinkingLevel,
@@ -238,6 +239,10 @@ function getGoogleThinkingBudget(
return undefined;
}
function isAdaptiveReasoningLevel(value: unknown): value is "adaptive" {
return value === "adaptive";
}
function resolveGoogleThinkingConfig(
model: GoogleTransportModel,
options: GoogleTransportOptions | undefined,
@@ -268,6 +273,17 @@ function resolveGoogleThinkingConfig(
if (!options?.reasoning) {
return getDisabledThinkingConfig(model.id);
}
if (isAdaptiveReasoningLevel(options.reasoning)) {
if (isGoogleGemini3ProModel(model.id) || isGoogleGemini3FlashModel(model.id)) {
return { includeThoughts: true };
}
if (isGoogleGemini25ThinkingBudgetModel(model.id)) {
return normalizeGoogleThinkingConfig(model.id, {
includeThoughts: true,
thinkingBudget: -1,
});
}
}
if (isGoogleGemini3ProModel(model.id) || isGoogleGemini3FlashModel(model.id)) {
return {
includeThoughts: true,

View File

@@ -97,7 +97,7 @@ describe("sanitizeGoogleThinkingPayload — gemini-2.5-pro zero budget", () => {
});
});
it("fills thinkingLevel for Gemini 3 Flash negative budgets", () => {
it("rewrites Gemini 3 Flash negative budgets when a fixed thinking level is explicit", () => {
const payload = {
config: {
thinkingConfig: { thinkingBudget: -1, includeThoughts: true },
@@ -113,4 +113,37 @@ describe("sanitizeGoogleThinkingPayload — gemini-2.5-pro zero budget", () => {
thinkingLevel: "MEDIUM",
});
});
it("keeps Gemini 3 adaptive thinking on provider dynamic defaults", () => {
const payload = {
config: {
thinkingConfig: { thinkingBudget: 8192, includeThoughts: true },
},
};
sanitizeGoogleThinkingPayload({
payload,
modelId: "gemini-3-flash-preview",
thinkingLevel: "adaptive",
});
expect(payload.config.thinkingConfig).toEqual({
includeThoughts: true,
});
});
it("maps Gemini 2.5 adaptive thinking to thinkingBudget=-1", () => {
const payload = {
config: {
thinkingConfig: { thinkingBudget: 8192, includeThoughts: true },
},
};
sanitizeGoogleThinkingPayload({
payload,
modelId: "gemini-2.5-flash",
thinkingLevel: "adaptive",
});
expect(payload.config.thinkingConfig).toEqual({
includeThoughts: true,
thinkingBudget: -1,
});
});
});

View File

@@ -158,6 +158,10 @@ export function isGoogleThinkingRequiredModel(modelId: string): boolean {
return normalizeLowercaseStringOrEmpty(modelId).includes("gemini-2.5-pro");
}
export function isGoogleGemini25ThinkingBudgetModel(modelId: string): boolean {
return /(?:^|\/)gemini-2\.5-/.test(normalizeLowercaseStringOrEmpty(modelId));
}
export function isGoogleGemini3ProModel(modelId: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(modelId);
return /(?:^|\/)gemini-(?:3(?:\.\d+)?-pro|pro-latest)(?:-|$)/.test(normalized);
@@ -187,15 +191,19 @@ export function resolveGoogleGemini3ThinkingLevel(params: {
case "low":
return "LOW";
case "medium":
case "adaptive":
case "high":
case "max":
case "xhigh":
return "HIGH";
case "adaptive":
return undefined;
case undefined:
break;
}
if (typeof params.thinkingBudget === "number") {
if (params.thinkingBudget < 0) {
return undefined;
}
return params.thinkingBudget <= 2048 ? "LOW" : "HIGH";
}
return undefined;
@@ -210,18 +218,22 @@ export function resolveGoogleGemini3ThinkingLevel(params: {
case "low":
return "LOW";
case "medium":
case "adaptive":
return "MEDIUM";
case "high":
case "max":
case "xhigh":
return "HIGH";
case "adaptive":
return undefined;
case undefined:
break;
}
if (typeof params.thinkingBudget !== "number") {
return undefined;
}
if (params.thinkingBudget < 0) {
return undefined;
}
if (params.thinkingBudget <= 0) {
return "MINIMAL";
}
@@ -355,6 +367,29 @@ function sanitizeGoogleThinkingConfigContainer(params: {
const thinkingBudget = thinkingConfigObj.thinkingBudget;
if (
params.thinkingLevel === "adaptive" &&
typeof params.modelId === "string" &&
isGoogleGemini25ThinkingBudgetModel(params.modelId)
) {
delete thinkingConfigObj.thinkingLevel;
thinkingConfigObj.thinkingBudget = -1;
return;
}
if (
params.thinkingLevel === "adaptive" &&
typeof params.modelId === "string" &&
isGoogleGemini3ThinkingLevelModel(params.modelId)
) {
delete thinkingConfigObj.thinkingBudget;
delete thinkingConfigObj.thinkingLevel;
if (Object.keys(thinkingConfigObj).length === 0) {
delete configObj.thinkingConfig;
}
return;
}
if (typeof params.modelId === "string" && isGoogleGemini3ThinkingLevelModel(params.modelId)) {
const mappedLevel = resolveGoogleGemini3ThinkingLevel({
modelId: params.modelId,