diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py index ae02b43a6e..e18e78ea48 100644 --- a/plugins/web/firecrawl/provider.py +++ b/plugins/web/firecrawl/provider.py @@ -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) diff --git a/tools/web_tools.py b/tools/web_tools.py index e8c62142af..0634cedc4f 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -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