From 3a3c2da9168f93397eeb3109d521819e10dc44fd Mon Sep 17 00:00:00 2001 From: AkosCz Date: Mon, 23 Feb 2026 08:30:51 -0600 Subject: [PATCH] [Feature]: Add Gemini (Google Search grounding) as web_search provider (#13075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + docs/tools/web.md | 63 ++++- src/agents/tools/web-search.ts | 252 +++++++++++++++++- src/config/config.web-search-provider.test.ts | 127 +++++++++ src/config/schema.help.ts | 8 +- src/config/schema.labels.ts | 4 + src/config/types.tools.ts | 11 +- src/config/zod-schema.agent-runtime.ts | 11 +- 8 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 src/config/config.web-search-provider.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3046ef56cb7..5cd222ca52e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai ### Changes - Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. +- Web Search/Gemini: add grounded Gemini provider support with provider auto-detection and config/docs updates. (#13075, #13074) Thanks @akoscz. - Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. - Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. - Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. diff --git a/docs/tools/web.md b/docs/tools/web.md index b0e295cd22a..85093ad62bd 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,9 +1,10 @@ --- -summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)" +summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter, Gemini Google Search grounding)" read_when: - You want to enable web_search or web_fetch - You need Brave Search API key setup - You want to use Perplexity Sonar for web search + - You want to use Gemini with Google Search grounding title: "Web Tools" --- @@ -11,7 +12,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter). +- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, or Gemini with Google Search grounding. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -22,6 +23,7 @@ These are **not** browser automation. For JS-heavy sites or logins, use the - `web_search` calls your configured provider and returns results. - **Brave** (default): returns structured results (title, URL, snippet). - **Perplexity**: returns AI-synthesized answers with citations from real-time web search. + - **Gemini**: returns AI-synthesized answers grounded in Google Search with citations. - Results are cached by query for 15 minutes (configurable). - `web_fetch` does a plain HTTP GET and extracts readable content (HTML → markdown/text). It does **not** execute JavaScript. @@ -33,9 +35,23 @@ These are **not** browser automation. For JS-heavy sites or logins, use the | ------------------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- | | **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` | | **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` | +| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` | See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details. +### Auto-detection + +If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order: + +1. **Brave** — `BRAVE_API_KEY` env var or `search.apiKey` config +2. **Gemini** — `GEMINI_API_KEY` env var or `search.gemini.apiKey` config +3. **Perplexity** — `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `search.perplexity.apiKey` config +4. **Grok** — `XAI_API_KEY` env var or `search.grok.apiKey` config + +If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). + +### Explicit provider + Set the provider in config: ```json5 @@ -43,7 +59,7 @@ Set the provider in config: tools: { web: { search: { - provider: "brave", // or "perplexity" + provider: "brave", // or "perplexity" or "gemini" }, }, }, @@ -139,6 +155,47 @@ If no base URL is set, OpenClaw chooses a default based on the API key source: | `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions | | `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research | +## Using Gemini (Google Search grounding) + +Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding), +which returns AI-synthesized answers backed by live Google Search results with citations. + +### Getting a Gemini API key + +1. Go to [Google AI Studio](https://aistudio.google.com/apikey) +2. Create an API key +3. Set `GEMINI_API_KEY` in the Gateway environment, or configure `tools.web.search.gemini.apiKey` + +### Setting up Gemini search + +```json5 +{ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + // API key (optional if GEMINI_API_KEY is set) + apiKey: "AIza...", + // Model (defaults to "gemini-2.5-flash") + model: "gemini-2.5-flash", + }, + }, + }, + }, +} +``` + +**Environment alternative:** set `GEMINI_API_KEY` in the Gateway environment. +For a gateway install, put it in `~/.openclaw/.env`. + +### Notes + +- Citation URLs from Gemini grounding are automatically resolved from Google's + redirect URLs to direct URLs. +- The default model (`gemini-2.5-flash`) is fast and cost-effective. + Any Gemini model that supports grounding can be used. + ## web_search Search the web using your configured provider. diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index c3a5d7692d0..83b0cece160 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -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 { + 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> { 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, diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts new file mode 100644 index 00000000000..bc366ac8b48 --- /dev/null +++ b/src/config/config.web-search-provider.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { validateConfigObject } from "./config.js"; + +vi.mock("../runtime.js", () => ({ + defaultRuntime: { log: vi.fn(), error: vi.fn() }, +})); + +const { __testing } = await import("../agents/tools/web-search.js"); +const { resolveSearchProvider } = __testing; + +describe("web search provider config", () => { + it("accepts perplexity provider and config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + enabled: true, + provider: "perplexity", + perplexity: { + apiKey: "test-key", + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-pro", + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts gemini provider and config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + gemini: { + apiKey: "test-key", + model: "gemini-2.5-flash", + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts gemini provider with no extra config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + provider: "gemini", + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); + +describe("web search provider auto-detection", () => { + const savedEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.BRAVE_API_KEY; + delete process.env.GEMINI_API_KEY; + delete process.env.PERPLEXITY_API_KEY; + delete process.env.OPENROUTER_API_KEY; + delete process.env.XAI_API_KEY; + }); + + afterEach(() => { + process.env = { ...savedEnv }; + vi.restoreAllMocks(); + }); + + it("falls back to brave when no keys available", () => { + expect(resolveSearchProvider({})).toBe("brave"); + }); + + it("auto-detects brave when only BRAVE_API_KEY is set", () => { + process.env.BRAVE_API_KEY = "test-brave-key"; + expect(resolveSearchProvider({})).toBe("brave"); + }); + + it("auto-detects gemini when only GEMINI_API_KEY is set", () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + expect(resolveSearchProvider({})).toBe("gemini"); + }); + + it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => { + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; + expect(resolveSearchProvider({})).toBe("perplexity"); + }); + + it("auto-detects grok when only XAI_API_KEY is set", () => { + process.env.XAI_API_KEY = "test-xai-key"; + expect(resolveSearchProvider({})).toBe("grok"); + }); + + it("follows priority order — brave wins when multiple keys available", () => { + process.env.BRAVE_API_KEY = "test-brave-key"; + process.env.GEMINI_API_KEY = "test-gemini-key"; + process.env.XAI_API_KEY = "test-xai-key"; + expect(resolveSearchProvider({})).toBe("brave"); + }); + + it("gemini wins over perplexity and grok when brave unavailable", () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; + expect(resolveSearchProvider({})).toBe("gemini"); + }); + + it("explicit provider always wins regardless of keys", () => { + process.env.BRAVE_API_KEY = "test-brave-key"; + expect( + resolveSearchProvider({ provider: "gemini" } as unknown as Parameters< + typeof resolveSearchProvider + >[0]), + ).toBe("gemini"); + }); +}); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4aed9c674ce..3c0ea7d85e3 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -544,11 +544,17 @@ export const FIELD_HELP: Record = { 'Text suffix for cross-context markers (supports "{channel}").', "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", - "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', + "tools.web.search.provider": + 'Search provider ("brave", "perplexity", "grok", or "gemini"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.gemini.apiKey": + "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", + "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', + "tools.web.search.grok.apiKey": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", + "tools.web.search.grok.model": 'Grok model override (default: "grok-3").', "tools.web.search.perplexity.apiKey": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", "tools.web.search.perplexity.baseUrl": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 0f85a61d0b9..1891def7732 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -212,6 +212,10 @@ export const FIELD_LABELS: Record = { "tools.web.search.perplexity.apiKey": "Perplexity API Key", "tools.web.search.perplexity.baseUrl": "Perplexity Base URL", "tools.web.search.perplexity.model": "Perplexity Model", + "tools.web.search.gemini.apiKey": "Gemini Search API Key", + "tools.web.search.gemini.model": "Gemini Search Model", + "tools.web.search.grok.apiKey": "Grok Search API Key", + "tools.web.search.grok.model": "Grok Search Model", "tools.web.fetch.enabled": "Enable Web Fetch Tool", "tools.web.fetch.maxChars": "Web Fetch Max Chars", "tools.web.fetch.maxCharsCap": "Web Fetch Hard Max Chars", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 164eacc6ae0..6366d6581f1 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -430,8 +430,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "perplexity", or "grok"). */ - provider?: "brave" | "perplexity" | "grok"; + /** Search provider ("brave", "perplexity", "grok", or "gemini"). */ + provider?: "brave" | "perplexity" | "grok" | "gemini"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: string; /** Default search results count (1-10). */ @@ -458,6 +458,13 @@ export type ToolsConfig = { /** Include inline citations in response text as markdown links (default: false). */ inlineCitations?: boolean; }; + /** Gemini-specific configuration (used when provider="gemini"). */ + gemini?: { + /** Gemini API key (defaults to GEMINI_API_KEY env var). */ + apiKey?: string; + /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ + model?: string; + }; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 43a2e0ef96d..e88c22614f0 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -239,7 +239,9 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), - provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok")]).optional(), + provider: z + .union([z.literal("brave"), z.literal("perplexity"), z.literal("grok"), z.literal("gemini")]) + .optional(), apiKey: z.string().optional().register(sensitive), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), @@ -260,6 +262,13 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + gemini: z + .object({ + apiKey: z.string().optional().register(sensitive), + model: z.string().optional(), + }) + .strict() + .optional(), }) .strict() .optional();