diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e927996c3c85..177c20418c4b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2299,6 +2299,14 @@ def _ensure_hermes_home_managed(home: Path): "password": True, "category": "tool", }, + "APIFY_API_TOKEN": { + "description": "Apify API token for web search (RAG Web Browser) and page extract (Website Content Crawler)", + "prompt": "Apify API token", + "url": "https://apify.com/account/integrations", + "tools": ["web_search", "web_extract"], + "password": True, + "category": "tool", + }, "SEARXNG_URL": { "description": "URL of your SearXNG instance for free self-hosted web search", "prompt": "SearXNG URL (e.g. http://localhost:8080)", @@ -5257,7 +5265,7 @@ def set_config_value(key: str, value: str): 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', - 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', + 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'APIFY_API_TOKEN', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', diff --git a/plugins/web/apify/__init__.py b/plugins/web/apify/__init__.py new file mode 100644 index 000000000000..9b1b611e861f --- /dev/null +++ b/plugins/web/apify/__init__.py @@ -0,0 +1,14 @@ +"""Apify web search + extract + crawl plugin — bundled, auto-loaded. + +Uses apify/rag-web-browser for search and apify/website-content-crawler +for both single-page extract and multi-page crawl. Requires APIFY_API_TOKEN. +""" + +from __future__ import annotations + +from plugins.web.apify.provider import ApifyWebSearchProvider + + +def register(ctx) -> None: + """Register the Apify provider with the plugin context.""" + ctx.register_web_search_provider(ApifyWebSearchProvider()) \ No newline at end of file diff --git a/plugins/web/apify/plugin.yaml b/plugins/web/apify/plugin.yaml new file mode 100644 index 000000000000..4c4f70595bd8 --- /dev/null +++ b/plugins/web/apify/plugin.yaml @@ -0,0 +1,7 @@ +name: web-apify +version: 1.0.0 +description: "Apify web search (RAG Web Browser) and page extract (Website Content Crawler)" +author: Apify +kind: backend +provides_web_providers: + - apify diff --git a/plugins/web/apify/provider.py b/plugins/web/apify/provider.py new file mode 100644 index 000000000000..a0cf39566de2 --- /dev/null +++ b/plugins/web/apify/provider.py @@ -0,0 +1,454 @@ +"""Apify web search + extract + crawl — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Three +capabilities advertised: + +- ``supports_search()`` -> True (apify/rag-web-browser Actor) +- ``supports_extract()`` -> True (apify/website-content-crawler, single-page) +- ``supports_crawl()`` -> True (apify/website-content-crawler, multi-page) + +search() is sync; extract() and crawl() are async (each Actor call runs in +``asyncio.to_thread`` with an ``asyncio.wait_for`` guard). + +Config keys this provider responds to:: + + web: + search_backend: "apify" # explicit per-capability + extract_backend: "apify" # explicit per-capability + backend: "apify" # shared fallback + +Env vars:: + + APIFY_API_TOKEN=... # https://apify.com/account/integrations +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Dict, List, Optional + +from agent.web_search_provider import WebSearchProvider +from tools.apify_client import check_apify_api_key, get_apify_client +from tools.website_policy import check_website_access + +logger = logging.getLogger(__name__) + +_RAG_ACTOR = "apify/rag-web-browser" +_WCC_ACTOR = "apify/website-content-crawler" + +_get_apify_client = get_apify_client # alias kept for tests that patch this name + + +# --------------------------------------------------------------------------- +# Actor execution +# --------------------------------------------------------------------------- + + +def _run_actor_blocking(actor_id: str, run_input: Dict[str, Any]) -> List[Dict[str, Any]]: + """Start an Apify Actor, wait for completion, return dataset items. + + Blocking — intended to be called via asyncio.to_thread from async methods. + Returns [] when the Actor succeeds but produces no dataset items. + Raises RuntimeError on non-SUCCEEDED status so callers can surface the failure. + Raises ValueError if the client is unconfigured. + """ + client = _get_apify_client() + started = client.actor(actor_id).start(run_input=run_input) + run_id = started.id + logger.info("Apify %s started — https://console.apify.com/actors/runs/%s", actor_id, run_id) + run = client.run(run_id).wait_for_finish() + if run is None: + raise RuntimeError( + f"Apify {actor_id} run {run_id}: wait_for_finish returned None — " + f"check https://console.apify.com/actors/runs/{run_id}" + ) + if run.status != "SUCCEEDED": + raise RuntimeError( + f"Apify {actor_id} run {run_id} finished with status {run.status} — " + f"check https://console.apify.com/actors/runs/{run_id} for details" + ) + dataset_id = run.default_dataset_id + if not dataset_id: + return [] + return list(client.dataset(dataset_id).list_items().items) + + +def _run_wcc_crawl(url: str, max_pages: int, max_depth: int) -> List[Dict[str, Any]]: + """Multi-page crawl via apify/website-content-crawler. + + Intended to be called via asyncio.to_thread from crawl(). + Returns raw dataset items (plain dicts). + """ + return _run_actor_blocking( + _WCC_ACTOR, + { + "startUrls": [{"url": url}], + "maxCrawlPages": max_pages, + "maxCrawlDepth": max_depth, + "outputFormats": ["markdown"], + "saveMarkdown": True, + "saveHtml": False, + }, + ) + + +def _run_website_content_crawler(url: str, output_formats: List[str]) -> Optional[Dict[str, Any]]: + """Single-page extract via apify/website-content-crawler. + + Intended to be called via asyncio.to_thread from extract(). + Returns the first dataset item, or None if the Actor returned no items. + """ + items = _run_actor_blocking( + _WCC_ACTOR, + { + "startUrls": [{"url": url}], + "maxCrawlPages": 1, + "outputFormats": output_formats, + }, + ) + if not items: + return None + item = items[0] + return item if isinstance(item, dict) else None + + +# --------------------------------------------------------------------------- +# Response normalization +# --------------------------------------------------------------------------- + + +def _normalize_rag_search_results(items: List[Any], limit: int) -> List[Dict[str, Any]]: + """Normalize RAG Web Browser dataset items to the registry web search shape.""" + results: List[Dict[str, Any]] = [] + for item in items[:limit]: + if not isinstance(item, dict): + continue + sr = item.get("searchResult") or {} + if not isinstance(sr, dict): + sr = {} + title = sr.get("title") or item.get("title", "") + url = sr.get("url") or item.get("url", "") + description = sr.get("description") or item.get("markdown", "") + if description and len(description) > 500: + description = description[:500] + results.append({ + "title": title, + "url": url, + "description": description, + "position": len(results) + 1, + }) + return results + + +# --------------------------------------------------------------------------- +# Provider class +# --------------------------------------------------------------------------- + + +class ApifyWebSearchProvider(WebSearchProvider): + """Apify web search + extract + crawl provider. + + search() — apify/rag-web-browser Actor (sync, 60s timeout in run input) + extract() — apify/website-content-crawler Actor (async, per-URL, 60s asyncio guard) + crawl() — apify/website-content-crawler Actor (async, multi-page, 300s ceiling) + """ + + @property + def name(self) -> str: + return "apify" + + @property + def display_name(self) -> str: + return "Apify" + + def is_available(self) -> bool: + return check_apify_api_key() + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + def supports_crawl(self) -> bool: + return True + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a web search via Apify RAG Web Browser Actor. + + Sync; blocks until the Actor run completes (up to requestTimeoutSecs). + """ + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + + logger.info("Apify search: '%s' (limit=%d)", query, limit) + try: + items = _run_actor_blocking( + _RAG_ACTOR, + { + "query": query, + "maxResults": limit, + "requestTimeoutSecs": 60, + }, + ) + web_results = _normalize_rag_search_results(items, limit) + logger.info("Apify search: found %d results", len(web_results)) + return {"success": True, "data": {"web": web_results}} + except Exception as exc: # noqa: BLE001 + logger.warning("Apify search error: %s", exc) + return {"success": False, "error": f"Apify search failed: {exc}"} + + async def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]: + """Extract content from URLs via Apify Website Content Crawler. + + Each URL is crawled in a background thread (asyncio.to_thread) with a + 60s asyncio.wait_for guard. Website-access policy is checked before + each Actor call. Per-URL failures are returned as error items, not raised. + + kwargs: + format: "markdown" | "html" | None (default: both) + """ + from tools.interrupt import is_interrupted as _is_interrupted + + if _is_interrupted(): + return [{"url": u, "error": "Interrupted", "title": ""} for u in urls] + + fmt = kwargs.get("format") + if fmt == "markdown": + output_formats = ["markdown"] + elif fmt == "html": + output_formats = ["html"] + else: + output_formats = ["markdown", "html"] + + results: List[Dict[str, Any]] = [] + + for url in urls: + if _is_interrupted(): + results.append({"url": url, "error": "Interrupted", "title": ""}) + continue + + blocked = check_website_access(url) + if blocked: + logger.info( + "Blocked web_extract for %s by rule %s", + blocked["host"], + blocked["rule"], + ) + results.append( + { + "url": url, + "title": "", + "content": "", + "error": blocked["message"], + "blocked_by_policy": { + "host": blocked["host"], + "rule": blocked["rule"], + "source": blocked["source"], + }, + } + ) + continue + + try: + logger.info("Apify extracting: %s", url) + try: + item = await asyncio.wait_for( + asyncio.to_thread( + _run_website_content_crawler, + url, + output_formats, + ), + timeout=60, + ) + except asyncio.TimeoutError: + logger.warning("Apify WCC timed out for %s", url) + results.append( + { + "url": url, + "title": "", + "content": "", + "error": ( + "Extract timed out after 60s — page may be too large " + "or unresponsive. Try browser_navigate instead." + ), + } + ) + continue + + if item is None: + results.append( + {"url": url, "title": "", "content": "", "error": "Actor returned no content"} + ) + continue + + final_url = item.get("url", url) + + 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"], + ) + results.append( + { + "url": final_url, + "title": item.get("title", ""), + "content": "", + "raw_content": "", + "error": final_blocked["message"], + "blocked_by_policy": { + "host": final_blocked["host"], + "rule": final_blocked["rule"], + "source": final_blocked["source"], + }, + } + ) + continue + + content_markdown = item.get("markdown") + content_html = item.get("html") + title = item.get("title", "") + metadata = item.get("metadata") or {} + + if fmt == "markdown" or (fmt is None and content_markdown): + chosen_content = content_markdown or "" + else: + chosen_content = content_html or content_markdown or "" + + results.append( + { + "url": final_url, + "title": title, + "content": chosen_content, + "raw_content": chosen_content, + "metadata": metadata, + } + ) + except Exception as exc: # noqa: BLE001 + logger.debug("Apify extract failed for %s: %s", url, exc) + results.append( + { + "url": url, + "title": "", + "content": "", + "raw_content": "", + "error": str(exc), + } + ) + + return results + + async def crawl(self, url: str, **kwargs: Any) -> Dict[str, Any]: + """Crawl a seed URL via Apify Website Content Crawler. + + Multi-page crawl wrapped in asyncio.to_thread with a 300s ceiling. + Per-page URLs are re-checked against website-access policy. Per-page + failures are returned as error items, not raised. + + kwargs: + instructions: str — logged and dropped (WCC has no NL instructions param) + limit: int — max pages to crawl (default 20) + depth: "basic" → maxCrawlDepth=2, "advanced" → maxCrawlDepth=5, + int → direct, None → 0 (unlimited, capped by limit) + """ + from tools.interrupt import is_interrupted as _is_interrupted + + if _is_interrupted(): + return {"results": [{"url": url, "title": "", "content": "", "error": "Interrupted"}]} + + instructions = kwargs.get("instructions") + try: + limit = int(kwargs.get("limit", 20)) + except (TypeError, ValueError): + limit = 20 + depth_raw = kwargs.get("depth") + + if depth_raw == "basic": + max_depth = 2 + elif depth_raw == "advanced": + max_depth = 5 + elif isinstance(depth_raw, int): + max_depth = depth_raw + else: + max_depth = 0 + + if instructions: + logger.info("Apify crawl: 'instructions' ignored (not supported by WCC)") + + logger.info("Apify crawl: %s (limit=%d, depth=%s)", url, limit, depth_raw or "unlimited") + + try: + items = await asyncio.wait_for( + asyncio.to_thread(_run_wcc_crawl, url, limit, max_depth), + timeout=300, + ) + except asyncio.TimeoutError: + logger.warning("Apify crawl timed out for %s", url) + return {"results": [{"url": url, "title": "", "content": "", + "error": "Crawl timed out after 300s"}]} + except Exception as exc: # noqa: BLE001 + logger.warning("Apify crawl error: %s", exc) + return {"results": [{"url": url, "title": "", "content": "", "error": str(exc)}]} + + pages: List[Dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + page_url = item.get("url", url) + title = item.get("title", "") + + blocked = check_website_access(page_url) + if blocked: + logger.info( + "Blocked crawled page %s by rule %s", + blocked["host"], + blocked["rule"], + ) + pages.append({ + "url": page_url, + "title": title, + "content": "", + "raw_content": "", + "error": blocked["message"], + "blocked_by_policy": { + "host": blocked["host"], + "rule": blocked["rule"], + "source": blocked["source"], + }, + }) + continue + + content = item.get("markdown") or item.get("text") or "" + pages.append({ + "url": page_url, + "title": title, + "content": content, + "raw_content": content, + "metadata": item.get("metadata") or {}, + }) + + logger.info("Apify crawl: %d pages collected", len(pages)) + return {"results": pages} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Apify", + "badge": "paid", + "tag": ( + "JS-rendered, bot-protected, and geo-gated pages via Apify's " + "residential proxy infrastructure. Uses RAG Web Browser for search " + "and Website Content Crawler for extract." + ), + "env_vars": [ + { + "key": "APIFY_API_TOKEN", + "prompt": "Apify API token", + "url": "https://apify.com/account/integrations", + }, + ], + } diff --git a/pyproject.toml b/pyproject.toml index ae2472b7a105..7d272b99a5c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ anthropic = ["anthropic==0.86.0"] # search provider (configured via `hermes tools` or config.yaml). exa = ["exa-py==2.10.2"] firecrawl = ["firecrawl-py==4.17.0"] +apify = ["apify-client==3.0.1"] parallel-web = ["parallel-web==0.4.2"] # Image generation backends fal = ["fal-client==0.13.1"] diff --git a/tests/plugins/web/test_web_search_provider_plugins.py b/tests/plugins/web/test_web_search_provider_plugins.py index 47d7791977b9..de9cc9b2d9c4 100644 --- a/tests/plugins/web/test_web_search_provider_plugins.py +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -48,6 +48,7 @@ def _clear_web_env(monkeypatch: pytest.MonkeyPatch) -> None: "TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN", "XAI_API_KEY", + "APIFY_API_TOKEN", ): monkeypatch.delenv(k, raising=False) @@ -73,12 +74,13 @@ def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None: class TestBundledPluginsRegister: """All eight bundled web plugins discover and register correctly.""" - def test_all_seven_plugins_present_in_registry(self) -> None: + def test_all_bundled_plugins_present_in_registry(self) -> None: _ensure_plugins_loaded() from agent.web_search_registry import list_providers names = sorted(p.name for p in list_providers()) assert names == [ + "apify", "brave-free", "ddgs", "exa", @@ -104,6 +106,7 @@ def test_all_seven_plugins_present_in_registry(self) -> None: ("firecrawl", True, True, True), # xai: search-only via Grok's agentic web_search tool. ("xai", True, False, False), + ("apify", True, True, True), ], ) def test_capability_flags_match_spec( @@ -124,7 +127,7 @@ def test_capability_flags_match_spec( @pytest.mark.parametrize( "plugin_name", - ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"], + ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai", "apify"], ) def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None: _ensure_plugins_loaded() @@ -137,7 +140,7 @@ def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None: @pytest.mark.parametrize( "plugin_name", - ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"], + ["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai", "apify"], ) def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None: """``get_setup_schema()`` returns a dict the picker can consume.""" @@ -254,6 +257,16 @@ def test_xai_requires_api_key_or_oauth(self, monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setenv("XAI_API_KEY", "real") assert p.is_available() is True + def test_apify_requires_api_token(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("apify") + assert p is not None + assert p.is_available() is False # no APIFY_API_TOKEN + monkeypatch.setenv("APIFY_API_TOKEN", "test-token") + assert p.is_available() is True + # --------------------------------------------------------------------------- # Registry resolution semantics (Option B — conservative smart fallback) @@ -377,6 +390,14 @@ def test_tavily_extract_is_sync(self) -> None: assert p is not None assert inspect.iscoroutinefunction(p.extract) is False + def test_apify_extract_is_async(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("apify") + assert p is not None + assert inspect.iscoroutinefunction(p.extract) is True + # --------------------------------------------------------------------------- # Error response shape (preserved bit-for-bit from legacy) @@ -500,3 +521,39 @@ def test_xai_search_returns_error_dict_when_unconfigured(self) -> None: assert isinstance(result, dict) assert result.get("success") is False assert "error" in result + + def test_apify_search_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("apify") + assert p is not None + result = p.search("test query", limit=5) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result + + def test_apify_extract_returns_per_url_errors_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("apify") + assert p is not None + result = asyncio.run(p.extract(["https://example.com"])) + assert isinstance(result, list) + assert len(result) == 1 + assert "error" in result[0] + assert result[0]["url"] == "https://example.com" + + def test_apify_crawl_returns_error_dict_when_unconfigured(self) -> None: + _ensure_plugins_loaded() + from agent.web_search_registry import get_provider + + p = get_provider("apify") + assert p is not None + result = asyncio.run(p.crawl("https://example.com")) + assert isinstance(result, dict) + assert "results" in result + assert len(result["results"]) >= 1 + assert "error" in result["results"][0] + assert result["results"][0]["url"] == "https://example.com" diff --git a/tools/apify_client.py b/tools/apify_client.py new file mode 100644 index 000000000000..62e14ce09d91 --- /dev/null +++ b/tools/apify_client.py @@ -0,0 +1,62 @@ +"""Shared Apify SDK client — lazy import, token validation, and cache. + +Extracted so both the web-search provider (plugins/web/apify/provider.py) and +the Actor execution tools (tools/apify_tool.py) can import the client without +depending on each other. +""" +from __future__ import annotations + +import os +from typing import Any, Optional + +_CLIENT_CLS: Optional[type] = None +_CLIENT: Optional[Any] = None +_CLIENT_CONFIG: Optional[Any] = None + + +def _load_client_cls() -> type: + global _CLIENT_CLS + if _CLIENT_CLS is None: + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.apify", prompt=False) + except ImportError: + pass + except Exception as exc: # noqa: BLE001 + raise ImportError(str(exc)) + from apify_client import ApifyClient + _CLIENT_CLS = ApifyClient + return _CLIENT_CLS + + +def check_apify_api_key() -> bool: + """Return True when APIFY_API_TOKEN is configured.""" + return bool(os.getenv("APIFY_API_TOKEN", "").strip()) + + +def get_apify_client() -> Any: + """Return a cached ApifyClient built from APIFY_API_TOKEN. + + Raises ValueError when the token is not set. + """ + global _CLIENT, _CLIENT_CONFIG + api_token = os.getenv("APIFY_API_TOKEN", "").strip() + if not api_token: + raise ValueError( + "Apify tools are not configured. " + "Set APIFY_API_TOKEN (get one at https://apify.com/account/integrations)." + ) + client_config = ("direct", api_token) + if _CLIENT is not None and _CLIENT_CONFIG == client_config: + return _CLIENT + _CLIENT = _load_client_cls()(token=api_token) + _CLIENT_CONFIG = client_config + return _CLIENT + + +def reset_client_for_tests() -> None: + """Drop cached client so tests can re-instantiate cleanly.""" + global _CLIENT, _CLIENT_CONFIG, _CLIENT_CLS + _CLIENT = None + _CLIENT_CONFIG = None + _CLIENT_CLS = None diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 1a8708ef25c0..e1ff6ea50f9d 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -90,6 +90,7 @@ # ─── Web search backends ─────────────────────────────────────────────── "search.exa": ("exa-py==2.10.2",), "search.firecrawl": ("firecrawl-py==4.17.0",), + "search.apify": ("apify-client==3.0.1",), "search.parallel": ("parallel-web==0.4.2",), # ─── TTS providers ───────────────────────────────────────────────────── diff --git a/tools/web_tools.py b/tools/web_tools.py index a55fe78c41e4..ac9f00b6275a 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -140,7 +140,7 @@ def _get_backend() -> str: keys manually without running setup. """ configured = (_load_web_config().get("backend") or "").lower().strip() - if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}: + if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai", "apify"}: return configured # Fallback for manual / legacy config — pick the highest-priority @@ -228,6 +228,8 @@ def _is_backend_available(backend: str) -> bool: return has_xai_credentials() except Exception: return False + if backend == "apify": + return _has_env("APIFY_API_TOKEN") return False @@ -1367,11 +1369,11 @@ async def _process_tavily_crawl(result): def check_web_api_key() -> bool: """Check whether the configured web backend is available.""" configured = _load_web_config().get("backend", "").lower().strip() - if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"}: + if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "apify"}: return _is_backend_available(configured) return any( _is_backend_available(backend) - for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs") + for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "apify") )