mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 05:32:53 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user