fix(web): honor the stored web backend selection; no silent backend swaps

_get_backend returns the stored web.backend verbatim (mapping the managed
'nous' selection to the firecrawl provider) — unknown names surface the
honest selection-naming error at dispatch instead of silently rerouting
through the credential ladder, which now runs only on never-configured
installs. _get_capability_backend no longer discards an explicit
search/extract backend when its availability probe fails. The firecrawl
client resolves strictly: 'nous' => managed gateway only (unavailable =>
selection-naming error), stored vendor => direct only (no FIRECRAWL key
=> error, never a silent managed fallback billed to Nous).
This commit is contained in:
Teknium
2026-08-19 15:24:26 -07:00
parent 2dea073a1c
commit d7119ea2a6
2 changed files with 175 additions and 36 deletions
+84 -24
View File
@@ -48,7 +48,7 @@ from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from typing import Any, Dict, List, NoReturn, Optional, TYPE_CHECKING
from agent.web_search_provider import WebSearchProvider
from tools.url_safety import is_safe_url
@@ -168,11 +168,22 @@ def _has_direct_firecrawl_config() -> bool:
def check_firecrawl_api_key() -> bool:
"""Return True when Firecrawl backend (direct or gateway) is usable.
"""Return True when the Firecrawl backend selected via `hermes tools`
(or, on a never-configured install, either route) is usable.
Re-exported by :mod:`tools.web_tools` for backward compatibility with
existing tests and the ``hermes tools`` setup flow.
"""
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
)
selected = read_selection("web")
if selected == NOUS_MANAGED_PROVIDER:
return _is_tool_gateway_ready()
if selected is not None:
return _has_direct_firecrawl_config()
return _has_direct_firecrawl_config() or _is_tool_gateway_ready()
@@ -188,7 +199,7 @@ def _firecrawl_backend_help_suffix() -> str:
)
def _raise_web_backend_configuration_error() -> None:
def _raise_web_backend_configuration_error() -> "NoReturn":
"""Raise a clear error for unsupported web backend configuration."""
import tools.web_tools as _wt
@@ -212,46 +223,95 @@ def _raise_web_backend_configuration_error() -> None:
def _get_firecrawl_client() -> Any:
"""Get or create the cached Firecrawl client.
When ``web.use_gateway`` is set in config, the managed Tool Gateway is
preferred even if direct Firecrawl credentials are present. Otherwise
direct Firecrawl takes precedence when explicitly configured.
Strict selection semantics (switch on the stored ``web`` selection):
- ``"nous"`` (or legacy ``use_gateway: true``) → managed Tool Gateway
ONLY; unavailable is a selection-naming error (a present
FIRECRAWL_API_KEY does not reroute).
- any other stored web backend → direct Firecrawl ONLY; missing config
is a selection-naming error — never a silent managed fallback billed
to Nous.
- never-configured web section → legacy behavior: direct config when
present, else the managed gateway.
Raises ValueError when neither path is usable.
Raises ValueError when the resolved path is unusable.
The cached client is stored on :mod:`tools.web_tools` (as
``_firecrawl_client`` and ``_firecrawl_client_config``) rather than on
this plugin module so that unit tests that reset the cache via
``tools.web_tools._firecrawl_client = None`` keep working. Helper
functions (``prefers_gateway``, ``resolve_managed_tool_gateway``,
``_read_nous_access_token``, ``Firecrawl``) are also looked up via
:mod:`tools.web_tools` for the same reason — see
:func:`_is_tool_gateway_ready`.
functions (``resolve_managed_tool_gateway``, ``_read_nous_access_token``,
``Firecrawl``) are also looked up via :mod:`tools.web_tools` for the same
reason — see :func:`_is_tool_gateway_ready`.
"""
import tools.web_tools as _wt
from tools.tool_backend_helpers import (
NOUS_MANAGED_PROVIDER,
read_selection,
selection_error,
selection_exists,
)
selected = read_selection("web")
direct_config = _get_direct_firecrawl_config()
if direct_config is not None and not _wt.prefers_gateway("web"):
kwargs, client_config = direct_config
else:
def _managed_kwargs():
managed_gateway = _wt.resolve_managed_tool_gateway(
"firecrawl", token_reader=_wt._read_nous_access_token
)
if managed_gateway is None:
return None
kwargs = {
"api_key": managed_gateway.nous_user_token,
"api_url": managed_gateway.gateway_origin,
}
return kwargs, (
"tool-gateway",
kwargs["api_url"],
managed_gateway.nous_user_token,
)
if selected == NOUS_MANAGED_PROVIDER:
managed = _managed_kwargs()
if managed is None:
logger.error(
"Firecrawl client initialization failed: the Nous "
"Subscription web selection is stored but the tool gateway "
"is unavailable."
)
raise ValueError(selection_error(
"web",
NOUS_MANAGED_PROVIDER,
"the Nous Tool Gateway is not available (not entitled or "
"unreachable)",
))
kwargs, client_config = managed
elif selected is not None or selection_exists("web"):
# Stored vendor selection (or per-capability web keys routing to
# firecrawl): direct Firecrawl only.
if direct_config is None:
logger.error(
"Firecrawl client initialization failed: direct Firecrawl "
"selected but FIRECRAWL_API_KEY/FIRECRAWL_API_URL is not set."
)
raise ValueError(selection_error(
"web",
selected or "firecrawl",
"neither FIRECRAWL_API_KEY nor FIRECRAWL_API_URL is set",
))
kwargs, client_config = direct_config
elif direct_config is not None:
kwargs, client_config = direct_config
else:
# Never-configured web section: legacy managed fallback.
managed = _managed_kwargs()
if managed is None:
logger.error(
"Firecrawl client initialization failed: "
"missing direct config and tool-gateway auth."
)
_raise_web_backend_configuration_error()
kwargs = {
"api_key": managed_gateway.nous_user_token,
"api_url": managed_gateway.gateway_origin,
}
client_config = (
"tool-gateway",
kwargs["api_url"],
managed_gateway.nous_user_token,
)
kwargs, client_config = managed
cached = getattr(_wt, "_firecrawl_client", None)
cached_config = getattr(_wt, "_firecrawl_client_config", None)
+91 -12
View File
@@ -223,16 +223,36 @@ def _list_registered_web_providers():
def _get_backend() -> str:
"""Determine which web backend to use (shared fallback).
Reads ``web.backend`` from config.yaml (set by ``hermes tools``).
Falls back to whichever API key is present for users who configured
keys manually without running setup.
Reads ``web.backend`` from config.yaml (set by ``hermes tools``). A
stored backend name is returned as-is — no availability probe, no
fallback — so the vendor path can raise its own honest error when the
selection is broken. The credential/entitlement autodetect ladder runs
ONLY when no web selection has ever been stored.
"""
configured = (_load_web_config().get("backend") or "").lower().strip()
if configured in _LEGACY_WEB_BACKENDS or _registered_web_provider(configured) is not None:
if configured:
# Strict: the stored selection is final, known name or not — an
# unknown/typoed name surfaces as the vendor path's honest error
# rather than silently rerouting through the credential ladder.
# The managed "Nous Subscription" selection ("nous") is serviced by
# the firecrawl provider, whose client resolver routes it through
# the managed Tool Gateway.
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
if configured == NOUS_MANAGED_PROVIDER:
return "firecrawl"
return configured
# Fallback for manual / legacy config — pick the highest-priority
# available backend. Explicit user credentials (TAVILY_API_KEY etc.)
from tools.tool_backend_helpers import selection_exists
if selection_exists("web"):
# A web selection exists (e.g. use_gateway key or per-capability
# backends) but the shared backend name is empty — keep the
# firecrawl default rather than credential-laddering.
return "firecrawl"
# Never-configured install — pick the highest-priority 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
@@ -298,12 +318,16 @@ def _get_extract_backend() -> str:
def _get_capability_backend(capability: str) -> str:
"""Shared helper for per-capability backend selection.
Reads ``web.{capability}_backend`` from config; if set and available,
uses it. Otherwise falls through to the shared ``_get_backend()``.
Reads ``web.{capability}_backend`` from config; a stored value is
returned unconditionally (strict selection — no availability probe).
A selected-but-broken backend surfaces the vendor path's honest error
instead of being silently replaced by whatever the credential ladder
finds. Falls through to the shared ``_get_backend()`` only when no
per-capability override is stored.
"""
cfg = _load_web_config()
specific = (cfg.get(f"{capability}_backend") or "").lower().strip()
if specific and _is_backend_available(specific):
if specific:
return specific
return _get_backend()
@@ -692,9 +716,35 @@ 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).
from tools.tool_backend_helpers import (
selection_error,
selection_exists,
)
if provider is None and backend and selection_exists("web"):
disabled_key = _disabled_web_plugin_for(capability="search")
if disabled_key:
_vendor = disabled_key.split("/", 1)[-1]
error_text = (
f"web.search_backend is set to '{_vendor}', but its "
f"plugin ('{disabled_key}') is disabled in config. "
f"Re-enable it with `hermes plugins enable {disabled_key}` "
"(or remove it from plugins.disabled)."
)
else:
error_text = selection_error(
"web",
f"'{backend}'",
"no registered web search provider has that name",
)
response_data = {"success": False, "error": error_text}
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
debug_call_data["error"] = error_text
_debug.log_call("web_search_tool", debug_call_data)
_debug.save()
return result_json
# Never-configured install: fall back to the availability-walked
# active provider (legacy autodetect behavior).
provider = get_active_search_provider()
if provider is None:
@@ -898,6 +948,35 @@ async def web_extract_tool(
},
ensure_ascii=False,
)
from tools.tool_backend_helpers import (
selection_error,
selection_exists,
)
if backend and selection_exists("web"):
# Strict selection: a stored-but-unregistered backend
# errors by name instead of silently switching to
# whatever the availability walk finds.
disabled_key = _disabled_web_plugin_for(capability="extract")
if disabled_key:
_vendor = disabled_key.split("/", 1)[-1]
error_text = (
f"web.extract_backend is set to '{_vendor}', but "
f"its plugin ('{disabled_key}') is disabled in "
f"config. Re-enable it with `hermes plugins "
f"enable {disabled_key}` (or remove it from "
"plugins.disabled)."
)
else:
error_text = selection_error(
"web",
f"'{backend}'",
"no registered web extract provider has that name",
)
return json.dumps(
{"success": False, "error": error_text},
ensure_ascii=False,
)
provider = get_active_extract_provider()
if provider is None:
# If the configured backend is a bundled web plugin the