fix: normalize baseUrl for custom Google Generative AI providers

Custom providers using `api: "google-generative-ai"` (e.g. a paid
Google tier) resolved in the model picker but failed at runtime with
HTTP 404 because the base URL lacked the required `/v1beta` path
segment and provider normalization was gated on the provider key
being exactly `"google"`.

Two targeted fixes, both keyed on the semantic `api` field rather
than provider name strings:

1. `models-config.providers.ts` — change the normalization gate from
   `normalizedKey === "google"` to
   `normalizedProvider?.api === "google-generative-ai"` and add
   `normalizeGoogleBaseUrl()` to ensure the canonical `/v1beta` suffix.

2. `pi-embedded-runner/model.ts` — apply
   `normalizeGoogleGenerativeAiBaseUrl()` in three resolution paths
   (`applyConfiguredProviderOverrides`, `buildInlineProviderModels`,
   fallback model construction) so the base URL is corrected at
   runtime regardless of how the model was discovered.

No changes to name-only call sites (`model-selection`,
`live-model-filter`, `model-forward-compat`); those paths are not
required for custom provider resolution and broadening their provider
checks would incorrectly capture unrelated providers like
`google-antigravity`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aria
2026-03-13 17:58:56 +08:00
committed by Peter Steinberger
parent e10ea53ea1
commit 63b0036248
5 changed files with 216 additions and 29 deletions

View File

@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
- Runtime/build: stabilize long-lived lazy `dist` runtime entry paths and harden bundled plugin npm staging so local rebuilds stop breaking on missing hashed chunks or broken shell `npm` shims. (#53855) Thanks @vincentkoc.
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
- Models/google: normalize bare Google Generative AI API roots for custom provider names, and keep built-in Google model-id rewrites working when `api` is declared only on individual models, so custom Google lanes and older configs stop missing `/v1beta` or preview-id normalization. (#44969) Thanks @Kathie-yu.
## 2026.3.23

View File

@@ -20,11 +20,19 @@ function createGoogleModelsConfig(models: ModelDefinitionConfig[]): OpenClawConf
};
}
async function expectGeneratedGoogleModelIds(ids: string[]) {
async function readGeneratedProvider(providerKey: string) {
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { models: Array<{ id: string }> }>;
providers: Record<string, { baseUrl?: string; models: Array<{ id: string }> }>;
}>();
expect(parsed.providers.google?.models?.map((model) => model.id)).toEqual(ids);
return parsed.providers[providerKey];
}
async function expectGeneratedProvider(providerKey: string, params: { ids: string[]; baseUrl?: string }) {
const provider = await readGeneratedProvider(providerKey);
expect(provider?.models?.map((model) => model.id)).toEqual(params.ids);
if (params.baseUrl !== undefined) {
expect(provider?.baseUrl).toBe(params.baseUrl);
}
}
describe("models-config", () => {
@@ -56,7 +64,9 @@ describe("models-config", () => {
]);
await ensureOpenClawModelsJson(cfg);
await expectGeneratedGoogleModelIds(["gemini-3-pro-preview", "gemini-3-flash-preview"]);
await expectGeneratedProvider("google", {
ids: ["gemini-3-pro-preview", "gemini-3-flash-preview"],
});
});
});
@@ -76,7 +86,76 @@ describe("models-config", () => {
]);
await ensureOpenClawModelsJson(cfg);
await expectGeneratedGoogleModelIds(["gemini-3-flash-preview"]);
await expectGeneratedProvider("google", {
ids: ["gemini-3-flash-preview"],
});
});
});
it("normalizes custom Google Generative AI providers by api instead of provider name", async () => {
await withModelsTempHome(async () => {
const cfg = {
models: {
providers: {
"google-paid": {
baseUrl: "https://generativelanguage.googleapis.com",
apiKey: "GEMINI_KEY", // pragma: allowlist secret
api: "google-generative-ai",
models: [
{
id: "gemini-3-pro",
name: "Gemini 3 Pro",
api: "google-generative-ai",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
],
},
},
},
} satisfies OpenClawConfig;
await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider("google-paid", {
ids: ["gemini-3-pro-preview"],
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
});
});
});
it("keeps built-in google normalization when api is only defined on models", async () => {
await withModelsTempHome(async () => {
const cfg = {
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com",
apiKey: "GEMINI_KEY", // pragma: allowlist secret
models: [
{
id: "gemini-3-flash",
name: "Gemini 3 Flash",
api: "google-generative-ai",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
],
},
},
},
} satisfies OpenClawConfig;
await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider("google", {
ids: ["gemini-3-flash-preview"],
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
});
});
});
});

View File

@@ -17,6 +17,7 @@ import {
} from "../plugin-sdk/provider-catalog.js";
import { isRecord } from "../utils.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js";
import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
@@ -330,8 +331,25 @@ function normalizeProviderModels(
return mutated ? { ...provider, models } : provider;
}
function shouldNormalizeGoogleProvider(providerKey: string, provider: ProviderConfig): boolean {
if (providerKey === "google" || providerKey === "google-vertex") {
return true;
}
if (provider.api === "google-generative-ai") {
return true;
}
return provider.models.some((model) => model.api === "google-generative-ai");
}
function normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig {
return normalizeProviderModels(provider, normalizeGoogleModelId);
const modelNormalized = normalizeProviderModels(provider, normalizeGoogleModelId);
const normalizedBaseUrl = modelNormalized.baseUrl
? normalizeGoogleApiBaseUrl(modelNormalized.baseUrl)
: modelNormalized.baseUrl;
if (normalizedBaseUrl !== modelNormalized.baseUrl) {
return { ...modelNormalized, baseUrl: normalizedBaseUrl ?? modelNormalized.baseUrl };
}
return modelNormalized;
}
function normalizeAntigravityProvider(provider: ProviderConfig): ProviderConfig {
@@ -608,7 +626,7 @@ export function normalizeProviders(params: {
}
}
if (normalizedKey === "google" || normalizedKey === "google-vertex") {
if (shouldNormalizeGoogleProvider(normalizedKey, normalizedProvider)) {
const googleNormalized = normalizeGoogleProvider(normalizedProvider);
if (googleNormalized !== normalizedProvider) {
mutated = true;

View File

@@ -177,6 +177,25 @@ describe("buildInlineProviderModels", () => {
});
});
it("normalizes bare Google API hosts for custom Google Generative AI providers", () => {
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
"google-paid ": {
baseUrl: "https://generativelanguage.googleapis.com",
api: "google-generative-ai",
models: [makeModel("gemini-2.5-pro")],
},
};
const result = buildInlineProviderModels(providers);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
provider: "google-paid",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
});
});
it("merges provider-level headers into inline models", () => {
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
proxy: {
@@ -284,6 +303,54 @@ describe("resolveModel", () => {
expect(result.model?.id).toBe("missing-model");
});
it("normalizes Google fallback baseUrls for custom providers", () => {
const cfg = {
models: {
providers: {
"google-paid": {
baseUrl: "https://generativelanguage.googleapis.com",
api: "google-generative-ai",
models: [],
},
},
},
} as OpenClawConfig;
const result = resolveModelForTest("google-paid", "missing-model", "/tmp/agent", cfg);
expect(result.model?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
});
it("normalizes configured Google override baseUrls when provider api is omitted", () => {
mockDiscoveredModel({
provider: "google",
modelId: "gemini-2.5-pro",
templateModel: {
...makeModel("gemini-2.5-pro"),
provider: "google",
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
},
});
const cfg = {
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com",
models: [{ id: "gemini-2.5-pro", name: "gemini-2.5-pro" }],
},
},
},
} as OpenClawConfig;
const result = resolveModelForTest("google", "gemini-2.5-pro", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.api).toBe("google-generative-ai");
expect(result.model?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
});
it("includes provider headers in provider fallback model", () => {
const cfg = {
models: {

View File

@@ -19,8 +19,13 @@ import {
shouldSuppressBuiltInModel,
} from "../model-suppression.js";
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js";
import { normalizeResolvedProviderModel } from "./model.provider-normalization.js";
function normalizeGoogleGenerativeAiBaseUrl(baseUrl: string | undefined): string | undefined {
return baseUrl ? normalizeGoogleApiBaseUrl(baseUrl) : baseUrl;
}
type InlineModelEntry = ModelDefinitionConfig & {
provider: string;
baseUrl?: string;
@@ -175,10 +180,15 @@ function applyConfiguredProviderOverrides(params: {
? resolvedInput.filter((item) => item === "text" || item === "image")
: (["text"] as Array<"text" | "image">);
const resolvedApi = configuredModel?.api ?? providerConfig.api ?? discoveredModel.api;
let resolvedBaseUrl = providerConfig.baseUrl ?? discoveredModel.baseUrl;
if (resolvedApi === "google-generative-ai") {
resolvedBaseUrl = normalizeGoogleGenerativeAiBaseUrl(resolvedBaseUrl) ?? resolvedBaseUrl;
}
return {
...discoveredModel,
api: configuredModel?.api ?? providerConfig.api ?? discoveredModel.api,
baseUrl: providerConfig.baseUrl ?? discoveredModel.baseUrl,
api: resolvedApi,
baseUrl: resolvedBaseUrl,
reasoning: configuredModel?.reasoning ?? discoveredModel.reasoning,
input: normalizedInput,
cost: configuredModel?.cost ?? discoveredModel.cost,
@@ -207,24 +217,31 @@ export function buildInlineProviderModels(
const providerHeaders = sanitizeModelHeaders(entry?.headers, {
stripSecretRefMarkers: true,
});
return (entry?.models ?? []).map((model) => ({
...model,
provider: trimmed,
baseUrl: entry?.baseUrl,
api: model.api ?? entry?.api,
headers: (() => {
const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers, {
stripSecretRefMarkers: true,
});
if (!providerHeaders && !modelHeaders) {
return undefined;
}
return {
...providerHeaders,
...modelHeaders,
};
})(),
}));
return (entry?.models ?? []).map((model) => {
const modelApi = model.api ?? entry?.api;
let baseUrl = entry?.baseUrl;
if (modelApi === "google-generative-ai") {
baseUrl = normalizeGoogleGenerativeAiBaseUrl(baseUrl) ?? baseUrl;
}
return {
...model,
provider: trimmed,
baseUrl,
api: modelApi,
headers: (() => {
const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers, {
stripSecretRefMarkers: true,
});
if (!providerHeaders && !modelHeaders) {
return undefined;
}
return {
...providerHeaders,
...modelHeaders,
};
})(),
};
});
});
}
@@ -358,6 +375,11 @@ function resolveConfiguredFallbackModel(params: {
if (!providerConfig && !modelId.startsWith("mock-")) {
return undefined;
}
const fallbackApi = providerConfig?.api ?? "openai-responses";
let fallbackBaseUrl = providerConfig?.baseUrl;
if (fallbackApi === "google-generative-ai") {
fallbackBaseUrl = normalizeGoogleGenerativeAiBaseUrl(fallbackBaseUrl) ?? fallbackBaseUrl;
}
return normalizeResolvedModel({
provider,
cfg,
@@ -365,9 +387,9 @@ function resolveConfiguredFallbackModel(params: {
model: {
id: modelId,
name: modelId,
api: providerConfig?.api ?? "openai-responses",
api: fallbackApi,
provider,
baseUrl: providerConfig?.baseUrl,
baseUrl: fallbackBaseUrl,
reasoning: configuredModel?.reasoning ?? false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },