[Feature]: Add Gemini (Google Search grounding) as web_search provider (#13075)

* feat: add Gemini (Google Search grounding) as web_search provider

Add Gemini as a fourth web search provider alongside Brave, Perplexity,
and Grok. Uses Gemini's built-in Google Search grounding tool to return
search results with citations.

- Add runGeminiSearch() with Google Search grounding via tools API
- Resolve Gemini's grounding redirect URLs to direct URLs via parallel
  HEAD requests (5s timeout, graceful fallback)
- Add Gemini config block (apiKey, model) with env var fallback
- Default model: gemini-2.5-flash (fast, cheap, grounding-capable)
- Strip API key from error messages for security
- Add config validation tests for Gemini provider
- Update docs/tools/web.md with Gemini provider documentation

Closes #13074

* feat: auto-detect search provider from available API keys

When no explicit provider is configured, resolveSearchProvider now
checks for available API keys in priority order (Brave → Gemini →
Perplexity → Grok) and selects the first provider with a valid key.

- Add auto-detection logic using existing resolve*ApiKey functions
- Export resolveSearchProvider via __testing_provider for tests
- Add 8 tests covering auto-detection, priority order, and explicit override
- Update docs/tools/web.md with auto-detection documentation

* fix: merge __testing exports, downgrade auto-detect log to debug

* fix: use defaultRuntime.log instead of .debug (not in RuntimeEnv type)

* fix: mark gemini apiKey as sensitive in zod schema

* fix: address Greptile review — add externalContent to Gemini payload, add Gemini/Grok entries to schema labels/help, remove dead schema-fields.ts

* fix(web-search): add JSON parse guard for Gemini API responses

Addresses Greptile review comment: add try/catch to handle non-JSON
responses from Gemini API gracefully, preventing runtime errors on
malformed responses.

Note: FIELD_HELP entries for gemini.apiKey and gemini.model were
already present in schema.help.ts, and gemini.apiKey was already
marked as sensitive in zod-schema.agent-runtime.ts (both fixed in
earlier commits).

* fix: use structured readResponseText result in Gemini error path

readResponseText returns { text, truncated, bytesRead }, not a string.
The Gemini error handler was using the result object directly, which
would always be truthy and never fall through to res.statusText.
Align with Perplexity/xAI/Brave error patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix import order and formatting after rebase onto main

* Web search: send Gemini API key via header

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
AkosCz
2026-02-23 08:30:51 -06:00
committed by GitHub
parent 3f03cdea56
commit 3a3c2da916
8 changed files with 466 additions and 11 deletions

View File

@@ -1,6 +1,7 @@
import { Type } from "@sinclair/typebox";
import { formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { wrapWebContent } from "../../security/external-content.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import type { AnyAgentTool } from "./common.js";
@@ -18,7 +19,7 @@ import {
writeCache,
} from "./web-shared.js";
const SEARCH_PROVIDERS = ["brave", "perplexity", "grok"] as const;
const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "gemini"] as const;
const DEFAULT_SEARCH_COUNT = 5;
const MAX_SEARCH_COUNT = 10;
@@ -183,6 +184,41 @@ function extractGrokContent(data: GrokSearchResponse): {
return { text, annotationCitations: [] };
}
type GeminiConfig = {
apiKey?: string;
model?: string;
};
type GeminiGroundingResponse = {
candidates?: Array<{
content?: {
parts?: Array<{
text?: string;
}>;
};
groundingMetadata?: {
groundingChunks?: Array<{
web?: {
uri?: string;
title?: string;
};
}>;
searchEntryPoint?: {
renderedContent?: string;
};
webSearchQueries?: string[];
};
}>;
error?: {
code?: number;
message?: string;
status?: string;
};
};
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta";
function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {
@@ -227,6 +263,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (provider === "gemini") {
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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
return {
error: "missing_brave_api_key",
message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
@@ -245,9 +289,49 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
if (raw === "grok") {
return "grok";
}
if (raw === "gemini") {
return "gemini";
}
if (raw === "brave") {
return "brave";
}
// Auto-detect provider from available API keys (priority order)
if (raw === "") {
// 1. Brave
if (resolveSearchApiKey(search)) {
defaultRuntime.log(
'web_search: no provider configured, auto-detected "brave" from available API keys',
);
return "brave";
}
// 2. Gemini
const geminiConfig = resolveGeminiConfig(search);
if (resolveGeminiApiKey(geminiConfig)) {
defaultRuntime.log(
'web_search: no provider configured, auto-detected "gemini" from available API keys',
);
return "gemini";
}
// 3. Perplexity
const perplexityConfig = resolvePerplexityConfig(search);
const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig);
if (perplexityKey) {
defaultRuntime.log(
'web_search: no provider configured, auto-detected "perplexity" from available API keys',
);
return "perplexity";
}
// 4. Grok
const grokConfig = resolveGrokConfig(search);
if (resolveGrokApiKey(grokConfig)) {
defaultRuntime.log(
'web_search: no provider configured, auto-detected "grok" from available API keys',
);
return "grok";
}
}
return "brave";
}
@@ -389,6 +473,130 @@ function resolveGrokInlineCitations(grok?: GrokConfig): boolean {
return grok?.inlineCitations === true;
}
function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig {
if (!search || typeof search !== "object") {
return {};
}
const gemini = "gemini" in search ? search.gemini : undefined;
if (!gemini || typeof gemini !== "object") {
return {};
}
return gemini as GeminiConfig;
}
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
const fromConfig = normalizeApiKey(gemini?.apiKey);
if (fromConfig) {
return fromConfig;
}
const fromEnv = normalizeApiKey(process.env.GEMINI_API_KEY);
return fromEnv || undefined;
}
function resolveGeminiModel(gemini?: GeminiConfig): string {
const fromConfig =
gemini && "model" in gemini && typeof gemini.model === "string" ? gemini.model.trim() : "";
return fromConfig || DEFAULT_GEMINI_MODEL;
}
async function runGeminiSearch(params: {
query: string;
apiKey: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": params.apiKey,
},
body: JSON.stringify({
contents: [
{
parts: [{ text: params.query }],
},
],
tools: [{ google_search: {} }],
}),
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
});
if (!res.ok) {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
// Strip API key from any error detail to prevent accidental key leakage in logs
const safeDetail = (detailResult.text || res.statusText).replace(/key=[^&\s]+/gi, "key=***");
throw new Error(`Gemini API error (${res.status}): ${safeDetail}`);
}
let data: GeminiGroundingResponse;
try {
data = (await res.json()) as GeminiGroundingResponse;
} catch (err) {
const safeError = String(err).replace(/key=[^&\s]+/gi, "key=***");
throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: err });
}
if (data.error) {
const rawMsg = data.error.message || data.error.status || "unknown";
const safeMsg = rawMsg.replace(/key=[^&\s]+/gi, "key=***");
throw new Error(`Gemini API error (${data.error.code}): ${safeMsg}`);
}
const candidate = data.candidates?.[0];
const content =
candidate?.content?.parts
?.map((p) => p.text)
.filter(Boolean)
.join("\n") ?? "No response";
const groundingChunks = candidate?.groundingMetadata?.groundingChunks ?? [];
const rawCitations = groundingChunks
.filter((chunk) => chunk.web?.uri)
.map((chunk) => ({
url: chunk.web!.uri!,
title: chunk.web?.title || undefined,
}));
// Resolve Google grounding redirect URLs to direct URLs with concurrency cap.
// Gemini typically returns 3-8 citations; cap at 10 concurrent to be safe.
const MAX_CONCURRENT_REDIRECTS = 10;
const citations: Array<{ url: string; title?: string }> = [];
for (let i = 0; i < rawCitations.length; i += MAX_CONCURRENT_REDIRECTS) {
const batch = rawCitations.slice(i, i + MAX_CONCURRENT_REDIRECTS);
const resolved = await Promise.all(
batch.map(async (citation) => {
const resolvedUrl = await resolveRedirectUrl(citation.url);
return { ...citation, url: resolvedUrl };
}),
);
citations.push(...resolved);
}
return { content, citations };
}
const REDIRECT_TIMEOUT_MS = 5000;
/**
* Resolve a redirect URL to its final destination using a HEAD request.
* Returns the original URL if resolution fails or times out.
*/
async function resolveRedirectUrl(url: string): Promise<string> {
try {
const res = await fetch(url, {
method: "HEAD",
redirect: "follow",
signal: withTimeout(undefined, REDIRECT_TIMEOUT_MS),
});
return res.url || url;
} catch {
return url;
}
}
function resolveSearchCount(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
@@ -590,13 +798,16 @@ async function runWebSearch(params: {
perplexityModel?: string;
grokModel?: string;
grokInlineCitations?: boolean;
geminiModel?: string;
}): Promise<Record<string, unknown>> {
const cacheKey = normalizeCacheKey(
params.provider === "brave"
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
: params.provider === "perplexity"
? `${params.provider}:${params.query}:${params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}:${params.freshness || "default"}`
: `${params.provider}:${params.query}:${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`,
: params.provider === "gemini"
? `${params.provider}:${params.query}:${params.geminiModel ?? DEFAULT_GEMINI_MODEL}`
: `${params.provider}:${params.query}:${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`,
);
const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) {
@@ -661,6 +872,32 @@ async function runWebSearch(params: {
return payload;
}
if (params.provider === "gemini") {
const geminiResult = await runGeminiSearch({
query: params.query,
apiKey: params.apiKey,
model: params.geminiModel ?? DEFAULT_GEMINI_MODEL,
timeoutSeconds: params.timeoutSeconds,
});
const payload = {
query: params.query,
provider: params.provider,
model: params.geminiModel ?? DEFAULT_GEMINI_MODEL,
tookMs: Date.now() - start, // Includes redirect URL resolution time
externalContent: {
untrusted: true,
source: "web_search",
provider: params.provider,
wrapped: true,
},
content: wrapWebContent(geminiResult.content),
citations: geminiResult.citations,
};
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
}
if (params.provider !== "brave") {
throw new Error("Unsupported web search provider.");
}
@@ -741,13 +978,16 @@ export function createWebSearchTool(options?: {
const provider = resolveSearchProvider(search);
const perplexityConfig = resolvePerplexityConfig(search);
const grokConfig = resolveGrokConfig(search);
const geminiConfig = resolveGeminiConfig(search);
const description =
provider === "perplexity"
? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
: provider === "grok"
? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
: provider === "gemini"
? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
return {
label: "Web Search",
@@ -762,7 +1002,9 @@ export function createWebSearchTool(options?: {
? perplexityAuth?.apiKey
: provider === "grok"
? resolveGrokApiKey(grokConfig)
: resolveSearchApiKey(search);
: provider === "gemini"
? resolveGeminiApiKey(geminiConfig)
: resolveSearchApiKey(search);
if (!apiKey) {
return jsonResult(missingSearchKeyPayload(provider));
@@ -810,6 +1052,7 @@ export function createWebSearchTool(options?: {
perplexityModel: resolvePerplexityModel(perplexityConfig),
grokModel: resolveGrokModel(grokConfig),
grokInlineCitations: resolveGrokInlineCitations(grokConfig),
geminiModel: resolveGeminiModel(geminiConfig),
});
return jsonResult(result);
},
@@ -817,6 +1060,7 @@ export function createWebSearchTool(options?: {
}
export const __testing = {
resolveSearchProvider,
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
isDirectPerplexityBaseUrl,