diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 433e6dd8fdfc..7924c7ea4b53 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -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 + `` / `` 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). diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 6362b8ab056f..e0b7bbf4b5c2 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -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, diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index a14164501bb6..63225b1d32d2 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -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 / 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), }; } diff --git a/src/utils/provider-utils.test.ts b/src/utils/provider-utils.test.ts index dc81d202f3a1..9dbda61393ec 100644 --- a/src/utils/provider-utils.test.ts +++ b/src/utils/provider-utils.test.ts @@ -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], diff --git a/src/utils/provider-utils.ts b/src/utils/provider-utils.ts index b5bb1d3e4b27..1df67bd2bce2 100644 --- a/src/utils/provider-utils.ts +++ b/src/utils/provider-utils.ts @@ -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"; }