From 010a45097e49fff00c32db954f56a2f9d6d08f17 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:29:13 -0500 Subject: [PATCH] fix(web): per-capability backend key no longer reroutes the other capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #113017. _get_backend() treated any web selection — including per-capability keys (web.search_backend / web.extract_backend) — as a stored shared selection and short-circuited to firecrawl with no ladder. Setting only web.extract_backend therefore silently rerouted web_search off autodetect. The no-ladder firecrawl branch now requires a shared selection (read_selection: use_gateway); per-capability keys name only their own capability, and the other keeps its normal cascade. Test: new tests/tools/test_web_backend_capability_isolation.py — red on base (search resolved to firecrawl), green after fix; managed use_gateway still resolves firecrawl with no ladder. --- .../test_web_backend_capability_isolation.py | 46 +++++++++++++++++++ tools/web_tools.py | 10 ++-- 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/tools/test_web_backend_capability_isolation.py diff --git a/tests/tools/test_web_backend_capability_isolation.py b/tests/tools/test_web_backend_capability_isolation.py new file mode 100644 index 0000000000..fc7927456a --- /dev/null +++ b/tests/tools/test_web_backend_capability_isolation.py @@ -0,0 +1,46 @@ +"""A per-capability web backend key must not reroute the other capability. + +Regression for #113017: ``web.extract_backend: keenable`` (with no +``web.backend``) made ``_get_search_backend()`` resolve to ``firecrawl`` +through the shared ``_get_backend()`` fallback, because +``selection_exists("web")`` counts per-capability keys as a stored selection. +""" +from __future__ import annotations + + +def _ladder_env(monkeypatch, raw_web): + from tools import web_tools + from tools import tool_backend_helpers as helpers + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: dict(raw_web)) + monkeypatch.setattr( + helpers, "_raw_section", + lambda section: dict(raw_web) if section == "web" else None, + ) + # No keyed backends, no gateway: the ladder can only reach ddgs. + for var in ( + "TAVILY_API_KEY", "PERPLEXITY_API_KEY", "EXA_API_KEY", + "PARALLEL_API_KEY", "KEENABLE_API_KEY", + "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", + "BRAVE_SEARCH_API_KEY", "SEARXNG_URL", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True) + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) + + +def test_extract_only_config_does_not_reroute_search(monkeypatch): + from tools import web_tools + + _ladder_env(monkeypatch, {"extract_backend": "keenable"}) + # Broken before the fix: search resolved to "firecrawl" through the shared fallback. + assert web_tools._get_search_backend() == "ddgs" + assert web_tools._get_extract_backend() == "keenable" + + +def test_managed_use_gateway_still_resolves_firecrawl(monkeypatch): + from tools import web_tools + + _ladder_env(monkeypatch, {"use_gateway": True}) + # Ladder would find ddgs if it ran — the managed selection must not reach it. + assert web_tools._get_backend() == "firecrawl" diff --git a/tools/web_tools.py b/tools/web_tools.py index a94cfc7534..b9819344d9 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -18,7 +18,7 @@ _firecrawl_client = _firecrawl_client_config = _parallel_client = _async_paralle from plugins.web.firecrawl.provider import _is_tool_gateway_ready, check_firecrawl_api_key from tools.debug_helpers import DebugSession -from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, selection_exists +from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_selection, selection_exists from tools.url_safety import async_is_safe_url from tools.web_tools_rescue import _rescue_eligible, _rescue_search from tools.web_tools_truncate import _effective_char_limit, _trim_results, _truncate_results, convert_base64_images_to_links @@ -100,13 +100,15 @@ def _probe(provider, method: str, context: str = "") -> Optional[bool]: def _get_backend() -> str: """Shared web backend name. A stored ``web.backend`` is returned as-is — no availability probe, no fallback — so a broken selection surfaces the vendor's honest error rather than silently rerouting. - Autodetect runs ONLY when no web selection has ever been stored.""" + The managed ``use_gateway`` selection also resolves to firecrawl with no ladder. Autodetect runs + whenever no SHARED web selection was ever stored: per-capability keys (``web.search_backend``, + ``web.extract_backend``) name only their own capability and never reroute the other (#113017).""" configured = _configured_backend() if configured: # "nous" (managed subscription) is serviced by firecrawl, routed through the managed Tool Gateway. return "firecrawl" if configured == NOUS_MANAGED_PROVIDER else configured - if selection_exists("web"): - # Selection exists (use_gateway / per-capability keys) but no shared name: firecrawl, no ladder. + if read_selection("web") is not None: + # Shared selection exists (use_gateway) but no shared name: firecrawl, no ladder. return "firecrawl" # Never-configured install. Explicit user credentials beat the managed-gateway probe (a Nous OAuth