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