fix(gemini): reuse google provider config for web search

This commit is contained in:
Peter Steinberger
2026-05-02 05:14:51 +01:00
parent 7dc5b9484f
commit ed6df7dd8b
18 changed files with 504 additions and 48 deletions

View File

@@ -9,10 +9,26 @@ import plugin from "./index.js";
import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
const GOOGLE_API_KEY =
process.env.GEMINI_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || "";
process.env.GEMINI_API_KEY?.trim() ||
process.env.GOOGLE_API_KEY?.trim() ||
process.env.GEMINI_PROVIDER_API_KEY?.trim() ||
"";
const LIVE = isLiveTestEnabled() && GOOGLE_API_KEY.length > 0;
const describeLive = LIVE ? describe : describe.skip;
async function withGoogleApiEnvUnset<T>(fn: () => Promise<T>): Promise<T> {
const geminiApiKey = process.env.GEMINI_API_KEY;
const googleApiKey = process.env.GOOGLE_API_KEY;
delete process.env.GEMINI_API_KEY;
delete process.env.GOOGLE_API_KEY;
try {
return await fn();
} finally {
process.env.GEMINI_API_KEY = geminiApiKey;
process.env.GOOGLE_API_KEY = googleApiKey;
}
}
function isTransientGeminiSearchError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
@@ -124,4 +140,32 @@ describeLive("google plugin live", () => {
expect((result?.content as string).length).toBeGreaterThan(20);
expect(Array.isArray(result?.citations)).toBe(true);
}, 120_000);
it("runs Gemini web search through the Google model provider config fallback", async () => {
await withGoogleApiEnvUnset(async () => {
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool?.({
config: {
models: {
providers: {
google: {
apiKey: GOOGLE_API_KEY,
},
},
},
},
searchConfig: { provider: "gemini", cacheTtlMinutes: 0, timeoutSeconds: 90 },
} as never);
const result = await tool?.execute({ query: "OpenClaw GitHub", count: 1 });
expect(process.env.GEMINI_API_KEY).toBeUndefined();
expect(process.env.GOOGLE_API_KEY).toBeUndefined();
expect(result?.provider).toBe("gemini");
expect(typeof result?.content).toBe("string");
expect((result?.content as string).length).toBeGreaterThan(20);
expect(Array.isArray(result?.citations)).toBe(true);
expect((result?.citations as unknown[]).length).toBeGreaterThan(0);
});
}, 120_000);
});

View File

@@ -155,7 +155,8 @@ function resolveGeminiTimeRangeFilter(
export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined {
return (
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
readProviderEnvValue(["GEMINI_API_KEY"])
readProviderEnvValue(["GEMINI_API_KEY"]) ??
readConfiguredSecretString(gemini?.providerApiKey, "models.providers.google.apiKey")
);
}
@@ -267,7 +268,7 @@ export async function executeGeminiSearch(
return {
error: "missing_gemini_api_key",
message:
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, configure plugins.entries.google.config.webSearch.apiKey, or reuse models.providers.google.apiKey. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
docs: "https://docs.openclaw.ai/tools/web",
};
}

View File

@@ -6,6 +6,8 @@ export type GeminiConfig = {
apiKey?: unknown;
baseUrl?: unknown;
model?: unknown;
providerApiKey?: unknown;
providerBaseUrl?: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -25,7 +27,11 @@ export function resolveGeminiApiKey(
gemini?: GeminiConfig,
env: Record<string, string | undefined> = process.env,
): string | undefined {
return trimToUndefined(gemini?.apiKey) ?? trimToUndefined(env.GEMINI_API_KEY);
return (
trimToUndefined(gemini?.apiKey) ??
trimToUndefined(env.GEMINI_API_KEY) ??
trimToUndefined(gemini?.providerApiKey)
);
}
export function resolveGeminiModel(gemini?: GeminiConfig): string {
@@ -33,5 +39,7 @@ export function resolveGeminiModel(gemini?: GeminiConfig): string {
}
export function resolveGeminiBaseUrl(gemini?: GeminiConfig): string {
return normalizeGoogleApiBaseUrl(trimToUndefined(gemini?.baseUrl));
return normalizeGoogleApiBaseUrl(
trimToUndefined(gemini?.baseUrl) ?? trimToUndefined(gemini?.providerBaseUrl),
);
}

View File

@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
createWebSearchProviderContractFields,
mergeScopedSearchConfig,
@@ -12,6 +13,7 @@ import {
} from "./gemini-web-search-provider.shared.js";
const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey";
const GOOGLE_PROVIDER_CREDENTIAL_PATH = "models.providers.google.apiKey";
type GeminiWebSearchRuntime = typeof import("./gemini-web-search-provider.runtime.js");
@@ -64,7 +66,54 @@ function createGeminiToolDefinition(
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function resolveGoogleModelProviderConfig(
config?: OpenClawConfig,
): Record<string, unknown> | undefined {
const provider = config?.models?.providers?.google;
return isRecord(provider) ? provider : undefined;
}
function getGoogleModelProviderCredentialFallback(
config?: OpenClawConfig,
): { path: string; value: unknown } | undefined {
const provider = resolveGoogleModelProviderConfig(config);
return provider && provider.apiKey !== undefined
? { path: GOOGLE_PROVIDER_CREDENTIAL_PATH, value: provider.apiKey }
: undefined;
}
function withGoogleModelProviderFallbacks(
searchConfig: Record<string, unknown> | undefined,
config?: OpenClawConfig,
): Record<string, unknown> | undefined {
const provider = resolveGoogleModelProviderConfig(config);
if (!provider || (provider.apiKey === undefined && provider.baseUrl === undefined)) {
return searchConfig;
}
const gemini = isRecord(searchConfig?.gemini) ? { ...searchConfig.gemini } : {};
if (provider.apiKey !== undefined) {
gemini.providerApiKey = provider.apiKey;
}
if (provider.baseUrl !== undefined) {
gemini.providerBaseUrl = provider.baseUrl;
}
return {
...(searchConfig ?? {}),
gemini,
};
}
export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
const contractFields = createWebSearchProviderContractFields({
credentialPath: GEMINI_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "gemini" },
configuredCredential: { pluginId: "google" },
});
return {
id: "gemini",
label: "Gemini (Google Search)",
@@ -77,17 +126,17 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
credentialPath: GEMINI_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: GEMINI_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "gemini" },
configuredCredential: { pluginId: "google" },
}),
...contractFields,
getConfiguredCredentialFallback: getGoogleModelProviderCredentialFallback,
createTool: (ctx) =>
createGeminiToolDefinition(
mergeScopedSearchConfig(
ctx.searchConfig,
"gemini",
resolveProviderWebSearchPluginConfig(ctx.config, "google"),
withGoogleModelProviderFallbacks(
mergeScopedSearchConfig(
ctx.searchConfig,
"gemini",
resolveProviderWebSearchPluginConfig(ctx.config, "google"),
),
ctx.config,
),
),
};

View File

@@ -59,6 +59,14 @@ describe("google web search provider", () => {
});
});
it("uses provider api keys only after env fallbacks", () => {
withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => {
expect(__testing.resolveGeminiApiKey({ providerApiKey: "AIza-provider-test" })).toBe(
"AIza-env-test",
);
});
});
it("stores configured credentials at the canonical plugin config path", () => {
const provider = createGeminiWebSearchProvider();
const config = {} as OpenClawConfig;
@@ -102,6 +110,126 @@ describe("google web search provider", () => {
);
});
it("reuses the Google model provider key when no web search key or env key is set", async () => {
await withEnvAsync({ GEMINI_API_KEY: undefined }, async () => {
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
models: {
providers: {
google: {
apiKey: "AIza-provider-test",
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "OpenClaw provider key fallback" });
expect(
(mockFetch.mock.calls[0]?.[1]?.headers as Record<string, string>)["x-goog-api-key"],
).toBe("AIza-provider-test");
});
});
it("keeps plugin web search keys ahead of env and provider keys", async () => {
await withEnvAsync({ GEMINI_API_KEY: "AIza-env-test" }, async () => {
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: "AIza-plugin-test",
},
},
},
},
},
models: {
providers: {
google: {
apiKey: "AIza-provider-test",
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "OpenClaw plugin key precedence" });
expect(
(mockFetch.mock.calls[0]?.[1]?.headers as Record<string, string>)["x-goog-api-key"],
).toBe("AIza-plugin-test");
});
});
it("routes Gemini web search through provider-level google.baseUrl as a fallback", async () => {
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
models: {
providers: {
google: {
apiKey: "AIza-provider-test",
baseUrl: "https://generativelanguage.googleapis.com/provider/v1beta/",
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "OpenClaw provider baseUrl fallback" });
expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
"https://generativelanguage.googleapis.com/provider/v1beta/models/gemini-2.5-flash:generateContent",
);
});
it("keeps plugin webSearch.baseUrl ahead of provider-level google.baseUrl", async () => {
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: "AIza-plugin-test",
baseUrl: "https://generativelanguage.googleapis.com/plugin/v1beta/",
},
},
},
},
},
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/provider/v1beta/",
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "OpenClaw plugin baseUrl precedence" });
expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
"https://generativelanguage.googleapis.com/plugin/v1beta/models/gemini-2.5-flash:generateContent",
);
});
it("passes freshness to Gemini Google Search grounding as a time range", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-15T12:00:00Z"));