diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index e9bcd8e2079..28323122aca 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -340,12 +340,13 @@ class TestBackendSelection: patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}): assert _get_backend() == "exa" - def test_fallback_parallel_takes_priority_over_exa(self): - """Exa should only win the fallback path when it is the only configured backend.""" + def test_fallback_exa_takes_priority_over_parallel(self): + """Direct-credential backends are tried in the order tavily > exa > parallel + so an explicit Exa key wins when both Exa and Parallel are configured.""" from tools.web_tools import _get_backend with patch("tools.web_tools._load_web_config", return_value={}), \ patch.dict(os.environ, {"EXA_API_KEY": "exa-test", "PARALLEL_API_KEY": "par-test"}): - assert _get_backend() == "parallel" + assert _get_backend() == "exa" def test_fallback_tavily_only_key(self): """Only TAVILY_API_KEY set → 'tavily'.""" @@ -354,27 +355,27 @@ class TestBackendSelection: patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}): assert _get_backend() == "tavily" - def test_fallback_tavily_with_firecrawl_prefers_firecrawl(self): - """Tavily + Firecrawl keys, no config → 'firecrawl' (backward compat).""" + def test_fallback_tavily_beats_firecrawl_direct(self): + """Tavily ranks above firecrawl in the explicit-credential block.""" from tools.web_tools import _get_backend with patch("tools.web_tools._load_web_config", return_value={}), \ patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test", "FIRECRAWL_API_KEY": "fc-test"}): - assert _get_backend() == "firecrawl" + assert _get_backend() == "tavily" - def test_fallback_tavily_with_parallel_prefers_parallel(self): - """Tavily + Parallel keys, no config → 'parallel' (Parallel takes priority over Tavily).""" + def test_fallback_tavily_beats_parallel(self): + """Tavily is first in the explicit-credential block so it wins over parallel.""" from tools.web_tools import _get_backend with patch("tools.web_tools._load_web_config", return_value={}), \ patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test", "PARALLEL_API_KEY": "par-test"}): - # Parallel + no Firecrawl → parallel - assert _get_backend() == "parallel" + assert _get_backend() == "tavily" - def test_fallback_both_keys_defaults_to_firecrawl(self): - """Both keys set, no config → 'firecrawl' (backward compat).""" + def test_fallback_parallel_beats_firecrawl_direct(self): + """Parallel + Firecrawl-direct → parallel (parallel is the higher-priority + explicit-credential backend; firecrawl-direct ranks below it).""" from tools.web_tools import _get_backend with patch("tools.web_tools._load_web_config", return_value={}), \ patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key", "FIRECRAWL_API_KEY": "fc-test"}): - assert _get_backend() == "firecrawl" + assert _get_backend() == "parallel" def test_fallback_firecrawl_only_key(self): """Only FIRECRAWL_API_KEY set → 'firecrawl'.""" @@ -396,6 +397,27 @@ class TestBackendSelection: patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}): assert _get_backend() == "parallel" + def test_managed_gateway_does_not_preempt_explicit_tavily(self): + """Regression: a Nous OAuth token (managed gateway "ready") must NOT + beat an explicitly configured TAVILY_API_KEY in the fallback path. + Free Nous tiers don't include web search, so the user's deliberate + Tavily setup would fail at runtime with "no subscription" if the + gateway pre-empted it.""" + from tools.web_tools import _get_backend + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=True), \ + patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}): + assert _get_backend() == "tavily" + + def test_managed_gateway_only_falls_through_to_firecrawl(self): + """When no explicit-credential backend is configured, a Nous-managed + gateway token still selects firecrawl — the convenience path is + preserved, just no longer pre-empts.""" + from tools.web_tools import _get_backend + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch("tools.web_tools._is_tool_gateway_ready", return_value=True): + assert _get_backend() == "firecrawl" + class TestParallelClientConfig: """Test suite for Parallel client initialization.""" diff --git a/tools/web_tools.py b/tools/web_tools.py index d8d922dc0ac..133489b0a89 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -153,15 +153,18 @@ def _get_backend() -> str: return configured # Fallback for manual / legacy config — pick the highest-priority - # available backend. Firecrawl also counts as available when the managed - # tool gateway is configured for Nous subscribers. - # Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so - # existing paid setups are unaffected. + # available backend. Explicit user credentials (TAVILY_API_KEY etc.) + # beat the managed-tool-gateway probe so a deliberate setup is not + # pre-empted by a Nous OAuth token whose subscription tier may not + # actually grant web-search access (the gateway then fails at runtime + # with "no subscription" and the tool returns an error to the agent + # without falling back). Free-tier backends trail the paid ones. backend_candidates = ( - ("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()), - ("parallel", _has_env("PARALLEL_API_KEY")), ("tavily", _has_env("TAVILY_API_KEY")), ("exa", _has_env("EXA_API_KEY")), + ("parallel", _has_env("PARALLEL_API_KEY")), + ("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL")), + ("firecrawl", _is_tool_gateway_ready()), ("searxng", _has_env("SEARXNG_URL")), ("brave-free", _has_env("BRAVE_SEARCH_API_KEY")), ("ddgs", _ddgs_package_importable()),