d6773cf26f
- Tavily plugin deleted (plugins/web/tavily), keyless endpoints and ring entry removed from keyless_mcp, legacy backend set / credential ladder / preference walks / rescue key map scrubbed. - TAVILY_API_KEY deregistered across config, setup, status, dump, and nous_subscription surfaces. The tvly- redaction pattern stays -- legacy keys in user envs still deserve masking. - Sibling test pins migrated (keenable/exa stand in where tavily was the fixture vendor); tavily test suite deleted. - Docs updated: web-search, configuration, integrations, environment-variables, tools-reference, web-dashboard, provider plugin dev guide. Live-verified from an isolated HERMES_HOME with all web creds blanked: zero-config resolution lands in the 4-vendor ring, live keyless ring search succeeds, no tavily anywhere in resolution order.
821 lines
36 KiB
Python
821 lines
36 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.
|
|
_get_parallel_client() — Parallel client configuration, singleton caching.
|
|
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, AsyncMock
|
|
|
|
|
|
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
|
|
# local web_tools import and the managed_tool_gateway import so the
|
|
# full firecrawl client init path sees True.
|
|
self._managed_patchers = [
|
|
patch("tools.web_tools.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("tools.web_tools.Firecrawl"):
|
|
with patch("tools.web_tools._read_nous_access_token", return_value=None):
|
|
from tools.web_tools 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.web_tools._read_nous_access_token", return_value="nous-token"):
|
|
with patch("tools.web_tools.Firecrawl") as mock_fc:
|
|
from tools.web_tools 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("tools.web_tools.Firecrawl") as mock_fc:
|
|
mock_fc.side_effect = [RuntimeError("init failed"), MagicMock()]
|
|
from tools.web_tools 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("tools.web_tools.Firecrawl"):
|
|
with patch("tools.web_tools._read_nous_access_token", return_value=None):
|
|
from tools.web_tools 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.web_tools._read_nous_access_token", return_value=None):
|
|
with patch("tools.web_tools.Firecrawl", side_effect=AssertionError("SDK path should not run")):
|
|
from tools.web_tools 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"]
|
|
|
|
|
|
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",
|
|
)
|
|
|
|
def setup_method(self):
|
|
for key in self._ENV_KEYS:
|
|
os.environ.pop(key, None)
|
|
self._managed_patchers = [
|
|
patch("tools.web_tools.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 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_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_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
|
|
|
|
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)
|
|
|
|
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 tools.web_tools import _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 tools.web_tools import _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 tools.web_tools import _get_parallel_client
|
|
client1 = _get_parallel_client()
|
|
client2 = _get_parallel_client()
|
|
assert client1 is client2
|
|
|
|
|
|
class TestWebSearchSchema:
|
|
"""Test suite for web_search tool schema and handler wiring."""
|
|
|
|
def test_schema_exposes_optional_limit(self):
|
|
import tools.web_tools
|
|
|
|
limit_schema = tools.web_tools.WEB_SEARCH_SCHEMA["parameters"]["properties"]["limit"]
|
|
|
|
assert limit_schema["type"] == "integer"
|
|
assert limit_schema["minimum"] == 1
|
|
assert limit_schema["maximum"] == 100
|
|
assert limit_schema["default"] == 5
|
|
assert "limit" not in tools.web_tools.WEB_SEARCH_SCHEMA["parameters"]["required"]
|
|
|
|
|
|
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",
|
|
)
|
|
|
|
def setup_method(self):
|
|
for key in self._ENV_KEYS:
|
|
os.environ.pop(key, None)
|
|
self._managed_patchers = [
|
|
patch("tools.web_tools.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_firecrawl_backend_accepts_managed_gateway(self):
|
|
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
|
|
with patch("tools.web_tools._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
|
|
|
|
|
|
def test_web_requires_env_includes_exa_key():
|
|
from tools.web_tools import _web_requires_env
|
|
|
|
assert "EXA_API_KEY" in _web_requires_env()
|
|
|
|
|
|
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",
|
|
"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.web_tools._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.web_tools._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.web_tools._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"
|
|
|
|
def test_tool_registry_entries_not_filtered_out(self):
|
|
"""web_search and web_extract tool entries must remain in the
|
|
registry when only a custom provider is available."""
|
|
with patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
|
patch("tools.web_tools._peek_nous_access_token", return_value=None):
|
|
import tools.web_tools
|
|
web_search_entry = tools.web_tools.registry.get_entry("web_search")
|
|
web_extract_entry = tools.web_tools.registry.get_entry("web_extract")
|
|
assert web_search_entry is not None, \
|
|
"web_search tool was filtered out despite custom provider being available"
|
|
assert web_extract_entry is not None, \
|
|
"web_extract tool was filtered out despite custom provider being available"
|
|
|
|
|
|
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.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)
|
|
|
|
import importlib
|
|
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_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") == ""
|