730 lines
26 KiB
TypeScript
730 lines
26 KiB
TypeScript
import { Type } from "@sinclair/typebox";
|
|
import {
|
|
readNumberParam,
|
|
readStringArrayParam,
|
|
readStringParam,
|
|
} from "openclaw/plugin-sdk/provider-web-search";
|
|
import {
|
|
buildSearchCacheKey,
|
|
DEFAULT_SEARCH_COUNT,
|
|
MAX_SEARCH_COUNT,
|
|
isoToPerplexityDate,
|
|
normalizeFreshness,
|
|
normalizeToIsoDate,
|
|
readCachedSearchPayload,
|
|
readConfiguredSecretString,
|
|
readProviderEnvValue,
|
|
resolveProviderWebSearchPluginConfig,
|
|
resolveSearchCacheTtlMs,
|
|
resolveSearchCount,
|
|
resolveSearchTimeoutSeconds,
|
|
resolveSiteName,
|
|
setProviderWebSearchPluginConfigValue,
|
|
throwWebSearchApiError,
|
|
type SearchConfigRecord,
|
|
type WebSearchCredentialResolutionSource,
|
|
type WebSearchProviderPlugin,
|
|
type WebSearchProviderToolDefinition,
|
|
withTrustedWebSearchEndpoint,
|
|
wrapWebContent,
|
|
writeCachedSearchPayload,
|
|
} from "openclaw/plugin-sdk/provider-web-search";
|
|
|
|
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-"];
|
|
|
|
type PerplexityConfig = {
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
model?: string;
|
|
};
|
|
|
|
type PerplexityTransport = "search_api" | "chat_completions";
|
|
type PerplexityBaseUrlHint = "direct" | "openrouter";
|
|
|
|
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 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 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 = typeof perplexity?.baseUrl === "string" ? perplexity.baseUrl.trim() : "";
|
|
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 = typeof perplexity?.model === "string" ? perplexity.model.trim() : "";
|
|
return model || DEFAULT_PERPLEXITY_MODEL;
|
|
}
|
|
|
|
function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
|
|
try {
|
|
return new URL(baseUrl.trim()).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: "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(
|
|
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
|
|
(perplexity?.model && perplexity.model.trim()),
|
|
);
|
|
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 => typeof url === "string" && Boolean(url.trim()),
|
|
);
|
|
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;
|
|
if (url?.trim()) {
|
|
citations.push(url.trim());
|
|
}
|
|
}
|
|
}
|
|
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 resolveRuntimeTransport(params: {
|
|
searchConfig?: Record<string, unknown>;
|
|
resolvedKey?: string;
|
|
keySource: WebSearchCredentialResolutionSource;
|
|
fallbackEnvVar?: string;
|
|
}): PerplexityTransport | undefined {
|
|
const perplexity = params.searchConfig?.perplexity;
|
|
const scoped =
|
|
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
|
? (perplexity as { baseUrl?: string; model?: string })
|
|
: undefined;
|
|
const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : "";
|
|
const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : "";
|
|
const baseUrl = (() => {
|
|
if (configuredBaseUrl) {
|
|
return configuredBaseUrl;
|
|
}
|
|
if (params.keySource === "env") {
|
|
if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") return PERPLEXITY_DIRECT_BASE_URL;
|
|
if (params.fallbackEnvVar === "OPENROUTER_API_KEY") return DEFAULT_PERPLEXITY_BASE_URL;
|
|
}
|
|
if ((params.keySource === "config" || params.keySource === "secretRef") && params.resolvedKey) {
|
|
return inferPerplexityBaseUrlFromApiKey(params.resolvedKey) === "openrouter"
|
|
? DEFAULT_PERPLEXITY_BASE_URL
|
|
: PERPLEXITY_DIRECT_BASE_URL;
|
|
}
|
|
return DEFAULT_PERPLEXITY_BASE_URL;
|
|
})();
|
|
return configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl)
|
|
? "chat_completions"
|
|
: "search_api";
|
|
}
|
|
|
|
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,
|
|
): WebSearchProviderToolDefinition {
|
|
const perplexityConfig = resolvePerplexityConfig(searchConfig);
|
|
const schemaTransport =
|
|
runtimeTransport ??
|
|
(perplexityConfig.baseUrl || perplexityConfig.model ? "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),
|
|
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 as Record<string, unknown>;
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
|
|
return {
|
|
id: "perplexity",
|
|
label: "Perplexity Search",
|
|
hint: "Structured results · domain/country/language/time filters",
|
|
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
|
|
placeholder: "pplx-...",
|
|
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) => {
|
|
const perplexity = searchConfig?.perplexity;
|
|
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
|
? (perplexity as Record<string, unknown>).apiKey
|
|
: undefined;
|
|
},
|
|
setCredentialValue: (searchConfigTarget, value) => {
|
|
const scoped = searchConfigTarget.perplexity;
|
|
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
|
searchConfigTarget.perplexity = { apiKey: value };
|
|
return;
|
|
}
|
|
(scoped as Record<string, unknown>).apiKey = value;
|
|
},
|
|
getConfiguredCredentialValue: (config) =>
|
|
resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey,
|
|
setConfiguredCredentialValue: (configTarget, value) => {
|
|
setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value);
|
|
},
|
|
resolveRuntimeMetadata: (ctx) => ({
|
|
perplexityTransport: resolveRuntimeTransport({
|
|
searchConfig: {
|
|
...(ctx.searchConfig as SearchConfigRecord | undefined),
|
|
perplexity: {
|
|
...((ctx.searchConfig as SearchConfigRecord | undefined)?.perplexity as
|
|
| Record<string, unknown>
|
|
| undefined),
|
|
...(resolveProviderWebSearchPluginConfig(ctx.config, "perplexity") as
|
|
| Record<string, unknown>
|
|
| undefined),
|
|
},
|
|
},
|
|
resolvedKey: ctx.resolvedCredential?.value,
|
|
keySource: ctx.resolvedCredential?.source ?? "missing",
|
|
fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar,
|
|
}),
|
|
}),
|
|
createTool: (ctx) =>
|
|
createPerplexityToolDefinition(
|
|
(() => {
|
|
const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined;
|
|
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "perplexity");
|
|
if (!pluginConfig) {
|
|
return searchConfig;
|
|
}
|
|
return {
|
|
...(searchConfig ?? {}),
|
|
perplexity: {
|
|
...resolvePerplexityConfig(searchConfig),
|
|
...pluginConfig,
|
|
},
|
|
} as SearchConfigRecord;
|
|
})(),
|
|
ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined,
|
|
),
|
|
};
|
|
}
|
|
|
|
export const __testing = {
|
|
inferPerplexityBaseUrlFromApiKey,
|
|
resolvePerplexityBaseUrl,
|
|
resolvePerplexityModel,
|
|
resolvePerplexityTransport,
|
|
isDirectPerplexityBaseUrl,
|
|
resolvePerplexityRequestModel,
|
|
resolvePerplexityApiKey,
|
|
normalizeToIsoDate,
|
|
isoToPerplexityDate,
|
|
} as const;
|