fix(web-search): restore OpenRouter compatibility for Perplexity (#39937) (#39937)

This commit is contained in:
Ayaan Zaidi
2026-03-08 20:37:54 +05:30
committed by GitHub
parent d9e8e8ac15
commit 28e46d04e5
9 changed files with 636 additions and 86 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.
- macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.
- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek.
- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
## 2026.3.7

View File

@@ -1,8 +1,8 @@
---
summary: "Perplexity Search API setup for web_search"
summary: "Perplexity Search API and Sonar/OpenRouter compatibility for web_search"
read_when:
- You want to use Perplexity Search for web search
- You need PERPLEXITY_API_KEY setup
- You need PERPLEXITY_API_KEY or OPENROUTER_API_KEY setup
title: "Perplexity Search"
---
@@ -11,13 +11,27 @@ title: "Perplexity Search"
OpenClaw supports Perplexity Search API as a `web_search` provider.
It returns structured results with `title`, `url`, and `snippet` fields.
For compatibility, OpenClaw also supports legacy Perplexity Sonar/OpenRouter setups.
If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplexity.apiKey`, or set `tools.web.search.perplexity.baseUrl` / `model`, the provider switches to the chat-completions path and returns AI-synthesized answers with citations instead of structured Search API results.
## Getting a Perplexity API key
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 or set `PERPLEXITY_API_KEY` in the Gateway environment.
## Config example
## OpenRouter compatibility
If you were already using OpenRouter for Perplexity Sonar, keep `provider: "perplexity"` and set `OPENROUTER_API_KEY` in the Gateway environment, or store an `sk-or-...` key in `tools.web.search.perplexity.apiKey`.
Optional legacy controls:
- `tools.web.search.perplexity.baseUrl`
- `tools.web.search.perplexity.model`
## Config examples
### Native Perplexity Search API
```json5
{
@@ -34,17 +48,38 @@ It returns structured results with `title`, `url`, and `snippet` fields.
}
```
### OpenRouter / Sonar compatibility
```json5
{
tools: {
web: {
search: {
provider: "perplexity",
perplexity: {
apiKey: "sk-or-v1-...",
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
},
},
},
},
}
```
## Where to set the key
**Via config:** run `openclaw configure --section web`. It stores the key in
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
**Via environment:** set `PERPLEXITY_API_KEY` in the Gateway process
environment. For a gateway install, put it in `~/.openclaw/.env` (or your
service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
in the Gateway process environment. For a gateway install, put it in
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
## Tool parameters
These parameters apply to the native Perplexity Search API path.
| Parameter | Description |
| --------------------- | ---------------------------------------------------- |
| `query` | Search query (required) |
@@ -58,6 +93,9 @@ service environment). See [Env vars](/help/faq#how-does-openclaw-load-environmen
| `max_tokens` | Total content budget (default: 25000, max: 1000000) |
| `max_tokens_per_page` | Per-page token limit (default: 2048) |
For the legacy Sonar/OpenRouter compatibility path, only `query` and `freshness` are supported.
Search API-only filters such as `country`, `language`, `date_after`, `date_before`, `domain_filter`, `max_tokens`, and `max_tokens_per_page` return explicit errors.
**Examples:**
```javascript
@@ -110,6 +148,7 @@ await web_search({
## Notes
- Perplexity Search API returns structured web search results (`title`, `url`, `snippet`)
- OpenRouter or explicit `baseUrl` / `model` switches Perplexity back to Sonar chat completions for compatibility
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`)
See [Web tools](/tools/web) for the full web_search configuration.

View File

@@ -29,13 +29,13 @@ See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexit
## Choosing a search provider
| Provider | Result shape | Provider-specific filters | Notes | API key |
| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------ | ----------------------------------- |
| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` |
| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` |
| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` |
| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls | `PERPLEXITY_API_KEY` |
| Provider | Result shape | Provider-specific filters | Notes | API key |
| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` |
| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` |
| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` |
| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
### Auto-detection
@@ -44,7 +44,7 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
1. **Brave**`BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
2. **Gemini**`GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
3. **Kimi**`KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
4. **Perplexity**`PERPLEXITY_API_KEY` env var or `tools.web.search.perplexity.apiKey` config
4. **Perplexity**`PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config
5. **Grok**`XAI_API_KEY` env var or `tools.web.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).
@@ -67,13 +67,15 @@ Brave provides paid plans; check the Brave API portal for the current limits and
2. Generate an API key in the dashboard
3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment.
For legacy Sonar/OpenRouter compatibility, set `OPENROUTER_API_KEY` instead, or configure `tools.web.search.perplexity.apiKey` with an `sk-or-...` key. Setting `tools.web.search.perplexity.baseUrl` or `model` also opts Perplexity back into the chat-completions compatibility path.
See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quickstart) for more details.
### Where to store the key
**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider.
**Via environment:** set `PERPLEXITY_API_KEY` or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
### Config examples
@@ -134,6 +136,26 @@ In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`
}
```
**Perplexity via OpenRouter / Sonar compatibility:**
```json5
{
tools: {
web: {
search: {
enabled: true,
provider: "perplexity",
perplexity: {
apiKey: "sk-or-v1-...", // optional if OPENROUTER_API_KEY is set
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
},
},
},
},
}
```
## Using Gemini (Google Search grounding)
Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding),
@@ -186,10 +208,10 @@ 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**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY` or `tools.web.search.perplexity.apiKey`
### Config
@@ -211,7 +233,10 @@ Search the web using your configured provider.
### Tool parameters
All parameters work for both Brave and Perplexity unless noted.
All parameters work for Brave and for native Perplexity Search API unless noted.
Perplexity's OpenRouter / Sonar compatibility path supports only `query` and `freshness`.
If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key, Search API-only filters return explicit errors.
| Parameter | Description |
| --------------------- | ----------------------------------------------------- |

View File

@@ -3,6 +3,13 @@ import { withEnv } from "../../test-utils/env.js";
import { __testing } from "./web-search.js";
const {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeBraveLanguageParams,
normalizeFreshness,
normalizeToIsoDate,
@@ -21,6 +28,82 @@ const {
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_");
describe("web_search perplexity compatibility routing", () => {
it("detects API key prefixes", () => {
expect(inferPerplexityBaseUrlFromApiKey("pplx-123")).toBe("direct");
expect(inferPerplexityBaseUrlFromApiKey("sk-or-v1-123")).toBe("openrouter");
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("resolves OpenRouter env auth and transport", () => {
withEnv({ PERPLEXITY_API_KEY: undefined, OPENROUTER_API_KEY: "sk-or-v1-test" }, () => {
expect(resolvePerplexityApiKey(undefined)).toEqual({
apiKey: "sk-or-v1-test",
source: "openrouter_env",
});
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
transport: "chat_completions",
});
});
});
it("uses native Search API for direct Perplexity when no legacy overrides exist", () => {
withEnv({ PERPLEXITY_API_KEY: "pplx-test", OPENROUTER_API_KEY: undefined }, () => {
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
transport: "search_api",
});
});
});
it("switches direct Perplexity to chat completions when model override is configured", () => {
expect(resolvePerplexityModel({ model: "perplexity/sonar-reasoning-pro" })).toBe(
"perplexity/sonar-reasoning-pro",
);
expect(
resolvePerplexityTransport({
apiKey: "pplx-test",
model: "perplexity/sonar-reasoning-pro",
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-reasoning-pro",
transport: "chat_completions",
});
});
it("treats unrecognized configured keys as direct Perplexity by default", () => {
expect(
resolvePerplexityTransport({
apiKey: "enterprise-perplexity-test",
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
transport: "search_api",
});
});
it("normalizes direct Perplexity models for chat completions", () => {
expect(isDirectPerplexityBaseUrl("https://api.perplexity.ai")).toBe(true);
expect(isDirectPerplexityBaseUrl("https://openrouter.ai/api/v1")).toBe(false);
expect(resolvePerplexityRequestModel("https://api.perplexity.ai", "perplexity/sonar-pro")).toBe(
"sonar-pro",
);
expect(
resolvePerplexityRequestModel("https://openrouter.ai/api/v1", "perplexity/sonar-pro"),
).toBe("perplexity/sonar-pro");
});
});
describe("web_search brave language param normalization", () => {
it("normalizes and auto-corrects swapped Brave language params", () => {
expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({

View File

@@ -27,7 +27,12 @@ const MAX_SEARCH_COUNT = 10;
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
const DEFAULT_GROK_MODEL = "grok-4-1-fast";
@@ -144,8 +149,11 @@ function normalizeToIsoDate(value: string): string | undefined {
return undefined;
}
function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) {
const baseSchema = {
function createWebSearchSchema(params: {
provider: (typeof SEARCH_PROVIDERS)[number];
perplexityTransport?: PerplexityTransport;
}) {
const querySchema = {
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
@@ -154,6 +162,9 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) {
maximum: MAX_SEARCH_COUNT,
}),
),
} as const;
const filterSchema = {
country: Type.Optional(
Type.String({
description:
@@ -182,9 +193,10 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) {
),
} as const;
if (provider === "brave") {
if (params.provider === "brave") {
return Type.Object({
...baseSchema,
...querySchema,
...filterSchema,
search_lang: Type.Optional(
Type.String({
description:
@@ -200,25 +212,34 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) {
});
}
if (provider === "perplexity") {
if (params.provider === "perplexity") {
if (params.perplexityTransport === "chat_completions") {
return Type.Object({
...querySchema,
freshness: filterSchema.freshness,
});
}
return Type.Object({
...baseSchema,
...querySchema,
...filterSchema,
domain_filter: Type.Optional(
Type.Array(Type.String(), {
description:
"Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.",
"Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.",
}),
),
max_tokens: Type.Optional(
Type.Number({
description: "Total content budget across all results (default: 25000, max: 1000000).",
description:
"Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).",
minimum: 1,
maximum: 1000000,
}),
),
max_tokens_per_page: Type.Optional(
Type.Number({
description: "Max tokens extracted per page (default: 2048).",
description:
"Native Perplexity Search API only. Max tokens extracted per page (default: 2048).",
minimum: 1,
}),
),
@@ -226,7 +247,10 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) {
}
// grok, gemini, kimi, etc.
return Type.Object(baseSchema);
return Type.Object({
...querySchema,
...filterSchema,
});
}
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
@@ -261,9 +285,13 @@ type BraveConfig = {
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type PerplexityApiKeySource = "config" | "perplexity_env" | "none";
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";
type PerplexityTransport = "search_api" | "chat_completions";
type PerplexityBaseUrlHint = "direct" | "openrouter";
type GrokConfig = {
apiKey?: string;
@@ -336,6 +364,15 @@ type KimiSearchResponse = {
}>;
};
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
citations?: string[];
};
type PerplexitySearchApiResult = {
title?: string;
url?: string;
@@ -459,7 +496,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 in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@@ -517,7 +554,30 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
// Auto-detect provider from available API keys (priority order)
if (raw === "") {
// 1. Perplexity
// 1. Brave
if (resolveSearchApiKey(search)) {
logVerbose(
'web_search: no provider configured, auto-detected "brave" from available API keys',
);
return "brave";
}
// 2. Gemini
const geminiConfig = resolveGeminiConfig(search);
if (resolveGeminiApiKey(geminiConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "gemini" from available API keys',
);
return "gemini";
}
// 3. Kimi
const kimiConfig = resolveKimiConfig(search);
if (resolveKimiApiKey(kimiConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "kimi" from available API keys',
);
return "kimi";
}
// 4. Perplexity
const perplexityConfig = resolvePerplexityConfig(search);
const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig);
if (perplexityKey) {
@@ -526,22 +586,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
);
return "perplexity";
}
// 2. Brave
if (resolveSearchApiKey(search)) {
logVerbose(
'web_search: no provider configured, auto-detected "brave" from available API keys',
);
return "brave";
}
// 3. Gemini
const geminiConfig = resolveGeminiConfig(search);
if (resolveGeminiApiKey(geminiConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "gemini" from available API keys',
);
return "gemini";
}
// 4. Grok
// 5. Grok
const grokConfig = resolveGrokConfig(search);
if (resolveGrokApiKey(grokConfig)) {
logVerbose(
@@ -549,17 +594,9 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
);
return "grok";
}
// 5. Kimi
const kimiConfig = resolveKimiConfig(search);
if (resolveKimiApiKey(kimiConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "kimi" from available API keys',
);
return "kimi";
}
}
return "perplexity";
return "brave";
}
function resolveBraveConfig(search?: WebSearchConfig): BraveConfig {
@@ -602,6 +639,11 @@ 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" };
}
@@ -609,6 +651,98 @@ function normalizeApiKey(key: unknown): string {
return normalizeSecretInput(key);
}
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 === "openrouter") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
return PERPLEXITY_DIRECT_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 isDirectPerplexityBaseUrl(baseUrl: string): boolean {
const trimmed = baseUrl.trim();
if (!trimmed) {
return false;
}
try {
return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai";
} catch {
return false;
}
}
function resolvePerplexityRequestModel(baseUrl: string, model: string): string {
if (!isDirectPerplexityBaseUrl(baseUrl)) {
return model;
}
return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model;
}
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
apiKey?: string;
source: PerplexityApiKeySource;
baseUrl: string;
model: string;
transport: PerplexityTransport;
} {
const auth = resolvePerplexityApiKey(perplexity);
const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey);
const model = resolvePerplexityModel(perplexity);
const hasLegacyOverride = Boolean(
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
(perplexity?.model && perplexity.model.trim()),
);
return {
...auth,
baseUrl,
model,
transport:
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
};
}
function resolveGrokConfig(search?: WebSearchConfig): GrokConfig {
if (!search || typeof search !== "object") {
return {};
@@ -1032,6 +1166,61 @@ async function runPerplexitySearchApi(params: {
);
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
freshness?: string;
}): Promise<{ content: string; citations: string[] }> {
const baseUrl = params.baseUrl.trim().replace(/\/$/, "");
const endpoint = `${baseUrl}/chat/completions`;
const model = resolvePerplexityRequestModel(baseUrl, params.model);
const body: Record<string, unknown> = {
model,
messages: [
{
role: "user",
content: params.query,
},
],
};
if (params.freshness) {
body.search_recency_filter = params.freshness;
}
return withTrustedWebSearchEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw Web Search",
},
body: JSON.stringify(body),
},
},
async (res) => {
if (!res.ok) {
return await throwWebSearchApiError(res, "Perplexity");
}
const data = (await res.json()) as PerplexitySearchResponse;
const content = data.choices?.[0]?.message?.content ?? "No response";
const citations = data.citations ?? [];
return { content, citations };
},
);
}
async function runGrokSearch(params: {
query: string;
apiKey: string;
@@ -1318,6 +1507,9 @@ async function runWebSearch(params: {
searchDomainFilter?: string[];
maxTokens?: number;
maxTokensPerPage?: number;
perplexityBaseUrl?: string;
perplexityModel?: string;
perplexityTransport?: PerplexityTransport;
grokModel?: string;
grokInlineCitations?: boolean;
geminiModel?: string;
@@ -1327,13 +1519,15 @@ async function runWebSearch(params: {
}): Promise<Record<string, unknown>> {
const effectiveBraveMode = params.braveMode ?? "web";
const providerSpecificKey =
params.provider === "grok"
? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`
: params.provider === "gemini"
? (params.geminiModel ?? DEFAULT_GEMINI_MODEL)
: params.provider === "kimi"
? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}`
: "";
params.provider === "perplexity"
? `${params.perplexityTransport ?? "search_api"}:${params.perplexityBaseUrl ?? PERPLEXITY_DIRECT_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}`
: params.provider === "grok"
? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`
: params.provider === "gemini"
? (params.geminiModel ?? DEFAULT_GEMINI_MODEL)
: params.provider === "kimi"
? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}`
: "";
const cacheKey = normalizeCacheKey(
params.provider === "brave" && effectiveBraveMode === "llm-context"
? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}`
@@ -1347,6 +1541,34 @@ async function runWebSearch(params: {
const start = Date.now();
if (params.provider === "perplexity") {
if (params.perplexityTransport === "chat_completions") {
const { content, citations } = await runPerplexitySearch({
query: params.query,
apiKey: params.apiKey,
baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
timeoutSeconds: params.timeoutSeconds,
freshness: params.freshness,
});
const payload = {
query: params.query,
provider: params.provider,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: params.provider,
wrapped: true,
},
content: wrapWebContent(content, "web_search"),
citations,
};
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
}
const results = await runPerplexitySearchApi({
query: params.query,
apiKey: params.apiKey,
@@ -1590,6 +1812,7 @@ export function createWebSearchTool(options?: {
const provider = resolveSearchProvider(search);
const perplexityConfig = resolvePerplexityConfig(search);
const perplexityTransport = resolvePerplexityTransport(perplexityConfig);
const grokConfig = resolveGrokConfig(search);
const geminiConfig = resolveGeminiConfig(search);
const kimiConfig = resolveKimiConfig(search);
@@ -1598,7 +1821,9 @@ export function createWebSearchTool(options?: {
const description =
provider === "perplexity"
? "Search the web using the Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports domain, region, language, and freshness filtering."
? perplexityTransport.transport === "chat_completions"
? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search."
: "Search the web using the Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports domain, region, language, and freshness filtering."
: provider === "grok"
? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search."
: provider === "kimi"
@@ -1613,13 +1838,15 @@ export function createWebSearchTool(options?: {
label: "Web Search",
name: "web_search",
description,
parameters: createWebSearchSchema(provider),
parameters: createWebSearchSchema({
provider,
perplexityTransport: provider === "perplexity" ? perplexityTransport.transport : undefined,
}),
execute: async (_toolCallId, args) => {
const perplexityAuth =
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
const perplexityRuntime = provider === "perplexity" ? perplexityTransport : undefined;
const apiKey =
provider === "perplexity"
? perplexityAuth?.apiKey
? perplexityRuntime?.apiKey
: provider === "grok"
? resolveGrokApiKey(grokConfig)
: provider === "kimi"
@@ -1631,23 +1858,40 @@ export function createWebSearchTool(options?: {
if (!apiKey) {
return jsonResult(missingSearchKeyPayload(provider));
}
const supportsStructuredPerplexityFilters =
provider === "perplexity" && perplexityRuntime?.transport === "search_api";
const params = args as Record<string, unknown>;
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined;
const country = readStringParam(params, "country");
if (country && provider !== "brave" && provider !== "perplexity") {
if (
country &&
provider !== "brave" &&
!(provider === "perplexity" && supportsStructuredPerplexityFilters)
) {
return jsonResult({
error: "unsupported_country",
message: `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`,
message:
provider === "perplexity"
? "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it."
: `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`,
docs: "https://docs.openclaw.ai/tools/web",
});
}
const language = readStringParam(params, "language");
if (language && provider !== "brave" && provider !== "perplexity") {
if (
language &&
provider !== "brave" &&
!(provider === "perplexity" && supportsStructuredPerplexityFilters)
) {
return jsonResult({
error: "unsupported_language",
message: `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`,
message:
provider === "perplexity"
? "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it."
: `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`,
docs: "https://docs.openclaw.ai/tools/web",
});
}
@@ -1724,10 +1968,17 @@ export function createWebSearchTool(options?: {
docs: "https://docs.openclaw.ai/tools/web",
});
}
if ((rawDateAfter || rawDateBefore) && provider !== "brave" && provider !== "perplexity") {
if (
(rawDateAfter || rawDateBefore) &&
provider !== "brave" &&
!(provider === "perplexity" && supportsStructuredPerplexityFilters)
) {
return jsonResult({
error: "unsupported_date_filter",
message: `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`,
message:
provider === "perplexity"
? "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them."
: `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`,
docs: "https://docs.openclaw.ai/tools/web",
});
}
@@ -1763,10 +2014,17 @@ export function createWebSearchTool(options?: {
});
}
const domainFilter = readStringArrayParam(params, "domain_filter");
if (domainFilter && domainFilter.length > 0 && provider !== "perplexity") {
if (
domainFilter &&
domainFilter.length > 0 &&
!(provider === "perplexity" && supportsStructuredPerplexityFilters)
) {
return jsonResult({
error: "unsupported_domain_filter",
message: `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`,
message:
provider === "perplexity"
? "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it."
: `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`,
docs: "https://docs.openclaw.ai/tools/web",
});
}
@@ -1793,6 +2051,18 @@ export function createWebSearchTool(options?: {
const maxTokens = readNumberParam(params, "max_tokens", { integer: true });
const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true });
if (
provider === "perplexity" &&
perplexityRuntime?.transport === "chat_completions" &&
(maxTokens !== undefined || maxTokensPerPage !== undefined)
) {
return jsonResult({
error: "unsupported_content_budget",
message:
"max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const result = await runWebSearch({
query,
@@ -1811,6 +2081,9 @@ export function createWebSearchTool(options?: {
searchDomainFilter: domainFilter,
maxTokens: maxTokens ?? undefined,
maxTokensPerPage: maxTokensPerPage ?? undefined,
perplexityBaseUrl: perplexityRuntime?.baseUrl,
perplexityModel: perplexityRuntime?.model,
perplexityTransport: perplexityRuntime?.transport,
grokModel: resolveGrokModel(grokConfig),
grokInlineCitations: resolveGrokInlineCitations(grokConfig),
geminiModel: resolveGeminiModel(geminiConfig),
@@ -1825,6 +2098,13 @@ export function createWebSearchTool(options?: {
export const __testing = {
resolveSearchProvider,
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeBraveLanguageParams,
normalizeFreshness,
normalizeToIsoDate,

View File

@@ -15,7 +15,11 @@ function installMockFetch(payload: unknown) {
return mockFetch;
}
function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string }) {
function createPerplexitySearchTool(perplexityConfig?: {
apiKey?: string;
baseUrl?: string;
model?: string;
}) {
return createWebSearchTool({
config: {
tools: {
@@ -109,6 +113,13 @@ function installPerplexitySearchApiFetch(results?: Array<Record<string, unknown>
});
}
function installPerplexityChatFetch() {
return installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: ["https://example.com"],
});
}
function createProviderSuccessPayload(
provider: "brave" | "perplexity" | "grok" | "gemini" | "kimi",
) {
@@ -414,6 +425,103 @@ describe("web_search perplexity Search API", () => {
});
});
describe("web_search perplexity OpenRouter compatibility", () => {
const priorFetch = global.fetch;
afterEach(() => {
vi.unstubAllEnvs();
global.fetch = priorFetch;
webSearchTesting.SEARCH_CACHE.clear();
});
it("routes OPENROUTER_API_KEY through chat completions", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
const mockFetch = installPerplexityChatFetch();
const tool = createPerplexitySearchTool();
const result = await tool?.execute?.("call-1", { query: "test" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions");
const body = parseFirstRequestBody(mockFetch);
expect(body.model).toBe("perplexity/sonar-pro");
expect(result?.details).toMatchObject({
provider: "perplexity",
citations: ["https://example.com"],
content: expect.stringContaining("ok"),
});
});
it("routes configured sk-or key through chat completions", async () => {
const mockFetch = installPerplexityChatFetch();
const tool = createPerplexitySearchTool({ apiKey: "sk-or-v1-test" }); // pragma: allowlist secret
await tool?.execute?.("call-1", { query: "test" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions");
const headers = (mockFetch.mock.calls[0]?.[1] as RequestInit | undefined)?.headers as
| Record<string, string>
| undefined;
expect(headers?.Authorization).toBe("Bearer sk-or-v1-test");
});
it("keeps freshness support on the compatibility path", async () => {
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
const mockFetch = installPerplexityChatFetch();
const tool = createPerplexitySearchTool();
await tool?.execute?.("call-1", { query: "test", freshness: "week" });
expect(mockFetch).toHaveBeenCalled();
const body = parseFirstRequestBody(mockFetch);
expect(body.search_recency_filter).toBe("week");
});
it("fails loud for Search API-only filters on the compatibility path", async () => {
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
const mockFetch = installPerplexityChatFetch();
const tool = createPerplexitySearchTool();
const result = await tool?.execute?.("call-1", {
query: "test",
domain_filter: ["nature.com"],
});
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "unsupported_domain_filter" });
});
it("hides Search API-only schema params on the compatibility path", () => {
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
const tool = createPerplexitySearchTool();
const properties = (tool?.parameters as { properties?: Record<string, unknown> } | undefined)
?.properties;
expect(properties?.freshness).toBeDefined();
expect(properties?.country).toBeUndefined();
expect(properties?.language).toBeUndefined();
expect(properties?.date_after).toBeUndefined();
expect(properties?.date_before).toBeUndefined();
expect(properties?.domain_filter).toBeUndefined();
expect(properties?.max_tokens).toBeUndefined();
expect(properties?.max_tokens_per_page).toBeUndefined();
});
it("keeps structured schema params on the native Search API path", () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const tool = createPerplexitySearchTool();
const properties = (tool?.parameters as { properties?: Record<string, unknown> } | undefined)
?.properties;
expect(properties?.country).toBeDefined();
expect(properties?.language).toBeDefined();
expect(properties?.freshness).toBeDefined();
expect(properties?.date_after).toBeDefined();
expect(properties?.date_before).toBeDefined();
expect(properties?.domain_filter).toBeDefined();
expect(properties?.max_tokens).toBeDefined();
expect(properties?.max_tokens_per_page).toBeDefined();
});
});
describe("web_search kimi provider", () => {
const priorFetch = global.fetch;

View File

@@ -17,6 +17,8 @@ describe("web search provider config", () => {
provider: "perplexity",
providerConfig: {
apiKey: "test-key", // pragma: allowlist secret
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
},
}),
);
@@ -96,8 +98,8 @@ describe("web search provider auto-detection", () => {
vi.restoreAllMocks();
});
it("falls back to perplexity when no keys available", () => {
expect(resolveSearchProvider({})).toBe("perplexity");
it("falls back to brave when no keys available", () => {
expect(resolveSearchProvider({})).toBe("brave");
});
it("auto-detects brave when only BRAVE_API_KEY is set", () => {
@@ -120,6 +122,11 @@ describe("web search provider auto-detection", () => {
expect(resolveSearchProvider({})).toBe("perplexity");
});
it("auto-detects perplexity when only OPENROUTER_API_KEY is set", () => {
process.env.OPENROUTER_API_KEY = "sk-or-v1-test"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("perplexity");
});
it("auto-detects grok when only XAI_API_KEY is set", () => {
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("grok");
@@ -135,12 +142,19 @@ describe("web search provider auto-detection", () => {
expect(resolveSearchProvider({})).toBe("kimi");
});
it("follows priority order — perplexity wins when multiple keys available", () => {
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
it("follows priority order — brave wins when multiple keys available", () => {
process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("perplexity");
expect(resolveSearchProvider({})).toBe("brave");
});
it("gemini wins over perplexity and grok when brave unavailable", () => {
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("gemini");
});
it("brave wins over gemini and grok when perplexity unavailable", () => {

View File

@@ -663,11 +663,11 @@ export const FIELD_HELP: Record<string, string> = {
'Kimi base URL override (default: "https://api.moonshot.ai/v1").',
"tools.web.search.kimi.model": 'Kimi model override (default: "moonshot-v1-128k").',
"tools.web.search.perplexity.apiKey":
"Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).",
"Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.",
"tools.web.search.perplexity.baseUrl":
"Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).",
"Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.",
"tools.web.search.perplexity.model":
'Perplexity model override (default: "perplexity/sonar-pro").',
'Optional Sonar/OpenRouter model override (default: "perplexity/sonar-pro"). Setting this opts Perplexity into the legacy chat-completions compatibility path.',
"tools.web.search.brave.mode":
'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).',
"tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).",

View File

@@ -278,8 +278,8 @@ export const ToolsWebSearchSchema = z
perplexity: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
// Legacy Sonar/OpenRouter fields — kept for backward compatibility
// so existing configs don't fail validation. Ignored at runtime.
// Legacy Sonar/OpenRouter compatibility fields.
// Setting either opts Perplexity back into the chat-completions path.
baseUrl: z.string().optional(),
model: z.string().optional(),
})