fix(providers): use native reasoning mode for Gemini instead of tagged (#89379)

* fix(providers): use native reasoning mode for direct Gemini API, keep CLI tagged

Gemini 2.5+ delivers reasoning via native thinkingParts (thinkingConfig.
includeThoughts). Having tagged mode active at the same time injects a
<think>…</think>/<final>…</final> directive into the system prompt; the
model opens a <think> block before a tool call, never closes it, and
returns an empty post-tool turn (content:[], payloads=0 error, #69220).

Fix: override resolveReasoningOutputMode in buildGoogleProvider() only —
not in the shared GOOGLE_GEMINI_PROVIDER_HOOKS. The Gemini CLI backend
(google-gemini-cli) runs gemini --output-format json and parses a text
response field, not native thought parts; it must stay on tagged mode.
A regression test confirms google-gemini-cli remains "tagged".

Also remove the dead BUILTIN_REASONING_OUTPUT_MODES entry keyed on
"google-generative-ai" from provider-utils.ts — that string is only
ever the transport model.api value, never the provider id passed to
resolveReasoningOutputMode, so the map was unreachable.

Fixes #69220

* docs: clarify Gemini reasoning output modes

* fix(google): keep Antigravity reasoning tagged

* fix(google): default direct reasoning checks to native

* fix(google): import reasoning context from plugin entry

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Yzx
2026-06-02 19:46:08 +08:00
committed by GitHub
parent e7aac172d5
commit b1bdc29d33
5 changed files with 94 additions and 22 deletions

View File

@@ -353,7 +353,7 @@ API key auth, and dynamic model resolution.
| --- | --- | --- |
| `openai-compatible` | Shared OpenAI-style replay policy for OpenAI-compatible transports, including tool-call-id sanitation, assistant-first ordering fixes, and generic Gemini-turn validation where the transport needs it | `moonshot`, `ollama`, `xai`, `zai` |
| `anthropic-by-model` | Claude-aware replay policy chosen by `modelId`, so Anthropic-message transports only get Claude-specific thinking-block cleanup when the resolved model is actually a Claude id | `amazon-bedrock`, `anthropic-vertex` |
| `google-gemini` | Native Gemini replay policy plus bootstrap replay sanitation and tagged reasoning-output mode | `google`, `google-gemini-cli` |
| `google-gemini` | Native Gemini replay policy plus bootstrap replay sanitation. The shared family keeps the text-output Gemini CLI on tagged reasoning; the direct `google` provider overrides `resolveReasoningOutputMode` to `native` because Gemini API thinking arrives as native thought parts. | `google`, `google-gemini-cli` |
| `passthrough-gemini` | Gemini thought-signature sanitation for Gemini models running through OpenAI-compatible proxy transports; does not enable native Gemini replay validation or bootstrap rewrites | `openrouter`, `kilocode`, `opencode`, `opencode-go` |
| `hybrid-anthropic-openai` | Hybrid policy for providers that mix Anthropic-message and OpenAI-compatible model surfaces in one plugin; optional Claude-only thinking-block dropping stays scoped to the Anthropic side | `minimax` |
@@ -376,6 +376,13 @@ API key auth, and dynamic model resolution.
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), plain-text tool-call compat (`createPlainTextToolCallCompatWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
For Gemini-family providers, keep the reasoning-output mode aligned with
the transport. Direct Google Gemini API providers should use `native`
reasoning output so OpenClaw consumes native thought parts without adding
`<think>` / `<final>` prompt directives. Text-only Gemini CLI-style
backends that parse a final JSON/text response can keep the shared
`google-gemini` tagged contract.
Some stream helpers stay provider-local on purpose. `@openclaw/anthropic-provider` keeps `wrapAnthropicProviderStream`, `resolveAnthropicBetas`, `resolveAnthropicFastMode`, `resolveAnthropicServiceTier`, and the lower-level Anthropic wrapper builders in its own public `api.ts` / `contract-api.ts` seam because they encode Claude OAuth beta handling and `context1m` gating. The xAI plugin similarly keeps native xAI Responses shaping in its own `wrapStreamFn` (`/fast` aliases, default `tool_stream`, unsupported strict-tool cleanup, xAI-specific reasoning-payload removal).
The same package-root pattern also backs `@openclaw/openai-provider` (provider builders, default-model helpers, realtime provider builders) and `@openclaw/openrouter-provider` (provider builder plus onboarding/config helpers).

View File

@@ -65,7 +65,13 @@ describe("google provider plugin hooks", () => {
modelApi: "google-generative-ai",
modelId: "gemini-3.1-pro-preview",
} as never),
).toBe("tagged");
).toBe("native");
expect(
provider.resolveReasoningOutputMode?.({
provider: "google",
modelId: "gemini-3.1-pro-preview",
} as never),
).toBe("native");
const sanitized = await Promise.resolve(
provider.sanitizeReplayHistory?.({
@@ -102,6 +108,60 @@ describe("google provider plugin hooks", () => {
expect(customEntries[0]?.customType).toBe("google-turn-ordering-bootstrap");
});
it("keeps google-gemini-cli on tagged reasoning mode", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const cliProvider = requireRegisteredProvider(providers, "google-gemini-cli");
expect(
cliProvider.resolveReasoningOutputMode?.({
provider: "google-gemini-cli",
modelApi: "google-gemini-cli",
modelId: "gemini-2.5-pro",
} as never),
).toBe("tagged");
});
it("keeps google-antigravity hook aliases on tagged reasoning mode", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google-antigravity");
expect(
provider.resolveReasoningOutputMode?.({
provider: "google-antigravity",
modelApi: "openai-completions",
modelId: "gemini-3-pro-low",
} as never),
).toBe("tagged");
});
it("keeps google-vertex hook aliases on native reasoning mode", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,
id: "google",
name: "Google Provider",
});
const provider = requireRegisteredProvider(providers, "google-vertex");
expect(
provider.resolveReasoningOutputMode?.({
provider: "google-vertex",
modelApi: "google-vertex",
modelId: "gemini-3.1-pro-preview",
} as never),
).toBe("native");
expect(
provider.resolveReasoningOutputMode?.({
provider: "google-vertex",
modelId: "gemini-3.1-pro-preview",
} as never),
).toBe("native");
});
it("owns Gemini tool schema normalization for direct and CLI providers", async () => {
const { providers } = await registerProviderPlugin({
plugin: googleProviderPlugin,

View File

@@ -1,4 +1,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type {
OpenClawPluginApi,
ProviderReasoningOutputModeContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeGoogleModelId } from "./model-id.js";
@@ -19,6 +22,18 @@ import {
createGoogleVertexTransportStreamFn,
} from "./transport-stream.js";
function resolveGoogleReasoningOutputMode(
ctx: ProviderReasoningOutputModeContext,
): "native" | "tagged" {
if (ctx.provider === "google" || ctx.provider === "google-vertex") {
const api = ctx.model?.api ?? ctx.modelApi;
if (!api || api === "google-generative-ai" || api === "google-vertex") {
return "native";
}
}
return "tagged";
}
export function buildGoogleProvider(): ProviderPlugin {
return {
id: "google",
@@ -81,6 +96,11 @@ export function buildGoogleProvider(): ProviderPlugin {
return undefined;
},
...GOOGLE_GEMINI_PROVIDER_HOOKS,
// Gemini 2.5+ delivers reasoning via native thinkingParts (thinkingConfig.includeThoughts).
// Tagged mode simultaneously injects <think>/<final> which the model opens before a tool
// call, never closes, leaving the post-tool turn empty (payloads=0). The CLI backend keeps
// tagged mode because it emits JSON text, not native thought parts.
resolveReasoningOutputMode: resolveGoogleReasoningOutputMode,
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
};
}

View File

@@ -22,8 +22,8 @@ describe("resolveReasoningOutputMode", () => {
resolveProviderReasoningOutputModeWithPluginMock.mockReturnValue(undefined);
});
it.each([["google-generative-ai", "tagged"]] as const)(
"falls back to the built-in map for %s",
it.each([["google-generative-ai", "native"]] as const)(
"falls back to native for %s when no plugin override is present",
(provider, expected) => {
expect(resolveReasoningOutputMode({ provider, workspaceDir: process.cwd() })).toBe(expected);
expect(resolveProviderReasoningOutputModeWithPluginMock).toHaveBeenCalledTimes(1);
@@ -73,7 +73,7 @@ describe("isReasoningTagProvider", () => {
});
it.each([
["google-generative-ai", true],
["google-generative-ai", false],
[null, false],
[undefined, false],
["", false],

View File

@@ -1,16 +1,9 @@
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderRuntimePluginHandle } from "../plugins/provider-hook-runtime.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import { resolveProviderReasoningOutputModeWithPlugin } from "../plugins/provider-runtime.js";
const BUILTIN_REASONING_OUTPUT_MODES = {
"google-generative-ai": "tagged",
} as const;
/**
* Utility functions for provider-specific logic and capabilities.
*/
@@ -30,7 +23,6 @@ export function resolveReasoningOutputMode(params: {
return "native";
}
const normalized = normalizeOptionalLowercaseString(provider) ?? "";
const pluginMode = resolveProviderReasoningOutputModeWithPlugin({
provider,
config: params.config,
@@ -51,13 +43,6 @@ export function resolveReasoningOutputMode(params: {
return pluginMode;
}
const builtInMode =
BUILTIN_REASONING_OUTPUT_MODES[normalized as keyof typeof BUILTIN_REASONING_OUTPUT_MODES];
if (builtInMode) {
return builtInMode;
}
// Keep a tiny built-in fallback for non-plugin Google surfaces.
return "native";
}