[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

@@ -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:<id>` + 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.

View File

@@ -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.

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,

View File

@@ -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");
});
});

View File

@@ -544,11 +544,17 @@ export const FIELD_HELP: Record<string, string> = {
'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":

View File

@@ -212,6 +212,10 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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). */

View File

@@ -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();