mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
| --------------------- | ----------------------------------------------------- |
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user