mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
[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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
127
src/config/config.web-search-provider.test.ts
Normal file
127
src/config/config.web-search-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user