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
This commit is contained in:
Vincent Koc
2026-04-14 11:19:41 +01:00
committed by GitHub
parent 3587e0ef95
commit 6c0bff111c
7 changed files with 205 additions and 14 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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<string, strin
};
}
function resolveTrustedGoogleGenerativeAiBaseUrl(baseUrl?: string): string {
const normalized =
normalizeGoogleGenerativeAiBaseUrl(baseUrl ?? DEFAULT_GOOGLE_API_BASE_URL) ??
DEFAULT_GOOGLE_API_BASE_URL;
let url: URL;
try {
url = new URL(normalized);
} catch {
throw new Error(
"Google Generative AI baseUrl must be a valid https URL on generativelanguage.googleapis.com",
);
}
if (
url.protocol !== "https:" ||
url.hostname.toLowerCase() !== "generativelanguage.googleapis.com"
) {
throw new Error(
"Google Generative AI baseUrl must use https://generativelanguage.googleapis.com",
);
}
return normalized;
}
export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
apiKey: string;
baseUrl?: string;
@@ -49,9 +76,9 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
transport: "http" | "media-understanding";
}) {
return resolveProviderHttpRequestConfig({
baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? DEFAULT_GOOGLE_API_BASE_URL),
baseUrl: resolveTrustedGoogleGenerativeAiBaseUrl(params.baseUrl),
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
allowPrivateNetwork: Boolean(params.baseUrl?.trim()),
allowPrivateNetwork: false,
headers: params.headers,
request: params.request,
defaultHeaders: parseGeminiAuth(params.apiKey).headers,

View File

@@ -305,6 +305,33 @@ describe("Google image-generation provider", () => {
);
});
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({

View File

@@ -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",
);
});
});

View File

@@ -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",
});
});
});

View File

@@ -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<TApi extends string | null | undefined>(params: {
@@ -68,20 +91,28 @@ export function resolveGoogleGenerativeAiTransport<TApi extends string | null |
}
export function resolveGoogleGenerativeAiApiOrigin(baseUrl?: string): string {
return normalizeGoogleApiBaseUrl(baseUrl).replace(/\/v1beta$/i, "");
return (
normalizeGoogleGenerativeAiBaseUrl(baseUrl) ?? normalizeGoogleApiBaseUrl(baseUrl)
).replace(/\/v1beta$/i, "");
}
export function shouldNormalizeGoogleGenerativeAiProviderConfig(
providerKey: string,
provider: GoogleProviderConfigLike,
): boolean {
if (providerKey === "google" || providerKey === "google-vertex") {
return true;
}
if (isGoogleGenerativeAiApi(provider.api)) {
return true;
}
return provider.models?.some((model) => 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(