diff --git a/README.es.md b/README.es.md index 0fe2c374c9..17ba71aa03 100644 --- a/README.es.md +++ b/README.es.md @@ -91,7 +91,7 @@ hermes doctor # Diagnostica cualquier problema Hermes funciona con cualquier proveedor que quieras — eso no cambiará. Pero si prefieres no recopilar cinco claves API separadas para el modelo, búsqueda web, generación de imágenes, TTS y un navegador en la nube, **[Nous Portal](https://portal.nousresearch.com)** las cubre todas bajo una sola suscripción: - **Más de 300 modelos** — elige cualquiera con `/model ` -- **Tool Gateway** — búsqueda web (Firecrawl), generación de imágenes (FAL), texto a voz (OpenAI), navegador en la nube (Browser Use), todo enrutado a través de tu suscripción. Sin cuentas adicionales. +- **Tool Gateway** — búsqueda web, generación de imágenes (FAL), texto a voz (OpenAI), navegador en la nube (Browser Use), todo enrutado a través de tu suscripción. Sin cuentas adicionales. Un comando desde una instalación nueva: diff --git a/README.md b/README.md index 0a8ef1e443..93bab974fd 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ hermes doctor # Diagnose any issues Hermes works with whatever provider you want — that's not changing. But if you'd rather not collect five separate API keys for the model, web search, image generation, TTS, and a cloud browser, **[Nous Portal](https://portal.nousresearch.com)** covers all of them under one subscription: - **300+ models** — pick any of them with `/model ` -- **Tool Gateway** — web search (Firecrawl), image generation (FAL), text-to-speech (OpenAI), cloud browser (Browser Use), all routed through your sub. No extra accounts. +- **Tool Gateway** — web search, image generation (FAL), text-to-speech (OpenAI), cloud browser (Browser Use), all routed through your sub. No extra accounts. One command from a fresh install: diff --git a/README.ur-pk.md b/README.ur-pk.md index 3cb67640c2..1cf357a1a7 100644 --- a/README.ur-pk.md +++ b/README.ur-pk.md @@ -106,7 +106,7 @@ hermes doctor # کسی بھی مسئلے کی تشخیص کریں ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے: - **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model ` کے ذریعے منتخب کریں -- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔ +- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔ نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے: diff --git a/README.zh-CN.md b/README.zh-CN.md index bf9f4a33db..44c22e1ec1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -77,7 +77,7 @@ hermes doctor # 诊断问题 Hermes 始终允许你使用任意服务商,这点不会改变。但如果你不想为模型、网页搜索、图像生成、TTS、云浏览器分别去申请五个不同的 API Key,**[Nous Portal](https://portal.nousresearch.com)** 用一个订阅就能覆盖全部: - **300+ 模型** — 用 `/model ` 随时切换 -- **Tool Gateway** — 网页搜索(Firecrawl)、图像生成(FAL)、文本转语音(OpenAI)、云浏览器(Browser Use),全部通过订阅托管。无需额外注册任何账户。 +- **Tool Gateway** — 网页搜索、图像生成(FAL)、文本转语音(OpenAI)、云浏览器(Browser Use),全部通过订阅托管。无需额外注册任何账户。 全新安装时一条命令即可: diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index dc792e38ad..cd4d4ee369 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -42,7 +42,7 @@ class _FeatureSpec: _FEATURES: Dict[str, _FeatureSpec] = { "web": _FeatureSpec( "Web tools", True, "firecrawl", "firecrawl", ("web", "backend"), - "Web search & extract (Firecrawl)", "Firecrawl/Exa/Parallel/Tavily/Perplexity/Keenable key or SearXNG", + "Web search & extract", "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( diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py index 389a22b347..f5feedecf5 100644 --- a/hermes_cli/portal_cli.py +++ b/hermes_cli/portal_cli.py @@ -12,7 +12,7 @@ SUBSCRIPTION_URL = "https://portal.nousresearch.com/manage-subscription" DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway" # Static `portal tools` catalog — the partners Tool Gateway routes to today: (key, label, partner). _CATALOG = [ - ("web", "Web search & extract", "Firecrawl"), + ("web", "Web search & extract", "Nous-managed"), ("image_gen", "Image generation", "FAL"), ("tts", "Text-to-speech", "OpenAI TTS"), ("browser", "Browser automation", "Browser Use"), diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index bd266cae49..95ded5341f 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -283,9 +283,9 @@ TOOL_CATEGORIES = { # Provider rows come from plugins.web. via _plugin_web_search_providers(). Only the two # non-provider firecrawl setup-flow rows live here: managed via Nous subscription, and self-hosted. "providers": [ - {"name": "Nous Subscription", "badge": "subscription", "tag": "Managed Firecrawl billed to your subscription", + {"name": "Nous Subscription", "badge": "subscription", "tag": "Managed web search and extract billed to your subscription", "web_backend": "firecrawl", "env_vars": [], **_NOUS, "managed_nous_feature": "web", - "override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"]}, + "override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PERPLEXITY_API_KEY"]}, {"name": "Firecrawl Self-Hosted", "badge": "free · self-hosted", "tag": "Run your own Firecrawl instance (Docker)", "web_backend": "firecrawl", "env_vars": [_key("FIRECRAWL_API_URL", "Your Firecrawl instance URL (e.g., http://localhost:3002)")]}, diff --git a/hermes_cli/tools_config_providers.py b/hermes_cli/tools_config_providers.py index 09ffe32bfa..d710dce9c5 100644 --- a/hermes_cli/tools_config_providers.py +++ b/hermes_cli/tools_config_providers.py @@ -835,7 +835,8 @@ def _print_provider_selection(provider: dict, managed_feature, *, reconfigure: b _print_success(f" Browser engine set to: {provider['browser_engine']}") if provider.get("web_backend"): tier = f" ({provider['web_tier']} tier)" if reconfigure and provider.get("web_tier") else "" - _print_success(f" Web backend set to: {provider['web_backend']}{tier}") + backend = NOUS_MANAGED_PROVIDER if managed_feature else provider["web_backend"] + _print_success(f" Web backend set to: {backend}{tier}") if reconfigure and provider.get("computer_use_backend"): _print_success(f" Computer Use backend set to: {provider['computer_use_backend']}") diff --git a/plugins/web/perplexity/provider.py b/plugins/web/perplexity/provider.py index f3bf2731d1..b2d446f5d8 100644 --- a/plugins/web/perplexity/provider.py +++ b/plugins/web/perplexity/provider.py @@ -18,11 +18,12 @@ Config keys this provider responds to:: Env vars:: - PERPLEXITY_API_KEY=... # https://www.perplexity.ai/account/api (required) + PERPLEXITY_API_KEY=... # required for direct search and extract 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. +No anonymous tier. The Nous Subscription selection serves search through +``perplexity-gateway.`` using the Nous token; a direct +key takes precedence. Managed extract stays on Firecrawl. Extract caveat: Perplexity's only supported page-content route returns the passages of a page relevant to a *query* (elisions marked ``…``), not the @@ -69,8 +70,18 @@ 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. +def _managed_gateway(token_reader=None): + """Nous Tool Gateway config when web_search is on the managed route, else None.""" + from tools import managed_tool_gateway as gw + from tools.web_tools import _managed_web_search + + if not _managed_web_search(): + return None + return gw.resolve_managed_tool_gateway("perplexity", token_reader=token_reader) + + +def _perplexity_request(endpoint: str, payload: Dict[str, Any], gateway=None) -> Dict[str, Any]: + """POST to Perplexity or the supplied gateway; return parsed JSON. 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, @@ -79,9 +90,14 @@ def _perplexity_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any from agent.web_search_provider import get_provider_env api_key = get_provider_env("PERPLEXITY_API_KEY") - if not api_key: + headers = _HEADERS + if gateway is not None: + # Nous-owned key behind the gateway: identify the harness only, not a per-user integration. + base_url, api_key, headers = gateway.gateway_origin.rstrip("/"), gateway.nous_user_token, {"User-Agent": _HEADERS["User-Agent"]} + elif api_key: + base_url = (get_provider_env("PERPLEXITY_BASE_URL") or _DEFAULT_BASE_URL).rstrip("/") + else: 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) @@ -92,7 +108,7 @@ def _perplexity_request(endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", - **_HEADERS, + **headers, }, ) if response.status_code >= 400: @@ -156,7 +172,7 @@ def _query_for_urls(urls: List[str]) -> str: class PerplexityWebSearchProvider(WebSearchProvider): - """Perplexity Search API (search) + content snippets (extract), keyed only.""" + """Direct or managed search; direct-key content snippets for extract.""" @property def name(self) -> str: @@ -167,10 +183,11 @@ class PerplexityWebSearchProvider(WebSearchProvider): return "Perplexity" def is_available(self) -> bool: - """Return True when ``PERPLEXITY_API_KEY`` is set to a non-empty value.""" + """True with a ``PERPLEXITY_API_KEY``, or on the managed route with a likely-usable Nous token.""" from agent.web_search_provider import get_provider_env + from tools.managed_tool_gateway import peek_nous_access_token - return bool(get_provider_env("PERPLEXITY_API_KEY")) + return bool(get_provider_env("PERPLEXITY_API_KEY")) or _managed_gateway(token_reader=peek_nous_access_token) is not None def supports_search(self) -> bool: return True @@ -191,15 +208,24 @@ class PerplexityWebSearchProvider(WebSearchProvider): 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", - }, - ) + from agent.web_search_provider import get_provider_env + from tools.web_tools import _managed_web_search + + direct = bool(get_provider_env("PERPLEXITY_API_KEY")) + gateway = None if direct else _managed_gateway() + if gateway is None and not direct and _managed_web_search(): + from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, selection_error + raise ValueError(selection_error( + "web", NOUS_MANAGED_PROVIDER, "the Nous Tool Gateway is not available (not entitled or unreachable)")) + logger.info("Perplexity search: '%s' (limit=%d%s)", query, limit, ", managed" if gateway else "") + payload = { + "query": query, + "max_results": max(1, min(limit, _MAX_SEARCH_RESULTS)), + "search_context_size": "low", + } + if gateway is not None: + payload["search_type"] = "fast" + raw = _perplexity_request("search", payload, gateway) return _normalize_search_results(raw) except ValueError as exc: return {"success": False, "error": str(exc)} diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 33d08bd2b5..500397850a 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -220,7 +220,7 @@ def test_prompt_enable_tool_gateway_pool_offers_covered_tools_only(monkeypatch): ns.prompt_enable_tool_gateway(config) blob = " ".join(captured["items"]).lower() - assert "firecrawl" in blob # web offered + assert "web search & extract" in blob # web offered assert "video" not in blob # video NOT offered to a pool user diff --git a/tests/hermes_cli/test_web_provider_selection.py b/tests/hermes_cli/test_web_provider_selection.py new file mode 100644 index 0000000000..ed27b4cbdf --- /dev/null +++ b/tests/hermes_cli/test_web_provider_selection.py @@ -0,0 +1,20 @@ +"""Web setup reports the selected backend without interrupting config writes.""" + +import pytest + + +@pytest.mark.parametrize("managed,backend", [(True, "firecrawl"), (False, "perplexity"), (False, "exa")]) +def test_web_selection_reports_the_backend_it_writes(monkeypatch, managed, backend): + import hermes_cli.tools_config_providers as providers + + monkeypatch.setattr(providers, "_nous_provider_gate", lambda *a, **kw: True) + messages = [] + monkeypatch.setattr(providers, "_print_success", messages.append) + row = {"name": "Test provider", "web_backend": backend, "env_vars": []} + if managed: + row["managed_nous_feature"] = "web" + config = {} + providers._configure_provider(row, config) + selected = config["web"]["backend"] + assert selected == ("nous" if managed else backend) + assert f" Web backend set to: {selected}" in messages diff --git a/tests/tools/test_web_managed_routing.py b/tests/tools/test_web_managed_routing.py new file mode 100644 index 0000000000..fc88e64672 --- /dev/null +++ b/tests/tools/test_web_managed_routing.py @@ -0,0 +1,136 @@ +"""Real config, dispatch and local HTTP must preserve web billing boundaries.""" + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread + +import pytest +import httpx + + +@pytest.fixture +def local_gateway(monkeypatch): + from tools import managed_tool_gateway + + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + requests.append((self.path, dict(self.headers), body)) + if self.path == "/v2/search": + status = 200 + payload = {"success": True, "data": {"web": [{ + "title": "Local fixture", "url": "https://example.test", "description": "fixture", + }]}} + else: + status, payload = 503, {"error": "local test outage"} + content = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + def log_message(self, format, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_port}" + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "test-nous-token") + monkeypatch.setenv("PERPLEXITY_GATEWAY_URL", base + "/perplexity") + monkeypatch.setenv("FIRECRAWL_GATEWAY_URL", base) + monkeypatch.setenv("PERPLEXITY_BASE_URL", base + "/direct") + # Only entitlement is stubbed: route and credential resolution remain real. + monkeypatch.setattr(managed_tool_gateway, "managed_nous_tools_enabled", lambda **kw: True) + + # CI omits the firecrawl extra; stand in for the SDK, still over real HTTP. + class FirecrawlSDK: + def __init__(self, api_key, api_url): + self.api_key, self.api_url = api_key, api_url + + def search(self, query, limit): + response = httpx.post( + f"{self.api_url}/v2/search", json={"query": query, "limit": limit}, + headers={"Authorization": f"Bearer {self.api_key}"}, + ) + return response.json() + + monkeypatch.setattr("plugins.web.firecrawl.provider.Firecrawl", FirecrawlSDK) + monkeypatch.setattr("tools.web_tools._firecrawl_client", None, raising=False) + try: + yield requests + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +@pytest.mark.parametrize( + "selection,direct_key,expected_paths", + [ + ({"backend": "nous"}, False, ["/perplexity/search", "/v2/search"]), + ({"backend": "nous"}, True, ["/direct/search"]), + ({"backend": "nous", "search_backend": "perplexity"}, True, ["/direct/search"]), + ({"search_backend": "perplexity"}, False, []), + ({"backend": "nous", "search_backend": "perplexity"}, False, []), + ({"backend": "perplexity"}, False, []), + ({"backend": "firecrawl", "search_backend": "perplexity"}, False, []), + ({}, False, ["/perplexity/search", "/v2/search"]), + ({"extract_backend": "exa"}, False, ["/perplexity/search", "/v2/search"]), + ], +) +def test_only_managed_search_may_use_billed_fallback( + monkeypatch, tmp_path, local_gateway, selection, direct_key, expected_paths, +): + from hermes_cli.config import atomic_config_write + from tools import web_tools + from tests.tools.conftest import register_all_web_providers + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + atomic_config_write(tmp_path / "config.yaml", { + "web": {**selection, "keyless_rescue": False, "cache_enabled": True}, + }) + if direct_key: + monkeypatch.setenv("PERPLEXITY_API_KEY", "test-direct-key") + else: + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + register_all_web_providers() + expected = [ + (path, "Bearer test-direct-key" if path == "/direct/search" else "Bearer test-nous-token") + for path in expected_paths + ] + for attempt in (1, 2): + result = json.loads(web_tools.web_search_tool("local fixture", limit=3)) + # A fallback must not be cached: the identical query retries Perplexity. + assert [(path, headers["Authorization"]) for path, headers, _ in local_gateway] == expected * attempt + assert result["success"] == bool(expected_paths and expected_paths[-1] == "/v2/search") + if result["success"]: + assert result["data"]["fallback_from"] == "managed_primary" + assert "local test outage" in result["data"]["backend_error"] + assert web_tools._get_extract_backend() == selection.get("extract_backend", "firecrawl") + for path, headers, body in local_gateway: + if path != "/v2/search": + managed = path == "/perplexity/search" + assert body.get("search_type") == ("fast" if managed else None) + assert body["search_context_size"] == "low" + assert headers.get("X-Pplx-Integration") == (None if managed else "hermes-agent") + + +def test_unentitled_managed_search_names_the_gateway(monkeypatch, tmp_path, local_gateway): + from hermes_cli.config import atomic_config_write + from tools import managed_tool_gateway, web_tools + from tests.tools.conftest import register_all_web_providers + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + atomic_config_write(tmp_path / "config.yaml", {"web": {"backend": "nous", "keyless_rescue": False}}) + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + monkeypatch.setattr(managed_tool_gateway, "managed_nous_tools_enabled", lambda **kw: False) + register_all_web_providers() + + error = json.loads(web_tools.web_search_tool("local fixture", limit=3))["error"] + assert "Nous Tool Gateway" in error and "hermes tools" in error + assert "PERPLEXITY_API_KEY" not in error + assert local_gateway == [] diff --git a/tools/web_tools.py b/tools/web_tools.py index 22b00afa2a..81062b2921 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -20,7 +20,7 @@ from plugins.web.firecrawl.provider import _is_tool_gateway_ready, check_firecra from tools.debug_helpers import DebugSession from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_selection, selection_exists from tools.url_safety import async_is_safe_url -from tools.web_tools_rescue import _rescue_eligible, _rescue_search +from tools.web_tools_rescue import _managed_search_fallback, _rescue_eligible, _rescue_search from tools.web_tools_truncate import _effective_char_limit, _trim_results, _truncate_results, convert_base64_images_to_links from tools.web_tools_extract import ( _extract_safe_urls, _merge_in_order, _no_provider_error, _resolve_extract_provider, _result_entry, @@ -151,9 +151,22 @@ def _keyless_backend() -> Optional[str]: return None +def _managed_web_search() -> bool: + """True when web_search is on the managed Nous route: the stored ``nous`` selection, or a + never-configured install whose autodetect lands on the gateway. A stored vendor selection never is.""" + if _configured_backend("search_backend"): + return False + selected = read_selection("web") + if selected is not None: + return selected == NOUS_MANAGED_PROVIDER + return _get_backend() == "firecrawl" and not (_has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL")) and _is_tool_gateway_ready() + + def _get_search_backend() -> str: - """Backend for web_search: ``web.search_backend`` (strict, no probe) > ``web.backend`` > autodetect.""" - return _configured_backend("search_backend") or _get_backend() + """Backend for web_search: ``web.search_backend`` (strict, no probe) > ``web.backend`` > autodetect. + The managed Nous route serves search from Perplexity (extract stays on Firecrawl); managed Firecrawl + is the per-call fallback, see ``_memoized_search``.""" + return _configured_backend("search_backend") or ("perplexity" if _managed_web_search() else _get_backend()) def _get_extract_backend() -> str: @@ -190,7 +203,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"), + "perplexity": lambda: _has_env("PERPLEXITY_API_KEY") or _managed_web_search(), "searxng": lambda: _has_env("SEARXNG_URL"), "brave-free": lambda: _has_env("BRAVE_SEARCH_API_KEY"), "ddgs": lambda: _ddgs_package_importable(), @@ -226,7 +239,7 @@ def _web_requires_env() -> list[str]: Contract: set var -> tool sees it; extras are harmless for the not-logged-in.""" return [ "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", + "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", "PERPLEXITY_GATEWAY_URL", "TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_SCHEME", "TOOL_GATEWAY_USER_TOKEN", ] @@ -328,14 +341,25 @@ def _memoized_search(provider, query: str, limit: int) -> dict: fetch_limit = bucket_limit(limit) try: resp = provider.search(query, fetch_limit) - except Exception as exc: # noqa: BLE001 — candidate for rescue - if not _rescue_eligible(provider): + except Exception as exc: # noqa: BLE001 — candidate for fallback / rescue + served = _served_after_failure(str(exc), fetch_limit) + if served is None: raise - return _rescue_search(provider.name, str(exc), query, fetch_limit), True - if not resp.get("success") and _rescue_eligible(provider): - return _rescue_search(provider.name, str(resp.get("error", "")), query, fetch_limit), True + return served, True + if not resp.get("success"): + served = _served_after_failure(str(resp.get("error", "")), fetch_limit) + if served is not None: + return served, True return resp, False + def _served_after_failure(error: str, fetch_limit: int) -> Optional[dict]: + """Managed Firecrawl for a failed managed Perplexity call, else the one-shot keyless rescue when + eligible; None means the vendor's own failure stands.""" + fallback = _managed_search_fallback(provider, error, query, fetch_limit) + if fallback is not None: + return fallback + return _rescue_search(provider.name, error, query, fetch_limit) if _rescue_eligible(provider) else None + response_data = search_memo.lookup(provider.name, query, limit) if response_data is None: with search_memo.flight_lock(provider.name, query, limit): diff --git a/tools/web_tools_rescue.py b/tools/web_tools_rescue.py index 1e9a65efaa..4ae364cd3b 100644 --- a/tools/web_tools_rescue.py +++ b/tools/web_tools_rescue.py @@ -47,6 +47,30 @@ def _ring_vendor_keyless(name: str) -> bool: return use_keyless(name, get_provider_env(key_var) if key_var else "") +def _managed_search_fallback(provider, original_error: str, query: str, limit: int): + """Try managed Firecrawl for this call only; None leaves the original error for keyless rescue.""" + from agent.web_search_provider import get_provider_env + from tools.web_tools import _managed_web_search + if (getattr(provider, "name", "") != "perplexity" + or get_provider_env("PERPLEXITY_API_KEY") or not _managed_web_search()): + return None + logger.warning("web_search managed Perplexity failed (%s); serving this call from managed Firecrawl", (original_error or "")[:200]) + try: + from agent.web_search_registry import get_provider + resp = get_provider("firecrawl").search(query, limit) + except Exception as exc: # noqa: BLE001 — fallback is best-effort + resp = {"success": False, "error": str(exc)} + if not resp.get("success"): + logger.warning("managed Firecrawl fallback failed too: %s", str(resp.get("error", ""))[:200]) + return None + resp.setdefault("data", {}).update( + fallback_from="managed_primary", + backend_error=f"Primary managed search failed this call ({(original_error or 'unknown error')[:300]}); " + "result served by the managed fallback. The next call will use the primary again.", + ) + return resp + + def _rescue_eligible(provider) -> bool: """True when a failed call on *provider* should get a one-shot rescue. diff --git a/website/docs/guides/run-hermes-with-nous-portal.md b/website/docs/guides/run-hermes-with-nous-portal.md index 75ecba41d2..8cda466a76 100644 --- a/website/docs/guides/run-hermes-with-nous-portal.md +++ b/website/docs/guides/run-hermes-with-nous-portal.md @@ -14,7 +14,7 @@ This guide walks you through running Hermes Agent on a [Nous Portal](https://por - A web browser on the machine you're setting up (or SSH port forwarding — see [OAuth over SSH](./oauth-over-ssh.md)) - About 5 minutes -You do **not** need: an OpenAI key, an Anthropic key, a Firecrawl account, a FAL account, a Browser Use account, or any other per-vendor credential. That's the whole point. +You do **not** need: an OpenAI key, an Anthropic key, a web search account, a FAL account, a Browser Use account, or any other per-vendor credential. That's the whole point. ## 1. Get a subscription @@ -91,7 +91,7 @@ Try something that exercises both the model and the Tool Gateway: Hey, search the web for "Hermes Agent release notes" and summarize the top 3 hits. ``` -You should see Hermes call `web_search` (Firecrawl-backed, through the gateway) and respond with a summary. If the search runs and the response makes sense, you're done — the Portal is wired up end to end. +You should see Hermes call `web_search` (through the gateway) and respond with a summary. If the search runs and the response makes sense, you're done — the Portal is wired up end to end. ## 5. Pick the model you actually want @@ -257,7 +257,7 @@ hermes auth logout nous # wipes the local refresh token | Without Portal | With Portal | |----------------|-------------| | 1× OpenRouter / Anthropic / OpenAI key in `.env` | 1× OAuth refresh token, no `.env` keys | -| 1× Firecrawl key for web | Web routed through gateway | +| 1× web search key | Web routed through gateway | | 1× FAL key for image gen | Image gen routed through gateway | | 1× Browser Use / Browserbase key for browser | Browser routed through gateway | | 1× OpenAI key for TTS / voice mode | TTS routed through gateway | diff --git a/website/docs/integrations/nous-portal.md b/website/docs/integrations/nous-portal.md index e4170a68f4..40868778f3 100644 --- a/website/docs/integrations/nous-portal.md +++ b/website/docs/integrations/nous-portal.md @@ -54,13 +54,13 @@ The same subscription unlocks the [Tool Gateway](../user-guide/features/tool-gat | Tool | Partner | What it does | |------|---------|--------------| -| **Web search & extract** | Firecrawl | Agent-grade search and full-page extraction. No Firecrawl API key, no rate limit babysitting. | +| **Web search & extract** | Nous-managed | Agent-grade search and full-page extraction. No search API key, no rate limit babysitting. | | **Image generation** | FAL | Nine models under one endpoint: FLUX 2 Klein 9B, FLUX 2 Pro, Z-Image Turbo, Nano Banana Pro (Gemini 3 Pro Image), GPT Image 1.5, GPT Image 2, Ideogram V3, Recraft V4 Pro, Qwen Image. | | **Text-to-speech** | OpenAI TTS | High-quality TTS without a separate OpenAI key. Enables [voice mode](../user-guide/features/voice-mode.md) across messaging platforms. | | **Cloud browser automation** | Browser Use | Headless Chromium sessions for `browser_navigate`, `browser_click`, `browser_type`, `browser_vision`. No Browserbase account needed. | | **Cloud terminal sandbox** | Modal | Serverless terminal sandboxes for code execution (optional add-on). | -Without the gateway, hooking each of those up means a Firecrawl account, a FAL account, a Browser Use account, an OpenAI key, and a Modal account — five separate signups, five separate dashboards, five separate top-up flows. With the gateway, all of it routes through one subscription. +Without the gateway, hooking each of those up means a web search account, a FAL account, a Browser Use account, an OpenAI key, and a Modal account — five separate signups, five separate dashboards, five separate top-up flows. With the gateway, all of it routes through one subscription. You can also enable just specific gateway tools (e.g. web search but not image generation) — see [Mixing the gateway with your own backends](#mixing-the-gateway-with-your-own-backends) below. @@ -70,7 +70,7 @@ Because everything routes through one OAuth-authenticated Portal session, you do ### Cross-platform parity -[Native Windows](../user-guide/windows-native.md) makes per-tool API key setup its rough edge — installing a Firecrawl account, a FAL account, a Browser Use account, an OpenAI key from Windows is the highest-friction part of getting a useful agent. A Portal subscription smooths that out: one OAuth covers the model and every gateway tool, so Windows users get the same experience as macOS/Linux without manually configuring four backends. +[Native Windows](../user-guide/windows-native.md) makes per-tool API key setup its rough edge — installing a web search account, a FAL account, a Browser Use account, an OpenAI key from Windows is the highest-friction part of getting a useful agent. A Portal subscription smooths that out: one OAuth covers the model and every gateway tool, so Windows users get the same experience as macOS/Linux without manually configuring four backends. ## A note on Hermes 4 diff --git a/website/docs/user-guide/features/tool-gateway.md b/website/docs/user-guide/features/tool-gateway.md index 2ed437f297..552135ce06 100644 --- a/website/docs/user-guide/features/tool-gateway.md +++ b/website/docs/user-guide/features/tool-gateway.md @@ -9,7 +9,7 @@ sidebar_position: 2 **One subscription. Every tool built in.** -The Tool Gateway is included with every paid [Nous Portal](https://portal.nousresearch.com) subscription. It routes Hermes' tool calls — web search, image generation, text-to-speech, and cloud browser automation — through infrastructure Nous already runs, so you don't have to sign up with Firecrawl, FAL, OpenAI, Browser Use, or anyone else just to make your agent useful. +The Tool Gateway is included with every paid [Nous Portal](https://portal.nousresearch.com) subscription. It routes Hermes' tool calls — web search, image generation, text-to-speech, and cloud browser automation — through infrastructure Nous already runs, so you don't have to sign up with a web search vendor, FAL, OpenAI, Browser Use, or anyone else just to make your agent useful.
Start or manage subscription → @@ -19,7 +19,7 @@ The Tool Gateway is included with every paid [Nous Portal](https://portal.nousre | | Tool | What you get | |---|---|---| -| 🔍 | **Web search & extract** | Agent-grade web search and full-page extraction via Firecrawl. No rate limits to worry about — the gateway handles scaling. | +| 🔍 | **Web search & extract** | Agent-grade web search and full-page extraction, managed by Nous. No rate limits to worry about — the gateway handles scaling. | | 🎨 | **Image generation** | Nine models under one endpoint: **FLUX 2 Klein 9B**, **FLUX 2 Pro**, **Z-Image Turbo**, **Nano Banana Pro** (Gemini 3 Pro Image), **GPT Image 1.5**, **GPT Image 2**, **Ideogram V3**, **Recraft V4 Pro**, **Qwen Image**. Pick per-generation with a flag, or let Hermes default to FLUX 2 Klein. | | 🔊 | **Text-to-speech** | OpenAI TTS voices wired into the `text_to_speech` tool. Drop voice notes into Telegram, generate audio for pipelines, narrate anything. | | 🌐 | **Cloud browser automation** | Headless Chromium sessions via Browser Use. `browser_navigate`, `browser_click`, `browser_type`, `browser_vision` — all the agent-driving primitives, no Browserbase account required. | @@ -31,7 +31,7 @@ All four are pay-as-you-use billed against your Nous subscription. Use any combi Building an agent that can actually *do things* means stitching together 5+ API subscriptions — each with their own signup, rate limits, billing, and quirks. The gateway collapses that into one account: - **One bill.** Pay Nous; we handle the rest. -- **One signup.** No Firecrawl, FAL, Browser Use, or OpenAI audio accounts to manage. +- **One signup.** No web search, FAL, Browser Use, or OpenAI audio accounts to manage. - **One key.** Your Nous Portal OAuth covers every tool. - **Same quality.** Same backends the direct-key route uses — just fronted by us. @@ -99,7 +99,7 @@ The gateway is per-tool. Turn it on for just what you want: - **All tools through Nous** — easiest; one subscription, done. - **Gateway for web + images, bring your own TTS** — keep your ElevenLabs voice, let Nous handle the rest. -- **Gateway only for things you don't have keys for** — "I already pay for Browserbase, but I don't want a Firecrawl account" works fine. +- **Gateway only for things you don't have keys for** — "I already pay for Browserbase, but I don't want a web search account" works fine. Switch any tool at any time via: diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md index a6dc15bfaf..81e515d344 100644 --- a/website/docs/user-guide/features/web-search.md +++ b/website/docs/user-guide/features/web-search.md @@ -45,7 +45,7 @@ A fresh install with **no web credentials at all** gets working `web_search` and **Choosing free vs paid explicitly:** in `hermes tools`, Exa, Parallel, and Keenable each appear as two rows — **Free (keyless)** and **Paid (API key)**. Picking Free pins that vendor's anonymous endpoint (even if you later add a key); picking Paid pins the keyed path (a missing key then errors instead of silently downgrading to the free tier). The selection is stored as `web.provider_tier.: free|paid`; leave it unset for auto (key present → paid, otherwise the keyless ring). :::tip Nous Subscribers -If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription, web search and extract are available through the **[Tool Gateway](tool-gateway.md)** via managed Firecrawl — no API key needed. New installs can run `hermes setup --portal` to log in and turn on all gateway tools at once; existing installs can flip just web via `hermes tools`. +If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription, web search and extract are available through the **[Tool Gateway](tool-gateway.md)** as managed web search — no API key needed. New installs can run `hermes setup --portal` to log in and turn on all gateway tools at once; existing installs can flip just web via `hermes tools`. ::: --- @@ -440,7 +440,8 @@ If no shared backend has **ever** been selected (no `web.backend` written by you | `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 | +| `FIRECRAWL_API_KEY` or `FIRECRAWL_API_URL` | firecrawl | +| Nous Tool Gateway ready (Portal subscription) | managed web search via the Tool Gateway | | `SEARXNG_URL` | searxng | | `BRAVE_SEARCH_API_KEY` | brave-free | | `ddgs` package importable | ddgs | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md index 2c312207f3..60f8078f51 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md @@ -14,7 +14,7 @@ description: "完整操作指南:订阅、配置、切换模型、启用 gatew - 在你正在配置的机器上有可用的浏览器(或 SSH 端口转发——参见 [OAuth over SSH](./oauth-over-ssh.md)) - 约 5 分钟时间 -你**不需要**:OpenAI 密钥、Anthropic 密钥、Firecrawl 账号、FAL 账号、Browser Use 账号,或任何其他按供应商分配的凭证。这正是 Portal 的意义所在。 +你**不需要**:OpenAI 密钥、Anthropic 密钥、网页搜索账号、FAL 账号、Browser Use 账号,或任何其他按供应商分配的凭证。这正是 Portal 的意义所在。 ## 1. 获取订阅 @@ -91,7 +91,7 @@ hermes chat Hey, search the web for "Hermes Agent release notes" and summarize the top 3 hits. ``` -你应该看到 Hermes 调用 `web_search`(通过 gateway 由 Firecrawl 提供支持)并返回摘要。如果搜索正常执行且响应内容合理,说明配置完成——Portal 已端到端连通。 +你应该看到 Hermes 调用 `web_search`(通过 gateway)并返回摘要。如果搜索正常执行且响应内容合理,说明配置完成——Portal 已端到端连通。 ## 5. 选择你实际需要的模型 @@ -254,7 +254,7 @@ hermes auth logout nous # 清除本地 refresh token | 不使用 Portal | 使用 Portal | |----------------|-------------| | 1 个 OpenRouter / Anthropic / OpenAI 密钥写入 `.env` | 1 个 OAuth refresh token,无需 `.env` 密钥 | -| 1 个 Firecrawl 密钥用于网页搜索 | 网页搜索通过 gateway 路由 | +| 1 个网页搜索密钥 | 网页搜索通过 gateway 路由 | | 1 个 FAL 密钥用于图像生成 | 图像生成通过 gateway 路由 | | 1 个 Browser Use / Browserbase 密钥用于浏览器 | 浏览器通过 gateway 路由 | | 1 个 OpenAI 密钥用于 TTS / 语音模式 | TTS 通过 gateway 路由 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md index 19a15c31fa..ecb8fa8b11 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md @@ -50,13 +50,13 @@ Portal 代理了来自整个生态系统的精选 agentic 模型目录——统 | 工具 | 合作方 | 功能说明 | |------|---------|--------------| -| **网页搜索与抓取** | Firecrawl | Agent 级搜索与整页内容提取。无需 Firecrawl API 密钥,无需管理速率限制。 | +| **网页搜索与抓取** | Nous 托管 | Agent 级搜索与整页内容提取。无需搜索 API 密钥,无需管理速率限制。 | | **图像生成** | FAL | 单一端点下的九个模型:FLUX 2 Klein 9B、FLUX 2 Pro、Z-Image Turbo、Nano Banana Pro(Gemini 3 Pro Image)、GPT Image 1.5、GPT Image 2、Ideogram V3、Recraft V4 Pro、Qwen Image。 | | **文字转语音** | OpenAI TTS | 无需独立 OpenAI 密钥的高质量 TTS。在各消息平台上启用[语音模式](../user-guide/features/voice-mode.md)。 | | **云端浏览器自动化** | Browser Use | 用于 `browser_navigate`、`browser_click`、`browser_type`、`browser_vision` 的无头 Chromium 会话。无需 Browserbase 账号。 | | **云端终端沙箱** | Modal | 用于代码执行的无服务器终端沙箱(可选附加项)。 | -不使用 gateway 的话,接入上述每项服务意味着:一个 Firecrawl 账号、一个 FAL 账号、一个 Browser Use 账号、一个 OpenAI 密钥、一个 Modal 账号——五次独立注册、五个独立控制台、五套独立充值流程。使用 gateway 后,所有内容通过一个订阅统一路由。 +不使用 gateway 的话,接入上述每项服务意味着:一个网页搜索账号、一个 FAL 账号、一个 Browser Use 账号、一个 OpenAI 密钥、一个 Modal 账号——五次独立注册、五个独立控制台、五套独立充值流程。使用 gateway 后,所有内容通过一个订阅统一路由。 你也可以只启用特定的 gateway 工具(例如只开启网页搜索,不开启图像生成)——详见下方[将 gateway 与自有后端混用](#mixing-the-gateway-with-your-own-backends)。 @@ -66,7 +66,7 @@ Portal 代理了来自整个生态系统的精选 agentic 模型目录——统 ### 跨平台一致性 -[原生 Windows](../user-guide/windows-native.md) 上,逐个配置 API 密钥是其最大痛点——在 Windows 上分别安装 Firecrawl 账号、FAL 账号、Browser Use 账号、OpenAI 密钥,是整个 agent 配置过程中摩擦最高的部分。Portal 订阅消除了这一问题:一次 OAuth 覆盖模型和所有 gateway 工具,Windows 用户无需手动配置四个后端,即可获得与 macOS/Linux 相同的体验。 +[原生 Windows](../user-guide/windows-native.md) 上,逐个配置 API 密钥是其最大痛点——在 Windows 上分别安装网页搜索账号、FAL 账号、Browser Use 账号、OpenAI 密钥,是整个 agent 配置过程中摩擦最高的部分。Portal 订阅消除了这一问题:一次 OAuth 覆盖模型和所有 gateway 工具,Windows 用户无需手动配置四个后端,即可获得与 macOS/Linux 相同的体验。 ## 关于 Hermes 4 的说明 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tool-gateway.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tool-gateway.md index 45574815dd..9bb5c8862f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tool-gateway.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/tool-gateway.md @@ -1,6 +1,6 @@ --- title: "Nous Tool Gateway(工具网关)" -description: "通过 Nous 订阅统一使用网页搜索、文生图、语音合成与浏览器自动化,无需单独申请 Firecrawl、FAL、OpenAI、Browser Use 等 API Key" +description: "通过 Nous 订阅统一使用网页搜索、文生图、语音合成与浏览器自动化,无需单独申请网页搜索、FAL、OpenAI、Browser Use 等 API Key" sidebar_label: "Tool Gateway" sidebar_position: 2 --- @@ -11,13 +11,13 @@ sidebar_position: 2 Tool Gateway 包含在付费 Nous Portal 订阅中。**[管理订阅 →](https://portal.nousresearch.com/manage-subscription)** ::: -**Tool Gateway** 让已付费的 [Nous Portal](https://portal.nousresearch.com) 用户通过同一份订阅,直接使用网页搜索、文生图、语音合成(TTS)与浏览器自动化,而**不必**再分别注册 Firecrawl、FAL、OpenAI、Browser Use 等服务的 API Key。 +**Tool Gateway** 让已付费的 [Nous Portal](https://portal.nousresearch.com) 用户通过同一份订阅,直接使用网页搜索、文生图、语音合成(TTS)与浏览器自动化,而**不必**再分别注册网页搜索、FAL、OpenAI、Browser Use 等服务的 API Key。 ## 包含能力 | 工具 | 作用 | 若不用网关,可改用 | |------|------|---------------------| -| **网页搜索与抓取** | 通过 Firecrawl 搜索并抽取页面内容 | `FIRECRAWL_API_KEY`、`EXA_API_KEY`、`PARALLEL_API_KEY`、`TAVILY_API_KEY` | +| **网页搜索与抓取** | 由 Nous 托管的搜索与页面内容抽取 | `FIRECRAWL_API_KEY`、`EXA_API_KEY`、`PARALLEL_API_KEY`、`TAVILY_API_KEY` | | **文生图** | 通过 FAL 生成图像(8 个模型:FLUX 2 Klein/Pro、GPT-Image、Nano Banana Pro、Ideogram、Recraft V4 Pro、Qwen、Z-Image) | `FAL_KEY` | | **语音合成** | 通过 OpenAI TTS 将文字转为语音 | `VOICE_TOOLS_OPENAI_KEY`、`ELEVENLABS_API_KEY` | | **浏览器自动化** | 通过 Browser Use 控制云端浏览器 | `BROWSER_USE_API_KEY`、`BROWSERBASE_API_KEY` | @@ -49,7 +49,7 @@ Your Nous subscription includes the Tool Gateway. text-to-speech, and browser automation through your Nous subscription. No need to sign up for separate API keys — just pick the tools you want. - ○ Web search & extract (Firecrawl) — not configured + ○ Web search & extract — not configured ○ Image generation (FAL) — not configured ○ Text-to-speech (OpenAI TTS) — not configured ○ Browser automation (Browser Use) — not configured @@ -97,7 +97,7 @@ browser: 当某工具类别的选择键为 `nous` 时,运行时会把 API 调用路由到 Nous Tool Gateway,而不是使用直连 Key: -1. **网页工具** — `web_search` / `web_extract` 走网关的 Firecrawl 端点 +1. **网页工具** — `web_search` / `web_extract` 走网关的托管搜索端点 2. **文生图** — `image_generate` 走网关的 FAL 端点 3. **TTS** — `text_to_speech` 走网关的 OpenAI Audio 端点 4. **浏览器** — `browser_navigate` 等走网关的 Browser Use 端点 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-search.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-search.md index 2d164de0ec..1ecddaa6a4 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-search.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/web-search.md @@ -35,7 +35,7 @@ Brave Search、DDGS 和 xAI 均为**仅搜索**——如果同时需要 `web_ext **按能力拆分:** 搜索和提取可分别使用不同的提供商——例如搜索使用 SearXNG(免费),提取使用 Firecrawl。详见下方[按能力配置](#per-capability-configuration)。 :::tip Nous 订阅用户 -如果您拥有付费 [Nous Portal](https://portal.nousresearch.com) 订阅,网页搜索和提取可通过 **[Tool Gateway](tool-gateway.md)** 使用托管的 Firecrawl——无需 API 密钥。新安装可运行 `hermes setup --portal` 登录并一次性开启所有 gateway 工具;现有安装可通过 `hermes tools` 单独开启网页功能。 +如果您拥有付费 [Nous Portal](https://portal.nousresearch.com) 订阅,网页搜索和提取可通过 **[Tool Gateway](tool-gateway.md)** 使用托管网页搜索——无需 API 密钥。新安装可运行 `hermes setup --portal` 登录并一次性开启所有 gateway 工具;现有安装可通过 `hermes tools` 单独开启网页功能。 ::: ---