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