From 383d44bc9a9e31658a5a76d0afc18e53507239bd Mon Sep 17 00:00:00 2001 From: tomekpanek Date: Thu, 4 Jun 2026 22:08:10 +0200 Subject: [PATCH] fix(web): rank explicit credentials above managed-gateway probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend selection ordered firecrawl (including the Nous-managed-tool-gateway probe) ahead of explicit-credential backends, so a user who had both a Nous OAuth token AND a TAVILY_API_KEY (or EXA/PARALLEL key) got firecrawl auto-selected — then the request failed at runtime because the free Nous tier does not include web search, and there is no fallback to the next available backend. Explicit user setup lost to a managed convenience. Reorder so direct-credential backends (tavily > exa > parallel > firecrawl- direct) are tried first, then the managed-gateway firecrawl probe, then free-tier fallbacks. Behaviour for users with only Nous OAuth (no explicit key) is unchanged — firecrawl-via-gateway is still selected. Behaviour change to flag: a user with BOTH a Nous OAuth token AND a TAVILY_API_KEY (or EXA/PARALLEL key) now gets the explicit backend instead of the managed gateway. This matches the principle of least surprise — a user does not set TAVILY_API_KEY without intent — and sidesteps the silent runtime failure of the gateway path on free tiers. --- tests/tools/test_web_tools_config.py | 48 ++++++++++++++++++++-------- tools/web_tools.py | 15 +++++---- 2 files changed, 44 insertions(+), 19 deletions(-) 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()),