/ tests / tools / test_web_tools_config.py
test_web_tools_config.py
  1  """Tests for web backend client configuration and singleton behavior.
  2  
  3  Coverage:
  4    _get_firecrawl_client() — configuration matrix, singleton caching,
  5    constructor failure recovery, return value verification, edge cases.
  6    _get_backend() — backend selection logic with env var combinations.
  7    _get_parallel_client() — Parallel client configuration, singleton caching.
  8    check_web_api_key() — unified availability check across all web backends.
  9  """
 10  
 11  import importlib
 12  import json
 13  import os
 14  import sys
 15  import types
 16  import pytest
 17  from unittest.mock import patch, MagicMock, AsyncMock
 18  
 19  
 20  class TestFirecrawlClientConfig:
 21      """Test suite for Firecrawl client initialization."""
 22  
 23      def setup_method(self):
 24          """Reset client and env vars before each test."""
 25          import tools.web_tools
 26          tools.web_tools._firecrawl_client = None
 27          tools.web_tools._firecrawl_client_config = None
 28          for key in (
 29              "FIRECRAWL_API_KEY",
 30              "FIRECRAWL_API_URL",
 31              "FIRECRAWL_GATEWAY_URL",
 32              "TOOL_GATEWAY_DOMAIN",
 33              "TOOL_GATEWAY_SCHEME",
 34              "TOOL_GATEWAY_USER_TOKEN",
 35          ):
 36              os.environ.pop(key, None)
 37          # Enable managed tools by default for these tests — patch both the
 38          # local web_tools import and the managed_tool_gateway import so the
 39          # full firecrawl client init path sees True.
 40          self._managed_patchers = [
 41              patch("tools.web_tools.managed_nous_tools_enabled", return_value=True),
 42              patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
 43          ]
 44          for p in self._managed_patchers:
 45              p.start()
 46  
 47      def teardown_method(self):
 48          """Reset client after each test."""
 49          import tools.web_tools
 50          tools.web_tools._firecrawl_client = None
 51          tools.web_tools._firecrawl_client_config = None
 52          for key in (
 53              "FIRECRAWL_API_KEY",
 54              "FIRECRAWL_API_URL",
 55              "FIRECRAWL_GATEWAY_URL",
 56              "TOOL_GATEWAY_DOMAIN",
 57              "TOOL_GATEWAY_SCHEME",
 58              "TOOL_GATEWAY_USER_TOKEN",
 59          ):
 60              os.environ.pop(key, None)
 61          for p in self._managed_patchers:
 62              p.stop()
 63  
 64      # ── Configuration matrix ─────────────────────────────────────────
 65  
 66      def test_no_config_raises_with_helpful_message(self):
 67          """Neither key nor URL → ValueError with guidance."""
 68          with patch("tools.web_tools.Firecrawl"):
 69              with patch("tools.web_tools._read_nous_access_token", return_value=None):
 70                  from tools.web_tools import _get_firecrawl_client
 71                  with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
 72                      _get_firecrawl_client()
 73  
 74      def test_tool_gateway_domain_builds_firecrawl_gateway_origin(self):
 75          """Shared gateway domain should derive the Firecrawl vendor hostname."""
 76          with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}):
 77              with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
 78                  with patch("tools.web_tools.Firecrawl") as mock_fc:
 79                      from tools.web_tools import _get_firecrawl_client
 80                      result = _get_firecrawl_client()
 81                      mock_fc.assert_called_once_with(
 82                          api_key="nous-token",
 83                          api_url="https://firecrawl-gateway.nousresearch.com",
 84                      )
 85                      assert result is mock_fc.return_value
 86  
 87      def test_tool_gateway_scheme_can_switch_derived_gateway_origin_to_http(self):
 88          """Shared gateway scheme should allow local plain-http vendor hosts."""
 89          with patch.dict(os.environ, {
 90              "TOOL_GATEWAY_DOMAIN": "nousresearch.com",
 91              "TOOL_GATEWAY_SCHEME": "http",
 92          }):
 93              with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
 94                  with patch("tools.web_tools.Firecrawl") as mock_fc:
 95                      from tools.web_tools import _get_firecrawl_client
 96                      result = _get_firecrawl_client()
 97                      mock_fc.assert_called_once_with(
 98                          api_key="nous-token",
 99                          api_url="http://firecrawl-gateway.nousresearch.com",
100                      )
101                      assert result is mock_fc.return_value
102  
103      def test_invalid_tool_gateway_scheme_raises(self):
104          """Unexpected shared gateway schemes should fail fast."""
105          with patch.dict(os.environ, {
106              "TOOL_GATEWAY_DOMAIN": "nousresearch.com",
107              "TOOL_GATEWAY_SCHEME": "ftp",
108          }):
109              with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
110                  from tools.web_tools import _get_firecrawl_client
111                  with pytest.raises(ValueError, match="TOOL_GATEWAY_SCHEME"):
112                      _get_firecrawl_client()
113  
114      def test_explicit_firecrawl_gateway_url_takes_precedence(self):
115          """An explicit Firecrawl gateway origin should override the shared domain."""
116          with patch.dict(os.environ, {
117              "FIRECRAWL_GATEWAY_URL": "https://firecrawl-gateway.localhost:3009/",
118              "TOOL_GATEWAY_DOMAIN": "nousresearch.com",
119          }):
120              with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
121                  with patch("tools.web_tools.Firecrawl") as mock_fc:
122                      from tools.web_tools import _get_firecrawl_client
123                      _get_firecrawl_client()
124                      mock_fc.assert_called_once_with(
125                          api_key="nous-token",
126                          api_url="https://firecrawl-gateway.localhost:3009",
127                      )
128  
129      def test_default_gateway_domain_targets_nous_production_origin(self):
130          """Default gateway origin should point at the Firecrawl vendor hostname."""
131          with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
132              with patch("tools.web_tools.Firecrawl") as mock_fc:
133                  from tools.web_tools import _get_firecrawl_client
134                  _get_firecrawl_client()
135                  mock_fc.assert_called_once_with(
136                      api_key="nous-token",
137                      api_url="https://firecrawl-gateway.nousresearch.com",
138                  )
139  
140      def test_nous_auth_token_respects_hermes_home_override(self, tmp_path):
141          """Auth lookup should read from HERMES_HOME/auth.json, not ~/.hermes/auth.json."""
142          real_home = tmp_path / "real-home"
143          (real_home / ".hermes").mkdir(parents=True)
144  
145          hermes_home = tmp_path / "hermes-home"
146          hermes_home.mkdir()
147          (hermes_home / "auth.json").write_text(json.dumps({
148              "providers": {
149                  "nous": {
150                      "access_token": "nous-token",
151                  }
152              }
153          }))
154  
155          with patch.dict(os.environ, {
156              "HOME": str(real_home),
157              "HERMES_HOME": str(hermes_home),
158          }, clear=False):
159              import tools.web_tools
160              importlib.reload(tools.web_tools)
161              assert tools.web_tools._read_nous_access_token() == "nous-token"
162  
163      def test_check_auxiliary_model_re_resolves_backend_each_call(self):
164          """Availability checks should not be pinned to module import state."""
165          import tools.web_tools
166  
167          # Simulate the pre-fix import-time cache slot for regression coverage.
168          tools.web_tools.__dict__["_aux_async_client"] = None
169  
170          with patch(
171              "tools.web_tools.get_async_text_auxiliary_client",
172              side_effect=[(None, None), (MagicMock(base_url="https://api.openrouter.ai/v1"), "test-model")],
173          ):
174              assert tools.web_tools.check_auxiliary_model() is False
175              assert tools.web_tools.check_auxiliary_model() is True
176  
177      @pytest.mark.asyncio
178      async def test_summarizer_re_resolves_backend_after_initial_unavailable_state(self):
179          """Summarization should pick up a backend that becomes available later in-process."""
180          import tools.web_tools
181  
182          tools.web_tools.__dict__["_aux_async_client"] = None
183  
184          response = MagicMock()
185          response.choices = [MagicMock(message=MagicMock(content="summary text"))]
186  
187          with patch(
188              "tools.web_tools._resolve_web_extract_auxiliary",
189              side_effect=[(None, None, {}), (MagicMock(base_url="https://api.openrouter.ai/v1"), "test-model", {})],
190          ), patch(
191              "tools.web_tools.async_call_llm",
192              new=AsyncMock(return_value=response),
193          ) as mock_async_call:
194              assert tools.web_tools.check_auxiliary_model() is False
195              result = await tools.web_tools._call_summarizer_llm(
196                  "Some content worth summarizing",
197                  "Source: https://example.com\n\n",
198                  None,
199              )
200  
201          assert result == "summary text"
202          mock_async_call.assert_awaited_once()
203  
204      # ── Singleton caching ────────────────────────────────────────────
205  
206      def test_singleton_returns_same_instance(self):
207          """Second call returns cached client without re-constructing."""
208          with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
209              with patch("tools.web_tools.Firecrawl") as mock_fc:
210                  from tools.web_tools import _get_firecrawl_client
211                  client1 = _get_firecrawl_client()
212                  client2 = _get_firecrawl_client()
213                  assert client1 is client2
214                  mock_fc.assert_called_once()  # constructed only once
215  
216      def test_constructor_failure_allows_retry(self):
217          """If Firecrawl() raises, next call should retry (not return None)."""
218          import tools.web_tools
219          with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
220              with patch("tools.web_tools.Firecrawl") as mock_fc:
221                  mock_fc.side_effect = [RuntimeError("init failed"), MagicMock()]
222                  from tools.web_tools import _get_firecrawl_client
223  
224                  with pytest.raises(RuntimeError):
225                      _get_firecrawl_client()
226  
227                  # Client stayed None, so retry should work
228                  assert tools.web_tools._firecrawl_client is None
229                  result = _get_firecrawl_client()
230                  assert result is not None
231  
232      # ── Edge cases ───────────────────────────────────────────────────
233  
234      def test_empty_string_key_no_url_raises(self):
235          """FIRECRAWL_API_KEY='' with no URL → should raise."""
236          with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}):
237              with patch("tools.web_tools.Firecrawl"):
238                  with patch("tools.web_tools._read_nous_access_token", return_value=None):
239                      from tools.web_tools import _get_firecrawl_client
240                      with pytest.raises(ValueError):
241                          _get_firecrawl_client()
242  
243  
244  class TestBackendSelection:
245      """Test suite for _get_backend() backend selection logic.
246  
247      The backend is configured via config.yaml (web.backend), set by
248      ``hermes tools``.  Falls back to key-based detection for legacy/manual
249      setups.
250      """
251  
252      _ENV_KEYS = (
253          "EXA_API_KEY",
254          "PARALLEL_API_KEY",
255          "FIRECRAWL_API_KEY",
256          "FIRECRAWL_API_URL",
257          "FIRECRAWL_GATEWAY_URL",
258          "TOOL_GATEWAY_DOMAIN",
259          "TOOL_GATEWAY_SCHEME",
260          "TOOL_GATEWAY_USER_TOKEN",
261          "TAVILY_API_KEY",
262      )
263  
264      def setup_method(self):
265          for key in self._ENV_KEYS:
266              os.environ.pop(key, None)
267          self._managed_patchers = [
268              patch("tools.web_tools.managed_nous_tools_enabled", return_value=True),
269              patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
270          ]
271          for p in self._managed_patchers:
272              p.start()
273  
274      def teardown_method(self):
275          for key in self._ENV_KEYS:
276              os.environ.pop(key, None)
277          for p in self._managed_patchers:
278              p.stop()
279  
280      # ── Config-based selection (web.backend in config.yaml) ───────────
281  
282      def test_config_parallel(self):
283          """web.backend=parallel in config → 'parallel' regardless of keys."""
284          from tools.web_tools import _get_backend
285          with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
286              assert _get_backend() == "parallel"
287  
288      def test_config_exa(self):
289          """web.backend=exa in config → 'exa' regardless of other keys."""
290          from tools.web_tools import _get_backend
291          with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \
292               patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
293              assert _get_backend() == "exa"
294  
295      def test_config_firecrawl(self):
296          """web.backend=firecrawl in config → 'firecrawl' even if Parallel key set."""
297          from tools.web_tools import _get_backend
298          with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}), \
299               patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
300              assert _get_backend() == "firecrawl"
301  
302      def test_config_tavily(self):
303          """web.backend=tavily in config → 'tavily' regardless of other keys."""
304          from tools.web_tools import _get_backend
305          with patch("tools.web_tools._load_web_config", return_value={"backend": "tavily"}):
306              assert _get_backend() == "tavily"
307  
308      def test_config_tavily_overrides_env_keys(self):
309          """web.backend=tavily in config → 'tavily' even if Firecrawl key set."""
310          from tools.web_tools import _get_backend
311          with patch("tools.web_tools._load_web_config", return_value={"backend": "tavily"}), \
312               patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
313              assert _get_backend() == "tavily"
314  
315      def test_config_case_insensitive(self):
316          """web.backend=Parallel (mixed case) → 'parallel'."""
317          from tools.web_tools import _get_backend
318          with patch("tools.web_tools._load_web_config", return_value={"backend": "Parallel"}):
319              assert _get_backend() == "parallel"
320  
321      def test_config_tavily_case_insensitive(self):
322          """web.backend=Tavily (mixed case) → 'tavily'."""
323          from tools.web_tools import _get_backend
324          with patch("tools.web_tools._load_web_config", return_value={"backend": "Tavily"}):
325              assert _get_backend() == "tavily"
326  
327      # ── Fallback (no web.backend in config) ───────────────────────────
328  
329      def test_fallback_parallel_only_key(self):
330          """Only PARALLEL_API_KEY set → 'parallel'."""
331          from tools.web_tools import _get_backend
332          with patch("tools.web_tools._load_web_config", return_value={}), \
333               patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
334              assert _get_backend() == "parallel"
335  
336      def test_fallback_exa_only_key(self):
337          """Only EXA_API_KEY set → 'exa'."""
338          from tools.web_tools import _get_backend
339          with patch("tools.web_tools._load_web_config", return_value={}), \
340               patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}):
341              assert _get_backend() == "exa"
342  
343      def test_fallback_parallel_takes_priority_over_exa(self):
344          """Exa should only win the fallback path when it is the only configured backend."""
345          from tools.web_tools import _get_backend
346          with patch("tools.web_tools._load_web_config", return_value={}), \
347               patch.dict(os.environ, {"EXA_API_KEY": "exa-test", "PARALLEL_API_KEY": "par-test"}):
348              assert _get_backend() == "parallel"
349  
350      def test_fallback_tavily_only_key(self):
351          """Only TAVILY_API_KEY set → 'tavily'."""
352          from tools.web_tools import _get_backend
353          with patch("tools.web_tools._load_web_config", return_value={}), \
354               patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}):
355              assert _get_backend() == "tavily"
356  
357      def test_fallback_tavily_with_firecrawl_prefers_firecrawl(self):
358          """Tavily + Firecrawl keys, no config → 'firecrawl' (backward compat)."""
359          from tools.web_tools import _get_backend
360          with patch("tools.web_tools._load_web_config", return_value={}), \
361               patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test", "FIRECRAWL_API_KEY": "fc-test"}):
362              assert _get_backend() == "firecrawl"
363  
364      def test_fallback_tavily_with_parallel_prefers_parallel(self):
365          """Tavily + Parallel keys, no config → 'parallel' (Parallel takes priority over Tavily)."""
366          from tools.web_tools import _get_backend
367          with patch("tools.web_tools._load_web_config", return_value={}), \
368               patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test", "PARALLEL_API_KEY": "par-test"}):
369              # Parallel + no Firecrawl → parallel
370              assert _get_backend() == "parallel"
371  
372      def test_fallback_both_keys_defaults_to_firecrawl(self):
373          """Both keys set, no config → 'firecrawl' (backward compat)."""
374          from tools.web_tools import _get_backend
375          with patch("tools.web_tools._load_web_config", return_value={}), \
376               patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key", "FIRECRAWL_API_KEY": "fc-test"}):
377              assert _get_backend() == "firecrawl"
378  
379      def test_fallback_firecrawl_only_key(self):
380          """Only FIRECRAWL_API_KEY set → 'firecrawl'."""
381          from tools.web_tools import _get_backend
382          with patch("tools.web_tools._load_web_config", return_value={}), \
383               patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
384              assert _get_backend() == "firecrawl"
385  
386      def test_fallback_no_keys_defaults_to_firecrawl(self):
387          """No keys, no config → 'firecrawl' (will fail at client init)."""
388          from tools.web_tools import _get_backend
389          with patch("tools.web_tools._load_web_config", return_value={}):
390              assert _get_backend() == "firecrawl"
391  
392      def test_invalid_config_falls_through_to_fallback(self):
393          """web.backend=invalid → ignored, uses key-based fallback."""
394          from tools.web_tools import _get_backend
395          with patch("tools.web_tools._load_web_config", return_value={"backend": "nonexistent"}), \
396               patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
397              assert _get_backend() == "parallel"
398  
399  
400  class TestParallelClientConfig:
401      """Test suite for Parallel client initialization."""
402  
403      def setup_method(self):
404          import tools.web_tools
405          tools.web_tools._parallel_client = None
406          os.environ.pop("PARALLEL_API_KEY", None)
407          fake_parallel = types.ModuleType("parallel")
408  
409          class Parallel:
410              def __init__(self, api_key):
411                  self.api_key = api_key
412  
413          class AsyncParallel:
414              def __init__(self, api_key):
415                  self.api_key = api_key
416  
417          fake_parallel.Parallel = Parallel
418          fake_parallel.AsyncParallel = AsyncParallel
419          sys.modules["parallel"] = fake_parallel
420  
421      def teardown_method(self):
422          import tools.web_tools
423          tools.web_tools._parallel_client = None
424          os.environ.pop("PARALLEL_API_KEY", None)
425          sys.modules.pop("parallel", None)
426  
427      def test_creates_client_with_key(self):
428          """PARALLEL_API_KEY set → creates Parallel client."""
429          with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
430              from tools.web_tools import _get_parallel_client
431              from parallel import Parallel
432              client = _get_parallel_client()
433              assert client is not None
434              assert isinstance(client, Parallel)
435  
436      def test_no_key_raises_with_helpful_message(self):
437          """No PARALLEL_API_KEY → ValueError with guidance."""
438          from tools.web_tools import _get_parallel_client
439          with pytest.raises(ValueError, match="PARALLEL_API_KEY"):
440              _get_parallel_client()
441  
442      def test_singleton_returns_same_instance(self):
443          """Second call returns cached client."""
444          with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
445              from tools.web_tools import _get_parallel_client
446              client1 = _get_parallel_client()
447              client2 = _get_parallel_client()
448              assert client1 is client2
449  
450  
451  class TestWebSearchSchema:
452      """Test suite for web_search tool schema and handler wiring."""
453  
454      def test_schema_exposes_optional_limit(self):
455          import tools.web_tools
456  
457          limit_schema = tools.web_tools.WEB_SEARCH_SCHEMA["parameters"]["properties"]["limit"]
458  
459          assert limit_schema["type"] == "integer"
460          assert limit_schema["minimum"] == 1
461          assert limit_schema["maximum"] == 100
462          assert limit_schema["default"] == 5
463          assert "limit" not in tools.web_tools.WEB_SEARCH_SCHEMA["parameters"]["required"]
464  
465      def test_registered_handler_passes_limit(self):
466          import tools.web_tools
467  
468          entry = tools.web_tools.registry.get_entry("web_search")
469          with patch("tools.web_tools.web_search_tool", return_value='{"success": true}') as mock_search:
470              result = entry.handler({"query": "site:example.com docs", "limit": 12})
471  
472          assert result == '{"success": true}'
473          mock_search.assert_called_once_with("site:example.com docs", limit=12)
474  
475      def test_registered_handler_defaults_limit_to_five(self):
476          import tools.web_tools
477  
478          entry = tools.web_tools.registry.get_entry("web_search")
479          with patch("tools.web_tools.web_search_tool", return_value='{"success": true}') as mock_search:
480              result = entry.handler({"query": "docs"})
481  
482          assert result == '{"success": true}'
483          mock_search.assert_called_once_with("docs", limit=5)
484  
485      def test_web_search_clamps_limit_before_backend_call(self):
486          import tools.web_tools
487  
488          with patch("tools.web_tools._get_backend", return_value="parallel"), \
489               patch("tools.web_tools._parallel_search", return_value={"success": True, "data": {"web": []}}) as mock_search, \
490               patch("tools.interrupt.is_interrupted", return_value=False), \
491               patch.object(tools.web_tools._debug, "log_call"), \
492               patch.object(tools.web_tools._debug, "save"):
493              result = json.loads(tools.web_tools.web_search_tool("docs", limit=500))
494  
495          assert result == {"success": True, "data": {"web": []}}
496          mock_search.assert_called_once_with("docs", 100)
497  
498  
499  class TestWebSearchErrorHandling:
500      """Test suite for web_search_tool() error responses."""
501  
502      def test_search_error_response_does_not_expose_diagnostics(self):
503          import tools.web_tools
504  
505          firecrawl_client = MagicMock()
506          firecrawl_client.search.side_effect = RuntimeError("boom")
507  
508          with patch("tools.web_tools._get_backend", return_value="firecrawl"), \
509               patch("tools.web_tools._get_firecrawl_client", return_value=firecrawl_client), \
510               patch("tools.interrupt.is_interrupted", return_value=False), \
511               patch.object(tools.web_tools._debug, "log_call") as mock_log_call, \
512               patch.object(tools.web_tools._debug, "save"):
513              result = json.loads(tools.web_tools.web_search_tool("test query", limit=3))
514  
515          assert result == {"error": "Error searching web: boom"}
516  
517          debug_payload = mock_log_call.call_args.args[1]
518          assert debug_payload["error"] == "Error searching web: boom"
519          assert "traceback" not in debug_payload["error"]
520          assert "exception_type" not in debug_payload["error"]
521          assert "config" not in result
522          assert "exception_type" not in result
523          assert "exception_chain" not in result
524          assert "traceback" not in result
525  
526  
527  class TestCheckWebApiKey:
528      """Test suite for check_web_api_key() unified availability check."""
529  
530      _ENV_KEYS = (
531          "EXA_API_KEY",
532          "PARALLEL_API_KEY",
533          "FIRECRAWL_API_KEY",
534          "FIRECRAWL_API_URL",
535          "FIRECRAWL_GATEWAY_URL",
536          "TOOL_GATEWAY_DOMAIN",
537          "TOOL_GATEWAY_SCHEME",
538          "TOOL_GATEWAY_USER_TOKEN",
539          "TAVILY_API_KEY",
540      )
541  
542      def setup_method(self):
543          for key in self._ENV_KEYS:
544              os.environ.pop(key, None)
545          self._managed_patchers = [
546              patch("tools.web_tools.managed_nous_tools_enabled", return_value=True),
547              patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
548          ]
549          for p in self._managed_patchers:
550              p.start()
551  
552      def teardown_method(self):
553          for key in self._ENV_KEYS:
554              os.environ.pop(key, None)
555          for p in self._managed_patchers:
556              p.stop()
557  
558      def test_parallel_key_only(self):
559          with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
560              from tools.web_tools import check_web_api_key
561              assert check_web_api_key() is True
562  
563      def test_exa_key_only(self):
564          with patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}):
565              from tools.web_tools import check_web_api_key
566              assert check_web_api_key() is True
567  
568      def test_firecrawl_key_only(self):
569          with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
570              from tools.web_tools import check_web_api_key
571              assert check_web_api_key() is True
572  
573      def test_firecrawl_url_only(self):
574          with patch.dict(os.environ, {"FIRECRAWL_API_URL": "http://localhost:3002"}):
575              from tools.web_tools import check_web_api_key
576              assert check_web_api_key() is True
577  
578      def test_tavily_key_only(self):
579          with patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}):
580              from tools.web_tools import check_web_api_key
581              assert check_web_api_key() is True
582  
583      def test_no_keys_returns_false(self):
584          from tools.web_tools import check_web_api_key
585          assert check_web_api_key() is False
586  
587      def test_both_keys_returns_true(self):
588          with patch.dict(os.environ, {
589              "PARALLEL_API_KEY": "test-key",
590              "FIRECRAWL_API_KEY": "fc-test",
591          }):
592              from tools.web_tools import check_web_api_key
593              assert check_web_api_key() is True
594  
595      def test_all_three_keys_returns_true(self):
596          with patch.dict(os.environ, {
597              "PARALLEL_API_KEY": "test-key",
598              "FIRECRAWL_API_KEY": "fc-test",
599              "TAVILY_API_KEY": "tvly-test",
600          }):
601              from tools.web_tools import check_web_api_key
602              assert check_web_api_key() is True
603  
604      def test_tool_gateway_returns_true(self):
605          with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
606              from tools.web_tools import check_web_api_key
607              assert check_web_api_key() is True
608  
609      def test_configured_backend_must_match_available_provider(self):
610          with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
611              with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
612                  with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
613                      from tools.web_tools import check_web_api_key
614                      assert check_web_api_key() is False
615  
616      def test_configured_firecrawl_backend_accepts_managed_gateway(self):
617          with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
618              with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
619                  with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
620                      from tools.web_tools import check_web_api_key
621                      assert check_web_api_key() is True
622  
623  
624  def test_web_requires_env_includes_exa_key():
625      from tools.web_tools import _web_requires_env
626  
627      assert "EXA_API_KEY" in _web_requires_env()