Files
hermes-agent/tests/tools/test_web_tools_config.py
Brooklyn Nicholson a510d6c64a fix(web/firecrawl): accept the server timeout in the keyless scrape client
#131868 passes timeout=60_000 to the Firecrawl client's scrape() so the
server-side deadline matches our 60 s wait. The SDK accepts it, but the
keyless REST client's scrape() took only url/formats, so every extract
raised TypeError for keyless users with the keyless ring disabled or the
tier pinned to paid. Accept the kwarg and forward it in the v2 payload,
which takes the same field.
2026-10-02 21:12:48 -05:00

1036 lines
47 KiB
Python

"""Tests for web backend client configuration and singleton behavior.
Coverage:
_get_firecrawl_client() — configuration matrix, singleton caching,
constructor failure recovery, return value verification, edge cases.
_get_backend() — backend selection logic with env var combinations.
plugins.web.parallel.provider._get_sync_client() — Parallel client configuration, singleton caching.
plugins.web._common.cached_sdk_client() — Parallel/Exa clients follow the currently resolved key.
check_web_api_key() — unified availability check across all web backends.
"""
import importlib
import json
import os
import sys
import types
import pytest
from unittest.mock import patch, MagicMock
class TestFirecrawlClientConfig:
"""Test suite for Firecrawl client initialization."""
def setup_method(self):
"""Reset client and env vars before each test."""
import tools.web_tools
tools.web_tools._firecrawl_client = None
tools.web_tools._firecrawl_client_config = None
for key in (
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
):
os.environ.pop(key, None)
# Enable managed tools by default for these tests — patch both the
# tool_backend_helpers definition and the managed_tool_gateway import so the
# full firecrawl client init path sees True.
self._managed_patchers = [
patch("tools.tool_backend_helpers.managed_nous_tools_enabled", return_value=True),
patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
]
for p in self._managed_patchers:
p.start()
def teardown_method(self):
"""Reset client after each test."""
import tools.web_tools
tools.web_tools._firecrawl_client = None
tools.web_tools._firecrawl_client_config = None
for key in (
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
):
os.environ.pop(key, None)
for p in self._managed_patchers:
p.stop()
# ── Configuration matrix ─────────────────────────────────────────
def test_no_config_raises_with_helpful_message(self):
"""Neither key nor URL → ValueError with guidance."""
with patch("plugins.web.firecrawl.provider.Firecrawl"):
with patch("tools.managed_tool_gateway.read_nous_access_token", return_value=None):
from plugins.web.firecrawl.provider import _get_firecrawl_client
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
_get_firecrawl_client()
def test_tool_gateway_domain_builds_firecrawl_gateway_origin(self):
"""Shared gateway domain should derive the Firecrawl vendor hostname."""
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}):
with patch("tools.managed_tool_gateway.read_nous_access_token", return_value="nous-token"):
with patch("plugins.web.firecrawl.provider.Firecrawl") as mock_fc:
from plugins.web.firecrawl.provider import _get_firecrawl_client
result = _get_firecrawl_client()
mock_fc.assert_called_once_with(
api_key="nous-token",
api_url="https://firecrawl-gateway.nousresearch.com",
)
assert result is mock_fc.return_value
# ── Singleton caching ────────────────────────────────────────────
def test_constructor_failure_allows_retry(self):
"""If Firecrawl() raises, next call should retry (not return None)."""
import tools.web_tools
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
with patch("plugins.web.firecrawl.provider.Firecrawl") as mock_fc:
mock_fc.side_effect = [RuntimeError("init failed"), MagicMock()]
from plugins.web.firecrawl.provider import _get_firecrawl_client
with pytest.raises(RuntimeError):
_get_firecrawl_client()
# Client stayed None, so retry should work
assert tools.web_tools._firecrawl_client is None
result = _get_firecrawl_client()
assert result is not None
# ── Edge cases ───────────────────────────────────────────────────
def test_empty_string_key_no_url_raises(self):
"""FIRECRAWL_API_KEY='' with no URL → should raise."""
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}):
with patch("plugins.web.firecrawl.provider.Firecrawl"):
with patch("tools.managed_tool_gateway.read_nous_access_token", return_value=None):
from plugins.web.firecrawl.provider import _get_firecrawl_client
with pytest.raises(ValueError):
_get_firecrawl_client()
def test_explicit_firecrawl_config_without_creds_uses_keyless_client(self):
"""Explicit Firecrawl config should build the keyless cloud client."""
from plugins.web.firecrawl import provider as firecrawl_provider
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
with patch("tools.managed_tool_gateway.read_nous_access_token", return_value=None):
with patch("plugins.web.firecrawl.provider.Firecrawl", side_effect=AssertionError("SDK path should not run")):
from plugins.web.firecrawl.provider import _get_firecrawl_client
result = _get_firecrawl_client()
assert isinstance(result, firecrawl_provider._KeylessFirecrawlClient)
assert result.api_url == "https://api.firecrawl.dev"
def test_keyless_firecrawl_search_omits_authorization_header(self, monkeypatch):
"""Keyless Firecrawl search must not send a bearer header."""
from plugins.web.firecrawl import provider as firecrawl_provider
captured = {}
class _Response:
def raise_for_status(self):
return None
def json(self):
return {"success": True, "data": {"web": []}}
def _fake_post(url, *, json, headers, timeout):
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
captured["timeout"] = timeout
return _Response()
monkeypatch.setattr(firecrawl_provider.httpx, "post", _fake_post)
client = firecrawl_provider._KeylessFirecrawlClient()
result = client.search(query="firecrawl", limit=1)
assert result["success"] is True
assert captured["url"] == "https://api.firecrawl.dev/v2/search"
assert captured["json"] == {"query": "firecrawl", "limit": 1}
assert captured["headers"] == {"Content-Type": "application/json"}
assert "Authorization" not in captured["headers"]
def test_keyless_firecrawl_scrape_omits_authorization_header(self, monkeypatch):
"""Keyless Firecrawl scrape must not send a bearer header."""
from plugins.web.firecrawl import provider as firecrawl_provider
captured = {}
class _Response:
def raise_for_status(self):
return None
def json(self):
return {"success": True, "data": {"markdown": "# ok"}}
def _fake_post(url, *, json, headers, timeout):
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
captured["timeout"] = timeout
return _Response()
monkeypatch.setattr(firecrawl_provider.httpx, "post", _fake_post)
client = firecrawl_provider._KeylessFirecrawlClient()
result = client.scrape(url="https://example.com", formats=["markdown"])
assert result["success"] is True
assert captured["url"] == "https://api.firecrawl.dev/v2/scrape"
assert captured["json"] == {"url": "https://example.com", "formats": ["markdown"]}
assert captured["headers"] == {"Content-Type": "application/json"}
assert "Authorization" not in captured["headers"]
def test_extract_through_keyless_client_forwards_server_timeout(self, monkeypatch):
"""_scrape_one passes the SDK's ``timeout`` kwarg; the keyless client must accept and forward it."""
import asyncio
from plugins.web.firecrawl import provider as firecrawl_provider
captured = {}
class _Response:
def raise_for_status(self):
return None
def json(self):
return {"success": True, "data": {"markdown": "# ok", "metadata": {"sourceURL": "https://example.com"}}}
def _fake_post(url, *, json, headers, timeout):
captured["json"] = json
return _Response()
monkeypatch.setattr(firecrawl_provider.httpx, "post", _fake_post)
monkeypatch.setattr(firecrawl_provider, "_get_firecrawl_client", firecrawl_provider._KeylessFirecrawlClient)
monkeypatch.setattr(firecrawl_provider, "check_website_access", lambda url: None)
entry = asyncio.run(firecrawl_provider._scrape_one("https://example.com", ["markdown"], None))
assert "error" not in entry or not entry["error"], entry
assert captured["json"]["timeout"] == 60_000
class TestBackendSelection:
"""Test suite for _get_backend() backend selection logic.
The backend is configured via config.yaml (web.backend), set by
``hermes tools``. Falls back to key-based detection for legacy/manual
setups.
"""
_ENV_KEYS = (
"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",
"KEENABLE_API_KEY",
"TAVILY_API_KEY",
)
def setup_method(self):
for key in self._ENV_KEYS:
os.environ.pop(key, None)
self._managed_patchers = [
patch("tools.tool_backend_helpers.managed_nous_tools_enabled", return_value=True),
patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
]
for p in self._managed_patchers:
p.start()
def teardown_method(self):
for key in self._ENV_KEYS:
os.environ.pop(key, None)
for p in self._managed_patchers:
p.stop()
# ── Config-based selection (web.backend in config.yaml) ───────────
def test_config_parallel(self):
"""web.backend=parallel in config → 'parallel' regardless of keys."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
assert _get_backend() == "parallel"
# ── Fallback (no web.backend in config) ───────────────────────────
def test_fallback_parallel_only_key(self):
"""Only PARALLEL_API_KEY set → 'parallel'."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
assert _get_backend() == "parallel"
def test_fallback_exa_only_key(self):
"""Only EXA_API_KEY set → 'exa'."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}):
assert _get_backend() == "exa"
def test_fallback_exa_takes_priority_over_parallel(self):
"""Direct-credential backends are tried in the order tavily > exa > parallel > keenable
so an explicit Exa key wins when both Exa and Parallel are configured."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"EXA_API_KEY": "exa-test", "PARALLEL_API_KEY": "par-test"}):
assert _get_backend() == "exa"
def test_fallback_keenable_only_key(self):
"""Only KEENABLE_API_KEY set → 'keenable'."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"KEENABLE_API_KEY": "kn-test"}):
assert _get_backend() == "keenable"
def test_fallback_exa_beats_firecrawl_direct(self):
"""Exa ranks above firecrawl in the explicit-credential block."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"EXA_API_KEY": "exa-test", "FIRECRAWL_API_KEY": "fc-test"}):
assert _get_backend() == "exa"
def test_fallback_tavily_only_key(self):
"""Only TAVILY_API_KEY set → 'tavily'."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}):
assert _get_backend() == "tavily"
def test_fallback_tavily_beats_firecrawl_direct(self):
"""Tavily ranks above firecrawl in the explicit-credential block."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test", "FIRECRAWL_API_KEY": "fc-test"}):
assert _get_backend() == "tavily"
def test_fallback_tavily_beats_exa(self):
"""Tavily ranks above Exa in the explicit-credential block."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test", "EXA_API_KEY": "exa-test"}):
assert _get_backend() == "tavily"
def test_fallback_parallel_beats_firecrawl_direct(self):
"""Parallel + Firecrawl-direct → parallel (parallel is the higher-priority
explicit-credential backend; firecrawl-direct ranks below it)."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key", "FIRECRAWL_API_KEY": "fc-test"}):
assert _get_backend() == "parallel"
def test_fallback_firecrawl_only_key(self):
"""Only FIRECRAWL_API_KEY set → 'firecrawl'."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
assert _get_backend() == "firecrawl"
def test_fallback_no_keys_defaults_to_firecrawl(self):
"""No keys, no config, keyless tier off → 'firecrawl' sentinel.
With the keyless tier on (default), zero credentials resolves to
the Parallel/Exa free tier instead — covered in
test_web_keyless_fallback.py.
"""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \
patch("tools.web_tools._ddgs_package_importable", return_value=False), \
patch("tools.web_tools._list_registered_web_providers", return_value=[]), \
patch("agent.web_search_registry._keyless_tier_enabled", return_value=False):
assert _get_backend() == "firecrawl"
def test_invalid_config_is_returned_verbatim(self):
"""Strict selection: web.backend=nonexistent is returned as-is so the
dispatch path raises the honest selection-naming error — never
silently rerouted through the credential ladder."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={"backend": "nonexistent"}), \
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
assert _get_backend() == "nonexistent"
def test_stored_backend_wins_over_other_credentials(self):
"""Strict selection: a stored web.backend beats env keys for other
vendors — no availability probe, no credential override."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}), \
patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}):
assert _get_backend() == "firecrawl"
def test_nous_backend_maps_to_firecrawl(self):
"""The managed 'nous' selection is serviced by the firecrawl
provider (whose client resolver routes managed)."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={"backend": "nous"}):
assert _get_backend() == "firecrawl"
def test_managed_gateway_does_not_preempt_explicit_exa(self):
"""Regression: a Nous OAuth token (managed gateway "ready") must NOT
beat an explicitly configured EXA_API_KEY in the fallback path.
Free Nous tiers don't include web search, so the user's deliberate
Exa setup would fail at runtime with "no subscription" if the
gateway pre-empted it."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch("tools.web_tools._is_tool_gateway_ready", return_value=True), \
patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}):
assert _get_backend() == "exa"
def test_managed_gateway_does_not_preempt_explicit_tavily(self):
"""A Nous OAuth token must not beat an explicit TAVILY_API_KEY."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch("tools.web_tools._is_tool_gateway_ready", return_value=True), \
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}):
assert _get_backend() == "tavily"
def test_managed_gateway_only_falls_through_to_firecrawl(self):
"""When no explicit-credential backend is configured, a Nous-managed
gateway token still selects firecrawl — the convenience path is
preserved, just no longer pre-empts."""
from tools.web_tools import _get_backend
with patch("tools.web_tools._load_web_config", return_value={}), \
patch("tools.web_tools._is_tool_gateway_ready", return_value=True):
assert _get_backend() == "firecrawl"
class TestParallelClientConfig:
"""Test suite for Parallel client initialization."""
def setup_method(self):
import tools.web_tools
tools.web_tools._parallel_client = None
os.environ.pop("PARALLEL_API_KEY", None)
fake_parallel = types.ModuleType("parallel")
class Parallel:
def __init__(self, api_key):
self.api_key = api_key
class AsyncParallel:
def __init__(self, api_key):
self.api_key = api_key
fake_parallel.Parallel = Parallel
fake_parallel.AsyncParallel = AsyncParallel
sys.modules["parallel"] = fake_parallel
# The fake parallel module above answers every SDK touch; the real
# lazy-dep gate (a version-pinned metadata check) must not refuse first
# on an install without the parallel extra.
self._lazy_gate_patch = patch(
"tools.lazy_deps.ensure", lambda *args, **kwargs: None
)
self._lazy_gate_patch.start()
def teardown_method(self):
import tools.web_tools
tools.web_tools._parallel_client = None
os.environ.pop("PARALLEL_API_KEY", None)
sys.modules.pop("parallel", None)
self._lazy_gate_patch.stop()
def test_creates_client_with_key(self):
"""PARALLEL_API_KEY set → creates Parallel client."""
with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
from plugins.web.parallel.provider import _get_sync_client as _get_parallel_client
from parallel import Parallel
client = _get_parallel_client()
assert client is not None
assert isinstance(client, Parallel)
def test_no_key_raises_with_helpful_message(self):
"""No PARALLEL_API_KEY → ValueError with guidance."""
from plugins.web.parallel.provider import _get_sync_client as _get_parallel_client
with pytest.raises(ValueError, match="PARALLEL_API_KEY"):
_get_parallel_client()
def test_singleton_returns_same_instance(self):
"""Second call returns cached client."""
with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
from plugins.web.parallel.provider import _get_sync_client as _get_parallel_client
client1 = _get_parallel_client()
client2 = _get_parallel_client()
assert client1 is client2
def test_client_follows_the_key_reload_applies(self):
"""/reload (reload_env) fixing or removing the key reaches the next call: the client built
with the old key is never handed out again."""
from hermes_cli.config import get_env_path, reload_env
from plugins.web.parallel.provider import _get_sync_client as _get_parallel_client
with patch.dict(os.environ):
get_env_path().write_text("PARALLEL_API_KEY=typo-key\n")
reload_env()
assert _get_parallel_client().api_key == "typo-key"
get_env_path().write_text("PARALLEL_API_KEY=fixed-key\n")
reload_env()
assert _get_parallel_client().api_key == "fixed-key"
get_env_path().write_text("")
reload_env()
with pytest.raises(ValueError, match="PARALLEL_API_KEY"):
_get_parallel_client()
class TestExaClientConfig:
"""Exa shares the key-checked client cache with Parallel."""
def setup_method(self):
import tools.web_tools
tools.web_tools._exa_client = None
os.environ.pop("EXA_API_KEY", None)
fake_exa = types.ModuleType("exa_py")
class Exa:
def __init__(self, api_key):
self.api_key = api_key
self.headers = {}
def search(self, query, num_results, contents):
hit = types.SimpleNamespace(url="https://example.com", title="t", highlights=[self.api_key])
return types.SimpleNamespace(results=[hit])
fake_exa.Exa = Exa
sys.modules["exa_py"] = fake_exa
self._lazy_gate_patch = patch("tools.lazy_deps.ensure", lambda *args, **kwargs: None)
self._lazy_gate_patch.start()
def teardown_method(self):
import tools.web_tools
tools.web_tools._exa_client = None
os.environ.pop("EXA_API_KEY", None)
sys.modules.pop("exa_py", None)
self._lazy_gate_patch.stop()
def test_multiplexed_profiles_search_with_their_own_key(self, tmp_path, monkeypatch):
"""One process serving two profiles: each turn's search goes out with its own profile's key, and a
profile WITHOUT a key is refused — never served on the launch profile's ``os.environ`` key."""
from agent import secret_scope
from plugins.web.exa.provider import ExaWebSearchProvider
homes = {}
for name, line in (("a", "EXA_API_KEY=key-a\n"), ("b", "EXA_API_KEY=key-b\n"), ("nokey", "")):
homes[name] = tmp_path / name
homes[name].mkdir()
(homes[name] / ".env").write_text(line)
monkeypatch.setenv("EXA_API_KEY", "key-launch")
monkeypatch.setattr("plugins.web.keyless_mcp.keyless_enabled", lambda: False)
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
def sent_with(name):
token = secret_scope.set_secret_scope(secret_scope.build_profile_secret_scope(homes[name]))
try:
result = ExaWebSearchProvider().search("q")
finally:
secret_scope.reset_secret_scope(token)
if not result["success"]:
return result["error"]
return result["data"]["web"][0]["description"]
assert [sent_with("a"), sent_with("b"), sent_with("a")] == ["key-a", "key-b", "key-a"]
assert "EXA_API_KEY" in sent_with("nokey") and "key-launch" not in sent_with("nokey")
assert sent_with("a") == "key-a"
def test_single_profile_process_env_still_serves_the_key(self, monkeypatch):
"""Control: no profile scope bound (plain CLI / systemd-injected env) keeps reading os.environ."""
from plugins.web.exa.provider import ExaWebSearchProvider
monkeypatch.setenv("EXA_API_KEY", "key-env")
monkeypatch.setattr("plugins.web.keyless_mcp.keyless_enabled", lambda: False)
result = ExaWebSearchProvider().search("q")
assert result["success"], result
assert result["data"]["web"][0]["description"] == "key-env"
class TestWebSearchSchema:
"""Test suite for web_search tool schema and handler wiring."""
def test_web_search_clamps_limit_before_backend_call(self):
import tools.web_tools
# After the web-provider plugin migration, _parallel_search lives in
# plugins.web.parallel.provider.ParallelWebSearchProvider.search; the
# tool dispatcher resolves a provider from the registry and calls
# provider.search(query, limit). Mock the provider lookup so we can
# assert the limit is clamped before reaching the backend.
fake_search = MagicMock(return_value={"success": True, "data": {"web": []}})
fake_provider = MagicMock(
name="ParallelWebSearchProvider",
supports_search=MagicMock(return_value=True),
)
fake_provider.search = fake_search
fake_provider.name = "parallel"
with patch("tools.web_tools._get_search_backend", return_value="parallel"), \
patch("agent.web_search_registry.get_provider", return_value=fake_provider), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch.object(tools.web_tools._debug, "log_call"), \
patch.object(tools.web_tools._debug, "save"):
result = json.loads(tools.web_tools.web_search_tool("docs", limit=500))
assert result == {"success": True, "data": {"web": []}}
fake_search.assert_called_once_with("docs", 100)
class TestWebSearchErrorHandling:
"""Test suite for web_search_tool() error responses."""
def test_search_error_response_does_not_expose_diagnostics(self):
import tools.web_tools
# After the web-provider plugin migration, the firecrawl client lives
# at plugins.web.firecrawl.provider._get_firecrawl_client. We mock the
# registry's get_provider to return a fake provider whose .search()
# raises so we can verify error sanitization.
fake_provider = MagicMock(
name="FirecrawlWebSearchProvider",
supports_search=MagicMock(return_value=True),
)
fake_provider.search.side_effect = RuntimeError("boom")
fake_provider.name = "firecrawl"
with patch("tools.web_tools._get_search_backend", return_value="firecrawl"), \
patch("agent.web_search_registry.get_provider", return_value=fake_provider), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch.object(tools.web_tools._debug, "log_call") as mock_log_call, \
patch.object(tools.web_tools._debug, "save"):
result = json.loads(tools.web_tools.web_search_tool("test query", limit=3))
assert result == {"error": "Error searching web: boom"}
debug_payload = mock_log_call.call_args.args[1]
assert debug_payload["error"] == "Error searching web: boom"
assert "traceback" not in debug_payload["error"]
assert "exception_type" not in debug_payload["error"]
assert "config" not in result
assert "exception_type" not in result
assert "exception_chain" not in result
assert "traceback" not in result
class TestCheckWebApiKey:
"""Test suite for check_web_api_key() unified availability check."""
_ENV_KEYS = (
"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",
"KEENABLE_API_KEY",
"TAVILY_API_KEY",
)
def setup_method(self):
for key in self._ENV_KEYS:
os.environ.pop(key, None)
self._managed_patchers = [
patch("tools.tool_backend_helpers.managed_nous_tools_enabled", return_value=True),
patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
# ddgs availability is package-presence driven and the plugin
# registry can hold an available ddgs provider. Neutralize both
# fallback surfaces so this class only exercises env-key/gateway
# resolution — otherwise these tests flip on machines where the
# optional ``ddgs`` package is installed (dev venvs) vs CI.
patch("tools.web_tools._ddgs_package_importable", return_value=False),
patch("agent.web_search_registry.get_active_search_provider", return_value=None),
patch("agent.web_search_registry.get_active_extract_provider", return_value=None),
]
for p in self._managed_patchers:
p.start()
def teardown_method(self):
for key in self._ENV_KEYS:
os.environ.pop(key, None)
for p in self._managed_patchers:
p.stop()
def test_parallel_key_only(self):
with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_exa_key_only(self):
with patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_null_backend_value_does_not_crash(self):
# config.yaml with ``web:\n backend:`` yields backend=None. The gate
# must not raise AttributeError on None.lower() — mirrors _get_backend.
with patch("tools.web_tools._load_web_config", return_value={"backend": None}):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is False
def test_configured_xai_backend_still_lights_gate(self):
"""An explicit ``web.backend: xai`` selection still counts toward the
gate: the bundled web-xai plugin's provider can serve it when loaded,
and a stored selection is returned as-is by _get_backend either way."""
with patch("tools.web_tools._load_web_config", return_value={"backend": "xai"}), \
patch("tools.xai_http.has_xai_credentials", return_value=True):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_xai_only_env_end_to_end_toolset_gate(self, monkeypatch, tmp_path):
"""E2e through the registry: a real XAI_API_KEY env var -> the real
has_xai_credentials probe -> check_fn -> get_tool_definitions. The web
toolset must serve zero tools (xai can never be dispatched to), and it
must light up once a real web key joins."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # isolate auth.json / credential pool
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
for k in ("PERPLEXITY_API_KEY", "SEARXNG_URL", "BRAVE_SEARCH_API_KEY"):
monkeypatch.delenv(k, raising=False)
from tools.registry import invalidate_check_fn_cache
import model_tools
with patch("tools.web_tools._load_web_config", return_value={}):
invalidate_check_fn_cache()
names = {d["function"]["name"]
for d in model_tools.get_tool_definitions(enabled_toolsets=["web"])}
assert names == set()
monkeypatch.setenv("TAVILY_API_KEY", "tavily-test-key")
invalidate_check_fn_cache()
names = {d["function"]["name"]
for d in model_tools.get_tool_definitions(enabled_toolsets=["web"])}
assert names == {"web_search", "web_extract"}
def test_configured_firecrawl_backend_accepts_managed_gateway(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
with patch("tools.managed_tool_gateway.peek_nous_access_token", return_value="nous-token"):
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_explicit_unavailable_active_provider_is_not_ready(self):
"""#78412: get_active_* may return a configured backend whose
is_available() is False. check_web_api_key must still report False so
doctor does not paint a green check for a backend that cannot run.
"""
class _UnavailableProvider:
name = "firecrawl"
def is_available(self):
return False
unavailable = _UnavailableProvider()
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}), \
patch("tools.web_tools._is_backend_available", return_value=False), \
patch(
"agent.web_search_registry.get_active_search_provider",
return_value=unavailable,
), \
patch(
"agent.web_search_registry.get_active_extract_provider",
return_value=unavailable,
):
from tools.web_tools import check_web_api_key, _provider_is_ready
assert _provider_is_ready(unavailable) is False
assert check_web_api_key() is False
def test_explicit_available_active_provider_is_ready(self):
"""Registry-selected available provider still lights the gate."""
class _AvailableProvider:
name = "custom-ok"
def is_available(self):
return True
available = _AvailableProvider()
with patch("tools.web_tools._load_web_config", return_value={"backend": "custom-ok"}), \
patch("tools.web_tools._is_backend_available", return_value=False), \
patch(
"agent.web_search_registry.get_active_search_provider",
return_value=available,
), \
patch(
"agent.web_search_registry.get_active_extract_provider",
return_value=None,
):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
class TestNonBuiltinProviderAvailability:
"""Regression: a plugin-registered WebSearchProvider with no built-in
provider credentials must still light up web_search / web_extract tools.
The web_tools availability gate delegates non-legacy backend names to the
web_search_registry's provider ``is_available()``. This class verifies
that a custom (non-built-in) provider discovered via the registry is
sufficient to make check_web_api_key() return True, _get_backend() return
the custom name, the per-capability selection honor it (issue #32698), and
the tool registry entries remain active.
Original tests contributed by @m0n5t3r (PR #28652 / issue #28651).
"""
# All env vars that could make a built-in provider available.
_WEB_ENV_KEYS = (
"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",
"KEENABLE_API_KEY",
"TAVILY_API_KEY",
"SEARXNG_URL",
"BRAVE_SEARCH_API_KEY",
"XAI_API_KEY",
)
@staticmethod
def _create_fake_provider(*, search=True, extract=True):
"""Dynamically create a WebSearchProvider subclass.
Uses a local class definition (not a nested class) to avoid
Python 3.13 __bases__ deallocator issue with nested class
reassignment.
"""
from agent.web_search_provider import WebSearchProvider
class FakePluginProvider(WebSearchProvider):
@property
def name(self):
return "fake-plugin-prov"
def is_available(self):
return True
def supports_search(self):
return search
def supports_extract(self):
return extract
return FakePluginProvider()
def setup_method(self):
"""Strip all built-in web provider env vars and reset the registry."""
for key in self._WEB_ENV_KEYS:
os.environ.pop(key, None)
from agent.web_search_registry import _reset_for_tests, register_provider
_reset_for_tests()
register_provider(self._create_fake_provider())
def teardown_method(self):
"""Reset the registry and restore env after each test."""
from agent.web_search_registry import _reset_for_tests
_reset_for_tests()
for key in self._WEB_ENV_KEYS:
os.environ.pop(key, None)
def test_check_web_api_key_returns_true_for_custom_provider(self):
"""With only a custom provider registered (no built-in creds),
check_web_api_key() must return True."""
with patch("tools.web_tools._ddgs_package_importable", return_value=False), \
patch("tools.managed_tool_gateway.peek_nous_access_token", return_value=None):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_get_backend_discovers_custom_provider(self):
"""_get_backend() must return the custom provider name when it's
the only available provider."""
with patch("tools.web_tools._ddgs_package_importable", return_value=False), \
patch("tools.managed_tool_gateway.peek_nous_access_token", return_value=None):
from tools.web_tools import _get_backend
assert _get_backend() == "fake-plugin-prov"
def test_capability_backend_honors_custom_extract_provider(self):
"""Per-capability selection (_get_extract_backend) must resolve the
custom provider when configured, instead of dead-ending — issue #32698."""
with patch("tools.web_tools._ddgs_package_importable", return_value=False), \
patch("tools.managed_tool_gateway.peek_nous_access_token", return_value=None), \
patch("tools.web_tools._load_web_config",
return_value={"extract_backend": "fake-plugin-prov"}):
from tools.web_tools import _get_extract_backend
assert _get_extract_backend() == "fake-plugin-prov"
class TestFirecrawlEnvResolution:
"""Verify Firecrawl reads env values from hermes_cli.config.get_env_value,
not just os.getenv. This catches the regression reported in #40190 where
values stored in ~/.hermes/.env were invisible to the provider."""
def test_direct_config_reads_via_get_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""_get_direct_firecrawl_config() must use get_env_value, not os.getenv."""
# Ensure os.environ does NOT carry the key
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
monkeypatch.delenv("FIRECRAWL_API_URL", raising=False)
fake_key = "fc-test-key-from-dotenv"
with patch(
"hermes_cli.config.get_env_value",
side_effect=lambda k: fake_key if k == "FIRECRAWL_API_KEY" else None,
):
from plugins.web.firecrawl.provider import _get_direct_firecrawl_config
result = _get_direct_firecrawl_config()
assert result is not None, "get_env_value fallback should find the key"
mode, kwargs, _cache_key = result
assert mode == "sdk"
assert kwargs["api_key"] == fake_key
def test_direct_config_reads_url_via_get_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Self-hosted URL from .env must be picked up."""
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
monkeypatch.delenv("FIRECRAWL_API_URL", raising=False)
fake_url = "https://firecrawl.internal.example.com"
with patch(
"hermes_cli.config.get_env_value",
side_effect=lambda k: fake_url if k == "FIRECRAWL_API_URL" else None,
):
from plugins.web.firecrawl.provider import _get_direct_firecrawl_config
result = _get_direct_firecrawl_config()
assert result is not None
mode, kwargs, _cache_key = result
assert mode == "sdk"
assert kwargs["api_url"] == fake_url.rstrip("/")
class TestSiblingProvidersEnvResolution:
"""The same #40190 bug class widened: every keyed web provider must
resolve its credential through the config-aware lookup (os.environ OR
~/.hermes/.env), not bare os.getenv. Parametrized over the four
providers that previously read only the process environment."""
_CASES = [
("plugins.web.exa.provider", "ExaWebSearchProvider", "EXA_API_KEY"),
("plugins.web.parallel.provider", "ParallelWebSearchProvider", "PARALLEL_API_KEY"),
("plugins.web.keenable.provider", "KeenableWebSearchProvider", "KEENABLE_API_KEY"),
("plugins.web.tavily.provider", "TavilyWebSearchProvider", "TAVILY_API_KEY"),
("plugins.web.brave_free.provider", "BraveFreeWebSearchProvider", "BRAVE_SEARCH_API_KEY"),
]
@pytest.mark.parametrize("module_path,cls_name,env_key", _CASES)
def test_is_available_reads_via_get_env_value(
self, monkeypatch, module_path, cls_name, env_key
):
"""is_available() must see a key that lives only in the .env layer."""
monkeypatch.delenv(env_key, raising=False)
module = importlib.import_module(module_path)
provider = getattr(module, cls_name)()
assert provider.is_available() is False
with patch(
"hermes_cli.config.get_env_value",
side_effect=lambda k: "test-key-from-dotenv" if k == env_key else None,
):
assert provider.is_available() is True, (
f"{cls_name}.is_available() ignored {env_key} from the "
"config-aware env layer (get_env_value)"
)
def test_keenable_search_reads_key_via_get_env_value(self, monkeypatch):
"""Keyed Keenable must Bearer-auth with a key that lives only in .env."""
monkeypatch.delenv("KEENABLE_API_KEY", raising=False)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"results": []}
mock_response.text = "{}"
with patch(
"hermes_cli.config.get_env_value",
side_effect=lambda k: "kn-from-dotenv" if k == "KEENABLE_API_KEY" else None,
), patch(
"requests.post", return_value=mock_response
) as mock_post:
from plugins.web.keenable.provider import KeenableWebSearchProvider
KeenableWebSearchProvider().search("q", limit=2)
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer kn-from-dotenv"
assert headers["X-Keenable-Title"] == "hermes-agent"
def test_tavily_request_reads_key_via_get_env_value(self, monkeypatch):
"""Keyed Tavily must Bearer-auth with a key that lives only in .env."""
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"results": []}
mock_response.text = "{}"
with patch(
"hermes_cli.config.get_env_value",
side_effect=lambda k: "tvly-from-dotenv" if k == "TAVILY_API_KEY" else None,
), patch(
"plugins.web.tavily.provider.httpx.post", return_value=mock_response
) as mock_post:
from plugins.web.tavily.provider import _tavily_request
_tavily_request("search", {"query": "q"})
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer tvly-from-dotenv"
assert headers["X-Client-Name"] == "hermes-agent"
assert "X-Tavily-Access-Mode" not in headers
def test_get_provider_env_unset_returns_empty(self, monkeypatch):
monkeypatch.delenv("WSP_TEST_UNSET_KEY", raising=False)
with patch("hermes_cli.config.get_env_value", return_value=None):
from agent.web_search_provider import get_provider_env
assert get_provider_env("WSP_TEST_UNSET_KEY") == ""
def test_xai_only_gate_agrees_with_dispatcher_when_web_xai_plugin_loaded(monkeypatch, tmp_path):
"""With the bundled web-xai plugin registered (the default), the registry resolves xai
as the single eligible search provider while _get_backend never autodetects it. The
gate must follow the dispatcher: keyless off -> no servable backend -> tools stay off
(#116175 review follow-up). Module-level on purpose: TestCheckWebApiKey neutralizes the
registry path with get_active_*_provider -> None."""
from agent import web_search_registry as registry
from plugins.web.xai.provider import XAIWebSearchProvider
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
for k in ("PERPLEXITY_API_KEY", "SEARXNG_URL", "BRAVE_SEARCH_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY"):
monkeypatch.delenv(k, raising=False)
with registry._lock:
saved = dict(registry._providers)
registry._providers.clear()
registry.register_provider(XAIWebSearchProvider())
try:
with patch("tools.web_tools._load_web_config", return_value={}), \
patch("tools.web_tools._ensure_web_plugins_loaded", lambda: None), \
patch("tools.web_tools.check_firecrawl_api_key", return_value=False), \
patch("agent.web_search_registry._keyless_tier_enabled", return_value=False):
from tools.web_tools import _get_backend, check_web_api_key
assert registry.get_active_search_provider().name == "xai"
assert _get_backend() == "firecrawl" # legacy sentinel: nothing servable
assert check_web_api_key() is False
finally:
with registry._lock:
registry._providers.clear()
registry._providers.update(saved)