mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:00:44 +00:00
fix(gemini): reuse google provider config for web search
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user