feat: add managed tool gateway and Nous subscription support

- add managed modal and gateway-backed tool integrations\n- improve CLI setup, auth, and configuration for subscriber flows\n- expand tests and docs for managed tool support
This commit is contained in:
Robin Fernandes 2026-03-26 15:27:27 -07:00
parent cbf195e806
commit 95dc9aaa75
44 changed files with 4567 additions and 423 deletions

View file

@ -5,12 +5,14 @@ Coverage:
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.
check_web_api_key() unified availability check across all web backends.
"""
import importlib
import json
import os
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import patch, MagicMock, AsyncMock
class TestFirecrawlClientConfig:
@ -20,14 +22,30 @@ class TestFirecrawlClientConfig:
"""Reset client and env vars before each test."""
import tools.web_tools
tools.web_tools._firecrawl_client = None
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"):
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)
def teardown_method(self):
"""Reset client after each test."""
import tools.web_tools
tools.web_tools._firecrawl_client = None
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"):
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)
# ── Configuration matrix ─────────────────────────────────────────
@ -67,9 +85,152 @@ class TestFirecrawlClientConfig:
def test_no_config_raises_with_helpful_message(self):
"""Neither key nor URL → ValueError with guidance."""
with patch("tools.web_tools.Firecrawl"):
from tools.web_tools import _get_firecrawl_client
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
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
def test_tool_gateway_scheme_can_switch_derived_gateway_origin_to_http(self):
"""Shared gateway scheme should allow local plain-http vendor hosts."""
with patch.dict(os.environ, {
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
"TOOL_GATEWAY_SCHEME": "http",
}):
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="http://firecrawl-gateway.nousresearch.com",
)
assert result is mock_fc.return_value
def test_invalid_tool_gateway_scheme_raises(self):
"""Unexpected shared gateway schemes should fail fast."""
with patch.dict(os.environ, {
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
"TOOL_GATEWAY_SCHEME": "ftp",
}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
from tools.web_tools import _get_firecrawl_client
with pytest.raises(ValueError, match="TOOL_GATEWAY_SCHEME"):
_get_firecrawl_client()
def test_explicit_firecrawl_gateway_url_takes_precedence(self):
"""An explicit Firecrawl gateway origin should override the shared domain."""
with patch.dict(os.environ, {
"FIRECRAWL_GATEWAY_URL": "https://firecrawl-gateway.localhost:3009/",
"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
_get_firecrawl_client()
mock_fc.assert_called_once_with(
api_key="nous-token",
api_url="https://firecrawl-gateway.localhost:3009",
)
def test_default_gateway_domain_targets_nous_production_origin(self):
"""Default gateway origin should point at the Firecrawl vendor hostname."""
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
_get_firecrawl_client()
mock_fc.assert_called_once_with(
api_key="nous-token",
api_url="https://firecrawl-gateway.nousresearch.com",
)
def test_direct_mode_is_preferred_over_tool_gateway(self):
"""Explicit Firecrawl config should win over the gateway fallback."""
with patch.dict(os.environ, {
"FIRECRAWL_API_KEY": "fc-test",
"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
_get_firecrawl_client()
mock_fc.assert_called_once_with(api_key="fc-test")
def test_nous_auth_token_respects_hermes_home_override(self, tmp_path):
"""Auth lookup should read from HERMES_HOME/auth.json, not ~/.hermes/auth.json."""
real_home = tmp_path / "real-home"
(real_home / ".hermes").mkdir(parents=True)
hermes_home = tmp_path / "hermes-home"
hermes_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "nous-token",
}
}
}))
with patch.dict(os.environ, {
"HOME": str(real_home),
"HERMES_HOME": str(hermes_home),
}, clear=False):
import tools.web_tools
importlib.reload(tools.web_tools)
assert tools.web_tools._read_nous_access_token() == "nous-token"
def test_check_auxiliary_model_re_resolves_backend_each_call(self):
"""Availability checks should not be pinned to module import state."""
import tools.web_tools
# Simulate the pre-fix import-time cache slot for regression coverage.
tools.web_tools.__dict__["_aux_async_client"] = None
with patch(
"tools.web_tools.get_async_text_auxiliary_client",
side_effect=[(None, None), (MagicMock(base_url="https://api.openrouter.ai/v1"), "test-model")],
):
assert tools.web_tools.check_auxiliary_model() is False
assert tools.web_tools.check_auxiliary_model() is True
@pytest.mark.asyncio
async def test_summarizer_re_resolves_backend_after_initial_unavailable_state(self):
"""Summarization should pick up a backend that becomes available later in-process."""
import tools.web_tools
tools.web_tools.__dict__["_aux_async_client"] = None
response = MagicMock()
response.choices = [MagicMock(message=MagicMock(content="summary text"))]
fake_client = MagicMock(base_url="https://api.openrouter.ai/v1")
fake_client.chat.completions.create = AsyncMock(return_value=response)
with patch(
"tools.web_tools.get_async_text_auxiliary_client",
side_effect=[(None, None), (fake_client, "test-model")],
):
assert tools.web_tools.check_auxiliary_model() is False
result = await tools.web_tools._call_summarizer_llm(
"Some content worth summarizing",
"Source: https://example.com\n\n",
None,
)
assert result == "summary text"
fake_client.chat.completions.create.assert_awaited_once()
# ── Singleton caching ────────────────────────────────────────────
@ -117,9 +278,10 @@ class TestFirecrawlClientConfig:
"""FIRECRAWL_API_KEY='' with no URL → should raise."""
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}):
with patch("tools.web_tools.Firecrawl"):
from tools.web_tools import _get_firecrawl_client
with pytest.raises(ValueError):
_get_firecrawl_client()
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()
class TestBackendSelection:
@ -130,7 +292,16 @@ class TestBackendSelection:
setups.
"""
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
_ENV_KEYS = (
"PARALLEL_API_KEY",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
"TAVILY_API_KEY",
)
def setup_method(self):
for key in self._ENV_KEYS:
@ -276,10 +447,47 @@ class TestParallelClientConfig:
assert client1 is client2
class TestWebSearchErrorHandling:
"""Test suite for web_search_tool() error responses."""
def test_search_error_response_does_not_expose_diagnostics(self):
import tools.web_tools
firecrawl_client = MagicMock()
firecrawl_client.search.side_effect = RuntimeError("boom")
with patch("tools.web_tools._get_backend", return_value="firecrawl"), \
patch("tools.web_tools._get_firecrawl_client", return_value=firecrawl_client), \
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 = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
_ENV_KEYS = (
"PARALLEL_API_KEY",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
"TAVILY_API_KEY",
)
def setup_method(self):
for key in self._ENV_KEYS:
@ -329,3 +537,22 @@ class TestCheckWebApiKey:
}):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_tool_gateway_returns_true(self):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_configured_backend_must_match_available_provider(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
with patch("tools.web_tools._read_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 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._read_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