Merge remote-tracking branch 'origin/main' into ethie/pm-clean

This commit is contained in:
ethernet
2026-09-24 16:17:47 -04:00
22 changed files with 296 additions and 64 deletions

View File

@@ -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 <nombre>`
- **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:

View File

@@ -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 <name>`
- **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:

View File

@@ -106,7 +106,7 @@ hermes doctor # کسی بھی مسئلے کی تشخیص کریں
ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے:
- **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model <name>` کے ذریعے منتخب کریں
- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔
- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔
نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے:

View File

@@ -77,7 +77,7 @@ hermes doctor # 诊断问题
Hermes 始终允许你使用任意服务商,这点不会改变。但如果你不想为模型、网页搜索、图像生成、TTS、云浏览器分别去申请五个不同的 API Key,**[Nous Portal](https://portal.nousresearch.com)** 用一个订阅就能覆盖全部:
- **300+ 模型** — 用 `/model <name>` 随时切换
- **Tool Gateway** — 网页搜索(Firecrawl)、图像生成(FAL)、文本转语音(OpenAI)、云浏览器(Browser Use),全部通过订阅托管。无需额外注册任何账户。
- **Tool Gateway** — 网页搜索、图像生成(FAL)、文本转语音(OpenAI)、云浏览器(Browser Use),全部通过订阅托管。无需额外注册任何账户。
全新安装时一条命令即可:

View File

@@ -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(

View File

@@ -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"),

View File

@@ -283,9 +283,9 @@ TOOL_CATEGORIES = {
# Provider rows come from plugins.web.<vendor> 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)")]},

View File

@@ -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']}")

View File

@@ -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.<TOOL_GATEWAY_DOMAIN>`` 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)}

View File

@@ -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

View File

@@ -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

View File

@@ -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 == []

View File

@@ -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):

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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

View File

@@ -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.
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap', margin: '1.5rem 0'}}>
<a href="https://portal.nousresearch.com/manage-subscription" style={{background: 'var(--ifm-color-primary)', color: 'white', padding: '0.75rem 1.5rem', borderRadius: '6px', textDecoration: 'none', fontWeight: 'bold'}}>Start or manage subscription →</a>
@@ -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:

View File

@@ -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.<name>: 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 |

View File

@@ -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 路由 |

View File

@@ -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 的说明

View File

@@ -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 端点

View File

@@ -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` 单独开启网页功能。
:::
---