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

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