Files
hermes-agent/plugins/web/firecrawl/provider.py
Teknium 3861f1da62 fix(web): pick own key or Nous Tool Gateway per web capability in Desktop
Desktop's web picker had no way to send one capability through the Nous
Tool Gateway while the other used your own key. "Use for Search/Extract"
on the Nous Subscription row wrote the row's vendor string (firecrawl)
into web.<cap>_backend, which the runtime reads as BYOK Firecrawl: the
gateway was never used, and the managed and BYOK rows lit up together.

- The managed per-capability pick now stores `nous`; search/extract
  dispatch, the registry and the Firecrawl client resolve the route per
  capability (search: managed Perplexity, extract: managed Firecrawl).
- A whole-toolset Nous pick clears stale per-capability pins (they
  outranked the new selection).
- /api/tools/toolsets/web/config reports search_via_nous /
  extract_via_nous and each row's managed_nous_feature; the panel lights
  the right row, labels the route, and refetches after a pick instead of
  mirroring the vendor string.

Fixes #100513. Builds on the approach in #100539 by @liuhao1024 and the
self-healing pin clear from #122676 by @praggybuilds.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Murphy (praggybuilds) <praggybuilds@users.noreply.github.com>
2026-10-06 02:53:56 -07:00

369 lines
18 KiB
Python

"""Firecrawl web search + extract provider (direct SDK, keyless cloud, or Nous tool-gateway).
Config: ``web.backend`` / ``web.search_backend`` / ``web.extract_backend: firecrawl``.
Env: FIRECRAWL_API_KEY, FIRECRAWL_API_URL (self-hosted), FIRECRAWL_GATEWAY_URL / TOOL_GATEWAY_*.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, List, Optional
import httpx
from plugins.web._common import BaseWebSearchProvider, keyless_extract, keyless_search, search_fail, search_ok, setup_schema
from tools import managed_tool_gateway as _gateway
from tools import tool_backend_helpers as _backend_helpers
from tools.url_safety import is_safe_url
# Module-level (cheap import) so tests can monkeypatch the policy gate on this module.
from tools.website_policy import check_website_access
logger = logging.getLogger(__name__)
_FIRECRAWL_CLOUD_API_URL = "https://api.firecrawl.dev"
# The SDK costs ~200ms of imports on a cold CLI; defer to first use (tests patch ``Firecrawl`` here).
_FIRECRAWL_CLS_CACHE: Optional[type] = None
def _load_firecrawl_cls() -> type:
"""Import and cache ``firecrawl.Firecrawl`` (lazy_deps install hint → ImportError)."""
global _FIRECRAWL_CLS_CACHE
if _FIRECRAWL_CLS_CACHE is None:
try:
from pm import ensure_import as _lazy_ensure
_lazy_ensure("firecrawl")
except ImportError:
pass
except Exception as exc: # noqa: BLE001 — surface install hint
raise ImportError(str(exc))
from firecrawl import Firecrawl as _cls # noqa: WPS433 — deliberately lazy
_FIRECRAWL_CLS_CACHE = _cls
return _FIRECRAWL_CLS_CACHE
class _FirecrawlProxy:
"""Callable proxy that looks like ``firecrawl.Firecrawl`` but imports lazily."""
__slots__ = ()
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return _load_firecrawl_cls()(*args, **kwargs)
def __instancecheck__(self, obj: Any) -> bool:
return isinstance(obj, _load_firecrawl_cls())
def __repr__(self) -> str:
return "<lazy firecrawl.Firecrawl proxy>"
Firecrawl = _FirecrawlProxy()
# --- Client construction (direct vs managed-gateway) ---------------------------
def _wt():
"""Client cache slots live on tools.web_tools so tests that reset ``_firecrawl_client`` there see it."""
import tools.web_tools as _mod
return _mod
def _env(name: str) -> str:
from hermes_cli.config import get_env_value
return (get_env_value(name) or "").strip()
def _get_direct_firecrawl_config() -> Optional[tuple]:
"""Direct Firecrawl ``(mode, kwargs, cache_key)`` or None. ``mode`` is ``"sdk"`` (keyed / self-hosted) or
``"keyless"`` (explicit selection + no credentials → anonymous public cloud; the explicit selection is
required so an unconfigured install never silently routes to it)."""
api_key = _env("FIRECRAWL_API_KEY")
api_url = _env("FIRECRAWL_API_URL").rstrip("/")
if api_key or api_url:
return "sdk", {k: v for k, v in (("api_key", api_key), ("api_url", api_url)) if v}, ("direct", api_url or None, api_key or None)
if _is_explicit_firecrawl_selection():
return "keyless", {"api_url": _FIRECRAWL_CLOUD_API_URL}, ("direct-keyless", _FIRECRAWL_CLOUD_API_URL, None)
return None
def _is_explicit_firecrawl_selection() -> bool:
from plugins.web.keyless_mcp import _web_config_selects
return _web_config_selects("firecrawl")
def _use_keyless_ring(capability: Optional[str] = None) -> bool:
"""Route via the keyless ring only with no direct credentials, when the managed Nous
gateway isn't the selected path for *capability*, and the keyless tier isn't disabled or pinned paid."""
if _env("FIRECRAWL_API_KEY") or _env("FIRECRAWL_API_URL"):
return False
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_web_capability_selection
from plugins.web.keyless_mcp import use_keyless
# Both probes are optional layers: a failing probe never blocks the ring.
for probe in (lambda: read_web_capability_selection(capability) == NOUS_MANAGED_PROVIDER,
lambda: _is_tool_gateway_ready() and not _is_explicit_firecrawl_selection()):
try:
if probe():
return False
except Exception: # noqa: BLE001
pass
return use_keyless("firecrawl", "")
class _KeylessFirecrawlClient:
"""Minimal REST client for Firecrawl's keyless cloud mode; duck-types the SDK's
``search`` / ``scrape`` and never sends an Authorization header."""
def __init__(self, api_url: str = _FIRECRAWL_CLOUD_API_URL):
self.api_url = api_url.rstrip("/")
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
response = httpx.post(f"{self.api_url}{path}", json=payload, headers={"Content-Type": "application/json"}, timeout=60.0)
response.raise_for_status()
return response.json()
search = lambda self, *, query, limit=5: self._post("/v2/search", {"query": query, "limit": limit}) # noqa: E731
def scrape(self, *, url, formats, timeout=None):
# _scrape_one passes the SDK's server-side ``timeout`` (ms); the v2 REST payload takes the same field.
payload = {"url": url, "formats": formats}
if timeout is not None:
payload["timeout"] = timeout
return self._post("/v2/scrape", payload)
def _get_firecrawl_gateway_url() -> str:
return _gateway.build_vendor_gateway_url("firecrawl")
def _is_tool_gateway_ready() -> bool:
"""True when gateway URL + Nous Subscriber token are available."""
return _gateway.resolve_managed_tool_gateway("firecrawl", token_reader=_gateway.peek_nous_access_token) is not None
def check_firecrawl_api_key() -> bool:
"""True when the route selected via ``hermes tools`` (or, on a never-configured
install, either route) is usable."""
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_web_capability_selection
def _usable(selected) -> bool:
if selected == NOUS_MANAGED_PROVIDER:
return _is_tool_gateway_ready()
return _get_direct_firecrawl_config() is not None or (selected is None and _is_tool_gateway_ready())
# Search and extract can each pick their route (own key vs gateway); usable if either one is.
return any(_usable(read_web_capability_selection(cap)) for cap in ("search", "extract"))
def is_managed_route(capability: Optional[str] = None) -> bool:
"""True when Firecrawl calls for *capability* go through the Nous Tool Gateway rather than the user's
own key / instance — the same decision :func:`_get_firecrawl_client` makes, without building a client."""
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_web_capability_selection
selected = read_web_capability_selection(capability)
if selected is not None or _is_explicit_firecrawl_selection():
return selected == NOUS_MANAGED_PROVIDER
return _get_direct_firecrawl_config() is None and _is_tool_gateway_ready()
def _firecrawl_backend_help_suffix() -> str:
return ", or use the Nous Tool Gateway via your subscription (FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN)" if _backend_helpers.managed_nous_tools_enabled() else ""
def _get_firecrawl_client(capability: Optional[str] = None) -> Any:
"""Get or create the cached Firecrawl client. Strict selection semantics on the stored selection for
*capability* (``web.<capability>_backend``, else the shared ``web`` selection): ``"nous"`` → managed Tool
Gateway ONLY; any other stored backend → direct Firecrawl ONLY (never a silent managed fallback billed to
Nous); never-configured → direct when present, else managed. Raises ValueError when the resolved path is
unusable."""
wt = _wt()
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_web_capability_selection, selection_error
selected = read_web_capability_selection(capability)
direct_config = _get_direct_firecrawl_config()
def _managed():
gw = _gateway.resolve_managed_tool_gateway("firecrawl", token_reader=_gateway.read_nous_access_token)
if gw is None:
return None
return "sdk", {"api_key": gw.nous_user_token, "api_url": gw.gateway_origin}, ("tool-gateway", gw.gateway_origin, gw.nous_user_token)
def _unconfigured_message() -> str:
message = "Web tools are not configured. Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL for a self-hosted Firecrawl instance."
if _backend_helpers.managed_nous_tools_enabled():
return message + " With your Nous subscription you can also use the Tool Gateway. run `hermes tools` and select Nous Subscription as the web provider."
return message + " " + _backend_helpers.nous_tool_gateway_unavailable_message("managed Firecrawl web tools")
# (resolved config, log detail, error message) per selection state; the message is built lazily.
if selected == NOUS_MANAGED_PROVIDER:
resolved, log, message = _managed(), "the Nous Subscription web selection is stored but the tool gateway is unavailable.", lambda: selection_error(
"web", NOUS_MANAGED_PROVIDER, "the Nous Tool Gateway is not available (not entitled or unreachable)")
elif selected is not None or _is_explicit_firecrawl_selection():
# Stored vendor selection (shared name, or a per-capability key naming firecrawl): direct only (no
# credentials → explicit selection unlocks keyless cloud mode). A per-capability key naming ANOTHER
# vendor is not a firecrawl selection: the other capability's ladder reached firecrawl on its own,
# so it resolves exactly like a never-configured install (#113017).
resolved, log, message = direct_config, "direct Firecrawl selected but FIRECRAWL_API_KEY/FIRECRAWL_API_URL is not set.", lambda: selection_error(
"web", selected or "firecrawl", "neither FIRECRAWL_API_KEY nor FIRECRAWL_API_URL is set")
elif direct_config is not None:
resolved = direct_config
else: # never-configured web section: legacy managed fallback
resolved, log, message = _managed(), "missing direct config and tool-gateway auth.", _unconfigured_message
if resolved is None:
logger.error("Firecrawl client initialization failed: %s", log)
raise ValueError(message())
client_mode, kwargs, client_config = resolved
cached = getattr(wt, "_firecrawl_client", None)
if cached is not None and getattr(wt, "_firecrawl_client_config", None) == client_config:
return cached
wt._firecrawl_client = _KeylessFirecrawlClient(api_url=kwargs["api_url"]) if client_mode == "keyless" else Firecrawl(**kwargs)
wt._firecrawl_client_config = client_config
return wt._firecrawl_client
# --- Response shape normalization (SDK / direct / gateway differ) --------------
def _to_plain_object(value: Any) -> Any:
"""SDK objects (pydantic ``model_dump`` / ``__dict__``) → plain data when possible."""
if value is None or isinstance(value, (dict, list, str, int, float, bool)):
return value
for attr, convert in (("model_dump", lambda v: v.model_dump()), ("__dict__", lambda v: {k: x for k, x in v.__dict__.items() if not k.startswith("_")})):
if hasattr(value, attr):
try:
return convert(value)
except Exception: # noqa: BLE001
pass
return value
def _normalize_result_list(values: Any) -> List[Dict[str, Any]]:
return [p for p in map(_to_plain_object, values) if isinstance(p, dict)] if isinstance(values, list) else []
def _extract_web_search_results(response: Any) -> List[Dict[str, Any]]:
"""Search results across SDK/direct/gateway response shapes."""
plain = _to_plain_object(response)
if isinstance(plain, dict):
data = plain.get("data")
if isinstance(data, list):
return _normalize_result_list(data)
candidates = [data.get("web"), data.get("results")] if isinstance(data, dict) else []
for candidate in candidates + [plain.get("web"), plain.get("results")]:
normalized = _normalize_result_list(candidate)
if normalized:
return normalized
if hasattr(response, "web"):
return _normalize_result_list(getattr(response, "web", []))
return []
def _extract_scrape_payload(scrape_result: Any) -> Dict[str, Any]:
plain = _to_plain_object(scrape_result)
if not isinstance(plain, dict):
return {}
return plain["data"] if isinstance(plain.get("data"), dict) else plain
def _error_entry(url: str, error: str, *, title: str = "", raw: bool = False, blocked: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Per-URL extract failure. ``raw`` adds ``raw_content`` (post-scrape failures carry
it, pre-scrape ones don't); ``blocked`` adds ``blocked_by_policy``."""
policy = {"blocked_by_policy": {k: blocked[k] for k in ("host", "rule", "source")}} if blocked else {}
return {"url": url, "title": title, "content": "", **({"raw_content": ""} if raw else {}), "error": error, **policy}
_SCRAPE_TIMEOUT_MSG = "Scrape timed out after 60s — page may be too large or unresponsive. Try browser_navigate instead."
_UNSAFE_REDIRECT_MSG = "Blocked: URL targets a private or internal network address"
async def _scrape_one(url: str, formats: List[str], format: Optional[str]) -> Dict[str, Any]:
"""Scrape one URL (60s timeout) and re-check SSRF + website policy against the
post-redirect URL. Never raises for scrape errors; returns an error entry instead."""
if blocked := check_website_access(url):
logger.info("Blocked web_extract for %s by rule %s", blocked["host"], blocked["rule"])
return _error_entry(url, blocked["message"], blocked=blocked)
try:
logger.info("Firecrawl scraping: %s", url)
try:
# Pass timeout (ms) to Firecrawl so the server-side deadline
# matches our asyncio deadline. Without this the API uses its
# 30 s default, causing SCRAPE_TIMEOUT before our 60 s
# client-side wait expires. See #43272.
scrape_result = await asyncio.wait_for(
asyncio.to_thread(
_get_firecrawl_client("extract").scrape,
url=url,
formats=formats,
timeout=60_000,
),
timeout=60,
)
except asyncio.TimeoutError:
logger.warning("Firecrawl scrape timed out for %s", url)
return _error_entry(url, _SCRAPE_TIMEOUT_MSG)
payload = _extract_scrape_payload(scrape_result)
metadata = payload.get("metadata", {})
# SDK may return a typed object for metadata (raw __dict__ here, unlike _to_plain_object).
if not isinstance(metadata, dict):
metadata = metadata.model_dump() if hasattr(metadata, "model_dump") else getattr(metadata, "__dict__", {})
title, final_url = metadata.get("title", ""), metadata.get("sourceURL", url)
if not is_safe_url(final_url):
logger.info("Blocked redirected web_extract for unsafe final URL: %s", final_url)
return _error_entry(final_url, _UNSAFE_REDIRECT_MSG, title=title, raw=True)
if final_blocked := check_website_access(final_url):
logger.info("Blocked redirected web_extract for %s by rule %s", final_blocked["host"], final_blocked["rule"])
return _error_entry(final_url, final_blocked["message"], title=title, raw=True, blocked=final_blocked)
markdown, html = payload.get("markdown"), payload.get("html")
content = markdown if format == "markdown" or (format is None and markdown) else html or markdown or ""
return {"url": final_url, "title": title, "content": content, "raw_content": content, "metadata": metadata}
except Exception as scrape_err: # noqa: BLE001
logger.debug("Firecrawl scrape failed for %s: %s", url, scrape_err)
return _error_entry(url, str(scrape_err), raw=True)
class FirecrawlWebSearchProvider(BaseWebSearchProvider):
"""Firecrawl search + extract provider with dual auth paths."""
NAME = "firecrawl"
DISPLAY_NAME = "Firecrawl"
EXTRACT = True
KEYLESS = True # default-on ring member unless pinned ``paid``
def is_available(self) -> bool:
return check_firecrawl_api_key()
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
"""Pre-flight errors (ValueError / ImportError) propagate so the dispatcher emits
the legacy ``tool_error`` envelope; in-flight errors become failure dicts."""
from tools.interrupt import is_interrupted
if is_interrupted():
return search_fail("Interrupted")
if _use_keyless_ring("search"):
return keyless_search("Firecrawl", "firecrawl", query, limit, logger)
logger.info("Firecrawl search: '%s' (limit=%d)", query, limit)
client = _get_firecrawl_client("search")
try:
web_results = _extract_web_search_results(client.search(query=query, limit=limit))
logger.info("Firecrawl: found %d search results", len(web_results))
return search_ok(web_results)
except Exception as exc: # noqa: BLE001
logger.warning("Firecrawl search error: %s", exc)
return search_fail(f"Firecrawl search failed: {exc}")
async def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]:
"""Per-URL scrape; failures become items with an ``error`` field.
``format``: "markdown" | "html" | both (markdown preferred)."""
from tools.interrupt import is_interrupted as _is_interrupted
if _is_interrupted():
return [{"url": u, "error": "Interrupted", "title": ""} for u in urls]
if _use_keyless_ring("extract"):
return await asyncio.to_thread(keyless_extract, "Firecrawl", "firecrawl", urls, logger)
format = kwargs.get("format")
formats = [format] if format in ("markdown", "html") else ["markdown", "html"]
return [
{"url": url, "error": "Interrupted", "title": ""} if _is_interrupted() else await _scrape_one(url, formats, format)
for url in urls
]
def get_setup_schema(self) -> Dict[str, Any]:
return setup_schema(
"Firecrawl", "keyless/paid · optional gateway",
"Full search + extract; supports keyless cloud, direct API, and Nous tool-gateway routing.",
"FIRECRAWL_API_KEY", "Firecrawl API key (optional; blank = keyless cloud or self-hosted)", "https://docs.firecrawl.dev/introduction",
)