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

View File

@ -1,27 +1,20 @@
---
summary: "Perplexity Sonar setup for web_search"
summary: "Perplexity Search API setup for web_search"
read_when:
- You want to use Perplexity Sonar for web search
- You need PERPLEXITY_API_KEY or OpenRouter setup
- You want to use Perplexity Search for web search
- You need PERPLEXITY_API_KEY setup
---
# Perplexity Sonar
# Perplexity Search API
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect
through Perplexitys direct API or via OpenRouter.
Clawdbot uses Perplexity Search API for the `web_search` tool when `provider: "perplexity"` is set.
Perplexity Search returns structured results (title, URL, snippet) for fast research.
## API options
## Getting a Perplexity API key
### Perplexity (direct)
- Base URL: https://api.perplexity.ai
- Environment variable: `PERPLEXITY_API_KEY`
### OpenRouter (alternative)
- Base URL: https://openrouter.ai/api/v1
- Environment variable: `OPENROUTER_API_KEY`
- Supports prepaid/crypto credits.
1) Create a Perplexity account at https://www.perplexity.ai/settings/api
2) Generate an API key in the dashboard
3) Store the key in config (recommended) or set `PERPLEXITY_API_KEY` in the Gateway environment.
## Config example
@ -32,9 +25,7 @@ through Perplexitys direct API or via OpenRouter.
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro"
apiKey: "pplx-..."
}
}
}
@ -51,8 +42,7 @@ through Perplexitys direct API or via OpenRouter.
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
baseUrl: "https://api.perplexity.ai"
apiKey: "pplx-..."
}
}
}
@ -60,20 +50,20 @@ through Perplexitys direct API or via OpenRouter.
}
```
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
to disambiguate.
## Where to set the key (recommended)
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`)
- `OPENROUTER_API_KEY` or `sk-or-...` → OpenRouter (`https://openrouter.ai/api/v1`)
- Unknown key formats → OpenRouter (safe fallback)
**Environment alternative:** set `PERPLEXITY_API_KEY` in the Gateway process
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).
## Models
## Notes
- `perplexity/sonar` — fast Q&A with web search
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
- `perplexity/sonar-reasoning-pro` — deep research
- Perplexity Search API returns structured results (title, URL, snippet) similar to Brave Search
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`)
- 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.

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:
- 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 need Perplexity or Brave Search API key setup
---
# 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).
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
- `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.
- **Perplexity** (recommended): returns structured results (title, URL, snippet) for fast research.
- **Brave**: returns structured results (title, URL, snippet) with free tier available.
- 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.
@ -30,10 +29,10 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
| Provider | Pros | Cons | API Key |
|----------|------|------|---------|
| **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` |
| **Perplexity** (recommended) | Fast, structured results, high-quality results | Requires Perplexity API access | `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:
@ -49,7 +48,7 @@ Set the provider in config:
}
```
Example: switch to Perplexity Sonar (direct API):
Example: switch to Perplexity Search:
```json5
{
@ -58,9 +57,7 @@ Example: switch to Perplexity Sonar (direct API):
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro"
apiKey: "pplx-..."
}
}
}
@ -86,17 +83,16 @@ current limits and pricing.
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).
## Using Perplexity (direct or via OpenRouter)
## Using Perplexity Search
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized
answers with citations. You can use them via OpenRouter (no credit card required - supports
crypto/prepaid).
Perplexity Search API returns structured search results (title, URL, snippet) for fast research.
It's the recommended provider for web search.
### Getting an OpenRouter API key
### Getting a Perplexity API key
1) Create an account at https://openrouter.ai/
2) Add credits (supports crypto, prepaid, or credit card)
3) Generate an API key in your account settings
1) Create a Perplexity account at https://www.perplexity.ai/settings/api
2) Generate an API key in the dashboard
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
@ -108,12 +104,7 @@ crypto/prepaid).
enabled: true,
provider: "perplexity",
perplexity: {
// API key (optional if OPENROUTER_API_KEY or 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"
apiKey: "pplx-..." // optional if PERPLEXITY_API_KEY is set
}
}
}
@ -121,22 +112,7 @@ crypto/prepaid).
}
```
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway
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 |
**Environment alternative:** set `PERPLEXITY_API_KEY` in the Gateway environment. For a gateway install, put it in `~/.clawdbot/.env`.
## web_search
@ -147,7 +123,7 @@ Search the web using your configured provider.
- `tools.web.search.enabled` must not be `false` (default: enabled)
- API key for your chosen provider:
- **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

View File

@ -2,56 +2,7 @@ import { describe, expect, it } from "vitest";
import { __testing } from "./web-search.js";
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } =
__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",
);
});
});
const { normalizeFreshness } = __testing;
describe("web_search freshness normalization", () => {
it("accepts Brave shortcut values", () => {

View File

@ -22,48 +22,54 @@ const DEFAULT_SEARCH_COUNT = 5;
const MAX_SEARCH_COUNT = 10;
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_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 PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
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 WebSearchSchema = Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
country: Type.Optional(
Type.String({
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
search_lang: Type.Optional(
Type.String({
description: "ISO language code for search results (e.g., 'de', 'en', 'fr').",
}),
),
ui_lang: Type.Optional(
Type.String({
description: "ISO language code for UI elements.",
}),
),
freshness: Type.Optional(
Type.String({
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'.",
}),
),
});
function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) {
const baseSchema = {
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
country: Type.Optional(
Type.String({
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
search_lang: Type.Optional(
Type.String({
description: "ISO language code for search results (e.g., 'de', 'en', 'fr').",
}),
),
ui_lang: Type.Optional(
Type.String({
description: "ISO language code for UI elements.",
}),
),
} as const;
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
? Web extends { search?: infer Search }
@ -86,22 +92,22 @@ type BraveSearchResponse = {
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";
type PerplexityApiKeySource = "config" | "perplexity_env" | "none";
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
citations?: string[];
type PerplexitySearchApiResult = {
title?: string;
url?: string;
snippet?: string;
date?: string;
last_updated?: string;
};
type PerplexityBaseUrlHint = "direct" | "openrouter";
type PerplexitySearchApiResponse = {
results?: PerplexitySearchApiResult[];
id?: string;
};
function resolveSearchConfig(cfg?: ClawdbotConfig): WebSearchConfig {
const search = cfg?.tools?.web?.search;
@ -127,7 +133,7 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
return {
error: "missing_perplexity_api_key",
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",
};
}
@ -169,11 +175,6 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
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" };
}
@ -181,46 +182,6 @@ function normalizeApiKey(key: unknown): string {
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 {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
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;
apiKey: string;
baseUrl: string;
model: string;
count: number;
timeoutSeconds: number;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = `${params.baseUrl.replace(/\/$/, "")}/chat/completions`;
country?: string;
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",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://clawdbot.com",
"X-Title": "Clawdbot Web Search",
},
body: JSON.stringify({
model: params.model,
messages: [
{
role: "user",
content: params.query,
},
],
}),
body: JSON.stringify(body),
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
});
if (!res.ok) {
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 content = data.choices?.[0]?.message?.content ?? "No response";
const citations = data.citations ?? [];
const data = (await res.json()) as PerplexitySearchApiResponse;
const results = Array.isArray(data.results) ? data.results : [];
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: {
@ -317,13 +289,12 @@ async function runWebSearch(params: {
search_lang?: string;
ui_lang?: string;
freshness?: string;
perplexityBaseUrl?: string;
perplexityModel?: string;
searchDomainFilter?: 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}:${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);
if (cached) return { ...cached.value, cached: true };
@ -331,21 +302,21 @@ async function runWebSearch(params: {
const start = Date.now();
if (params.provider === "perplexity") {
const { content, citations } = await runPerplexitySearch({
const results = await runPerplexitySearchApi({
query: params.query,
apiKey: params.apiKey,
baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
count: params.count,
timeoutSeconds: params.timeoutSeconds,
country: params.country,
searchDomainFilter: params.searchDomainFilter,
});
const payload = {
query: params.query,
provider: params.provider,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
count: results.length,
tookMs: Date.now() - start,
content,
citations,
results,
};
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
@ -418,14 +389,14 @@ export function createWebSearchTool(options?: {
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."
? "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.";
return {
label: "Web Search",
name: "web_search",
description,
parameters: WebSearchSchema,
parameters: createWebSearchSchema(provider),
execute: async (_toolCallId, args) => {
const perplexityAuth =
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
@ -443,13 +414,6 @@ export function createWebSearchTool(options?: {
const search_lang = readStringParam(params, "search_lang");
const ui_lang = readStringParam(params, "ui_lang");
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;
if (rawFreshness && !freshness) {
return jsonResult({
@ -470,12 +434,7 @@ export function createWebSearchTool(options?: {
search_lang,
ui_lang,
freshness,
perplexityBaseUrl: resolvePerplexityBaseUrl(
perplexityConfig,
perplexityAuth?.source,
perplexityAuth?.apiKey,
),
perplexityModel: resolvePerplexityModel(perplexityConfig),
searchDomainFilter: undefined, // Could be added as a parameter in the future
});
return jsonResult(result);
},
@ -483,7 +442,6 @@ export function createWebSearchTool(options?: {
}
export const __testing = {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
normalizeFreshness,
SEARCH_CACHE,
} as const;

View File

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
import { __testing as webSearchTesting } from "./web-search.js";
describe("web tools defaults", () => {
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;
afterEach(() => {
vi.unstubAllEnvs();
// @ts-expect-error global fetch cleanup
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");
const mockFetch = vi.fn(() =>
Promise.resolve({
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),
);
// @ts-expect-error mock fetch
@ -148,18 +161,37 @@ describe("web_search perplexity baseUrl defaults", () => {
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
});
await tool?.execute?.(1, { query: "test-openrouter" });
const result = await tool?.execute?.(1, { query: "test" });
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");
const mockFetch = vi.fn(() =>
Promise.resolve({
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),
);
// @ts-expect-error mock fetch
@ -169,19 +201,18 @@ describe("web_search perplexity baseUrl defaults", () => {
config: { tools: { web: { search: { provider: "perplexity" } } } },
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(result?.details).toMatchObject({ error: "unsupported_freshness" });
expect(mockFetch).toHaveBeenCalled();
expect(result?.details).toMatchObject({ provider: "perplexity", count: 1 });
});
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
it("passes country parameter to Perplexity Search API", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
json: () => Promise.resolve({ results: [] }),
} as Response),
);
// @ts-expect-error mock fetch
@ -191,69 +222,18 @@ describe("web_search perplexity baseUrl defaults", () => {
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
});
await tool?.execute?.(1, { query: "test-openrouter-env" });
await tool?.execute?.(1, { query: "test", country: "DE" });
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 () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
it("uses config API key when provided", async () => {
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" } } } },
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: [] }),
json: () => Promise.resolve({ results: [] }),
} as Response),
);
// @ts-expect-error mock fetch
@ -272,38 +252,12 @@ describe("web_search perplexity baseUrl defaults", () => {
},
sandboxed: true,
});
await tool?.execute?.(1, { query: "test-config-apikey" });
await tool?.execute?.(1, { query: "test" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions");
});
it("defaults to OpenRouter when apiKey looks like OpenRouter", async () => {
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");
const headers = mockFetch.mock.calls[0]?.[1]?.headers;
const authHeader =
typeof headers?.get === "function" ? headers.get("Authorization") : headers?.Authorization;
expect(authHeader).toBe("Bearer pplx-config");
});
});

View File

@ -35,7 +35,7 @@ export const CONFIGURE_SECTION_OPTIONS: Array<{
}> = [
{ value: "workspace", label: "Workspace", hint: "Set workspace + sessions" },
{ 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: "daemon",

View File

@ -94,12 +94,18 @@ async function promptWebToolsConfig(
): Promise<ClawdbotConfig> {
const existingSearch = nextConfig.tools?.web?.search;
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(
[
"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",
].join("\n"),
"Web search",
@ -107,7 +113,7 @@ async function promptWebToolsConfig(
const enableSearch = guardCancel(
await confirm({
message: "Enable web_search (Brave Search)?",
message: "Enable web_search?",
initialValue: existingSearch?.enabled ?? hasSearchKey,
}),
runtime,
@ -119,27 +125,81 @@ async function promptWebToolsConfig(
};
if (enableSearch) {
const keyInput = guardCancel(
await text({
message: hasSearchKey
? "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: hasSearchKey ? "Leave blank to keep current" : "BSA...",
const providerChoice = guardCancel(
await select({
message: "Choose web search provider",
options: [
{
value: "perplexity",
label: "Perplexity Search",
hint: "Recommended - structured results, fast",
},
{
value: "brave",
label: "Brave Search",
hint: "Structured results, free tier available",
},
],
initialValue: existingProvider,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key) {
nextSearch = { ...nextSearch, apiKey: key };
} else if (!hasSearchKey) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set BRAVE_API_KEY in the Gateway environment.",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
nextSearch = { ...nextSearch, provider: providerChoice };
if (providerChoice === "perplexity") {
const hasKey = Boolean(existingSearch?.perplexity?.apiKey);
const keyInput = guardCancel(
await text({
message: hasKey
? "Perplexity API key (leave blank to keep current or use PERPLEXITY_API_KEY)"
: "Perplexity API key (paste it here; leave blank to use PERPLEXITY_API_KEY)",
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",
perplexity: {
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.cacheTtlMinutes": "Cache TTL in minutes for web_search results.",
"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":
"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").',
"Perplexity API key (fallback: PERPLEXITY_API_KEY env var).",
"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.timeoutSeconds": "Timeout in seconds for web_fetch requests.",

View File

@ -331,12 +331,8 @@ export type ToolsConfig = {
cacheTtlMinutes?: number;
/** Perplexity-specific configuration (used when provider="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;
/** 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?: {

View File

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

View File

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

View File

@ -432,29 +432,39 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
);
}
const webSearchKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim();
const webSearchEnv = (process.env.BRAVE_API_KEY ?? "").trim();
const webSearchProvider = nextConfig.tools?.web?.search?.provider ?? "brave";
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);
await prompter.note(
hasWebSearchKey
? [
"Web search is enabled, so your agent can look things up online when needed.",
"",
`Provider: ${webSearchProvider === "perplexity" ? "Perplexity Search" : "Brave Search"}`,
webSearchKey
? "API key: stored in config (tools.web.search.apiKey)."
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).",
? `API key: stored in config (tools.web.search.${webSearchProvider === "perplexity" ? "perplexity.apiKey" : "apiKey"}).`
: `API key: provided via ${webSearchProvider === "perplexity" ? "PERPLEXITY_API_KEY" : "BRAVE_API_KEY"} env var (Gateway environment).`,
"Docs: https://docs.clawd.bot/tools/web",
].join("\n")
: [
"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:",
`- 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",
].join("\n"),
"Web search (optional)",