mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user