feat: add Perplexity search provider support

This commit is contained in:
Kesku 2026-01-25 14:41:15 -08:00
parent c8063bdcd8
commit ee3a4ec535
14 changed files with 301 additions and 418 deletions

View File

@ -7,7 +7,7 @@ read_when:
# Brave Search API # Brave Search API
Clawdbot uses Brave Search as the default provider for `web_search`. Clawdbot supports Brave Search as a web search provider for `web_search`.
## Get an API key ## Get an API key

View File

@ -1,27 +1,20 @@
--- ---
summary: "Perplexity Sonar setup for web_search" summary: "Perplexity Search API setup for web_search"
read_when: read_when:
- You want to use Perplexity Sonar for web search - You want to use Perplexity Search for web search
- You need PERPLEXITY_API_KEY or OpenRouter setup - You need PERPLEXITY_API_KEY setup
--- ---
# Perplexity Sonar # Perplexity Search API
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect Clawdbot uses Perplexity Search API for the `web_search` tool when `provider: "perplexity"` is set.
through Perplexitys direct API or via OpenRouter. Perplexity Search returns structured results (title, URL, snippet) for fast research.
## API options ## Getting a Perplexity API key
### Perplexity (direct) 1) Create a Perplexity account at https://www.perplexity.ai/settings/api
2) Generate an API key in the dashboard
- Base URL: https://api.perplexity.ai 3) Store the key in config (recommended) or set `PERPLEXITY_API_KEY` in the Gateway environment.
- Environment variable: `PERPLEXITY_API_KEY`
### OpenRouter (alternative)
- Base URL: https://openrouter.ai/api/v1
- Environment variable: `OPENROUTER_API_KEY`
- Supports prepaid/crypto credits.
## Config example ## Config example
@ -32,9 +25,7 @@ through Perplexitys direct API or via OpenRouter.
search: { search: {
provider: "perplexity", provider: "perplexity",
perplexity: { perplexity: {
apiKey: "pplx-...", apiKey: "pplx-..."
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro"
} }
} }
} }
@ -51,8 +42,7 @@ through Perplexitys direct API or via OpenRouter.
search: { search: {
provider: "perplexity", provider: "perplexity",
perplexity: { perplexity: {
apiKey: "pplx-...", apiKey: "pplx-..."
baseUrl: "https://api.perplexity.ai"
} }
} }
} }
@ -60,20 +50,20 @@ through Perplexitys direct API or via OpenRouter.
} }
``` ```
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set ## Where to set the key (recommended)
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
to disambiguate.
If no base URL is set, Clawdbot chooses a default based on the API key source: **Recommended:** run `clawdbot configure --section web`. It stores the key in
`~/.clawdbot/clawdbot.json` under `tools.web.search.perplexity.apiKey`.
- `PERPLEXITY_API_KEY` or `pplx-...` → direct Perplexity (`https://api.perplexity.ai`) **Environment alternative:** set `PERPLEXITY_API_KEY` in the Gateway process
- `OPENROUTER_API_KEY` or `sk-or-...` → OpenRouter (`https://openrouter.ai/api/v1`) environment. For a gateway install, put it in `~/.clawdbot/.env` (or your
- Unknown key formats → OpenRouter (safe fallback) service environment). See [Env vars](/help/faq#how-does-clawdbot-load-environment-variables).
## Models ## Notes
- `perplexity/sonar` — fast Q&A with web search - Perplexity Search API returns structured results (title, URL, snippet) similar to Brave Search
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`)
- `perplexity/sonar-reasoning-pro` — deep research - Supports country-specific search via the `country` parameter
- Supports domain filtering (can be added as a future enhancement)
See [Web tools](/tools/web) for the full web_search configuration. See [Web tools](/tools/web) for the full web_search configuration.

View File

@ -1,16 +1,15 @@
--- ---
summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)" summary: "Web search + fetch tools (Perplexity Search API, Brave Search API)"
read_when: read_when:
- You want to enable web_search or web_fetch - You want to enable web_search or web_fetch
- You need Brave Search API key setup - You need Perplexity or Brave Search API key setup
- You want to use Perplexity Sonar for web search
--- ---
# Web tools # Web tools
Clawdbot ships two lightweight web tools: Clawdbot 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 Perplexity Search API (recommended) or Brave Search API.
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the These are **not** browser automation. For JS-heavy sites or logins, use the
@ -19,8 +18,8 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
## How it works ## How it works
- `web_search` calls your configured provider and returns results. - `web_search` calls your configured provider and returns results.
- **Brave** (default): returns structured results (title, URL, snippet). - **Perplexity** (recommended): returns structured results (title, URL, snippet) for fast research.
- **Perplexity**: returns AI-synthesized answers with citations from real-time web search. - **Brave**: returns structured results (title, URL, snippet) with free tier available.
- Results are cached by query for 15 minutes (configurable). - Results are cached by query for 15 minutes (configurable).
- `web_fetch` does a plain HTTP GET and extracts readable content - `web_fetch` does a plain HTTP GET and extracts readable content
(HTML → markdown/text). It does **not** execute JavaScript. (HTML → markdown/text). It does **not** execute JavaScript.
@ -30,10 +29,10 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
| Provider | Pros | Cons | API Key | | Provider | Pros | Cons | API Key |
|----------|------|------|---------| |----------|------|------|---------|
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` | | **Perplexity** (recommended) | Fast, structured results, high-quality results | Requires Perplexity API access | `PERPLEXITY_API_KEY` |
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` | | **Brave** | Structured results, free tier available | Traditional search results | `BRAVE_API_KEY` |
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details. See [Perplexity Search setup](/perplexity) and [Brave Search setup](/brave-search) for provider-specific details.
Set the provider in config: Set the provider in config:
@ -49,7 +48,7 @@ Set the provider in config:
} }
``` ```
Example: switch to Perplexity Sonar (direct API): Example: switch to Perplexity Search:
```json5 ```json5
{ {
@ -58,9 +57,7 @@ Example: switch to Perplexity Sonar (direct API):
search: { search: {
provider: "perplexity", provider: "perplexity",
perplexity: { perplexity: {
apiKey: "pplx-...", apiKey: "pplx-..."
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro"
} }
} }
} }
@ -86,17 +83,16 @@ current limits and pricing.
environment. For a gateway install, put it in `~/.clawdbot/.env` (or your environment. For a gateway install, put it in `~/.clawdbot/.env` (or your
service environment). See [Env vars](/help/faq#how-does-clawdbot-load-environment-variables). service environment). See [Env vars](/help/faq#how-does-clawdbot-load-environment-variables).
## Using Perplexity (direct or via OpenRouter) ## Using Perplexity Search
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized Perplexity Search API returns structured search results (title, URL, snippet) for fast research.
answers with citations. You can use them via OpenRouter (no credit card required - supports It's the recommended provider for web search.
crypto/prepaid).
### Getting an OpenRouter API key ### Getting a Perplexity API key
1) Create an account at https://openrouter.ai/ 1) Create a Perplexity account at https://www.perplexity.ai/settings/api
2) Add credits (supports crypto, prepaid, or credit card) 2) Generate an API key in the dashboard
3) Generate an API key in your account settings 3) Run `clawdbot configure --section web` to store the key in config (recommended), or set `PERPLEXITY_API_KEY` in your environment.
### Setting up Perplexity search ### Setting up Perplexity search
@ -108,12 +104,7 @@ crypto/prepaid).
enabled: true, enabled: true,
provider: "perplexity", provider: "perplexity",
perplexity: { perplexity: {
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set) apiKey: "pplx-..." // optional if PERPLEXITY_API_KEY is set
apiKey: "sk-or-v1-...",
// Base URL (key-aware default if omitted)
baseUrl: "https://openrouter.ai/api/v1",
// Model (defaults to perplexity/sonar-pro)
model: "perplexity/sonar-pro"
} }
} }
} }
@ -121,22 +112,7 @@ crypto/prepaid).
} }
``` ```
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway **Environment alternative:** set `PERPLEXITY_API_KEY` in the Gateway environment. For a gateway install, put it in `~/.clawdbot/.env`.
environment. For a gateway install, put it in `~/.clawdbot/.env`.
If no base URL is set, Clawdbot chooses a default based on the API key source:
- `PERPLEXITY_API_KEY` or `pplx-...``https://api.perplexity.ai`
- `OPENROUTER_API_KEY` or `sk-or-...``https://openrouter.ai/api/v1`
- Unknown key formats → OpenRouter (safe fallback)
### Available Perplexity models
| Model | Description | Best for |
|-------|-------------|----------|
| `perplexity/sonar` | Fast Q&A with web search | Quick lookups |
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
## web_search ## web_search
@ -147,7 +123,7 @@ Search the web using your configured provider.
- `tools.web.search.enabled` must not be `false` (default: enabled) - `tools.web.search.enabled` must not be `false` (default: enabled)
- API key for your chosen provider: - API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey` - **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey` - **Perplexity**: `PERPLEXITY_API_KEY` or `tools.web.search.perplexity.apiKey`
### Config ### Config

View File

@ -2,56 +2,7 @@ import { describe, expect, it } from "vitest";
import { __testing } from "./web-search.js"; import { __testing } from "./web-search.js";
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } = const { normalizeFreshness } = __testing;
__testing;
describe("web_search perplexity baseUrl defaults", () => {
it("detects a Perplexity key prefix", () => {
expect(inferPerplexityBaseUrlFromApiKey("pplx-123")).toBe("direct");
});
it("detects an OpenRouter key prefix", () => {
expect(inferPerplexityBaseUrlFromApiKey("sk-or-v1-123")).toBe("openrouter");
});
it("returns undefined for unknown key formats", () => {
expect(inferPerplexityBaseUrlFromApiKey("unknown-key")).toBeUndefined();
});
it("prefers explicit baseUrl over key-based defaults", () => {
expect(resolvePerplexityBaseUrl({ baseUrl: "https://example.com" }, "config", "pplx-123")).toBe(
"https://example.com",
);
});
it("defaults to direct when using PERPLEXITY_API_KEY", () => {
expect(resolvePerplexityBaseUrl(undefined, "perplexity_env")).toBe("https://api.perplexity.ai");
});
it("defaults to OpenRouter when using OPENROUTER_API_KEY", () => {
expect(resolvePerplexityBaseUrl(undefined, "openrouter_env")).toBe(
"https://openrouter.ai/api/v1",
);
});
it("defaults to direct when config key looks like Perplexity", () => {
expect(resolvePerplexityBaseUrl(undefined, "config", "pplx-123")).toBe(
"https://api.perplexity.ai",
);
});
it("defaults to OpenRouter when config key looks like OpenRouter", () => {
expect(resolvePerplexityBaseUrl(undefined, "config", "sk-or-v1-123")).toBe(
"https://openrouter.ai/api/v1",
);
});
it("defaults to OpenRouter for unknown config key formats", () => {
expect(resolvePerplexityBaseUrl(undefined, "config", "weird-key")).toBe(
"https://openrouter.ai/api/v1",
);
});
});
describe("web_search freshness normalization", () => { describe("web_search freshness normalization", () => {
it("accepts Brave shortcut values", () => { it("accepts Brave shortcut values", () => {

View File

@ -22,48 +22,54 @@ const DEFAULT_SEARCH_COUNT = 5;
const MAX_SEARCH_COUNT = 10; const MAX_SEARCH_COUNT = 10;
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>(); const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
const WebSearchSchema = Type.Object({ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) {
query: Type.String({ description: "Search query string." }), const baseSchema = {
count: Type.Optional( query: Type.String({ description: "Search query string." }),
Type.Number({ count: Type.Optional(
description: "Number of results to return (1-10).", Type.Number({
minimum: 1, description: "Number of results to return (1-10).",
maximum: MAX_SEARCH_COUNT, minimum: 1,
}), maximum: MAX_SEARCH_COUNT,
), }),
country: Type.Optional( ),
Type.String({ country: Type.Optional(
description: Type.String({
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", description:
}), "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
), }),
search_lang: Type.Optional( ),
Type.String({ search_lang: Type.Optional(
description: "ISO language code for search results (e.g., 'de', 'en', 'fr').", Type.String({
}), description: "ISO language code for search results (e.g., 'de', 'en', 'fr').",
), }),
ui_lang: Type.Optional( ),
Type.String({ ui_lang: Type.Optional(
description: "ISO language code for UI elements.", Type.String({
}), description: "ISO language code for UI elements.",
), }),
freshness: Type.Optional( ),
Type.String({ } as const;
description:
"Filter results by discovery time (Brave only). Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.", if (provider === "brave") {
}), return Type.Object({
), ...baseSchema,
}); freshness: Type.Optional(
Type.String({
description:
"Filter results by discovery time. Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.",
}),
),
});
}
return Type.Object(baseSchema);
}
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search } ? Web extends { search?: infer Search }
@ -86,22 +92,22 @@ type BraveSearchResponse = {
type PerplexityConfig = { type PerplexityConfig = {
apiKey?: string; apiKey?: string;
baseUrl?: string;
model?: string;
}; };
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; type PerplexityApiKeySource = "config" | "perplexity_env" | "none";
type PerplexitySearchResponse = { type PerplexitySearchApiResult = {
choices?: Array<{ title?: string;
message?: { url?: string;
content?: string; snippet?: string;
}; date?: string;
}>; last_updated?: string;
citations?: string[];
}; };
type PerplexityBaseUrlHint = "direct" | "openrouter"; type PerplexitySearchApiResponse = {
results?: PerplexitySearchApiResult[];
id?: string;
};
function resolveSearchConfig(cfg?: ClawdbotConfig): WebSearchConfig { function resolveSearchConfig(cfg?: ClawdbotConfig): WebSearchConfig {
const search = cfg?.tools?.web?.search; const search = cfg?.tools?.web?.search;
@ -127,7 +133,7 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
return { return {
error: "missing_perplexity_api_key", error: "missing_perplexity_api_key",
message: message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.clawd.bot/tools/web", docs: "https://docs.clawd.bot/tools/web",
}; };
} }
@ -169,11 +175,6 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
return { apiKey: fromEnvPerplexity, source: "perplexity_env" }; return { apiKey: fromEnvPerplexity, source: "perplexity_env" };
} }
const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY);
if (fromEnvOpenRouter) {
return { apiKey: fromEnvOpenRouter, source: "openrouter_env" };
}
return { apiKey: undefined, source: "none" }; return { apiKey: undefined, source: "none" };
} }
@ -181,46 +182,6 @@ function normalizeApiKey(key: unknown): string {
return typeof key === "string" ? key.trim() : ""; return typeof key === "string" ? key.trim() : "";
} }
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined {
if (!apiKey) return undefined;
const normalized = apiKey.toLowerCase();
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
function resolvePerplexityBaseUrl(
perplexity?: PerplexityConfig,
apiKeySource: PerplexityApiKeySource = "none",
apiKey?: string,
): string {
const fromConfig =
perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string"
? perplexity.baseUrl.trim()
: "";
if (fromConfig) return fromConfig;
if (apiKeySource === "perplexity_env") return PERPLEXITY_DIRECT_BASE_URL;
if (apiKeySource === "openrouter_env") return DEFAULT_PERPLEXITY_BASE_URL;
if (apiKeySource === "config") {
const inferred = inferPerplexityBaseUrlFromApiKey(apiKey);
if (inferred === "direct") return PERPLEXITY_DIRECT_BASE_URL;
if (inferred === "openrouter") return DEFAULT_PERPLEXITY_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const fromConfig =
perplexity && "model" in perplexity && typeof perplexity.model === "string"
? perplexity.model.trim()
: "";
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
}
function resolveSearchCount(value: unknown, fallback: number): number { function resolveSearchCount(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback; const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed))); const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
@ -265,45 +226,56 @@ function resolveSiteName(url: string | undefined): string | undefined {
} }
} }
async function runPerplexitySearch(params: { async function runPerplexitySearchApi(params: {
query: string; query: string;
apiKey: string; apiKey: string;
baseUrl: string; count: number;
model: string;
timeoutSeconds: number; timeoutSeconds: number;
}): Promise<{ content: string; citations: string[] }> { country?: string;
const endpoint = `${params.baseUrl.replace(/\/$/, "")}/chat/completions`; searchDomainFilter?: string[];
}): Promise<
Array<{ title: string; url: string; description: string; published?: string; siteName?: string }>
> {
const body: Record<string, unknown> = {
query: params.query,
max_results: params.count,
};
const res = await fetch(endpoint, { if (params.country) {
body.country = params.country;
}
if (params.searchDomainFilter && params.searchDomainFilter.length > 0) {
// Perplexity Search API accepts domain filter as array
body.search_domain_filter = params.searchDomainFilter;
}
const res = await fetch(PERPLEXITY_SEARCH_ENDPOINT, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${params.apiKey}`, Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://clawdbot.com",
"X-Title": "Clawdbot Web Search",
}, },
body: JSON.stringify({ body: JSON.stringify(body),
model: params.model,
messages: [
{
role: "user",
content: params.query,
},
],
}),
signal: withTimeout(undefined, params.timeoutSeconds * 1000), signal: withTimeout(undefined, params.timeoutSeconds * 1000),
}); });
if (!res.ok) { if (!res.ok) {
const detail = await readResponseText(res); const detail = await readResponseText(res);
throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`); throw new Error(`Perplexity Search API error (${res.status}): ${detail || res.statusText}`);
} }
const data = (await res.json()) as PerplexitySearchResponse; const data = (await res.json()) as PerplexitySearchApiResponse;
const content = data.choices?.[0]?.message?.content ?? "No response"; const results = Array.isArray(data.results) ? data.results : [];
const citations = data.citations ?? [];
return { content, citations }; // Map to match Brave's format
return results.map((entry) => ({
title: entry.title ?? "",
url: entry.url ?? "",
description: entry.snippet ?? "",
published: entry.date ?? undefined,
siteName: resolveSiteName(entry.url ?? ""),
}));
} }
async function runWebSearch(params: { async function runWebSearch(params: {
@ -317,13 +289,12 @@ async function runWebSearch(params: {
search_lang?: string; search_lang?: string;
ui_lang?: string; ui_lang?: string;
freshness?: string; freshness?: string;
perplexityBaseUrl?: string; searchDomainFilter?: string[];
perplexityModel?: string;
}): Promise<Record<string, unknown>> { }): Promise<Record<string, unknown>> {
const cacheKey = normalizeCacheKey( const cacheKey = normalizeCacheKey(
params.provider === "brave" 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}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
: `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`, : `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.searchDomainFilter?.join(",") || "default"}`,
); );
const cached = readCache(SEARCH_CACHE, cacheKey); const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true }; if (cached) return { ...cached.value, cached: true };
@ -331,21 +302,21 @@ async function runWebSearch(params: {
const start = Date.now(); const start = Date.now();
if (params.provider === "perplexity") { if (params.provider === "perplexity") {
const { content, citations } = await runPerplexitySearch({ const results = await runPerplexitySearchApi({
query: params.query, query: params.query,
apiKey: params.apiKey, apiKey: params.apiKey,
baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL, count: params.count,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
timeoutSeconds: params.timeoutSeconds, timeoutSeconds: params.timeoutSeconds,
country: params.country,
searchDomainFilter: params.searchDomainFilter,
}); });
const payload = { const payload = {
query: params.query, query: params.query,
provider: params.provider, provider: params.provider,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, count: results.length,
tookMs: Date.now() - start, tookMs: Date.now() - start,
content, results,
citations,
}; };
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload; return payload;
@ -418,14 +389,14 @@ export function createWebSearchTool(options?: {
const description = const description =
provider === "perplexity" provider === "perplexity"
? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search." ? "Search the web using Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports region-specific search and domain filtering."
: "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."; : "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 { return {
label: "Web Search", label: "Web Search",
name: "web_search", name: "web_search",
description, description,
parameters: WebSearchSchema, parameters: createWebSearchSchema(provider),
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const perplexityAuth = const perplexityAuth =
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined; provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
@ -443,13 +414,6 @@ export function createWebSearchTool(options?: {
const search_lang = readStringParam(params, "search_lang"); const search_lang = readStringParam(params, "search_lang");
const ui_lang = readStringParam(params, "ui_lang"); const ui_lang = readStringParam(params, "ui_lang");
const rawFreshness = readStringParam(params, "freshness"); const rawFreshness = readStringParam(params, "freshness");
if (rawFreshness && provider !== "brave") {
return jsonResult({
error: "unsupported_freshness",
message: "freshness is only supported by the Brave web_search provider.",
docs: "https://docs.clawd.bot/tools/web",
});
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined; const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined;
if (rawFreshness && !freshness) { if (rawFreshness && !freshness) {
return jsonResult({ return jsonResult({
@ -470,12 +434,7 @@ export function createWebSearchTool(options?: {
search_lang, search_lang,
ui_lang, ui_lang,
freshness, freshness,
perplexityBaseUrl: resolvePerplexityBaseUrl( searchDomainFilter: undefined, // Could be added as a parameter in the future
perplexityConfig,
perplexityAuth?.source,
perplexityAuth?.apiKey,
),
perplexityModel: resolvePerplexityModel(perplexityConfig),
}); });
return jsonResult(result); return jsonResult(result);
}, },
@ -483,7 +442,6 @@ export function createWebSearchTool(options?: {
} }
export const __testing = { export const __testing = {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
normalizeFreshness, normalizeFreshness,
SEARCH_CACHE,
} as const; } as const;

View File

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js"; import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
import { __testing as webSearchTesting } from "./web-search.js";
describe("web tools defaults", () => { describe("web tools defaults", () => {
it("enables web_fetch by default (non-sandbox)", () => { it("enables web_fetch by default (non-sandbox)", () => {
@ -124,21 +125,33 @@ describe("web_search country and language parameters", () => {
}); });
}); });
describe("web_search perplexity baseUrl defaults", () => { describe("web_search perplexity Search API", () => {
const priorFetch = global.fetch; const priorFetch = global.fetch;
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
// @ts-expect-error global fetch cleanup // @ts-expect-error global fetch cleanup
global.fetch = priorFetch; global.fetch = priorFetch;
// Clear search cache to prevent test pollution
webSearchTesting.SEARCH_CACHE.clear();
}); });
it("defaults to Perplexity direct when PERPLEXITY_API_KEY is set", async () => { it("uses Perplexity Search API when PERPLEXITY_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test"); vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() => const mockFetch = vi.fn(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), json: () =>
Promise.resolve({
results: [
{
title: "Test",
url: "https://example.com",
snippet: "Test snippet",
date: "2024-01-01",
},
],
}),
} as Response), } as Response),
); );
// @ts-expect-error mock fetch // @ts-expect-error mock fetch
@ -148,18 +161,37 @@ describe("web_search perplexity baseUrl defaults", () => {
config: { tools: { web: { search: { provider: "perplexity" } } } }, config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true, sandboxed: true,
}); });
await tool?.execute?.(1, { query: "test-openrouter" }); const result = await tool?.execute?.(1, { query: "test" });
expect(mockFetch).toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions"); expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/search");
expect(mockFetch.mock.calls[0]?.[1]?.method).toBe("POST");
const body = JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string);
expect(body.query).toBe("test");
expect(result?.details).toMatchObject({
provider: "perplexity",
results: expect.arrayContaining([
expect.objectContaining({ title: "Test", url: "https://example.com" }),
]),
});
}); });
it("rejects freshness for Perplexity provider", async () => { it("does not include freshness parameter for Perplexity provider", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test"); vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() => const mockFetch = vi.fn(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), json: () =>
Promise.resolve({
results: [
{
title: "Test",
url: "https://example.com",
snippet: "Test snippet",
date: "2024-01-01",
},
],
}),
} as Response), } as Response),
); );
// @ts-expect-error mock fetch // @ts-expect-error mock fetch
@ -169,19 +201,18 @@ describe("web_search perplexity baseUrl defaults", () => {
config: { tools: { web: { search: { provider: "perplexity" } } } }, config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true, sandboxed: true,
}); });
const result = await tool?.execute?.(1, { query: "test", freshness: "pw" }); const result = await tool?.execute?.(1, { query: "test" });
expect(mockFetch).not.toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "unsupported_freshness" }); expect(result?.details).toMatchObject({ provider: "perplexity", count: 1 });
}); });
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => { it("passes country parameter to Perplexity Search API", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", ""); vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
const mockFetch = vi.fn(() => const mockFetch = vi.fn(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), json: () => Promise.resolve({ results: [] }),
} as Response), } as Response),
); );
// @ts-expect-error mock fetch // @ts-expect-error mock fetch
@ -191,69 +222,18 @@ describe("web_search perplexity baseUrl defaults", () => {
config: { tools: { web: { search: { provider: "perplexity" } } } }, config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true, sandboxed: true,
}); });
await tool?.execute?.(1, { query: "test-openrouter-env" }); await tool?.execute?.(1, { query: "test", country: "DE" });
expect(mockFetch).toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions"); const body = JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string);
expect(body.country).toBe("DE");
}); });
it("prefers PERPLEXITY_API_KEY when both env keys are set", async () => { it("uses config API key when provided", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
const mockFetch = vi.fn(() => const mockFetch = vi.fn(() =>
Promise.resolve({ Promise.resolve({
ok: true, ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), json: () => Promise.resolve({ results: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
});
await tool?.execute?.(1, { query: "test-both-env" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions");
});
it("uses configured baseUrl even when PERPLEXITY_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
perplexity: { baseUrl: "https://example.com/pplx" },
},
},
},
},
sandboxed: true,
});
await tool?.execute?.(1, { query: "test-config-baseurl" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/pplx/chat/completions");
});
it("defaults to Perplexity direct when apiKey looks like Perplexity", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response), } as Response),
); );
// @ts-expect-error mock fetch // @ts-expect-error mock fetch
@ -272,38 +252,12 @@ describe("web_search perplexity baseUrl defaults", () => {
}, },
sandboxed: true, sandboxed: true,
}); });
await tool?.execute?.(1, { query: "test-config-apikey" }); await tool?.execute?.(1, { query: "test" });
expect(mockFetch).toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions"); const headers = mockFetch.mock.calls[0]?.[1]?.headers;
}); const authHeader =
typeof headers?.get === "function" ? headers.get("Authorization") : headers?.Authorization;
it("defaults to OpenRouter when apiKey looks like OpenRouter", async () => { expect(authHeader).toBe("Bearer pplx-config");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
perplexity: { apiKey: "sk-or-v1-test" },
},
},
},
},
sandboxed: true,
});
await tool?.execute?.(1, { query: "test-openrouter-config" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions");
}); });
}); });

View File

@ -35,7 +35,7 @@ export const CONFIGURE_SECTION_OPTIONS: Array<{
}> = [ }> = [
{ value: "workspace", label: "Workspace", hint: "Set workspace + sessions" }, { value: "workspace", label: "Workspace", hint: "Set workspace + sessions" },
{ value: "model", label: "Model", hint: "Pick provider + credentials" }, { value: "model", label: "Model", hint: "Pick provider + credentials" },
{ value: "web", label: "Web tools", hint: "Configure Brave search + fetch" }, { value: "web", label: "Web tools", hint: "Configure web search (Perplexity/Brave) + fetch" },
{ value: "gateway", label: "Gateway", hint: "Port, bind, auth, tailscale" }, { value: "gateway", label: "Gateway", hint: "Port, bind, auth, tailscale" },
{ {
value: "daemon", value: "daemon",

View File

@ -94,12 +94,18 @@ async function promptWebToolsConfig(
): Promise<ClawdbotConfig> { ): Promise<ClawdbotConfig> {
const existingSearch = nextConfig.tools?.web?.search; const existingSearch = nextConfig.tools?.web?.search;
const existingFetch = nextConfig.tools?.web?.fetch; const existingFetch = nextConfig.tools?.web?.fetch;
const hasSearchKey = Boolean(existingSearch?.apiKey); const existingProvider = existingSearch?.provider ?? "brave";
const hasPerplexityKey = Boolean(
existingSearch?.perplexity?.apiKey || process.env.PERPLEXITY_API_KEY,
);
const hasBraveKey = Boolean(existingSearch?.apiKey || process.env.BRAVE_API_KEY);
const hasSearchKey = existingProvider === "perplexity" ? hasPerplexityKey : hasBraveKey;
note( note(
[ [
"Web search lets your agent look things up online using the `web_search` tool.", "Web search lets your agent look things up online using the `web_search` tool.",
"It requires a Brave Search API key (you can store it in the config or set BRAVE_API_KEY in the Gateway environment).", "Choose a provider: Perplexity Search (recommended) or Brave Search.",
"Both return structured results (title, URL, snippet) for fast research.",
"Docs: https://docs.clawd.bot/tools/web", "Docs: https://docs.clawd.bot/tools/web",
].join("\n"), ].join("\n"),
"Web search", "Web search",
@ -107,7 +113,7 @@ async function promptWebToolsConfig(
const enableSearch = guardCancel( const enableSearch = guardCancel(
await confirm({ await confirm({
message: "Enable web_search (Brave Search)?", message: "Enable web_search?",
initialValue: existingSearch?.enabled ?? hasSearchKey, initialValue: existingSearch?.enabled ?? hasSearchKey,
}), }),
runtime, runtime,
@ -119,27 +125,81 @@ async function promptWebToolsConfig(
}; };
if (enableSearch) { if (enableSearch) {
const keyInput = guardCancel( const providerChoice = guardCancel(
await text({ await select({
message: hasSearchKey message: "Choose web search provider",
? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)" options: [
: "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)", {
placeholder: hasSearchKey ? "Leave blank to keep current" : "BSA...", value: "perplexity",
label: "Perplexity Search",
hint: "Recommended - structured results, fast",
},
{
value: "brave",
label: "Brave Search",
hint: "Structured results, free tier available",
},
],
initialValue: existingProvider,
}), }),
runtime, runtime,
); );
const key = String(keyInput ?? "").trim();
if (key) { nextSearch = { ...nextSearch, provider: providerChoice };
nextSearch = { ...nextSearch, apiKey: key };
} else if (!hasSearchKey) { if (providerChoice === "perplexity") {
note( const hasKey = Boolean(existingSearch?.perplexity?.apiKey);
[ const keyInput = guardCancel(
"No key stored yet, so web_search will stay unavailable.", await text({
"Store a key here or set BRAVE_API_KEY in the Gateway environment.", message: hasKey
"Docs: https://docs.clawd.bot/tools/web", ? "Perplexity API key (leave blank to keep current or use PERPLEXITY_API_KEY)"
].join("\n"), : "Perplexity API key (paste it here; leave blank to use PERPLEXITY_API_KEY)",
"Web search", placeholder: hasKey ? "Leave blank to keep current" : "pplx-...",
}),
runtime,
); );
const key = String(keyInput ?? "").trim();
if (key) {
nextSearch = {
...nextSearch,
perplexity: { ...existingSearch?.perplexity, apiKey: key },
};
} else if (!hasKey && !process.env.PERPLEXITY_API_KEY) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set PERPLEXITY_API_KEY in the Gateway environment.",
"Get your API key at: https://www.perplexity.ai/settings/api",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
);
}
} else {
const hasKey = Boolean(existingSearch?.apiKey);
const keyInput = guardCancel(
await text({
message: hasKey
? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)"
: "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)",
placeholder: hasKey ? "Leave blank to keep current" : "BSA...",
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key) {
nextSearch = { ...nextSearch, apiKey: key };
} else if (!hasKey && !process.env.BRAVE_API_KEY) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set BRAVE_API_KEY in the Gateway environment.",
"Get your API key at: https://brave.com/search/api/",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
);
}
} }
} }

View File

@ -12,8 +12,6 @@ describe("web search provider config", () => {
provider: "perplexity", provider: "perplexity",
perplexity: { perplexity: {
apiKey: "test-key", apiKey: "test-key",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
}, },
}, },
}, },

View File

@ -428,11 +428,7 @@ const FIELD_HELP: Record<string, string> = {
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "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.cacheTtlMinutes": "Cache TTL in minutes for web_search results.",
"tools.web.search.perplexity.apiKey": "tools.web.search.perplexity.apiKey":
"Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", "Perplexity API key (fallback: PERPLEXITY_API_KEY env var).",
"tools.web.search.perplexity.baseUrl":
"Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).",
"tools.web.search.perplexity.model":
'Perplexity model override (default: "perplexity/sonar-pro").',
"tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).",
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
"tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.",

View File

@ -331,12 +331,8 @@ export type ToolsConfig = {
cacheTtlMinutes?: number; cacheTtlMinutes?: number;
/** Perplexity-specific configuration (used when provider="perplexity"). */ /** Perplexity-specific configuration (used when provider="perplexity"). */
perplexity?: { perplexity?: {
/** API key for Perplexity or OpenRouter (defaults to PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). */ /** API key for Perplexity (defaults to PERPLEXITY_API_KEY env var). */
apiKey?: string; apiKey?: string;
/** Base URL for API requests (defaults to OpenRouter: https://openrouter.ai/api/v1). */
baseUrl?: string;
/** Model to use (defaults to "perplexity/sonar-pro"). */
model?: string;
}; };
}; };
fetch?: { fetch?: {

View File

@ -166,8 +166,6 @@ export const ToolsWebSearchSchema = z
perplexity: z perplexity: z
.object({ .object({
apiKey: z.string().optional(), apiKey: z.string().optional(),
baseUrl: z.string().optional(),
model: z.string().optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@ -436,11 +436,7 @@ function resolveToolPolicies(params: {
function hasWebSearchKey(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean { function hasWebSearchKey(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): boolean {
const search = cfg.tools?.web?.search; const search = cfg.tools?.web?.search;
return Boolean( return Boolean(
search?.apiKey || search?.apiKey || search?.perplexity?.apiKey || env.BRAVE_API_KEY || env.PERPLEXITY_API_KEY,
search?.perplexity?.apiKey ||
env.BRAVE_API_KEY ||
env.PERPLEXITY_API_KEY ||
env.OPENROUTER_API_KEY,
); );
} }

View File

@ -432,29 +432,39 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
); );
} }
const webSearchKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim(); const webSearchProvider = nextConfig.tools?.web?.search?.provider ?? "brave";
const webSearchEnv = (process.env.BRAVE_API_KEY ?? "").trim(); const webSearchKey =
webSearchProvider === "perplexity"
? (nextConfig.tools?.web?.search?.perplexity?.apiKey ?? "").trim()
: (nextConfig.tools?.web?.search?.apiKey ?? "").trim();
const webSearchEnv =
webSearchProvider === "perplexity"
? (process.env.PERPLEXITY_API_KEY ?? "").trim()
: (process.env.BRAVE_API_KEY ?? "").trim();
const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv); const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv);
await prompter.note( await prompter.note(
hasWebSearchKey hasWebSearchKey
? [ ? [
"Web search is enabled, so your agent can look things up online when needed.", "Web search is enabled, so your agent can look things up online when needed.",
"", "",
`Provider: ${webSearchProvider === "perplexity" ? "Perplexity Search" : "Brave Search"}`,
webSearchKey webSearchKey
? "API key: stored in config (tools.web.search.apiKey)." ? `API key: stored in config (tools.web.search.${webSearchProvider === "perplexity" ? "perplexity.apiKey" : "apiKey"}).`
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).", : `API key: provided via ${webSearchProvider === "perplexity" ? "PERPLEXITY_API_KEY" : "BRAVE_API_KEY"} env var (Gateway environment).`,
"Docs: https://docs.clawd.bot/tools/web", "Docs: https://docs.clawd.bot/tools/web",
].join("\n") ].join("\n")
: [ : [
"If you want your agent to be able to search the web, youll need an API key.", "If you want your agent to be able to search the web, youll need an API key.",
"", "",
"Clawdbot uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search wont work.", "Clawdbot supports two web search providers:",
"- Perplexity Search (recommended) - structured results, fast",
"- Brave Search - structured results, free tier available",
"", "",
"Set it up interactively:", "Set it up interactively:",
`- Run: ${formatCliCommand("clawdbot configure --section web")}`, `- Run: ${formatCliCommand("clawdbot configure --section web")}`,
"- Enable web_search and paste your Brave Search API key", "- Choose a provider and paste your API key",
"", "",
"Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).", "Alternative: set PERPLEXITY_API_KEY or BRAVE_API_KEY in the Gateway environment (no config changes).",
"Docs: https://docs.clawd.bot/tools/web", "Docs: https://docs.clawd.bot/tools/web",
].join("\n"), ].join("\n"),
"Web search (optional)", "Web search (optional)",