From a68827de4819e4ed1b5209e0124bef314c73666b Mon Sep 17 00:00:00 2001 From: crazywriter1 Date: Wed, 20 May 2026 21:56:46 +0300 Subject: [PATCH 1/3] fix(web): xAI tool gating and search dispatch fallback Include xai in check_web_api_key() so web_search/web_extract register when web.backend is xai or only xAI credentials exist. Fall back to get_active_search_provider() when the named search backend is registered but is_available() is false (e.g. default firecrawl with xAI OAuth only). --- tests/tools/test_web_providers_xai.py | 38 +++++++++++++++++++++++++ tests/tools/test_web_tools_config.py | 14 +++++++++ tools/web_tools.py | 41 +++++++++++++++++++-------- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/tests/tools/test_web_providers_xai.py b/tests/tools/test_web_providers_xai.py index d5a3deaf689e..cca8bd932a55 100644 --- a/tests/tools/test_web_providers_xai.py +++ b/tests/tools/test_web_providers_xai.py @@ -694,6 +694,13 @@ def test_configured_backend_xai_accepted(self, monkeypatch): monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "xai"}) assert web_tools._get_backend() == "xai" + def test_check_web_api_key_true_when_xai_configured(self, monkeypatch): + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "xai"}) + monkeypatch.setenv("XAI_API_KEY", "sk-xai-test") + assert web_tools.check_web_api_key() is True + def test_xai_not_in_legacy_backend_candidate_chain(self, monkeypatch): """The hardcoded ``backend_candidates`` tuple in ``_get_backend()`` does not include xAI — by design, since the no-config legacy @@ -720,6 +727,37 @@ def test_xai_not_in_legacy_backend_candidate_chain(self, monkeypatch): monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False) assert web_tools._get_backend() != "xai" + def test_web_search_falls_back_when_named_backend_unavailable(self, monkeypatch): + """``_get_search_backend()`` can return ``firecrawl`` by default while + only xAI credentials exist; dispatch must not call the unavailable + provider's ``search()`` when registry resolution can pick xAI.""" + from tools import web_tools + + unavailable = MagicMock() + unavailable.supports_search.return_value = True + unavailable.is_available.return_value = False + unavailable.name = "firecrawl" + + active = MagicMock() + active.supports_search.return_value = True + active.is_available.return_value = True + active.name = "xai" + active.search.return_value = {"success": True, "data": {"web": []}} + + monkeypatch.setattr(web_tools, "_get_search_backend", lambda: "firecrawl") + with patch( + "agent.web_search_registry.get_provider", + return_value=unavailable, + ), patch( + "agent.web_search_registry.get_active_search_provider", + return_value=active, + ): + result = json.loads(web_tools.web_search_tool("test query", limit=3)) + + assert result["success"] is True + unavailable.search.assert_not_called() + active.search.assert_called_once_with("test query", 3) + # --------------------------------------------------------------------------- # OAuth credential resolution (end-to-end through tools.xai_http) diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 87fc27cc3728..321b8caec7b8 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -641,6 +641,20 @@ def test_configured_firecrawl_backend_accepts_managed_gateway(self): from tools.web_tools import check_web_api_key assert check_web_api_key() is True + def test_xai_key_only(self): + with patch.dict(os.environ, {"XAI_API_KEY": "sk-xai-test"}): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is True + + def test_configured_xai_backend_requires_xai_credentials(self): + with patch("tools.web_tools._load_web_config", return_value={"backend": "xai"}): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is False + with patch("tools.web_tools._load_web_config", return_value={"backend": "xai"}): + with patch.dict(os.environ, {"XAI_API_KEY": "sk-xai-test"}): + 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 diff --git a/tools/web_tools.py b/tools/web_tools.py index a55fe78c41e4..3f62ec041f02 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -799,9 +799,9 @@ def web_search_tool(query: str, limit: int = 5) -> str: if is_interrupted(): return tool_error("Interrupted", success=False) - # Dispatch through the web search registry. All 7 providers - # (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl) - # now live as plugins; the dispatcher is just a registry lookup + + # Dispatch through the web search registry. Bundled providers + # (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl, xai, + # …) register at import time; the dispatcher is a registry lookup + # delegation. Sync only — every provider's search() is sync. from agent.web_search_registry import ( get_active_search_provider, @@ -810,10 +810,17 @@ def web_search_tool(query: str, limit: int = 5) -> str: backend = _get_search_backend() provider = _wsp_get_provider(backend) if backend else None - if provider is None or not provider.supports_search(): - # Fall back to availability-walked active provider when the - # configured backend isn't a registered search provider (typo, - # uninstalled plugin, or capability mismatch). + if provider is not None and provider.supports_search(): + try: + provider_available = bool(provider.is_available()) + except Exception: + provider_available = False + else: + provider_available = False + if provider is None or not provider.supports_search() or not provider_available: + # Fall back when the name from config/auto-detect does not map to + # a usable search provider (typo, missing plugin, capability mismatch, + # or no credentials — e.g. default "firecrawl" with only xAI OAuth). provider = get_active_search_provider() if provider is None: @@ -1363,16 +1370,26 @@ async def _process_tavily_crawl(result): return tool_error(error_msg) +# Backends ``check_web_api_key()`` and ``hermes tools`` gating understand. +_WEB_AVAILABILITY_BACKENDS = ( + "exa", + "parallel", + "firecrawl", + "tavily", + "searxng", + "brave-free", + "ddgs", + "xai", +) + + # Convenience function to check Firecrawl credentials def check_web_api_key() -> bool: """Check whether the configured web backend is available.""" configured = _load_web_config().get("backend", "").lower().strip() - if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"}: + if configured in _WEB_AVAILABILITY_BACKENDS: return _is_backend_available(configured) - return any( - _is_backend_available(backend) - for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs") - ) + return any(_is_backend_available(backend) for backend in _WEB_AVAILABILITY_BACKENDS) def check_auxiliary_model() -> bool: From 4cfc1e7250a737f06b1b5ffa4a07bba61299af28 Mon Sep 17 00:00:00 2001 From: crazywriter1 Date: Wed, 20 May 2026 22:46:40 +0300 Subject: [PATCH 2/3] test(web): align unconfigured search error with dispatch fallback --- tests/tools/test_web_providers.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index 67d39e9a999e..b4f6dcdb72ce 100644 --- a/tests/tools/test_web_providers.py +++ b/tests/tools/test_web_providers.py @@ -287,12 +287,17 @@ def _clear_web_creds(self, monkeypatch): "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", "TOOL_GATEWAY_DOMAIN", + "XAI_API_KEY", ): monkeypatch.delenv(k, raising=False) def test_unconfigured_search_emits_top_level_error(self, monkeypatch): - """``web_search_tool`` with no creds returns ``{"error": "Error searching web: ..."}`` - — matching main's ``tool_error()`` envelope, not a per-result shape. + """``web_search_tool`` with no creds returns a top-level ``error`` string. + + When the default backend name is registered but unavailable and the + registry walk finds no provider, the dispatcher returns + ``{"success": false, "error": "No web search provider configured..."}`` + instead of delegating into Firecrawl and wrapping a SDK/config exception. """ import json from tools import web_tools @@ -304,10 +309,10 @@ def test_unconfigured_search_emits_top_level_error(self, monkeypatch): monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) result = json.loads(web_tools.web_search_tool("hello world", limit=3)) + assert result.get("success") is False, f"expected success=false, got {result}" assert "error" in result, f"expected top-level 'error' key, got {result}" - # ``Error searching web:`` prefix comes from web_tools' top-level except handler - assert "Error searching web:" in result["error"] - assert "FIRECRAWL_API_KEY" in result["error"] + assert "No web search provider configured" in result["error"] + assert "hermes tools" in result["error"] # No per-result burying assert "results" not in result From e26882e5bc11b60b59e3ada85a977fbd6304263f Mon Sep 17 00:00:00 2001 From: crazywriter1 Date: Mon, 13 Jul 2026 22:02:14 +0300 Subject: [PATCH 3/3] test: accept structured web_search errors without 'error'/'failed' wording --- tests/test_model_tools.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index 469b8a6921e9..235ca9315485 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -31,13 +31,16 @@ def test_unknown_tool_returns_error(self): assert "totally_fake_tool_xyz" in result["error"] def test_exception_returns_json_error(self): - # Even if something goes wrong, should return valid JSON + # Even if something goes wrong, should return valid JSON with a + # top-level error envelope. Message wording varies (exception wrap + # vs structured "No web search provider configured") — only require + # a non-empty error string, not specific substrings. result = handle_function_call("web_search", None) # None args may cause issues parsed = json.loads(result) assert isinstance(parsed, dict) assert "error" in parsed + assert isinstance(parsed["error"], str) assert len(parsed["error"]) > 0 - assert "error" in parsed["error"].lower() or "failed" in parsed["error"].lower() def test_tool_hooks_receive_session_and_tool_call_ids(self): with (