diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py index fcb0c44099..a16314a1e0 100644 --- a/agent/web_search_registry.py +++ b/agent/web_search_registry.py @@ -57,7 +57,7 @@ def _configured_backend(capability: str) -> Optional[str]: # Paid providers first so existing paid setups don't get downgraded to a free # tier on upgrade; filtered by ``is_available()`` at walk time. -_LEGACY_PREFERENCE = ("firecrawl", "parallel", "tavily", "exa", "searxng", "brave-free", "ddgs") +_LEGACY_PREFERENCE = ("firecrawl", "parallel", "tavily", "perplexity", "exa", "searxng", "brave-free", "ddgs") # Anonymous public free tiers (see plugins/web/keyless_mcp.py); strictly last # resort, i.e. zero web credentials and no importable ddgs. Unpinned keyless diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e9a8176796..e384a4dca4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -945,7 +945,7 @@ _ENV_CONFIG_KEYS = frozenset({ 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', - 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'API_SERVER_KEY', + 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'PERPLEXITY_API_KEY', 'API_SERVER_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', @@ -2800,6 +2800,7 @@ _SHOW_CONFIG_API_KEYS = ( ("PARALLEL_API_KEY", "Parallel"), ("FIRECRAWL_API_KEY", "Firecrawl"), ("TAVILY_API_KEY", "Tavily"), + ("PERPLEXITY_API_KEY", "Perplexity"), ("BROWSERBASE_API_KEY", "Browserbase"), ("BROWSER_USE_API_KEY", "Browser Use"), ("FAL_KEY", "FAL")) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 7220316f53..234ef5dcc1 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2490,6 +2490,10 @@ OPTIONAL_ENV_VARS = { "Tavily API key for AI-native web search and extract (optional — keyless works when " "Tavily is selected)", "Tavily API key", "https://app.tavily.com/home", tools=["web_search", "web_extract"]), + "PERPLEXITY_API_KEY": _tool( + "Perplexity API key for the Search API web backend (ranked results + query-relevant page " + "snippets)", "Perplexity API key", "https://www.perplexity.ai/account/api", + tools=["web_search", "web_extract"]), "KEENABLE_API_KEY": _tool( "Keenable API key for fast independent-index web search and page fetch (optional — " "keyless free tier works without it)", "Keenable API key", "https://keenable.ai", diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index fa9e5b6053..00de1e38ae 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -167,7 +167,7 @@ _API_KEYS = [ ("DASHSCOPE_API_KEY", "dashscope"), ("HF_TOKEN", "huggingface"), ("NVIDIA_API_KEY", "nvidia"), ("AI_GATEWAY_API_KEY", "ai_gateway"), ("OPENCODE_ZEN_API_KEY", "opencode_zen"), ("OPENCODE_GO_API_KEY", "opencode_go"), ("COMMANDCODE_API_KEY", "commandcode"), - ("KILOCODE_API_KEY", "kilocode"), ("FIRECRAWL_API_KEY", "firecrawl"), ("TAVILY_API_KEY", "tavily"), + ("KILOCODE_API_KEY", "kilocode"), ("FIRECRAWL_API_KEY", "firecrawl"), ("TAVILY_API_KEY", "tavily"), ("PERPLEXITY_API_KEY", "perplexity"), ("KEENABLE_API_KEY", "keenable"), ("BROWSERBASE_API_KEY", "browserbase"), ("FAL_KEY", "fal"), ("ELEVENLABS_API_KEY", "elevenlabs"), ("GITHUB_TOKEN", "github"), ] diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index 5f7fb37013..9f551b9cb8 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -43,8 +43,8 @@ class _FeatureSpec: _FEATURES: Dict[str, _FeatureSpec] = { "web": _FeatureSpec( "Web tools", True, "firecrawl", "firecrawl", ("web", "backend"), - "Web search & extract (Firecrawl)", "Firecrawl/Exa/Parallel/Keenable key or SearXNG", - ("PARALLEL_API_KEY", "TAVILY_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"), + "Web search & extract (Firecrawl)", "Firecrawl/Exa/Parallel/Tavily/Perplexity/Keenable key or SearXNG", + ("PARALLEL_API_KEY", "TAVILY_API_KEY", "PERPLEXITY_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"), ), "image_gen": _FeatureSpec( "Image generation", True, "fal", "fal-queue", ("image_gen", "provider"), "Image generation (FAL)", "FAL key", @@ -296,10 +296,11 @@ def _web_feature(web_cfg: Dict[str, object], tool_enabled: bool, managed: bool, "firecrawl": direct_firecrawl, "parallel": _any_env("PARALLEL_API_KEY") and not web_gw, "tavily": (_any_env("TAVILY_API_KEY") or "tavily" in {backend, search_backend, extract_backend}) and not web_gw, + "perplexity": _any_env("PERPLEXITY_API_KEY") and not web_gw, "searxng": _any_env("SEARXNG_URL"), } web_managed = backend == "firecrawl" and managed and not direct_firecrawl - active = web_managed or direct.get(backend) or direct.get(search_backend) or (extract_backend == "tavily" and direct["tavily"]) + active = web_managed or direct.get(backend) or direct.get(search_backend) or (extract_backend in ("tavily", "perplexity") and direct[extract_backend]) return _state( "web", available=bool(managed or any(direct.values())), active=bool(tool_enabled and active), managed_by_nous=web_managed, toolset_enabled=tool_enabled, @@ -527,7 +528,7 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]: fal_direct = fal_key_is_configured() audio_direct = bool(resolve_openai_audio_api_key()) return { - "web": _any_env("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PARALLEL_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY", "SEARXNG_URL"), + "web": _any_env("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PARALLEL_API_KEY", "TAVILY_API_KEY", "PERPLEXITY_API_KEY", "EXA_API_KEY", "SEARXNG_URL"), # Env-configured keyless local backend: a reachable self-hosted SearXNG is a working web setup even # with no stored selection (the autodetect cascade in tools/web_tools.py picks it up), so it must # not be classified "unconfigured" and pre-checked (#92647). diff --git a/hermes_cli/setup_summary.py b/hermes_cli/setup_summary.py index 75ea280801..9ca7a0b82e 100644 --- a/hermes_cli/setup_summary.py +++ b/hermes_cli/setup_summary.py @@ -31,7 +31,7 @@ _BROWSER_MISSING_HINTS = { "Local browser": "npm install -g agent-browser && agent-browser install --with-deps"} _BROWSER_MISSING_DEFAULT = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browser Use or Browserbase" _WEB_MISSING = ("EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, TAVILY_API_KEY, " - "KEENABLE_API_KEY, or SEARXNG_URL") + "PERPLEXITY_API_KEY, KEENABLE_API_KEY, or SEARXNG_URL") _DONE_BANNER = ( "┌─────────────────────────────────────────────────────────┐", diff --git a/hermes_cli/status_auth.py b/hermes_cli/status_auth.py index 58c5a84d48..afea2536f2 100644 --- a/hermes_cli/status_auth.py +++ b/hermes_cli/status_auth.py @@ -51,7 +51,7 @@ _API_KEYS: dict[str, str | tuple[str, ...]] = { "xAI / Grok": "XAI_API_KEY", "NVIDIA NIM": "NVIDIA_API_KEY", "Z.AI / GLM": "GLM_API_KEY", "Kimi": "KIMI_API_KEY", "StepFun Step Plan": "STEPFUN_API_KEY", "MiniMax": "MINIMAX_API_KEY", "MiniMax-CN": "MINIMAX_CN_API_KEY", "DeepInfra": "DEEPINFRA_API_KEY", "Firecrawl": "FIRECRAWL_API_KEY", - "Tavily": "TAVILY_API_KEY", "Keenable": "KEENABLE_API_KEY", + "Tavily": "TAVILY_API_KEY", "Perplexity": "PERPLEXITY_API_KEY", "Keenable": "KEENABLE_API_KEY", "Browser Use": "BROWSER_USE_API_KEY", # Optional — local browser works without this "Browserbase": "BROWSERBASE_API_KEY", # Optional — direct credentials only "FAL": "FAL_KEY", "ElevenLabs": "ELEVENLABS_API_KEY", "GitHub": "GITHUB_TOKEN"} diff --git a/plugins/web/perplexity/__init__.py b/plugins/web/perplexity/__init__.py new file mode 100644 index 0000000000..292633ca3c --- /dev/null +++ b/plugins/web/perplexity/__init__.py @@ -0,0 +1,15 @@ +"""Perplexity web search + snippets plugin — bundled, auto-loaded. + +Backed by the Perplexity Search API over httpx. Both search and extract are +sync; the dispatcher in :mod:`tools.web_tools` handles the wrap when the +caller is async. +""" + +from __future__ import annotations + +from plugins.web.perplexity.provider import PerplexityWebSearchProvider + + +def register(ctx) -> None: + """Register the Perplexity provider with the plugin context.""" + ctx.register_web_search_provider(PerplexityWebSearchProvider()) diff --git a/plugins/web/perplexity/plugin.yaml b/plugins/web/perplexity/plugin.yaml new file mode 100644 index 0000000000..a19edb0c28 --- /dev/null +++ b/plugins/web/perplexity/plugin.yaml @@ -0,0 +1,7 @@ +name: web-perplexity +version: 1.0.0 +description: "Perplexity Search API web search and query-relevant page snippets. Requires PERPLEXITY_API_KEY — get one at https://www.perplexity.ai/account/api." +author: NousResearch +kind: backend +provides_web_providers: + - perplexity diff --git a/plugins/web/perplexity/provider.py b/plugins/web/perplexity/provider.py new file mode 100644 index 0000000000..187addb47f --- /dev/null +++ b/plugins/web/perplexity/provider.py @@ -0,0 +1,245 @@ +"""Perplexity web search + page snippets — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Two +capabilities advertised: + +- ``supports_search()`` -> True (Perplexity Search API ``POST /search``) +- ``supports_extract()`` -> True (``POST /sdk/content/snippets`` — the + query-relevant page-excerpt route behind ``pplx content snippets``) + +Both are sync — the underlying call is ``httpx.post(...)``. + +Config keys this provider responds to:: + + web: + search_backend: "perplexity" # explicit per-capability + extract_backend: "perplexity" # explicit per-capability + backend: "perplexity" # shared fallback for both + +Env vars:: + + PERPLEXITY_API_KEY=... # https://www.perplexity.ai/account/api (required) + PERPLEXITY_BASE_URL=... # optional override of https://api.perplexity.ai + +Keyed only — Perplexity has no anonymous tier, so this provider is not a +member of the zero-config keyless ring and never resolves without a key. + +Extract caveat: Perplexity's only supported page-content route returns the +passages of a page relevant to a *query* (elisions marked ``…``), not the +whole page. ``web_extract`` has no query, so the URL's own path words are +used as the relevance query, which approximates "what is this page about". +Use Firecrawl / Exa / Parallel when a verbatim full-page dump is required. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List +from urllib.parse import urlparse + +import httpx + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + +_DEFAULT_BASE_URL = "https://api.perplexity.ai" +_KEY_URL = "https://www.perplexity.ai/account/api" + +# Search API hard cap for search_type=web. +_MAX_SEARCH_RESULTS = 20 +# Snippet budgets (backend limits: max_tokens 1-16384, per page 1-4096). +_MAX_TOKENS = 16384 +_MAX_TOKENS_PER_PAGE = 4096 + + +def _missing_key_error() -> str: + return f"PERPLEXITY_API_KEY is not set. Get a key at {_KEY_URL}" + + +def _perplexity_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """POST to the Perplexity API and return the parsed JSON response. + + Raises ``ValueError`` when the key is missing or on any non-2xx status, + carrying the response body so Perplexity's own error text (invalid key, + BAD_REQUEST, rate limit) reaches the model verbatim. + """ + from agent.web_search_provider import get_provider_env + + api_key = get_provider_env("PERPLEXITY_API_KEY") + if not api_key: + raise ValueError(_missing_key_error()) + base_url = (get_provider_env("PERPLEXITY_BASE_URL") or _DEFAULT_BASE_URL).rstrip("/") + url = f"{base_url}/{endpoint.lstrip('/')}" + logger.info("Perplexity %s request to %s", endpoint, url) + + response = httpx.post( + url, + json=payload, + timeout=60, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + if response.status_code >= 400: + body = (response.text or "").strip() + raise ValueError(body or f"HTTP {response.status_code}") + return response.json() + + +def _normalize_search_results(response: Dict[str, Any]) -> Dict[str, Any]: + """Map Search API ``{results: [{title,url,snippet,...}]}`` to the tool shape.""" + web_results = [] + for i, result in enumerate(response.get("results") or []): + web_results.append( + { + "title": result.get("title", "") or "", + "url": result.get("url", "") or "", + "description": result.get("snippet", "") or "", + "position": i + 1, + } + ) + return {"success": True, "data": {"web": web_results}} + + +def _normalize_snippets(response: Dict[str, Any], urls: List[str]) -> List[Dict[str, Any]]: + """Map ``{results: [{url,text?,tokens_count?,error?}]}`` to extract documents. + + One document per requested URL, in request order. A URL the backend + omitted or flagged with ``error`` becomes a document carrying ``error`` + rather than raising — a 200 does not mean every page succeeded. + """ + by_url = {r.get("url", ""): r for r in (response.get("results") or []) if isinstance(r, dict)} + documents: List[Dict[str, Any]] = [] + for url in urls: + result = by_url.get(url, {}) + text = result.get("text") or "" + doc: Dict[str, Any] = { + "url": url, + "title": "", + "content": text, + "raw_content": text, + "metadata": {"sourceURL": url}, + } + error = result.get("error") + if error or not text: + doc["error"] = str(error) if error else "no content returned" + documents.append(doc) + return documents + + +def _query_for_urls(urls: List[str]) -> str: + """Derive a relevance query from URL path words (``/bloom-filter`` -> ``bloom filter``).""" + words: List[str] = [] + for url in urls: + parsed = urlparse(url) + for token in parsed.path.replace("-", " ").replace("_", " ").replace("/", " ").split(): + if token.lower() not in words and not token.isdigit(): + words.append(token.lower()) + if not parsed.path.strip("/"): + words.append(parsed.netloc) + return " ".join(words)[:500] or " ".join(urls)[:500] + + +class PerplexityWebSearchProvider(WebSearchProvider): + """Perplexity Search API (search) + content snippets (extract), keyed only.""" + + @property + def name(self) -> str: + return "perplexity" + + @property + def display_name(self) -> str: + return "Perplexity" + + def is_available(self) -> bool: + """Return True when ``PERPLEXITY_API_KEY`` is set to a non-empty value.""" + from agent.web_search_provider import get_provider_env + + return bool(get_provider_env("PERPLEXITY_API_KEY")) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a Perplexity Search API query. + + ``search_context_size: low`` keeps ``snippet`` at description length; + the default (``high``) returns multi-KB page excerpts per hit, which + belongs in ``web_extract`` rather than a results list. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Perplexity search: '%s' (limit=%d)", query, limit) + raw = _perplexity_request( + "search", + { + "query": query, + "max_results": max(1, min(limit, _MAX_SEARCH_RESULTS)), + "search_context_size": "low", + }, + ) + return _normalize_search_results(raw) + except ValueError as exc: + return {"success": False, "error": str(exc)} + except Exception as exc: # noqa: BLE001 — including httpx errors + logger.warning("Perplexity search error: %s", exc) + return {"success": False, "error": f"Perplexity search failed: {exc}"} + + def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + """Return query-relevant snippets for one or more URLs. + + Sync — the underlying call is httpx.post(...). Per-URL failures + become items with ``error``; a missing key errors every URL. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return [{"url": u, "error": "Interrupted", "title": ""} for u in urls] + + logger.info("Perplexity snippets: %d URL(s)", len(urls)) + raw = _perplexity_request( + "sdk/content/snippets", + { + "query": _query_for_urls(urls), + "urls": list(urls), + "max_tokens": _MAX_TOKENS, + "max_tokens_per_page": _MAX_TOKENS_PER_PAGE, + }, + ) + return _normalize_snippets(raw, list(urls)) + except ValueError as exc: + return [{"url": u, "title": "", "content": "", "error": str(exc)} for u in urls] + except Exception as exc: # noqa: BLE001 + logger.warning("Perplexity extract error: %s", exc) + return [ + {"url": u, "title": "", "content": "", "error": f"Perplexity extract failed: {exc}"} + for u in urls + ] + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Perplexity", + "badge": "paid", + "tag": ( + "Perplexity Search API — ranked, date-stamped web results plus " + "query-relevant page snippets for extract." + ), + "env_vars": [ + { + "key": "PERPLEXITY_API_KEY", + "prompt": "Perplexity API key", + "url": _KEY_URL, + }, + ], + "web_tier": "paid", + } diff --git a/tests/conftest.py b/tests/conftest.py index 0c0c892618..18d27c8de3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -187,6 +187,7 @@ _CREDENTIAL_NAMES = frozenset({ "PARALLEL_API_KEY", "EXA_API_KEY", "TAVILY_API_KEY", + "PERPLEXITY_API_KEY", "WANDB_API_KEY", "ELEVENLABS_API_KEY", "HONCHO_API_KEY", diff --git a/tests/plugins/web/test_web_search_provider_plugins.py b/tests/plugins/web/test_web_search_provider_plugins.py index 9a0f253147..5377243340 100644 --- a/tests/plugins/web/test_web_search_provider_plugins.py +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -83,6 +83,7 @@ class TestBundledPluginsRegister: "firecrawl", "keenable", "parallel", + "perplexity", "searxng", "tavily", "xai", @@ -98,6 +99,7 @@ class TestBundledPluginsRegister: ("parallel", True, True), ("keenable", True, True), ("tavily", True, True), + ("perplexity", True, True), ("firecrawl", True, True), # xai: search-only via Grok's agentic web_search tool. ("xai", True, False), @@ -119,7 +121,7 @@ class TestBundledPluginsRegister: @pytest.mark.parametrize( "plugin_name", - ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "keenable", "xai"], + ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "perplexity", "firecrawl", "keenable", "xai"], ) def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None: _ensure_plugins_loaded() diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py index e8fec5cf5c..f5c2c431fd 100644 --- a/tests/tools/conftest.py +++ b/tests/tools/conftest.py @@ -84,6 +84,7 @@ def register_all_web_providers(): from plugins.web.parallel.provider import ParallelWebSearchProvider from plugins.web.keenable.provider import KeenableWebSearchProvider from plugins.web.tavily.provider import TavilyWebSearchProvider + from plugins.web.perplexity.provider import PerplexityWebSearchProvider from plugins.web.searxng.provider import SearXNGWebSearchProvider from plugins.web.xai.provider import XAIWebSearchProvider @@ -96,6 +97,7 @@ def register_all_web_providers(): ParallelWebSearchProvider, KeenableWebSearchProvider, TavilyWebSearchProvider, + PerplexityWebSearchProvider, SearXNGWebSearchProvider, XAIWebSearchProvider, ): diff --git a/tests/tools/test_web_tools_perplexity.py b/tests/tools/test_web_tools_perplexity.py new file mode 100644 index 0000000000..529e3aa7a6 --- /dev/null +++ b/tests/tools/test_web_tools_perplexity.py @@ -0,0 +1,86 @@ +"""Perplexity web backend — search + snippets dispatch through the real tools.""" + +import asyncio +import json +import os +from unittest.mock import MagicMock, patch + +from tests.tools.conftest import register_all_web_providers + + +def _ok(payload): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = payload + resp.text = json.dumps(payload) + return resp + + +def test_search_dispatch_maps_search_api_shape(): + """web_search on backend=perplexity hits /search with Bearer auth and maps snippet→description.""" + import tools.web_tools as wt + + register_all_web_providers() + payload = { + "results": [ + {"title": "Bloom filter", "url": "https://en.wikipedia.org/wiki/Bloom_filter", + "snippet": "space-efficient probabilistic structure", "date": "2004-04-17"}, + ], + "id": "abc", + } + with patch.dict(os.environ, {"PERPLEXITY_API_KEY": "pplx-test"}), \ + patch.object(wt, "_get_search_backend", return_value="perplexity"), \ + patch("plugins.web.perplexity.provider.httpx.post", return_value=_ok(payload)) as post: + out = json.loads(wt.web_search_tool("bloom filter", limit=3)) + + assert post.call_args.args[0] == "https://api.perplexity.ai/search" + assert post.call_args.kwargs["headers"]["Authorization"] == "Bearer pplx-test" + body = post.call_args.kwargs["json"] + assert body["query"] == "bloom filter" + assert 1 <= body["max_results"] <= 20 # dispatcher bucket-rounds the fetch limit + assert body["search_context_size"] == "low" + assert out["success"] is True + assert out["data"]["web"][0] == { + "title": "Bloom filter", + "url": "https://en.wikipedia.org/wiki/Bloom_filter", + "description": "space-efficient probabilistic structure", + "position": 1, + } + + +def test_extract_dispatch_snippets_per_url_and_missing_key(): + """web_extract on backend=perplexity posts every URL to /sdk/content/snippets; + a URL the backend failed carries ``error`` instead of content; no key → error, no HTTP.""" + import tools.web_tools as wt + + register_all_web_providers() + urls = ["https://tokio.rs/tokio/tutorial", "https://docs.rs/smol"] + payload = {"results": [ + {"url": urls[0], "text": "Tokio is an asynchronous runtime … for Rust.", "tokens_count": 12}, + {"url": urls[1], "error": "Page not found or unavailable."}, + ]} + with patch.dict(os.environ, {"PERPLEXITY_API_KEY": "pplx-test"}), \ + patch.object(wt, "_get_extract_backend", return_value="perplexity"), \ + patch("plugins.web.perplexity.provider.httpx.post", return_value=_ok(payload)) as post: + out = json.loads(asyncio.run(wt.web_extract_tool(urls))) + + assert post.call_args.args[0] == "https://api.perplexity.ai/sdk/content/snippets" + body = post.call_args.kwargs["json"] + assert body["urls"] == urls + assert body["query"] == "tokio tutorial smol" + assert body["max_tokens_per_page"] <= body["max_tokens"] + by_url = {r["url"]: r for r in out["results"]} + assert "Tokio is an asynchronous runtime" in by_url[urls[0]]["content"] + assert by_url[urls[1]]["error"] == "Page not found or unavailable." + + with patch.dict(os.environ, {}, clear=False), \ + patch("plugins.web.perplexity.provider.httpx.post") as post: + os.environ.pop("PERPLEXITY_API_KEY", None) + from plugins.web.perplexity.provider import PerplexityWebSearchProvider + p = PerplexityWebSearchProvider() + assert p.is_available() is False + res = p.search("x") + assert res["success"] is False and "PERPLEXITY_API_KEY" in res["error"] + docs = p.extract(["https://example.com"]) + assert "PERPLEXITY_API_KEY" in docs[0]["error"] + post.assert_not_called() diff --git a/tools/web_tools.py b/tools/web_tools.py index 56d1b9045e..a94cfc7534 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -113,7 +113,8 @@ def _get_backend() -> str: # token's tier may not grant web access; the gateway then fails at runtime with no fallback). # Free tiers trail paid. backend_candidates = ( - ("tavily", _has_env("TAVILY_API_KEY")), ("exa", _has_env("EXA_API_KEY")), + ("tavily", _has_env("TAVILY_API_KEY")), ("perplexity", _has_env("PERPLEXITY_API_KEY")), + ("exa", _has_env("EXA_API_KEY")), ("parallel", _has_env("PARALLEL_API_KEY")), ("keenable", _has_env("KEENABLE_API_KEY")), ("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL")), ("firecrawl", _is_tool_gateway_ready()), ("searxng", _has_env("SEARXNG_URL")), @@ -183,6 +184,7 @@ _BUILTIN_AVAILABILITY = { "firecrawl": lambda: check_firecrawl_api_key(), "tavily": lambda: _has_env("TAVILY_API_KEY") or any(_configured_backend(k) == "tavily" for k in ("backend", "search_backend", "extract_backend")), + "perplexity": lambda: _has_env("PERPLEXITY_API_KEY"), "searxng": lambda: _has_env("SEARXNG_URL"), "brave-free": lambda: _has_env("BRAVE_SEARCH_API_KEY"), "ddgs": lambda: _ddgs_package_importable(), @@ -217,7 +219,7 @@ def _web_requires_env() -> list[str]: on ``managed_nous_tools_enabled()`` cost a synchronous portal HTTP refresh at every CLI startup. Contract: set var -> tool sees it; extras are harmless for the not-logged-in.""" return [ - "EXA_API_KEY", "PARALLEL_API_KEY", "TAVILY_API_KEY", "KEENABLE_API_KEY", "FIRECRAWL_API_KEY", + "EXA_API_KEY", "PARALLEL_API_KEY", "TAVILY_API_KEY", "PERPLEXITY_API_KEY", "KEENABLE_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", "TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_SCHEME", "TOOL_GATEWAY_USER_TOKEN", ] diff --git a/website/docs/developer-guide/web-search-provider-plugin.md b/website/docs/developer-guide/web-search-provider-plugin.md index 257df89548..2cce42ac6c 100644 --- a/website/docs/developer-guide/web-search-provider-plugin.md +++ b/website/docs/developer-guide/web-search-provider-plugin.md @@ -6,7 +6,7 @@ description: "How to build a web-search/extract/crawl backend plugin for Hermes # Building a Web Search Provider Plugin -Web-search provider plugins register a backend that services `web_search`, `web_extract`, and (optionally) deep-crawl tool calls. Built-in providers — Firecrawl, SearXNG, Tavily, Exa, Parallel, Keenable, Brave Search (free tier), xAI, and DDGS — all ship as plugins under `plugins/web//`. You can add a new one, or override a bundled one, by dropping a directory next to them. +Web-search provider plugins register a backend that services `web_search`, `web_extract`, and (optionally) deep-crawl tool calls. Built-in providers — Firecrawl, SearXNG, Tavily, Perplexity, Exa, Parallel, Keenable, Brave Search (free tier), xAI, and DDGS — all ship as plugins under `plugins/web//`. You can add a new one, or override a bundled one, by dropping a directory next to them. :::tip Web search is one of several **backend plugins** Hermes supports. The others (with their own ABCs) are [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin), [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin), [Memory Provider Plugins](/developer-guide/memory-provider-plugin), [Context Engine Plugins](/developer-guide/context-engine-plugin), and [Model Provider Plugins](/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/developer-guide/plugins). @@ -157,7 +157,7 @@ Full contract in `agent/web_search_provider.py`. Methods you may override: | `search(query, limit)` | conditional | raises | Required when `supports_search()` returns `True` | | `extract(urls, **kwargs)` | conditional | raises | Required when `supports_extract()` returns `True` | -Providers can advertise multiple capabilities from a single class — Firecrawl, Tavily, Keenable, Exa, and Parallel all implement both search and extract. Brave Search and DDGS are search-only; SearXNG is search-only with a documented "pair me with an extract provider" workflow. +Providers can advertise multiple capabilities from a single class — Firecrawl, Tavily, Perplexity, Keenable, Exa, and Parallel all implement both search and extract. Brave Search and DDGS are search-only; SearXNG is search-only with a documented "pair me with an extract provider" workflow. ## Response shape diff --git a/website/docs/integrations/index.md b/website/docs/integrations/index.md index 37bac9d8bf..370309310c 100644 --- a/website/docs/integrations/index.md +++ b/website/docs/integrations/index.md @@ -42,7 +42,7 @@ Quick setup example: ```yaml web: - backend: firecrawl # firecrawl | searxng | brave-free | ddgs | tavily | keenable | exa | parallel | xai + backend: firecrawl # firecrawl | searxng | brave-free | ddgs | tavily | perplexity | keenable | exa | parallel | xai ``` If `web.backend` is not set, the backend is auto-detected from whichever API key is available. Self-hosted Firecrawl is also supported via `FIRECRAWL_API_URL`. diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 82f7983f96..76edd5cb22 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -155,6 +155,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) | | `TAVILY_API_KEY` | Optional Tavily API key for higher search/extract limits. After selecting Tavily as the web backend, keyless access works without it ([app.tavily.com](https://app.tavily.com/home), [keyless docs](https://docs.tavily.com/documentation/keyless)) | | `TAVILY_BASE_URL` | Override the Tavily API endpoint. Useful for corporate proxies and self-hosted Tavily-compatible search backends. Same pattern as `GROQ_BASE_URL`. | +| `PERPLEXITY_API_KEY` | Perplexity Search API key for the `perplexity` web backend — ranked search results plus query-relevant page snippets for extract ([perplexity.ai/account/api](https://www.perplexity.ai/account/api)) | +| `PERPLEXITY_BASE_URL` | Override the Perplexity API endpoint (default `https://api.perplexity.ai`) for proxies (optional) | | `SEARXNG_URL` | SearXNG instance URL for free self-hosted web search — no API key required ([searxng.github.io](https://searxng.github.io/searxng/)) | | `EXA_API_KEY` | Exa API key for AI-native web search and contents ([exa.ai](https://exa.ai/)) | | `BRAVE_SEARCH_API_KEY` | Brave Search API subscription token for web search (free tier available) ([brave.com/search/api](https://brave.com/search/api/)) | diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 1775066553..44db9c8993 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -320,8 +320,8 @@ The single `video_generate` tool covers both modalities — pass `image_url` to | Tool | Description | Requires environment | |------|-------------|----------------------| -| `web_search` | Search the web for information. Returns up to 5 results by default with titles, URLs, and descriptions. Accepts an optional `limit` (1-100, default 5). The query is passed through to the configured backend, so operators such as `site:domain`, `filetype:pdf`, `intitle:word`, `-term`, and `"exact phrase"` may work when the backend supports them. | EXA_API_KEY or PARALLEL_API_KEY or FIRECRAWL_API_KEY or TAVILY_API_KEY or KEENABLE_API_KEY | -| `web_extract` | Extract content from web page URLs. Returns clean page content in markdown/text (no LLM summarization — fast). Also works with PDF URLs (arxiv papers, documents) — pass the PDF link directly. Pages within the char budget (default 15000) return whole; larger pages return a head+tail window with a footer pointing at the full text saved on disk. Max 5 URLs per call. | EXA_API_KEY or PARALLEL_API_KEY or FIRECRAWL_API_KEY or TAVILY_API_KEY or KEENABLE_API_KEY | +| `web_search` | Search the web for information. Returns up to 5 results by default with titles, URLs, and descriptions. Accepts an optional `limit` (1-100, default 5). The query is passed through to the configured backend, so operators such as `site:domain`, `filetype:pdf`, `intitle:word`, `-term`, and `"exact phrase"` may work when the backend supports them. | EXA_API_KEY or PARALLEL_API_KEY or FIRECRAWL_API_KEY or TAVILY_API_KEY or PERPLEXITY_API_KEY or KEENABLE_API_KEY | +| `web_extract` | Extract content from web page URLs. Returns clean page content in markdown/text (no LLM summarization — fast). Also works with PDF URLs (arxiv papers, documents) — pass the PDF link directly. Pages within the char budget (default 15000) return whole; larger pages return a head+tail window with a footer pointing at the full text saved on disk. Max 5 URLs per call. | EXA_API_KEY or PARALLEL_API_KEY or FIRECRAWL_API_KEY or TAVILY_API_KEY or PERPLEXITY_API_KEY or KEENABLE_API_KEY | ## `x_search` toolset diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 95668e8f15..a663d1b317 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2407,7 +2407,7 @@ The `web_search` and `web_extract` tools support five backend providers. Configu ```yaml web: - backend: firecrawl # firecrawl | searxng | parallel | tavily | keenable | exa + backend: firecrawl # firecrawl | searxng | parallel | tavily | perplexity | keenable | exa # Or use per-capability keys to mix providers (e.g. free search + paid extract): search_backend: "searxng" @@ -2437,9 +2437,10 @@ web: | **SearXNG** | `SEARXNG_URL` | ✔ | — | | **Parallel** | `PARALLEL_API_KEY` (optional — keyless free tier) | ✔ | ✔ | | **Tavily** | `TAVILY_API_KEY` (optional — keyless when selected) | ✔ | ✔ | +| **Perplexity** | `PERPLEXITY_API_KEY` | ✔ | ✔ (query-relevant snippets) | | **Exa** | `EXA_API_KEY` (optional — keyless free tier) | ✔ | ✔ | -**Backend selection:** The runtime always uses the stored `web.backend` selection (set via `hermes tools`; `nous` routes through the managed Tool Gateway). Only if no web backend has ever been selected is one auto-detected from available API keys: if only `SEARXNG_URL` is set, SearXNG is used; if only `EXA_API_KEY` is set, Exa; if only `TAVILY_API_KEY` is set, Tavily; if only `PARALLEL_API_KEY` is set, Parallel; if only `KEENABLE_API_KEY` is set, Keenable. With **no selection and no credentials at all**, requests rotate round-robin across the keyless free-tier ring (Exa / Parallel / Firecrawl / Keenable) with automatic next-in-line failover on rate limits — see the [Web Search guide](/user-guide/features/web-search) for details. Once a selection exists, adding a key to `.env` does not change the route. Selecting Tavily, Firecrawl, or Keenable in `hermes tools` also works without a key. +**Backend selection:** The runtime always uses the stored `web.backend` selection (set via `hermes tools`; `nous` routes through the managed Tool Gateway). Only if no web backend has ever been selected is one auto-detected from available API keys: if only `SEARXNG_URL` is set, SearXNG is used; if only `EXA_API_KEY` is set, Exa; if only `TAVILY_API_KEY` is set, Tavily; if only `PERPLEXITY_API_KEY` is set, Perplexity; if only `PARALLEL_API_KEY` is set, Parallel; if only `KEENABLE_API_KEY` is set, Keenable. With **no selection and no credentials at all**, requests rotate round-robin across the keyless free-tier ring (Exa / Parallel / Firecrawl / Keenable) with automatic next-in-line failover on rate limits — see the [Web Search guide](/user-guide/features/web-search) for details. Once a selection exists, adding a key to `.env` does not change the route. Selecting Tavily, Firecrawl, or Keenable in `hermes tools` also works without a key. **SearXNG** is a free, self-hosted, privacy-respecting metasearch engine that queries 70+ search engines. No API key needed — just set `SEARXNG_URL` to your instance (e.g., `http://localhost:8080`). SearXNG is search-only; `web_extract` requires a separate extract provider (set `web.extract_backend`). See the [Web Search setup guide](/user-guide/features/web-search) for Docker setup instructions. diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md index 100c6e50e8..bcd6b34255 100644 --- a/website/docs/user-guide/features/web-search.md +++ b/website/docs/user-guide/features/web-search.md @@ -25,10 +25,11 @@ Both are configured through a single backend selection. Providers are chosen via | **Exa** | `EXA_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless ring member · 1 000 searches/mo with key | | **Parallel** | `PARALLEL_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless ring member · paid with key | | **Tavily** | `TAVILY_API_KEY` (optional) | ✔ | ✔ | ✔ Opt-in keyless when selected | +| **Perplexity** | `PERPLEXITY_API_KEY` | ✔ | ✔ (query-relevant snippets) | Paid (per-request Search API pricing) | | **Keenable** | `KEENABLE_API_KEY` (optional) | ✔ | ✔ | ✔ Keyless ring member · paid with key | | **xAI (Grok)** | `XAI_API_KEY` or `hermes auth add xai-oauth` | ✔ | — | Paid (SuperGrok or per-token) | -Brave Search, DDGS, and xAI are **search-only** — pair any of them with Firecrawl/Tavily/Keenable/Exa/Parallel when you also need `web_extract`. DDGS uses the [`ddgs` Python package](https://pypi.org/project/ddgs/) under the hood; if it isn't already installed, run `pip install ddgs` (or let Hermes lazy-install it on first use). xAI runs Grok's server-side `web_search` tool on the Responses API — results are LLM-generated rather than index-backed, so titles, descriptions, and URL choice are all model output (see the [trust-model caveat](#xai-grok) below). +Brave Search, DDGS, and xAI are **search-only** — pair any of them with Firecrawl/Tavily/Perplexity/Keenable/Exa/Parallel when you also need `web_extract`. DDGS uses the [`ddgs` Python package](https://pypi.org/project/ddgs/) under the hood; if it isn't already installed, run `pip install ddgs` (or let Hermes lazy-install it on first use). xAI runs Grok's server-side `web_search` tool on the Responses API — results are LLM-generated rather than index-backed, so titles, descriptions, and URL choice are all model output (see the [trust-model caveat](#xai-grok) below). **Per-capability split:** you can use different providers for search and extract independently — for example SearXNG (free) for search and Firecrawl for extract. See [Per-capability configuration](#per-capability-configuration) below. @@ -267,7 +268,7 @@ SearXNG handles search; you need a separate provider for `web_extract`. Use the # ~/.hermes/config.yaml web: search_backend: "searxng" - extract_backend: "firecrawl" # or tavily, keenable, exa, parallel + extract_backend: "firecrawl" # or tavily, perplexity, keenable, exa, parallel ``` With this config, Hermes uses SearXNG for all search queries and Firecrawl for URL extraction — combining free search with high-quality extraction. @@ -288,6 +289,19 @@ Get a key at [app.tavily.com](https://app.tavily.com/home). See [Tavily keyless] --- +### Perplexity + +[Perplexity's Search API](https://docs.perplexity.ai/docs/search/quickstart) returns ranked, date-stamped results from Perplexity's own index (`web_search`). For `web_extract` it uses the same query-relevant *snippets* route as the official `pplx` CLI: you get the passages of each page that matter, with elisions marked `…`, rather than a verbatim full-page dump — pick Firecrawl / Exa / Parallel as `web.extract_backend` when you need the whole page. Keyed only; there is no anonymous tier. + +```bash +# ~/.hermes/.env +PERPLEXITY_API_KEY=pplx-your-key-here +``` + +Get a key at [perplexity.ai/account/api](https://www.perplexity.ai/account/api). Set `PERPLEXITY_BASE_URL` to route through a proxy. + +--- + ### Exa Neural search with semantic understanding. Good for research and finding conceptually related content. @@ -370,7 +384,7 @@ Set one provider for all web capabilities: ```yaml # ~/.hermes/config.yaml web: - backend: "searxng" # firecrawl | searxng | brave-free | ddgs | tavily | keenable | exa | parallel | xai + backend: "searxng" # firecrawl | searxng | brave-free | ddgs | tavily | perplexity | keenable | exa | parallel | xai ``` ### Per-capability configuration @@ -398,6 +412,7 @@ If no backend has **ever** been selected (no `web.backend` / per-capability key | Credential present | Auto-selected backend | |--------------------|-----------------------| | `TAVILY_API_KEY` | tavily | +| `PERPLEXITY_API_KEY` | perplexity | | `EXA_API_KEY` | exa | | `PARALLEL_API_KEY` | parallel | | `FIRECRAWL_API_KEY` or `FIRECRAWL_API_URL` (or the Nous Tool Gateway is ready) | firecrawl | @@ -454,7 +469,7 @@ SearXNG cannot extract URL content. Set `web.extract_backend` to a provider that ```yaml web: search_backend: "searxng" - extract_backend: "firecrawl" # or tavily / keenable / exa / parallel + extract_backend: "firecrawl" # or tavily / perplexity / keenable / exa / parallel ``` ### SearXNG returns 0 results