From c86beb237e8c97972654f7f6f504d8367c8b2f51 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 17:19:57 -0400 Subject: [PATCH] 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. --- .../perplexity-web-search-provider.runtime.ts | 539 ++++++++++++++ .../perplexity-web-search-provider.test.ts | 2 +- .../src/perplexity-web-search-provider.ts | 680 ++---------------- extensions/perplexity/test-api.ts | 2 +- extensions/perplexity/web-search-provider.ts | 5 +- 5 files changed, 620 insertions(+), 608 deletions(-) create mode 100644 extensions/perplexity/src/perplexity-web-search-provider.runtime.ts diff --git a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts new file mode 100644 index 00000000000..570e53b73d6 --- /dev/null +++ b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts @@ -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>> { + const body: Record = { + 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 = { + 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, + searchConfig?: SearchConfigRecord, +): Promise> { + 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; diff --git a/extensions/perplexity/src/perplexity-web-search-provider.test.ts b/extensions/perplexity/src/perplexity-web-search-provider.test.ts index d507f605769..87bf1f2443f 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.test.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.test.ts @@ -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("_"); diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index 7faa62597c2..718ca1a14af 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -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 { + 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 { + const properties: Record = { + 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): 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>> { - const body: Record = { - 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 = { - 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, + 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; diff --git a/extensions/perplexity/test-api.ts b/extensions/perplexity/test-api.ts index c8d2a91ce71..6fec3a93f7f 100644 --- a/extensions/perplexity/test-api.ts +++ b/extensions/perplexity/test-api.ts @@ -1 +1 @@ -export { __testing } from "./src/perplexity-web-search-provider.js"; +export { __testing } from "./src/perplexity-web-search-provider.runtime.js"; diff --git a/extensions/perplexity/web-search-provider.ts b/extensions/perplexity/web-search-provider.ts index c501c44a28c..9200070af22 100644 --- a/extensions/perplexity/web-search-provider.ts +++ b/extensions/perplexity/web-search-provider.ts @@ -1,4 +1 @@ -export { - __testing, - createPerplexityWebSearchProvider, -} from "./src/perplexity-web-search-provider.js"; +export { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js";