test: lazy-load Perplexity web search runtime

Keep the Perplexity web-search public provider artifact metadata-only and move
execution, cache, HTTP, and runtime helper tests behind a lazy runtime seam.
This keeps bundled web-search contract checks from loading runtime-only code.
This commit is contained in:
Gustavo Madeira Santana
2026-04-17 17:19:57 -04:00
parent 2482e70fb8
commit c86beb237e
5 changed files with 620 additions and 608 deletions

View File

@@ -0,0 +1,539 @@
import {
readNumberParam,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
isoToPerplexityDate,
normalizeFreshness,
normalizeToIsoDate,
readCachedSearchPayload,
readConfiguredSecretString,
readProviderEnvValue,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
throwWebSearchApiError,
type SearchConfigRecord,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
DEFAULT_PERPLEXITY_BASE_URL,
inferPerplexityBaseUrlFromApiKey,
isDirectPerplexityBaseUrl,
PERPLEXITY_DIRECT_BASE_URL,
type PerplexityTransport,
} from "./perplexity-web-search-provider.shared.js";
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
annotations?: Array<{
type?: string;
url?: string;
url_citation?: {
url?: string;
};
}>;
};
}>;
citations?: string[];
};
type PerplexitySearchApiResponse = {
results?: Array<{
title?: string;
url?: string;
snippet?: string;
date?: string;
}>;
};
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
const perplexity = searchConfig?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as PerplexityConfig)
: {};
}
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
apiKey?: string;
source: "config" | "perplexity_env" | "openrouter_env" | "none";
} {
const fromConfig = readConfiguredSecretString(
perplexity?.apiKey,
"tools.web.search.perplexity.apiKey",
);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
}
const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]);
if (fromPerplexityEnv) {
return { apiKey: fromPerplexityEnv, source: "perplexity_env" };
}
const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]);
if (fromOpenRouterEnv) {
return { apiKey: fromOpenRouterEnv, source: "openrouter_env" };
}
return { apiKey: undefined, source: "none" };
}
function resolvePerplexityBaseUrl(
perplexity?: PerplexityConfig,
authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none",
configuredKey?: string,
): string {
const fromConfig = normalizeOptionalString(perplexity?.baseUrl) ?? "";
if (fromConfig) {
return fromConfig;
}
if (authSource === "perplexity_env") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (authSource === "openrouter_env") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
if (authSource === "config") {
return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter"
? DEFAULT_PERPLEXITY_BASE_URL
: PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const model = normalizeOptionalString(perplexity?.model) ?? "";
return model || DEFAULT_PERPLEXITY_MODEL;
}
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: "config" | "perplexity_env" | "openrouter_env" | "none";
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(
normalizeOptionalString(perplexity?.baseUrl) || normalizeOptionalString(perplexity?.model),
);
return {
...auth,
baseUrl,
model,
transport:
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
};
}
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
const topLevel = (data.citations ?? []).filter((url): url is string =>
Boolean(normalizeOptionalString(url)),
);
if (topLevel.length > 0) {
return [...new Set(topLevel)];
}
const citations: string[] = [];
for (const choice of data.choices ?? []) {
for (const annotation of choice.message?.annotations ?? []) {
if (annotation.type !== "url_citation") {
continue;
}
const url =
typeof annotation.url_citation?.url === "string"
? annotation.url_citation.url
: typeof annotation.url === "string"
? annotation.url
: undefined;
const normalizedUrl = normalizeOptionalString(url);
if (normalizedUrl) {
citations.push(normalizedUrl);
}
}
}
return [...new Set(citations)];
}
async function runPerplexitySearchApi(params: {
query: string;
apiKey: string;
count: number;
timeoutSeconds: number;
country?: string;
searchDomainFilter?: string[];
searchRecencyFilter?: string;
searchLanguageFilter?: string[];
searchAfterDate?: string;
searchBeforeDate?: string;
maxTokens?: number;
maxTokensPerPage?: number;
}): Promise<Array<Record<string, unknown>>> {
const body: Record<string, unknown> = {
query: params.query,
max_results: params.count,
};
if (params.country) {
body.country = params.country;
}
if (params.searchDomainFilter?.length) {
body.search_domain_filter = params.searchDomainFilter;
}
if (params.searchRecencyFilter) {
body.search_recency_filter = params.searchRecencyFilter;
}
if (params.searchLanguageFilter?.length) {
body.search_language_filter = params.searchLanguageFilter;
}
if (params.searchAfterDate) {
body.search_after_date = params.searchAfterDate;
}
if (params.searchBeforeDate) {
body.search_before_date = params.searchBeforeDate;
}
if (params.maxTokens !== undefined) {
body.max_tokens = params.maxTokens;
}
if (params.maxTokensPerPage !== undefined) {
body.max_tokens_per_page = params.maxTokensPerPage;
}
return withTrustedWebSearchEndpoint(
{
url: PERPLEXITY_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "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 Search");
}
const data = (await res.json()) as PerplexitySearchApiResponse;
return (data.results ?? []).map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url ?? "",
description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "",
published: entry.date ?? undefined,
siteName: resolveSiteName(entry.url) || undefined,
}));
},
);
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
freshness?: string;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
const body: Record<string, unknown> = {
model: resolvePerplexityRequestModel(params.baseUrl, params.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;
return {
content: data.choices?.[0]?.message?.content ?? "No response",
citations: extractPerplexityCitations(data),
};
},
);
}
export async function executePerplexitySearch(
args: Record<string, unknown>,
searchConfig?: SearchConfigRecord,
): Promise<Record<string, unknown>> {
const perplexityConfig = resolvePerplexityConfig(searchConfig);
const runtime = resolvePerplexityTransport(perplexityConfig);
if (!runtime.apiKey) {
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(args, "query", { required: true });
const count =
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const rawFreshness = readStringParam(args, "freshness");
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const structured = runtime.transport === "search_api";
const country = readStringParam(args, "country");
const language = readStringParam(args, "language");
const rawDateAfter = readStringParam(args, "date_after");
const rawDateBefore = readStringParam(args, "date_before");
const domainFilter = readStringArrayParam(args, "domain_filter");
const maxTokens = readNumberParam(args, "max_tokens", { integer: true });
const maxTokensPerPage = readNumberParam(args, "max_tokens_per_page", { integer: true });
if (!structured) {
if (country) {
return {
error: "unsupported_country",
message:
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (language) {
return {
error: "unsupported_language",
message:
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawDateAfter || rawDateBefore) {
return {
error: "unsupported_date_filter",
message:
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter?.length) {
return {
error: "unsupported_domain_filter",
message:
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (maxTokens !== undefined || maxTokensPerPage !== undefined) {
return {
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",
};
}
}
if (language && !/^[a-z]{2}$/iu.test(language)) {
return {
error: "invalid_language",
message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
if (rawDateAfter && !dateAfter) {
return {
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawDateBefore && !dateBefore) {
return {
error: "invalid_date",
message: "date_before must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (dateAfter && dateBefore && dateAfter > dateBefore) {
return {
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter?.length) {
const hasDeny = domainFilter.some((entry) => entry.startsWith("-"));
const hasAllow = domainFilter.some((entry) => !entry.startsWith("-"));
if (hasDeny && hasAllow) {
return {
error: "invalid_domain_filter",
message:
"domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter.length > 20) {
return {
error: "invalid_domain_filter",
message: "domain_filter supports a maximum of 20 domains.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
}
const cacheKey = buildSearchCacheKey([
"perplexity",
runtime.transport,
runtime.baseUrl,
runtime.model,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
language,
freshness,
dateAfter,
dateBefore,
domainFilter?.join(","),
maxTokens,
maxTokensPerPage,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const payload =
runtime.transport === "chat_completions"
? {
query,
provider: "perplexity",
model: runtime.model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
...(await (async () => {
const result = await runPerplexitySearch({
query,
apiKey: runtime.apiKey!,
baseUrl: runtime.baseUrl,
model: runtime.model,
timeoutSeconds,
freshness,
});
return {
content: wrapWebContent(result.content, "web_search"),
citations: result.citations,
};
})()),
}
: {
query,
provider: "perplexity",
count: 0,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
results: await runPerplexitySearchApi({
query,
apiKey: runtime.apiKey,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
timeoutSeconds,
country: country ?? undefined,
searchDomainFilter: domainFilter,
searchRecencyFilter: freshness,
searchLanguageFilter: language ? [language] : undefined,
searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined,
searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined,
maxTokens: maxTokens ?? undefined,
maxTokensPerPage: maxTokensPerPage ?? undefined,
}),
};
if (Array.isArray((payload as { results?: unknown[] }).results)) {
(payload as { count: number }).count = (payload as { results: unknown[] }).results.length;
(payload as { tookMs: number }).tookMs = Date.now() - start;
} else {
(payload as { tookMs: number }).tookMs = Date.now() - start;
}
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
}
export const __testing = {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeToIsoDate,
isoToPerplexityDate,
} as const;

View File

@@ -1,6 +1,6 @@
import { withEnv } from "openclaw/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import { __testing } from "./perplexity-web-search-provider.js";
import { __testing } from "./perplexity-web-search-provider.runtime.js";
const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_");
const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_");

View File

@@ -1,611 +1,103 @@
import { Type } from "@sinclair/typebox";
import {
readNumberParam,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
isoToPerplexityDate,
createWebSearchProviderContractFields,
mergeScopedSearchConfig,
normalizeFreshness,
normalizeToIsoDate,
readCachedSearchPayload,
readConfiguredSecretString,
readProviderEnvValue,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
throwWebSearchApiError,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
DEFAULT_PERPLEXITY_BASE_URL,
inferPerplexityBaseUrlFromApiKey,
isDirectPerplexityBaseUrl,
PERPLEXITY_DIRECT_BASE_URL,
resolvePerplexityRuntimeTransport,
type PerplexityTransport,
} from "./perplexity-web-search-provider.shared.js";
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { resolvePerplexityRuntimeTransport } from "./perplexity-web-search-provider.shared.js";
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
const PERPLEXITY_CREDENTIAL_PATH = "plugins.entries.perplexity.config.webSearch.apiKey";
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
annotations?: Array<{
type?: string;
url?: string;
url_citation?: {
url?: string;
};
}>;
function createPerplexityParameters(transport?: string): Record<string, unknown> {
const properties: Record<string, unknown> = {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
freshness: {
type: "string",
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
},
};
if (transport !== "chat_completions") {
properties.country = {
type: "string",
description: "Native Perplexity Search API only. 2-letter country code.",
};
}>;
citations?: string[];
};
properties.language = {
type: "string",
description: "Native Perplexity Search API only. ISO 639-1 language code.",
};
properties.date_after = {
type: "string",
description:
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
};
properties.date_before = {
type: "string",
description:
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
};
properties.domain_filter = {
type: "array",
items: { type: "string" },
description: "Native Perplexity Search API only. Domain filter (max 20).",
};
properties.max_tokens = {
type: "number",
description: "Native Perplexity Search API only. Total content budget across all results.",
minimum: 1,
maximum: 1000000,
};
properties.max_tokens_per_page = {
type: "number",
description: "Native Perplexity Search API only. Max tokens extracted per page.",
minimum: 1,
};
}
type PerplexitySearchApiResponse = {
results?: Array<{
title?: string;
url?: string;
snippet?: string;
date?: string;
}>;
};
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
const perplexity = searchConfig?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as PerplexityConfig)
: {};
}
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
apiKey?: string;
source: "config" | "perplexity_env" | "openrouter_env" | "none";
} {
const fromConfig = readConfiguredSecretString(
perplexity?.apiKey,
"tools.web.search.perplexity.apiKey",
);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
}
const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]);
if (fromPerplexityEnv) {
return { apiKey: fromPerplexityEnv, source: "perplexity_env" };
}
const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]);
if (fromOpenRouterEnv) {
return { apiKey: fromOpenRouterEnv, source: "openrouter_env" };
}
return { apiKey: undefined, source: "none" };
}
function resolvePerplexityBaseUrl(
perplexity?: PerplexityConfig,
authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none",
configuredKey?: string,
): string {
const fromConfig = normalizeOptionalString(perplexity?.baseUrl) ?? "";
if (fromConfig) {
return fromConfig;
}
if (authSource === "perplexity_env") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (authSource === "openrouter_env") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
if (authSource === "config") {
return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter"
? DEFAULT_PERPLEXITY_BASE_URL
: PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const model = normalizeOptionalString(perplexity?.model) ?? "";
return model || DEFAULT_PERPLEXITY_MODEL;
}
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: "config" | "perplexity_env" | "openrouter_env" | "none";
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(
normalizeOptionalString(perplexity?.baseUrl) || normalizeOptionalString(perplexity?.model),
);
return {
...auth,
baseUrl,
model,
transport:
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
type: "object",
properties,
required: ["query"],
};
}
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
const topLevel = (data.citations ?? []).filter((url): url is string =>
Boolean(normalizeOptionalString(url)),
function hasPerplexityLegacyOverride(searchConfig?: Record<string, unknown>): boolean {
const perplexity = isRecord(searchConfig?.perplexity) ? searchConfig.perplexity : undefined;
return (
(typeof perplexity?.baseUrl === "string" && perplexity.baseUrl.trim().length > 0) ||
(typeof perplexity?.model === "string" && perplexity.model.trim().length > 0)
);
if (topLevel.length > 0) {
return [...new Set(topLevel)];
}
const citations: string[] = [];
for (const choice of data.choices ?? []) {
for (const annotation of choice.message?.annotations ?? []) {
if (annotation.type !== "url_citation") {
continue;
}
const url =
typeof annotation.url_citation?.url === "string"
? annotation.url_citation.url
: typeof annotation.url === "string"
? annotation.url
: undefined;
const normalizedUrl = normalizeOptionalString(url);
if (normalizedUrl) {
citations.push(normalizedUrl);
}
}
}
return [...new Set(citations)];
}
async function runPerplexitySearchApi(params: {
query: string;
apiKey: string;
count: number;
timeoutSeconds: number;
country?: string;
searchDomainFilter?: string[];
searchRecencyFilter?: string;
searchLanguageFilter?: string[];
searchAfterDate?: string;
searchBeforeDate?: string;
maxTokens?: number;
maxTokensPerPage?: number;
}): Promise<Array<Record<string, unknown>>> {
const body: Record<string, unknown> = {
query: params.query,
max_results: params.count,
};
if (params.country) {
body.country = params.country;
}
if (params.searchDomainFilter?.length) {
body.search_domain_filter = params.searchDomainFilter;
}
if (params.searchRecencyFilter) {
body.search_recency_filter = params.searchRecencyFilter;
}
if (params.searchLanguageFilter?.length) {
body.search_language_filter = params.searchLanguageFilter;
}
if (params.searchAfterDate) {
body.search_after_date = params.searchAfterDate;
}
if (params.searchBeforeDate) {
body.search_before_date = params.searchBeforeDate;
}
if (params.maxTokens !== undefined) {
body.max_tokens = params.maxTokens;
}
if (params.maxTokensPerPage !== undefined) {
body.max_tokens_per_page = params.maxTokensPerPage;
}
return withTrustedWebSearchEndpoint(
{
url: PERPLEXITY_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "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 Search");
}
const data = (await res.json()) as PerplexitySearchApiResponse;
return (data.results ?? []).map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url ?? "",
description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "",
published: entry.date ?? undefined,
siteName: resolveSiteName(entry.url) || undefined,
}));
},
);
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
freshness?: string;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
const body: Record<string, unknown> = {
model: resolvePerplexityRequestModel(params.baseUrl, params.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;
return {
content: data.choices?.[0]?.message?.content ?? "No response",
citations: extractPerplexityCitations(data),
};
},
);
}
function createPerplexitySchema(transport?: PerplexityTransport) {
const querySchema = {
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
freshness: Type.Optional(
Type.String({ description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'." }),
),
};
if (transport === "chat_completions") {
return Type.Object(querySchema);
}
return Type.Object({
...querySchema,
country: Type.Optional(
Type.String({ description: "Native Perplexity Search API only. 2-letter country code." }),
),
language: Type.Optional(
Type.String({ description: "Native Perplexity Search API only. ISO 639-1 language code." }),
),
date_after: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
}),
),
domain_filter: Type.Optional(
Type.Array(Type.String(), {
description: "Native Perplexity Search API only. Domain filter (max 20).",
}),
),
max_tokens: Type.Optional(
Type.Number({
description: "Native Perplexity Search API only. Total content budget across all results.",
minimum: 1,
maximum: 1000000,
}),
),
max_tokens_per_page: Type.Optional(
Type.Number({
description: "Native Perplexity Search API only. Max tokens extracted per page.",
minimum: 1,
}),
),
});
}
function createPerplexityToolDefinition(
searchConfig?: SearchConfigRecord,
runtimeTransport?: PerplexityTransport,
searchConfig?: Record<string, unknown>,
runtimeTransport?: string,
): WebSearchProviderToolDefinition {
const perplexityConfig = resolvePerplexityConfig(searchConfig);
const schemaTransport =
runtimeTransport ??
(perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined);
(hasPerplexityLegacyOverride(searchConfig) ? "chat_completions" : undefined);
return {
description:
schemaTransport === "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 Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.",
parameters: createPerplexitySchema(schemaTransport),
parameters: createPerplexityParameters(schemaTransport),
execute: async (args) => {
const runtime = resolvePerplexityTransport(perplexityConfig);
if (!runtime.apiKey) {
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const params = args;
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const rawFreshness = readStringParam(params, "freshness");
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const structured = runtime.transport === "search_api";
const country = readStringParam(params, "country");
const language = readStringParam(params, "language");
const rawDateAfter = readStringParam(params, "date_after");
const rawDateBefore = readStringParam(params, "date_before");
const domainFilter = readStringArrayParam(params, "domain_filter");
const maxTokens = readNumberParam(params, "max_tokens", { integer: true });
const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true });
if (!structured) {
if (country) {
return {
error: "unsupported_country",
message:
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (language) {
return {
error: "unsupported_language",
message:
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawDateAfter || rawDateBefore) {
return {
error: "unsupported_date_filter",
message:
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter?.length) {
return {
error: "unsupported_domain_filter",
message:
"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.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (maxTokens !== undefined || maxTokensPerPage !== undefined) {
return {
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",
};
}
}
if (language && !/^[a-z]{2}$/i.test(language)) {
return {
error: "invalid_language",
message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
if (rawDateAfter && !dateAfter) {
return {
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawDateBefore && !dateBefore) {
return {
error: "invalid_date",
message: "date_before must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (dateAfter && dateBefore && dateAfter > dateBefore) {
return {
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter?.length) {
const hasDeny = domainFilter.some((entry) => entry.startsWith("-"));
const hasAllow = domainFilter.some((entry) => !entry.startsWith("-"));
if (hasDeny && hasAllow) {
return {
error: "invalid_domain_filter",
message:
"domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter.length > 20) {
return {
error: "invalid_domain_filter",
message: "domain_filter supports a maximum of 20 domains.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
}
const cacheKey = buildSearchCacheKey([
"perplexity",
runtime.transport,
runtime.baseUrl,
runtime.model,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
language,
freshness,
dateAfter,
dateBefore,
domainFilter?.join(","),
maxTokens,
maxTokensPerPage,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const payload =
runtime.transport === "chat_completions"
? {
query,
provider: "perplexity",
model: runtime.model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
...(await (async () => {
const result = await runPerplexitySearch({
query,
apiKey: runtime.apiKey!,
baseUrl: runtime.baseUrl,
model: runtime.model,
timeoutSeconds,
freshness,
});
return {
content: wrapWebContent(result.content, "web_search"),
citations: result.citations,
};
})()),
}
: {
query,
provider: "perplexity",
count: 0,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
results: await runPerplexitySearchApi({
query,
apiKey: runtime.apiKey,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
timeoutSeconds,
country: country ?? undefined,
searchDomainFilter: domainFilter,
searchRecencyFilter: freshness,
searchLanguageFilter: language ? [language] : undefined,
searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined,
searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined,
maxTokens: maxTokens ?? undefined,
maxTokensPerPage: maxTokensPerPage ?? undefined,
}),
};
if (Array.isArray((payload as { results?: unknown[] }).results)) {
(payload as { count: number }).count = (payload as { results: unknown[] }).results.length;
(payload as { tookMs: number }).tookMs = Date.now() - start;
} else {
(payload as { tookMs: number }).tookMs = Date.now() - start;
}
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
const { executePerplexitySearch } =
await import("./perplexity-web-search-provider.runtime.js");
return await executePerplexitySearch(args, searchConfig);
},
};
}
@@ -622,16 +114,12 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://www.perplexity.ai/settings/api",
docsUrl: "https://docs.openclaw.ai/perplexity",
autoDetectOrder: 50,
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "perplexity", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value);
},
credentialPath: PERPLEXITY_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: PERPLEXITY_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "perplexity" },
configuredCredential: { pluginId: "perplexity" },
}),
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolvePerplexityRuntimeTransport({
searchConfig: mergeScopedSearchConfig(
@@ -655,15 +143,3 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
),
};
}
export const __testing = {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeToIsoDate,
isoToPerplexityDate,
} as const;

View File

@@ -1 +1 @@
export { __testing } from "./src/perplexity-web-search-provider.js";
export { __testing } from "./src/perplexity-web-search-provider.runtime.js";

View File

@@ -1,4 +1 @@
export {
__testing,
createPerplexityWebSearchProvider,
} from "./src/perplexity-web-search-provider.js";
export { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js";