"""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, search_fail, search_ok 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" _SELECTION_KEYS = ("backend", "search_backend", "extract_backend") # --- Lazy Firecrawl SDK proxy ------------------------------------------------- # The SDK costs ~200ms of imports on a cold CLI; defer to first use. tools.web_tools # re-exports ``Firecrawl`` so ``patch("tools.web_tools.Firecrawl")`` keeps working. _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 tools.lazy_deps import ensure as _lazy_ensure _lazy_ensure("search.firecrawl", prompt=False) except ImportError: pass except Exception as exc: # noqa: BLE001 — surface install hint raise ImportError(str(exc)) from firecrawl import Firecrawl as _cls _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 "" Firecrawl = _FirecrawlProxy() # --- Client construction (direct vs managed-gateway) --------------------------- # Client cache slots and gateway/token helpers are read through tools.web_tools so # tests that reset ``tools.web_tools._firecrawl_client`` or patch # ``tools.web_tools._peek_nous_access_token`` see their changes. 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]: """Return direct Firecrawl ``(mode, kwargs, cache_key)`` or None. ``mode`` is ``"sdk"`` (keyed / self-hosted) or ``"keyless"`` (explicit Firecrawl selection with no credentials — public cloud API, anonymous rate-limited). Keyless requires the explicit selection so an unconfigured install never silently routes to it. """ api_key = _env("FIRECRAWL_API_KEY") api_url = _env("FIRECRAWL_API_URL").rstrip("/") if not api_key and not api_url: if _is_explicit_firecrawl_selection(): return "keyless", {"api_url": _FIRECRAWL_CLOUD_API_URL}, ("direct-keyless", _FIRECRAWL_CLOUD_API_URL, None) return None kwargs = {k: v for k, v in (("api_key", api_key), ("api_url", api_url)) if v} return "sdk", kwargs, ("direct", api_url or None, api_key or None) def _is_explicit_firecrawl_selection() -> bool: """True when config explicitly selects Firecrawl for web tools.""" import tools.web_tools as _wt cfg = _wt._load_web_config() return any((cfg.get(key) or "").lower().strip() == "firecrawl" for key in _SELECTION_KEYS) def _use_keyless_ring() -> bool: """True when Firecrawl calls should route via the keyless ring. Only when there are no direct credentials, the managed Nous gateway isn't the selected path, and the keyless tier isn't disabled or pinned paid. """ if _env("FIRECRAWL_API_KEY") or _env("FIRECRAWL_API_URL"): return False import tools.web_tools as _wt from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_selection try: if read_selection("web") == NOUS_MANAGED_PROVIDER: return False except Exception: # noqa: BLE001 — selection helpers optional pass try: if _wt._is_tool_gateway_ready() and not _is_explicit_firecrawl_selection(): return False except Exception: # noqa: BLE001 — probe optional pass from plugins.web.keyless_mcp import use_keyless return use_keyless("firecrawl", "") class _KeylessFirecrawlClient: """Minimal REST client for Firecrawl's keyless cloud mode. Duck-types the SDK's ``search`` / ``scrape``; 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() def search(self, *, query: str, limit: int = 5) -> Dict[str, Any]: return self._post("/v2/search", {"query": query, "limit": limit}) def scrape(self, *, url: str, formats: List[str]) -> Dict[str, Any]: return self._post("/v2/scrape", {"url": url, "formats": formats}) def _get_firecrawl_gateway_url() -> str: """Return the configured Firecrawl gateway URL.""" import tools.web_tools as _wt return _wt.build_vendor_gateway_url("firecrawl") def _is_tool_gateway_ready() -> bool: """True when gateway URL + Nous Subscriber token are available.""" import tools.web_tools as _wt return _wt.resolve_managed_tool_gateway("firecrawl", token_reader=_wt._peek_nous_access_token) is not None def check_firecrawl_api_key() -> bool: """True when the Firecrawl route selected via ``hermes tools`` (or, on a never-configured install, either route) is usable. Re-exported by tools.web_tools.""" 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() has_direct = _get_direct_firecrawl_config() is not None if selected is not None: return has_direct return has_direct or _is_tool_gateway_ready() def _firecrawl_backend_help_suffix() -> str: """Return optional managed-gateway guidance for Firecrawl help text.""" import tools.web_tools as _wt if not _wt.managed_nous_tools_enabled(): return "" return ", or use the Nous Tool Gateway via your subscription (FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN)" def _get_firecrawl_client() -> Any: """Get or create the cached Firecrawl client. Strict selection semantics on the stored ``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. """ 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() def _managed_kwargs(): gw = _wt.resolve_managed_tool_gateway("firecrawl", token_reader=_wt._read_nous_access_token) if gw is None: return None kwargs = {"api_key": gw.nous_user_token, "api_url": gw.gateway_origin} return kwargs, ("tool-gateway", kwargs["api_url"], gw.nous_user_token) client_mode = "sdk" 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: direct Firecrawl only. With no credentials the # explicit selection unlocks keyless cloud mode instead of erroring. 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", )) client_mode, kwargs, client_config = direct_config elif direct_config is not None: client_mode, 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.") 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 _wt.managed_nous_tools_enabled(): message += ( " With your Nous subscription you can also use the Tool Gateway. " "run `hermes tools` and select Nous Subscription as the web provider." ) else: message += " " + _wt.nous_tool_gateway_unavailable_message("managed Firecrawl web tools") raise ValueError(message) kwargs, client_config = managed cached = getattr(_wt, "_firecrawl_client", None) if cached is not None and getattr(_wt, "_firecrawl_client_config", None) == client_config: return cached if client_mode == "keyless": _wt._firecrawl_client = _KeylessFirecrawlClient(api_url=kwargs["api_url"]) else: _wt._firecrawl_client = _wt.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: """Convert SDK objects (pydantic ``model_dump`` / ``__dict__``) to plain data when possible.""" if value is None or isinstance(value, (dict, list, str, int, float, bool)): return value if hasattr(value, "model_dump"): try: return value.model_dump() except Exception: # noqa: BLE001 pass if hasattr(value, "__dict__"): try: return {k: v for k, v in value.__dict__.items() if not k.startswith("_")} except Exception: # noqa: BLE001 pass return value def _normalize_result_list(values: Any) -> List[Dict[str, Any]]: """Normalize mixed SDK/list payloads into a list of dicts.""" if not isinstance(values, list): return [] plain = (_to_plain_object(item) for item in values) return [p for p in plain if isinstance(p, dict)] def _extract_web_search_results(response: Any) -> List[Dict[str, Any]]: """Extract Firecrawl search results across SDK/direct/gateway response shapes.""" response_plain = _to_plain_object(response) if isinstance(response_plain, dict): data = response_plain.get("data") if isinstance(data, list): return _normalize_result_list(data) candidates = [] if isinstance(data, dict): candidates += [data.get("web"), data.get("results")] candidates += [response_plain.get("web"), response_plain.get("results")] for candidate in candidates: 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]: """Normalize Firecrawl scrape payload shape across SDK and gateway variants.""" result_plain = _to_plain_object(scrape_result) if not isinstance(result_plain, dict): return {} nested = result_plain.get("data") return nested if isinstance(nested, dict) else result_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 item. ``raw`` adds the ``raw_content`` key (post-scrape failures carry it, pre-scrape ones don't); ``blocked`` adds ``blocked_by_policy``.""" entry: Dict[str, Any] = {"url": url, "title": title, "content": ""} if raw: entry["raw_content"] = "" entry["error"] = error if blocked: entry["blocked_by_policy"] = {k: blocked[k] for k in ("host", "rule", "source")} return entry _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.""" blocked = check_website_access(url) if blocked: 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: scrape_result = await asyncio.wait_for( asyncio.to_thread(_get_firecrawl_client().scrape, url=url, formats=formats), timeout=60, ) except asyncio.TimeoutError: logger.warning("Firecrawl scrape timed out for %s", url) return _error_entry(url, _SCRAPE_TIMEOUT_MSG) scrape_payload = _extract_scrape_payload(scrape_result) metadata = scrape_payload.get("metadata", {}) content_markdown = scrape_payload.get("markdown") content_html = scrape_payload.get("html") # SDK may return a typed object for metadata (raw __dict__ here, unlike _to_plain_object). if not isinstance(metadata, dict): if hasattr(metadata, "model_dump"): metadata = metadata.model_dump() elif hasattr(metadata, "__dict__"): metadata = metadata.__dict__ else: metadata = {} title = metadata.get("title", "") final_url = 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) final_blocked = check_website_access(final_url) if final_blocked: 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) if format == "markdown" or (format is None and content_markdown): chosen_content = content_markdown else: chosen_content = content_html or content_markdown or "" return {"url": final_url, "title": title, "content": chosen_content, "raw_content": chosen_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) # --- Provider class ------------------------------------------------------------ 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]: """Sync search. Pre-flight errors (ValueError / ImportError) propagate so the dispatcher emits the legacy ``tool_error`` envelope; in-flight errors are returned as ``{"success": False, "error": ...}``.""" from tools.interrupt import is_interrupted if is_interrupted(): return search_fail("Interrupted") if _use_keyless_ring(): from plugins.web.keyless_mcp import search_with_failover logger.info("Firecrawl keyless search: '%s' (limit=%d)", query, limit) return search_with_failover("firecrawl", query, limit) logger.info("Firecrawl search: '%s' (limit=%d)", query, limit) client = _get_firecrawl_client() try: response = client.search(query=query, limit=limit) web_results = _extract_web_search_results(response) 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 via :func:`_scrape_one`; 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(): from plugins.web.keyless_mcp import extract_with_failover logger.info("Firecrawl keyless extract: %d URL(s)", len(urls)) return await asyncio.to_thread(extract_with_failover, "firecrawl", list(urls)) 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 { "name": "Firecrawl", "badge": "keyless/paid · optional gateway", "tag": "Full search + extract; supports keyless cloud, direct API, and Nous tool-gateway routing.", "env_vars": [ { "key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key (optional; blank = keyless cloud or self-hosted)", "url": "https://docs.firecrawl.dev/introduction", }, ], }