From 6c0bff111c1b7ffa95132b30a87b876bb35d7bb1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 14 Apr 2026 11:19:41 +0100 Subject: [PATCH] fix(google): strip Gemini compat base suffixes (#66445) * fix(google): cover Gemini image /openai base URLs * fix(google): strip Gemini compat base suffixes * fix(google): scope Gemini /openai normalization * fix(google): harden base URL normalization * fix(google): restrict Gemini auth base URLs * Update CHANGELOG.md * Update CHANGELOG.md --- CHANGELOG.md | 1 + extensions/google/api.test.ts | 72 ++++++++++++++++++- extensions/google/api.ts | 33 ++++++++- .../google/image-generation-provider.test.ts | 27 +++++++ ...media-understanding-provider.video.test.ts | 23 +++++- extensions/google/provider-policy-api.test.ts | 16 +++++ extensions/google/provider-policy.ts | 47 +++++++++--- 7 files changed, 205 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b89cd49f60d..213cf36aebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Slack/interactions: apply the configured global `allowFrom` owner allowlist to channel block-action and modal interactive events, require an expected sender id for cross-verification, and reject ambiguous channel types so interactive triggers can no longer bypass the documented allowlist intent in channels without a `users` list. Open-by-default behavior is preserved when no allowlists are configured. (#66028) Thanks @eleqtrizit. - Media-understanding/attachments: fail closed when a local attachment path cannot be canonically resolved via `realpath`, so a `realpath` error can no longer downgrade the canonical-roots allowlist check to a non-canonical comparison; attachments that also have a URL still fall back to the network fetch path. (#66022) Thanks @eleqtrizit. - Agents/gateway-tool: reject `config.patch` and `config.apply` calls from the model-facing gateway tool when they would newly enable any flag enumerated by `openclaw security audit` (for example `dangerouslyDisableDeviceAuth`, `allowInsecureAuth`, `dangerouslyAllowHostHeaderOriginFallback`, `hooks.gmail.allowUnsafeExternalContent`, `tools.exec.applyPatch.workspaceOnly: false`); already-enabled flags pass through unchanged so non-dangerous edits in the same patch still apply, and direct authenticated operator RPC behavior is unchanged. (#62006) Thanks @eleqtrizit. +- Google image generation: strip a trailing `/openai` suffix from configured Google base URLs only when calling the native Gemini image API so Gemini image requests stop 404ing without breaking explicit OpenAI-compatible Google endpoints. (#66445) Thanks @dapzthelegend. - Telegram/forum topics: persist learned topic names to the Telegram session sidecar store so agent context can keep using human topic names after a restart instead of relearning from future service metadata. (#66107) Thanks @obviyus. - Doctor/systemd: keep `openclaw doctor --repair` and service reinstall from re-embedding dotenv-backed secrets in user systemd units, while preserving newer inline overrides over stale state-dir `.env` values. (#66249) Thanks @tmimmanuel. - Ollama/OpenAI-compat: send `stream_options.include_usage` for Ollama streaming completions so local Ollama runs report real usage instead of falling back to bogus prompt-token counts that trigger premature compaction. (#64568) Thanks @xchunzhao and @vincentkoc. diff --git a/extensions/google/api.test.ts b/extensions/google/api.test.ts index 26863febe55..954c11cb2d0 100644 --- a/extensions/google/api.test.ts +++ b/extensions/google/api.test.ts @@ -1,6 +1,8 @@ +import type { ProviderRequestTransportOverrides } from "openclaw/plugin-sdk/provider-http"; import { describe, expect, it } from "vitest"; import { isGoogleGenerativeAiApi, + normalizeGoogleApiBaseUrl, normalizeGoogleGenerativeAiBaseUrl, parseGeminiAuth, resolveGoogleGenerativeAiHttpRequestConfig, @@ -38,6 +40,20 @@ describe("google generative ai helpers", () => { expect(normalizeGoogleGenerativeAiBaseUrl()).toBeUndefined(); }); + it("keeps /openai on generic Google base URL normalization and strips it only for native Gemini callers", () => { + expect( + normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1beta/openai"), + ).toBe("https://generativelanguage.googleapis.com/v1beta/openai"); + expect( + normalizeGoogleGenerativeAiBaseUrl("https://generativelanguage.googleapis.com/v1beta/openai"), + ).toBe("https://generativelanguage.googleapis.com/v1beta"); + expect( + normalizeGoogleGenerativeAiBaseUrl( + "https://generativelanguage.googleapis.com/v1alpha/openai/", + ), + ).toBe("https://generativelanguage.googleapis.com/v1alpha"); + }); + it("normalizes Google provider configs by provider key, provider api, or model api", () => { expect( shouldNormalizeGoogleGenerativeAiProviderConfig("google", { @@ -61,6 +77,12 @@ describe("google generative ai helpers", () => { models: [{ api: "openai-completions" }], }), ).toBe(false); + expect( + shouldNormalizeGoogleGenerativeAiProviderConfig("google", { + api: "openai-completions", + models: [{ api: "openai-completions" }], + }), + ).toBe(false); }); it("normalizes transport baseUrls only for Google Generative AI", () => { @@ -123,7 +145,7 @@ describe("google generative ai helpers", () => { }); expect(oauthConfig).toMatchObject({ baseUrl: "https://generativelanguage.googleapis.com/v1beta", - allowPrivateNetwork: true, + allowPrivateNetwork: false, }); expect(Object.fromEntries(new Headers(oauthConfig.headers).entries())).toEqual({ authorization: "Bearer oauth-token", @@ -144,4 +166,52 @@ describe("google generative ai helpers", () => { "x-goog-api-key": "api-key-123", }); }); + + it("preserves explicit OpenAI-compatible Google endpoints during provider normalization", () => { + expect( + resolveGoogleGenerativeAiTransport({ + api: "openai-completions", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + }), + ).toEqual({ + api: "openai-completions", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + }); + }); + + it("strips URL credentials during Google base URL normalization", () => { + const normalized = normalizeGoogleApiBaseUrl( + "https://user:secret@generativelanguage.googleapis.com/v1beta/openai?x=1#frag", + ); + expect(normalized).toBe("https://generativelanguage.googleapis.com/v1beta/openai"); + }); + + it("rejects non-Google Gemini base URLs and ignores smuggled private-network flags", () => { + expect(() => + resolveGoogleGenerativeAiHttpRequestConfig({ + apiKey: "api-key-123", + baseUrl: "https://proxy.example.com/v1beta", + capability: "image", + transport: "http", + }), + ).toThrow("Google Generative AI baseUrl must use https://generativelanguage.googleapis.com"); + + expect(() => + resolveGoogleGenerativeAiHttpRequestConfig({ + apiKey: "api-key-123", + baseUrl: "http://generativelanguage.googleapis.com/v1beta", + capability: "image", + transport: "http", + }), + ).toThrow("Google Generative AI baseUrl must use https://generativelanguage.googleapis.com"); + + const config = resolveGoogleGenerativeAiHttpRequestConfig({ + apiKey: "api-key-123", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + capability: "image", + transport: "http", + request: { allowPrivateNetwork: true } as unknown as ProviderRequestTransportOverrides, + }); + expect(config.allowPrivateNetwork).toBe(false); + }); }); diff --git a/extensions/google/api.ts b/extensions/google/api.ts index 9883e5dca2c..bb4939bc637 100644 --- a/extensions/google/api.ts +++ b/extensions/google/api.ts @@ -7,7 +7,11 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; import { parseGoogleOauthApiKey } from "./oauth-token-shared.js"; -import { DEFAULT_GOOGLE_API_BASE_URL, normalizeGoogleApiBaseUrl } from "./provider-policy.js"; +import { + DEFAULT_GOOGLE_API_BASE_URL, + normalizeGoogleApiBaseUrl, + normalizeGoogleGenerativeAiBaseUrl, +} from "./provider-policy.js"; export { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js"; export { DEFAULT_GOOGLE_API_BASE_URL, @@ -40,6 +44,29 @@ export function parseGeminiAuth(apiKey: string): { headers: Record { ); }); + it("strips a configured /openai suffix before calling the native Gemini image API", async () => { + mockGoogleApiKeyAuth(); + const fetchMock = installGoogleFetchMock(); + + const provider = buildGoogleImageGenerationProvider(); + await provider.generateImage({ + provider: "google", + model: "gemini-3-pro-image-preview", + prompt: "draw a fox", + cfg: { + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + models: [], + }, + }, + }, + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent", + expect.any(Object), + ); + }); + it("prefers scoped configured Gemini API keys over environment fallbacks", () => { expect( geminiWebSearchTesting.resolveGeminiApiKey({ diff --git a/extensions/google/media-understanding-provider.video.test.ts b/extensions/google/media-understanding-provider.video.test.ts index 08e8ca2e305..69b845ae25e 100644 --- a/extensions/google/media-understanding-provider.video.test.ts +++ b/extensions/google/media-understanding-provider.video.test.ts @@ -79,7 +79,7 @@ describe("describeGeminiVideo", () => { fileName: "clip.mp4", apiKey: "test-key", timeoutMs: 1500, - baseUrl: "https://example.com/v1beta/", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/", model: "gemini-3-pro", headers: { "X-Other": "1" }, fetchFn, @@ -88,7 +88,9 @@ describe("describeGeminiVideo", () => { expect(result.model).toBe("gemini-3-pro-preview"); expect(result.text).toBe("first\nsecond"); - expect(seenUrl).toBe("https://example.com/v1beta/models/gemini-3-pro-preview:generateContent"); + expect(seenUrl).toBe( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-preview:generateContent", + ); expect(seenInit?.method).toBe("POST"); expect(seenInit?.signal).toBeInstanceOf(AbortSignal); @@ -110,4 +112,21 @@ describe("describeGeminiVideo", () => { Buffer.from("video-bytes").toString("base64"), ); }); + + it("rejects non-Google video base URLs before sending authenticated requests", async () => { + await expect( + describeGeminiVideo({ + buffer: Buffer.from("video-bytes"), + fileName: "clip.mp4", + apiKey: "test-key", + timeoutMs: 1500, + baseUrl: "https://example.com/v1beta/", + fetchFn: async () => { + throw new Error("fetch should not run"); + }, + }), + ).rejects.toThrow( + "Google Generative AI baseUrl must use https://generativelanguage.googleapis.com", + ); + }); }); diff --git a/extensions/google/provider-policy-api.test.ts b/extensions/google/provider-policy-api.test.ts index a9996207aee..bd453334824 100644 --- a/extensions/google/provider-policy-api.test.ts +++ b/extensions/google/provider-policy-api.test.ts @@ -28,4 +28,20 @@ describe("google provider policy public artifact", () => { models: [{ id: "gemini-3-pro-preview" }], }); }); + + it("preserves explicit OpenAI-compatible Google endpoints during normalization", () => { + expect( + normalizeConfig({ + provider: "google", + providerConfig: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + models: [], + }, + }), + ).toMatchObject({ + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + }); + }); }); diff --git a/extensions/google/provider-policy.ts b/extensions/google/provider-policy.ts index 938657a3a41..5ed20e8dcda 100644 --- a/extensions/google/provider-policy.ts +++ b/extensions/google/provider-policy.ts @@ -29,14 +29,21 @@ function isGoogleGenerativeAiUrl(url: URL): boolean { ); } +function stripUrlUserInfo(url: URL): void { + url.username = ""; + url.password = ""; +} + export function normalizeGoogleApiBaseUrl(baseUrl?: string): string { const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL); try { const url = new URL(raw); url.hash = ""; url.search = ""; - if (isGoogleGenerativeAiUrl(url) && trimTrailingSlashes(url.pathname || "") === "") { - url.pathname = "/v1beta"; + stripUrlUserInfo(url); + if (isGoogleGenerativeAiUrl(url)) { + const normalizedPath = trimTrailingSlashes(url.pathname || ""); + url.pathname = normalizedPath || "/v1beta"; } return trimTrailingSlashes(url.toString()); } catch { @@ -52,7 +59,23 @@ export function isGoogleGenerativeAiApi(api?: string | null): boolean { } export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | undefined { - return baseUrl ? normalizeGoogleApiBaseUrl(baseUrl) : baseUrl; + if (!baseUrl) { + return baseUrl; + } + + const normalized = normalizeGoogleApiBaseUrl(baseUrl); + try { + const url = new URL(normalized); + stripUrlUserInfo(url); + if (isGoogleGenerativeAiUrl(url)) { + url.pathname = trimTrailingSlashes(url.pathname || "").replace(/\/openai$/i, "") || "/v1beta"; + return trimTrailingSlashes(url.toString()); + } + } catch { + // `normalizeGoogleApiBaseUrl` already returned the best-effort input form. + } + + return normalized; } export function resolveGoogleGenerativeAiTransport(params: { @@ -68,20 +91,28 @@ export function resolveGoogleGenerativeAiTransport isGoogleGenerativeAiApi(model?.api)) ?? false; + const hasGoogleGenerativeAiModelApi = + provider.models?.some((model) => isGoogleGenerativeAiApi(model?.api)) ?? false; + if (hasGoogleGenerativeAiModelApi) { + return true; + } + if (providerKey !== "google" && providerKey !== "google-vertex") { + return false; + } + const hasExplicitNonGoogleApi = normalizeOptionalString(provider.api) !== undefined; + return !hasExplicitNonGoogleApi; } export function shouldNormalizeGoogleProviderConfig(