feat(web): Perplexity Search API as a web_search + web_extract backend

Adds plugins/web/perplexity — a keyed-only WebSearchProvider over httpx:

- search: POST https://api.perplexity.ai/search (documented Search API),
  search_context_size=low so `snippet` stays description-sized;
  results[].snippet -> description, max_results capped at the API's 20.
- extract: POST /sdk/content/snippets — the query-relevant page-excerpt
  route behind `pplx content snippets` (the CLI's `content fetch` is
  deprecated upstream). web_extract has no query, so the URLs' path words
  serve as the relevance query; per-URL `error` entries survive a 200.
- Wired into the same touchpoints as the other keyed vendors: legacy
  backend set + credential ladder + availability probe (web_tools),
  registry preference walk, OPTIONAL_ENV_VARS, `hermes config`/status/
  dump key lists, nous_subscription direct-credential detection, setup
  summary, test conftests, docs.

Not a keyless-ring member (Perplexity has no anonymous tier). Related
closed PRs #9192 / #23981 / #45225 predate the plugin ABC.
This commit is contained in:
Teknium
2026-09-03 02:49:21 -07:00
parent d3630f8532
commit f1ccf436a2
21 changed files with 407 additions and 23 deletions
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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"))
+4
View File
@@ -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",
+1 -1
View File
@@ -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"),
]
+5 -4
View File
@@ -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).
+1 -1
View File
@@ -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 = (
"┌─────────────────────────────────────────────────────────┐",
+1 -1
View File
@@ -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"}
+15
View File
@@ -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())
+7
View File
@@ -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
+245
View File
@@ -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",
}
+1
View File
@@ -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",
@@ -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()
+2
View File
@@ -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,
):
+86
View File
@@ -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()
+4 -2
View File
@@ -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",
]
@@ -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/<name>/`. 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/<name>/`. 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
+1 -1
View File
@@ -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`.
@@ -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/)) |
+2 -2
View File
@@ -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
+3 -2
View File
@@ -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.
+19 -4
View File
@@ -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