Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
12
extensions/perplexity/README.md
Normal file
12
extensions/perplexity/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# OpenClaw Perplexity Plugin
|
||||
|
||||
Official OpenClaw plugin for Perplexity.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/perplexity-plugin
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
See <https://docs.openclaw.ai/tools/perplexity-search> for setup and configuration.
|
||||
12
extensions/perplexity/index.ts
Normal file
12
extensions/perplexity/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Perplexity plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "perplexity",
|
||||
name: "Perplexity Plugin",
|
||||
description: "Bundled Perplexity plugin",
|
||||
register(api) {
|
||||
api.registerWebSearchProvider(createPerplexityWebSearchProvider());
|
||||
},
|
||||
});
|
||||
12
extensions/perplexity/npm-shrinkwrap.json
generated
Normal file
12
extensions/perplexity/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/perplexity-plugin",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/perplexity-plugin",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
58
extensions/perplexity/openclaw.plugin.json
Normal file
58
extensions/perplexity/openclaw.plugin.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"id": "perplexity",
|
||||
"icon": "https://cdn.simpleicons.org/perplexity",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "perplexity",
|
||||
"envVars": ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "Perplexity API Key",
|
||||
"help": "Perplexity or OpenRouter API key for web search.",
|
||||
"sensitive": true,
|
||||
"placeholder": "pplx-..."
|
||||
},
|
||||
"webSearch.baseUrl": {
|
||||
"label": "Perplexity Base URL",
|
||||
"help": "Optional Perplexity/OpenRouter chat-completions base URL override."
|
||||
},
|
||||
"webSearch.model": {
|
||||
"label": "Perplexity Model",
|
||||
"help": "Optional Sonar/OpenRouter model override."
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
"webSearchProviders": ["perplexity"]
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"webSearch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": ["string", "object"]
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
extensions/perplexity/package.json
Normal file
35
extensions/perplexity/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/perplexity-plugin",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Perplexity plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/perplexity-plugin",
|
||||
"npmSpec": "@openclaw/perplexity-plugin",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
// Perplexity provider module implements model/runtime integration.
|
||||
import {
|
||||
readPositiveIntegerParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
isoToPerplexityDate,
|
||||
MAX_SEARCH_COUNT,
|
||||
normalizeFreshness,
|
||||
normalizeToIsoDate,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readProviderEnvValue,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
throwWebSearchApiError,
|
||||
type SearchConfigRecord,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-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 buildPerplexityRequestHeaders(apiKey: string, acceptJson = false): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(acceptJson ? { Accept: "application/json" } : {}),
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-Title": "OpenClaw Web Search",
|
||||
};
|
||||
}
|
||||
|
||||
async function readPerplexityJsonResponse<T>(response: Response, label: string): Promise<T> {
|
||||
return await readProviderJsonResponse<T>(response, label);
|
||||
}
|
||||
|
||||
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 uniqueStrings(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 uniqueStrings(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: buildPerplexityRequestHeaders(params.apiKey, true),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
return await throwWebSearchApiError(res, "Perplexity Search");
|
||||
}
|
||||
const data = await readPerplexityJsonResponse<PerplexitySearchApiResponse>(
|
||||
res,
|
||||
"Perplexity Search",
|
||||
);
|
||||
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: buildPerplexityRequestHeaders(params.apiKey),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
return await throwWebSearchApiError(res, "Perplexity");
|
||||
}
|
||||
const data = await readPerplexityJsonResponse<PerplexitySearchResponse>(res, "Perplexity");
|
||||
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. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
const count =
|
||||
readPositiveIntegerParam(args, "count", {
|
||||
max: MAX_SEARCH_COUNT,
|
||||
message: `count must be an integer from 1 to ${MAX_SEARCH_COUNT}.`,
|
||||
}) ??
|
||||
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 = readPositiveIntegerParam(args, "max_tokens", {
|
||||
max: 1_000_000,
|
||||
message: "max_tokens must be a positive integer.",
|
||||
});
|
||||
const maxTokensPerPage = readPositiveIntegerParam(args, "max_tokens_per_page", {
|
||||
message: "max_tokens_per_page must be a positive integer.",
|
||||
});
|
||||
|
||||
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,
|
||||
readPerplexityJsonResponse,
|
||||
normalizeToIsoDate,
|
||||
isoToPerplexityDate,
|
||||
} as const;
|
||||
export { testing as __testing };
|
||||
@@ -0,0 +1,125 @@
|
||||
// Perplexity provider module implements model/runtime integration.
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
mergeScopedSearchConfig,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
export const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
|
||||
|
||||
const PERPLEXITY_CREDENTIAL_PATH = "plugins.entries.perplexity.config.webSearch.apiKey";
|
||||
const PERPLEXITY_ONBOARDING_SCOPES: Array<"text-inference"> = ["text-inference"];
|
||||
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||
|
||||
export type PerplexityTransport = "search_api" | "chat_completions";
|
||||
type PerplexityRuntimeTransportContext = {
|
||||
searchConfig?: Record<string, unknown>;
|
||||
resolvedKey?: string;
|
||||
keySource: "config" | "secretRef" | "env" | "missing";
|
||||
fallbackEnvVar?: string;
|
||||
};
|
||||
|
||||
export function createPerplexityWebSearchProviderBase() {
|
||||
return {
|
||||
id: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
hint: "Requires Perplexity API key or OpenRouter API key · structured results",
|
||||
onboardingScopes: [...PERPLEXITY_ONBOARDING_SCOPES],
|
||||
credentialLabel: "Perplexity API key",
|
||||
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: PERPLEXITY_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: PERPLEXITY_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "scoped", scopeId: "perplexity" },
|
||||
configuredCredential: { pluginId: "perplexity" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePerplexityWebSearchRuntimeMetadata(
|
||||
ctx: Parameters<NonNullable<WebSearchProviderPlugin["resolveRuntimeMetadata"]>>[0],
|
||||
) {
|
||||
return {
|
||||
perplexityTransport: resolvePerplexityRuntimeTransport({
|
||||
searchConfig: mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"perplexity",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"),
|
||||
),
|
||||
resolvedKey: ctx.resolvedCredential?.value,
|
||||
keySource: ctx.resolvedCredential?.source ?? "missing",
|
||||
fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function inferPerplexityBaseUrlFromApiKey(
|
||||
apiKey?: string,
|
||||
): "direct" | "openrouter" | undefined {
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeLowercaseStringOrEmpty(apiKey);
|
||||
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return "direct";
|
||||
}
|
||||
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return "openrouter";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
return (
|
||||
normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePerplexityRuntimeTransport(
|
||||
params: PerplexityRuntimeTransportContext,
|
||||
): 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 = normalizeOptionalString(scoped?.baseUrl) ?? "";
|
||||
const configuredModel = normalizeOptionalString(scoped?.model) ?? "";
|
||||
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";
|
||||
}
|
||||
193
extensions/perplexity/src/perplexity-web-search-provider.test.ts
Normal file
193
extensions/perplexity/src/perplexity-web-search-provider.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Perplexity tests cover perplexity web search provider plugin behavior.
|
||||
import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
import { createPerplexityWebSearchProvider } 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("_");
|
||||
const openRouterPerplexityApiKey = ["sk", "or", "v1", "test"].join("-");
|
||||
const directPerplexityApiKey = ["pplx", "test"].join("-");
|
||||
const enterprisePerplexityApiKey = ["enterprise", "perplexity", "test"].join("-");
|
||||
|
||||
describe("perplexity web search provider", () => {
|
||||
it("points missing-key users to fetch/browser alternatives", async () => {
|
||||
await withEnvAsync(
|
||||
{ [perplexityApiKeyEnv]: undefined, [openRouterApiKeyEnv]: undefined },
|
||||
async () => {
|
||||
const provider = createPerplexityWebSearchProvider();
|
||||
const tool = provider.createTool({ config: {}, searchConfig: {} });
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await expect(tool.execute({ query: "OpenClaw docs" })).resolves.toEqual({
|
||||
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. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("infers provider routing from api key prefixes", () => {
|
||||
expect(testing.inferPerplexityBaseUrlFromApiKey("pplx-abc")).toBe("direct");
|
||||
expect(testing.inferPerplexityBaseUrlFromApiKey("sk-or-v1-abc")).toBe("openrouter");
|
||||
expect(testing.inferPerplexityBaseUrlFromApiKey("unknown")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves base url from auth source and request model by transport", () => {
|
||||
expect(testing.resolvePerplexityBaseUrl(undefined, "perplexity_env")).toBe(
|
||||
"https://api.perplexity.ai",
|
||||
);
|
||||
expect(testing.resolvePerplexityBaseUrl(undefined, "openrouter_env")).toBe(
|
||||
"https://openrouter.ai/api/v1",
|
||||
);
|
||||
expect(
|
||||
testing.resolvePerplexityRequestModel("https://api.perplexity.ai", "perplexity/sonar-pro"),
|
||||
).toBe("sonar-pro");
|
||||
expect(
|
||||
testing.resolvePerplexityRequestModel("https://openrouter.ai/api/v1", "perplexity/sonar-pro"),
|
||||
).toBe("perplexity/sonar-pro");
|
||||
});
|
||||
|
||||
it("chooses direct search_api transport only for direct base urls without legacy overrides", () => {
|
||||
expect(
|
||||
testing.resolvePerplexityTransport({
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
}).transport,
|
||||
).toBe("chat_completions");
|
||||
|
||||
expect(
|
||||
testing.resolvePerplexityTransport({
|
||||
apiKey: "pplx-secret",
|
||||
}).transport,
|
||||
).toBe("search_api");
|
||||
});
|
||||
|
||||
it("prefers explicit baseUrl over key-based defaults", () => {
|
||||
expect(
|
||||
testing.resolvePerplexityBaseUrl({ baseUrl: "https://example.com" }, "config", "pplx-123"),
|
||||
).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("resolves OpenRouter env auth and transport", () => {
|
||||
withEnv(
|
||||
{ [perplexityApiKeyEnv]: undefined, [openRouterApiKeyEnv]: openRouterPerplexityApiKey },
|
||||
() => {
|
||||
expect(testing.resolvePerplexityApiKey(undefined)).toEqual({
|
||||
apiKey: openRouterPerplexityApiKey,
|
||||
source: "openrouter_env",
|
||||
});
|
||||
expect(testing.resolvePerplexityTransport(undefined)).toEqual({
|
||||
apiKey: openRouterPerplexityApiKey,
|
||||
source: "openrouter_env",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
model: "perplexity/sonar-pro",
|
||||
transport: "chat_completions",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native Search API for direct Perplexity when no legacy overrides exist", () => {
|
||||
withEnv(
|
||||
{ [perplexityApiKeyEnv]: directPerplexityApiKey, [openRouterApiKeyEnv]: undefined },
|
||||
() => {
|
||||
expect(testing.resolvePerplexityTransport(undefined)).toEqual({
|
||||
apiKey: directPerplexityApiKey,
|
||||
source: "perplexity_env",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro",
|
||||
transport: "search_api",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("switches direct Perplexity to chat completions when model override is configured", () => {
|
||||
expect(testing.resolvePerplexityModel({ model: "perplexity/sonar-reasoning-pro" })).toBe(
|
||||
"perplexity/sonar-reasoning-pro",
|
||||
);
|
||||
expect(
|
||||
testing.resolvePerplexityTransport({
|
||||
apiKey: directPerplexityApiKey,
|
||||
model: "perplexity/sonar-reasoning-pro",
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: directPerplexityApiKey,
|
||||
source: "config",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-reasoning-pro",
|
||||
transport: "chat_completions",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats unrecognized configured keys as direct Perplexity by default", () => {
|
||||
expect(
|
||||
testing.resolvePerplexityTransport({
|
||||
apiKey: enterprisePerplexityApiKey,
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: enterprisePerplexityApiKey,
|
||||
source: "config",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro",
|
||||
transport: "search_api",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["max_tokens", 0, "max_tokens must be a positive integer."],
|
||||
["max_tokens", 1.5, "max_tokens must be a positive integer."],
|
||||
["max_tokens", 1_000_001, "max_tokens must be a positive integer."],
|
||||
["max_tokens_per_page", 1.5, "max_tokens_per_page must be a positive integer."],
|
||||
])("rejects invalid native token budget %s=%s", async (key, value, message) => {
|
||||
await withEnvAsync(
|
||||
{ [perplexityApiKeyEnv]: directPerplexityApiKey, [openRouterApiKeyEnv]: undefined },
|
||||
async () => {
|
||||
const provider = createPerplexityWebSearchProvider();
|
||||
const tool = provider.createTool({ config: {}, searchConfig: {} });
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await expect(tool.execute({ query: "OpenClaw docs", [key]: value })).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("reports malformed Search API JSON with a stable provider error", async () => {
|
||||
await expect(
|
||||
testing.readPerplexityJsonResponse(new Response("{ nope"), "Perplexity Search"),
|
||||
).rejects.toThrow("Perplexity Search: malformed JSON response");
|
||||
});
|
||||
|
||||
it("reports malformed chat completion JSON with a stable provider error", async () => {
|
||||
await expect(
|
||||
testing.readPerplexityJsonResponse(new Response("{ nope"), "Perplexity"),
|
||||
).rejects.toThrow("Perplexity: malformed JSON response");
|
||||
});
|
||||
|
||||
it("bounds successful Perplexity JSON bodies before parsing", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
await expect(
|
||||
testing.readPerplexityJsonResponse(streamed.response, "Perplexity Search"),
|
||||
).rejects.toThrow("Perplexity Search: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
121
extensions/perplexity/src/perplexity-web-search-provider.ts
Normal file
121
extensions/perplexity/src/perplexity-web-search-provider.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
// Perplexity provider module implements model/runtime integration.
|
||||
import {
|
||||
mergeScopedSearchConfig,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
type WebSearchProviderPlugin,
|
||||
type WebSearchProviderToolDefinition,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
createPerplexityWebSearchProviderBase,
|
||||
resolvePerplexityWebSearchRuntimeMetadata,
|
||||
} from "./perplexity-web-search-provider.shared.js";
|
||||
|
||||
const loadPerplexityWebSearchRuntime = createLazyRuntimeModule(
|
||||
() => import("./perplexity-web-search-provider.runtime.js"),
|
||||
);
|
||||
|
||||
function createPerplexityParameters(transport?: string): Record<string, unknown> {
|
||||
const properties: Record<string, unknown> = {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "integer",
|
||||
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.",
|
||||
};
|
||||
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: "integer",
|
||||
description: "Native Perplexity Search API only. Total content budget across all results.",
|
||||
minimum: 1,
|
||||
maximum: 1000000,
|
||||
};
|
||||
properties.max_tokens_per_page = {
|
||||
type: "integer",
|
||||
description: "Native Perplexity Search API only. Max tokens extracted per page.",
|
||||
minimum: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required: ["query"],
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
function createPerplexityToolDefinition(
|
||||
searchConfig?: Record<string, unknown>,
|
||||
runtimeTransport?: string,
|
||||
): WebSearchProviderToolDefinition {
|
||||
const schemaTransport =
|
||||
runtimeTransport ??
|
||||
(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: createPerplexityParameters(schemaTransport),
|
||||
execute: async (args) => {
|
||||
const { executePerplexitySearch } = await loadPerplexityWebSearchRuntime();
|
||||
return await executePerplexitySearch(args, searchConfig);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createPerplexityWebSearchProviderBase(),
|
||||
resolveRuntimeMetadata: resolvePerplexityWebSearchRuntimeMetadata,
|
||||
createTool: (ctx) =>
|
||||
createPerplexityToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
ctx.searchConfig,
|
||||
"perplexity",
|
||||
resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"),
|
||||
),
|
||||
ctx.runtimeMetadata?.perplexityTransport,
|
||||
),
|
||||
};
|
||||
}
|
||||
2
extensions/perplexity/test-api.ts
Normal file
2
extensions/perplexity/test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Perplexity API module exposes the plugin public contract.
|
||||
export { testing, testing as __testing } from "./src/perplexity-web-search-provider.runtime.js";
|
||||
16
extensions/perplexity/tsconfig.json
Normal file
16
extensions/perplexity/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
14
extensions/perplexity/web-search-contract-api.ts
Normal file
14
extensions/perplexity/web-search-contract-api.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Perplexity API module exposes the plugin public contract.
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
import {
|
||||
createPerplexityWebSearchProviderBase,
|
||||
resolvePerplexityWebSearchRuntimeMetadata,
|
||||
} from "./src/perplexity-web-search-provider.shared.js";
|
||||
|
||||
export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createPerplexityWebSearchProviderBase(),
|
||||
resolveRuntimeMetadata: resolvePerplexityWebSearchRuntimeMetadata,
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
2
extensions/perplexity/web-search-provider.ts
Normal file
2
extensions/perplexity/web-search-provider.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Perplexity provider module implements model/runtime integration.
|
||||
export { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js";
|
||||
Reference in New Issue
Block a user