refactor: centralize google API base URL handling

This commit is contained in:
Peter Steinberger
2026-03-24 09:59:56 -07:00
parent 129b1b5037
commit 9f47892bef
11 changed files with 156 additions and 19 deletions

View File

@@ -262,4 +262,54 @@ describe("Google image-generation provider", () => {
}),
);
});
it("normalizes a configured bare Google host to the v1beta API root", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-test-key",
source: "env",
mode: "api-key",
});
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: "image/png",
data: Buffer.from("png-data").toString("base64"),
},
},
],
},
},
],
}),
});
vi.stubGlobal("fetch", fetchMock);
const provider = buildGoogleImageGenerationProvider();
await provider.generateImage({
provider: "google",
model: "gemini-3-pro-image-preview",
prompt: "draw a cat",
cfg: {
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com",
models: [],
},
},
},
},
});
expect(fetchMock).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent",
expect.any(Object),
);
});
});

View File

@@ -5,9 +5,13 @@ import {
postJsonRequest,
} from "openclaw/plugin-sdk/media-understanding";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth";
import { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/provider-google";
import {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
normalizeGoogleModelId,
parseGeminiAuth,
} from "openclaw/plugin-sdk/provider-google";
const DEFAULT_GOOGLE_IMAGE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const DEFAULT_GOOGLE_IMAGE_MODEL = "gemini-3.1-flash-image-preview";
const DEFAULT_OUTPUT_MIME = "image/png";
const GOOGLE_SUPPORTED_SIZES = [
@@ -49,8 +53,7 @@ type GoogleGenerateImageResponse = {
};
function resolveGoogleBaseUrl(cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"]): string {
const direct = cfg?.models?.providers?.google?.baseUrl?.trim();
return direct || DEFAULT_GOOGLE_IMAGE_BASE_URL;
return normalizeGoogleApiBaseUrl(cfg?.models?.providers?.google?.baseUrl);
}
function normalizeGoogleImageModel(model: string | undefined): string {
@@ -131,10 +134,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
}
const model = normalizeGoogleImageModel(req.model);
const baseUrl = normalizeBaseUrl(
resolveGoogleBaseUrl(req.cfg),
DEFAULT_GOOGLE_IMAGE_BASE_URL,
);
const baseUrl = normalizeBaseUrl(resolveGoogleBaseUrl(req.cfg), DEFAULT_GOOGLE_API_BASE_URL);
const allowPrivate = Boolean(req.cfg?.models?.providers?.google?.baseUrl?.trim());
const authHeaders = parseGeminiAuth(auth.apiKey);
const headers = new Headers(authHeaders.headers);

View File

@@ -10,10 +10,15 @@ import {
type VideoDescriptionRequest,
type VideoDescriptionResult,
} from "openclaw/plugin-sdk/media-understanding";
import { normalizeGoogleModelId, parseGeminiAuth } from "./runtime-api.js";
import {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
normalizeGoogleModelId,
parseGeminiAuth,
} from "./runtime-api.js";
export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
export const DEFAULT_GOOGLE_AUDIO_BASE_URL = DEFAULT_GOOGLE_API_BASE_URL;
export const DEFAULT_GOOGLE_VIDEO_BASE_URL = DEFAULT_GOOGLE_API_BASE_URL;
const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview";
const DEFAULT_GOOGLE_VIDEO_MODEL = "gemini-3-flash-preview";
const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio.";
@@ -37,7 +42,10 @@ async function generateGeminiInlineDataText(params: {
missingTextError: string;
}): Promise<{ text: string; model: string }> {
const fetchFn = params.fetchFn ?? fetch;
const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl);
const baseUrl = normalizeBaseUrl(
normalizeGoogleApiBaseUrl(params.baseUrl ?? params.defaultBaseUrl),
DEFAULT_GOOGLE_API_BASE_URL,
);
const allowPrivate = Boolean(params.baseUrl?.trim());
const model = (() => {
const trimmed = params.model?.trim();

View File

@@ -1 +1,6 @@
export { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/provider-google";
export {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
normalizeGoogleModelId,
parseGeminiAuth,
} from "openclaw/plugin-sdk/provider-google";

View File

@@ -1,4 +1,5 @@
import { Type } from "@sinclair/typebox";
import { DEFAULT_GOOGLE_API_BASE_URL } from "openclaw/plugin-sdk/provider-google";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
@@ -27,7 +28,7 @@ import {
} from "openclaw/plugin-sdk/provider-web-search";
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta";
const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL;
type GeminiConfig = {
apiKey?: string;

View File

@@ -12,6 +12,7 @@ import {
} from "../config/discord-preview-streaming.js";
import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js";
import { DEFAULT_TALK_PROVIDER, normalizeTalkSection } from "../config/talk.js";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../infra/google-api-base-url.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
@@ -580,7 +581,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
if (!hasGoogleApiKey && legacyApiKey) {
rawGoogle.apiKey = legacyApiKey;
if (!rawGoogle.baseUrl) {
rawGoogle.baseUrl = "https://generativelanguage.googleapis.com/v1beta";
rawGoogle.baseUrl = DEFAULT_GOOGLE_API_BASE_URL;
}
if (!Array.isArray(rawGoogle.models)) {
rawGoogle.models = [];

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_GOOGLE_API_BASE_URL, normalizeGoogleApiBaseUrl } from "./google-api-base-url.js";
describe("normalizeGoogleApiBaseUrl", () => {
it("defaults to the Gemini v1beta API root", () => {
expect(normalizeGoogleApiBaseUrl()).toBe(DEFAULT_GOOGLE_API_BASE_URL);
});
it("normalizes the bare Google API host to the Gemini v1beta root", () => {
expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com")).toBe(
DEFAULT_GOOGLE_API_BASE_URL,
);
expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/")).toBe(
DEFAULT_GOOGLE_API_BASE_URL,
);
});
it("preserves explicit Google API paths", () => {
expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1beta")).toBe(
DEFAULT_GOOGLE_API_BASE_URL,
);
expect(normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1")).toBe(
"https://generativelanguage.googleapis.com/v1",
);
});
it("preserves custom proxy paths", () => {
expect(normalizeGoogleApiBaseUrl("https://proxy.example.com/google/v1beta/")).toBe(
"https://proxy.example.com/google/v1beta",
);
});
});

View File

@@ -0,0 +1,28 @@
const DEFAULT_GOOGLE_API_HOST = "generativelanguage.googleapis.com";
export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, "");
}
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
const raw = trimTrailingSlashes(baseUrl?.trim() || DEFAULT_GOOGLE_API_BASE_URL);
try {
const url = new URL(raw);
url.hash = "";
url.search = "";
if (
url.hostname.toLowerCase() === DEFAULT_GOOGLE_API_HOST &&
trimTrailingSlashes(url.pathname || "") === ""
) {
url.pathname = "/v1beta";
}
return trimTrailingSlashes(url.toString());
} catch {
if (/^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(raw)) {
return DEFAULT_GOOGLE_API_BASE_URL;
}
return raw;
}
}

View File

@@ -4,6 +4,10 @@ import {
} from "../agents/api-key-rotation.js";
import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js";
import { parseGeminiAuth } from "../infra/gemini-auth.js";
import {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
} from "../infra/google-api-base-url.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import type { EmbeddingInput } from "./embedding-inputs.js";
import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js";
@@ -22,7 +26,6 @@ export type GeminiEmbeddingClient = {
outputDimensionality?: number;
};
const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
export const DEFAULT_GEMINI_EMBEDDING_MODEL = "gemini-embedding-001";
const GEMINI_MAX_INPUT_TOKENS: Record<string, number> = {
"text-embedding-004": 2048,
@@ -205,9 +208,9 @@ function normalizeGeminiBaseUrl(raw: string): string {
const trimmed = raw.replace(/\/+$/, "");
const openAiIndex = trimmed.indexOf("/openai");
if (openAiIndex > -1) {
return trimmed.slice(0, openAiIndex);
return normalizeGoogleApiBaseUrl(trimmed.slice(0, openAiIndex));
}
return trimmed;
return normalizeGoogleApiBaseUrl(trimmed);
}
function buildGeminiModelPath(model: string): string {
@@ -302,7 +305,8 @@ export async function resolveGeminiEmbeddingClient(
);
const providerConfig = options.config.models?.providers?.google;
const rawBaseUrl = remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_GEMINI_BASE_URL;
const rawBaseUrl =
remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_GOOGLE_API_BASE_URL;
const baseUrl = normalizeGeminiBaseUrl(rawBaseUrl);
const ssrfPolicy = buildRemoteBaseUrlPolicy(baseUrl);
const headerOverrides = Object.assign({}, providerConfig?.headers, remote?.headers);

View File

@@ -1,4 +1,8 @@
// Private Google-specific helpers used by bundled Google plugins.
export { normalizeGoogleModelId } from "../agents/model-id-normalization.js";
export {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
} from "../infra/google-api-base-url.js";
export { parseGeminiAuth } from "../infra/gemini-auth.js";

View File

@@ -1,4 +1,8 @@
// Public Google provider helpers shared by bundled Google extensions.
export { normalizeGoogleModelId } from "../agents/model-id-normalization.js";
export {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
} from "../infra/google-api-base-url.js";
export { parseGeminiAuth } from "../infra/gemini-auth.js";