fix(web): rank explicit credentials above managed-gateway probe

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.
This commit is contained in:
tomekpanek 2026-06-04 22:08:10 +02:00 committed by Teknium
parent 243cada157
commit 383d44bc9a
2 changed files with 44 additions and 19 deletions

View file

@ -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."""

View file

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