File size: 18,584 Bytes
674d56a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 | """Keyless web search/extract via public free-tier endpoints (Exa, Parallel, Firecrawl, Keenable).
Resolved strictly LAST β after every keyed backend, the managed gateway, ddgs and custom plugin
providers β so it never pre-empts a deliberate setup. Privacy: no user identifiers are sent;
Parallel gets a random per-process ``session_id`` (rate limiting only) and its optional
``model_name`` analytics field is deliberately omitted. Disable with ``web.keyless_fallback: false``.
"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from typing import Any, Callable, Dict, List, Optional
from plugins.web._common import document as _page, page_error as _page_error, search_fail, search_ok, web_hit as _row
logger = logging.getLogger(__name__)
EXA_MCP_URL = "https://mcp.exa.ai/mcp"
PARALLEL_MCP_URL = "https://search.parallel.ai/mcp"
KEENABLE_API_URL = "https://api.keenable.ai"
_KEENABLE_TITLE = "hermes-agent"
# Parallel free-tier rate-limit correlation id β random per process, never persisted.
_SESSION_ID = uuid.uuid4().hex
_TIMEOUT_SECONDS = 30
class KeylessMCPError(RuntimeError):
"""A keyless MCP call failed (transport, rate limit, or tool error)."""
_RATE_LIMIT_MARKERS = ("rate limit", "rate-limit", "ratelimit", "too many requests", "429", "quota exceeded", "slow down")
# vendor -> (display label, env key, signup URL) for the standard failure hint.
_VENDOR_HINTS = {
"exa": ("Exa", "EXA_API_KEY", "https://exa.ai"), "parallel": ("Parallel", "PARALLEL_API_KEY", "https://parallel.ai"),
"firecrawl": ("Firecrawl", "FIRECRAWL_API_KEY", "https://firecrawl.dev"), "keenable": ("Keenable", "KEENABLE_API_KEY", "https://keenable.ai"),
}
def _is_rate_limitish(message: str) -> bool:
"""Heuristic: does an error message look like free-tier throttling?"""
return any(marker in (message or "").lower() for marker in _RATE_LIMIT_MARKERS)
def _fail_msg(vendor: str, kind: str, exc: Any, *, other_backends: bool = True) -> str:
label, env_key, site = _VENDOR_HINTS[vendor]
alt = " or another web backend via `hermes tools`" if other_backends else ""
return f"Keyless {label} {kind} failed: {exc}. Set {env_key} ({site}){alt} for reliable service."
def _search(vendor: str, rows: Callable[[], List[Dict[str, Any]]], catch: Any = (), fmt: Optional[Callable[[Exception], str]] = None) -> Dict[str, Any]:
"""``search_ok(rows())``; :class:`KeylessMCPError` β standard vendor hint, exception
types in ``catch`` β ``fmt(exc)``; anything else propagates."""
try:
return search_ok(rows())
except KeylessMCPError as exc:
return search_fail(_fail_msg(vendor, "search", exc))
except catch as exc:
return search_fail(fmt(exc))
def _per_url(urls: List[str], fetch: Callable[[str], Dict[str, Any]], vendor: str, catch: Any = Exception, hint: bool = False) -> List[Dict[str, Any]]:
"""Per-URL extract loop: a ``catch`` failure becomes an error entry (``hint`` adds the ``hermes tools`` hint)."""
def _one(url: str) -> Dict[str, Any]:
try:
return fetch(url)
except catch as exc: # noqa: BLE001 β per-URL error entry
return _page_error(url, _fail_msg(vendor, "extract", exc, other_backends=hint))
return [_one(u) for u in urls]
# --- Tier / config ------------------------------------------------------------
def keyless_enabled() -> bool:
"""Delegates to the registry so the ``web.keyless_fallback`` (default on) chokepoint lives with backend resolution."""
try:
from agent.web_search_registry import _keyless_tier_enabled
return _keyless_tier_enabled()
except Exception as exc: # noqa: BLE001 β resolver optional in stripped envs
logger.debug("keyless_enabled(): registry helper unavailable: %s", exc)
return True
_BACKEND_KEYS = ("backend", "search_backend", "extract_backend")
def _web_config_selects(name: str) -> bool:
"""True when any ``web.backend`` / ``search_backend`` / ``extract_backend`` names *name*."""
import tools.web_tools as _wt
web_cfg = _wt._load_web_config()
return any((web_cfg.get(key) or "").lower().strip() == name for key in _BACKEND_KEYS)
def provider_tier(name: str) -> str:
"""``web.provider_tier.<name>`` (``hermes tools`` Free/Paid rows): ``free``, ``paid``, or ``auto`` (anything else/unset)."""
try:
from hermes_cli.config import load_config
tiers = (load_config().get("web") or {}).get("provider_tier") or {}
value = str(tiers.get(name, "") or "").lower().strip()
return value if value in ("free", "paid") else "auto"
except Exception as exc: # noqa: BLE001 β config layer optional
logger.debug("provider_tier(%r) config read failed: %s", name, exc)
return "auto"
def use_keyless(name: str, api_key: str) -> bool:
"""Single chokepoint for search + extract: ``free`` β keyless even with a key; ``paid`` β keyed even
without one (the keyed path raises its usual missing-key error); ``auto`` β keyless only when no key + tier enabled."""
tier = provider_tier(name)
if tier in ("free", "paid"):
return tier == "free"
return not api_key and keyless_enabled()
# --- MCP transport ------------------------------------------------------------
def _parse_mcp_body(body: str) -> str:
"""First text content item from an MCP tools/call response β plain-JSON bodies
(Parallel) or SSE ``data: {...}`` lines (Exa). Raises :class:`KeylessMCPError` for
JSON-RPC errors and ``isError`` tool results (e.g. Exa's free-tier rate limit)."""
def _from_payload(payload: str) -> Optional[str]:
payload = payload.strip()
if not payload.startswith("{"):
return None
data = json.loads(payload)
err = data.get("error")
if err:
raise KeylessMCPError(str(err.get("message") or err))
result = data.get("result") or {}
texts = [c.get("text", "") for c in result.get("content") or [] if isinstance(c, dict)]
if result.get("isError"):
raise KeylessMCPError(" ".join(t for t in texts if t) or "MCP tool call failed")
return next((str(t) for t in texts if t), None)
stripped = body.strip()
candidates = [stripped] if stripped.startswith("{") else []
candidates += [line[len("data: "):] for line in body.splitlines() if line.startswith("data: ")]
for candidate in candidates:
try:
text = _from_payload(candidate)
except json.JSONDecodeError:
continue
if text is not None:
return text
raise KeylessMCPError("Unrecognized MCP response shape")
def mcp_call(url: str, tool: str, arguments: Dict[str, Any], timeout: int = _TIMEOUT_SECONDS) -> str:
"""POST a JSON-RPC ``tools/call`` and return the text payload. Raises
:class:`KeylessMCPError` on transport failures, non-2xx, JSON-RPC and tool errors."""
import requests
payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": arguments}}
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", "User-Agent": "hermes-agent"}
try:
response = requests.post(url, json=payload, headers=headers, timeout=timeout)
except requests.RequestException as exc:
raise KeylessMCPError(f"request failed: {exc}") from exc
if response.status_code >= 400:
raise KeylessMCPError(f"HTTP {response.status_code}: {response.text[:300]}")
return _parse_mcp_body(response.text)
# --- Parallel (search.parallel.ai) β JSON text payloads -----------------------
def parallel_search_keyless(query: str, limit: int = 5) -> Dict[str, Any]:
def _rows() -> List[Dict[str, Any]]:
text = mcp_call(PARALLEL_MCP_URL, "web_search", {"objective": query, "search_queries": [query], "session_id": _SESSION_ID})
results = json.loads(text).get("results") or []
return [
_row(r.get("url") or "", r.get("title") or "", " ".join(r.get("excerpts") or []), i + 1)
for i, r in enumerate(results[:max(limit, 0)] if limit else results)
]
return _search("parallel", _rows, (json.JSONDecodeError, TypeError, KeyError), lambda exc: f"Keyless Parallel search returned an unexpected payload: {exc}")
def parallel_extract_keyless(urls: List[str]) -> List[Dict[str, Any]]:
try:
data = json.loads(mcp_call(PARALLEL_MCP_URL, "web_fetch", {"urls": list(urls), "objective": "Full page content", "session_id": _SESSION_ID}))
except (KeylessMCPError, json.JSONDecodeError, TypeError) as exc:
message = _fail_msg("parallel", "extract", exc)
return [_page_error(u, message) for u in urls]
results = [
_page(r.get("url") or "", r.get("title") or "", r.get("full_content") or r.get("content") or "\n\n".join(r.get("excerpts") or []))
for r in data.get("results") or []
]
for error in data.get("errors") or []:
url = error.get("url") or ""
results.append({**_page_error(url, str(error.get("content") or error.get("error_type") or "extraction failed")), "metadata": {"sourceURL": url}})
# URLs the endpoint silently dropped still get an error entry (per-URL contract).
seen = {r["url"] for r in results}
results.extend(_page_error(u, "no content returned") for u in urls if u not in seen)
return results
# --- Exa (mcp.exa.ai) β formatted plain-text payloads -------------------------
def _after(line: str, prefix: str) -> str:
return line[len(prefix):].strip()
_EXA_LABELS = ("Title:", "URL:", "Highlights:", "Published:", "Author:")
def _parse_exa_search_text(text: str, limit: int) -> List[Dict[str, Any]]:
"""Parse Exa's ``---``-separated ``Title:/URL:/Published:/Author:/Highlights:`` blocks."""
results: List[Dict[str, Any]] = []
for block in text.split("\n---\n"):
title = url = ""
highlight_lines: List[str] = []
in_highlights = False
for stripped in map(str.strip, block.splitlines()):
if stripped.startswith("Title:"):
title = _after(stripped, "Title:")
elif stripped.startswith("URL:"):
url = _after(stripped, "URL:")
elif in_highlights and stripped and not stripped.startswith(_EXA_LABELS):
highlight_lines.append(stripped)
# Highlights run until the next labelled field.
if stripped.startswith(_EXA_LABELS):
in_highlights = stripped.startswith("Highlights:")
if url:
results.append(_row(url, title, " ".join(highlight_lines), len(results) + 1))
if limit and len(results) >= limit:
break
return results
def exa_search_keyless(query: str, limit: int = 5) -> Dict[str, Any]:
return _search("exa", lambda: _parse_exa_search_text(mcp_call(EXA_MCP_URL, "web_search_exa", {"query": query, "numResults": max(1, int(limit))}), limit))
def exa_extract_keyless(urls: List[str]) -> List[Dict[str, Any]]:
"""Called per-URL; the tool returns one combined text payload."""
def _fetch(url: str) -> Dict[str, Any]:
text = mcp_call(EXA_MCP_URL, "web_fetch_exa", {"urls": [url]})
# Title: first markdown H1 or ``Title:`` line, whichever comes first.
titles = (_after(s, "# " if s.startswith("# ") else "Title:") for s in map(str.strip, text.splitlines()) if s.startswith(("# ", "Title:")))
return _page(url, next(titles, ""), text)
return _per_url(urls, _fetch, "exa", catch=KeylessMCPError, hint=True)
# --- Firecrawl keyless (public cloud API, no auth header) ---------------------
def firecrawl_search_keyless(query: str, limit: int = 5) -> Dict[str, Any]:
from plugins.web.firecrawl.provider import _KeylessFirecrawlClient, _extract_web_search_results
rows = lambda: _extract_web_search_results(_KeylessFirecrawlClient().search(query=query, limit=limit)) # noqa: E731
return _search("firecrawl", rows, Exception, lambda exc: _fail_msg("firecrawl", "search", exc))
def firecrawl_extract_keyless(urls: List[str]) -> List[Dict[str, Any]]:
from plugins.web.firecrawl.provider import _KeylessFirecrawlClient, _extract_scrape_payload
client = _KeylessFirecrawlClient()
def _fetch(url: str) -> Dict[str, Any]:
payload = _extract_scrape_payload(client.scrape(url=url, formats=["markdown"])) or {}
metadata = payload.get("metadata") or {}
title = metadata.get("title") if isinstance(metadata, dict) else None
return _page(url, title or "", payload.get("markdown") or payload.get("html") or "")
return _per_url(urls, _fetch, "firecrawl")
# --- Keenable keyless (api.keenable.ai public endpoints) ----------------------
def _keenable_request(method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
"""Call a Keenable public endpoint with the mandatory X-Keenable-Title app id."""
import requests
headers = {"X-Keenable-Title": _KEENABLE_TITLE}
if method == "post":
headers["Content-Type"] = "application/json"
response = getattr(requests, method)(f"{KEENABLE_API_URL}{path}", headers=headers, timeout=_TIMEOUT_SECONDS, **kwargs)
if response.status_code >= 400:
raise KeylessMCPError((response.text or "").strip() or f"HTTP {response.status_code}")
return response.json()
def keenable_search_keyless(query: str, limit: int = 5) -> Dict[str, Any]:
def _rows() -> List[Dict[str, Any]]:
data = _keenable_request("post", "/v1/search/public", json={"query": query, "max_results": max(1, int(limit))})
return [
_row(r.get("url") or "", r.get("title") or "", r.get("snippet") or r.get("description") or "", i + 1)
for i, r in enumerate(data.get("results") or [])
]
return _search("keenable", _rows, Exception, lambda exc: f"Keyless Keenable search failed: {exc}.")
def keenable_extract_keyless(urls: List[str]) -> List[Dict[str, Any]]:
def _fetch(url: str) -> Dict[str, Any]:
data = _keenable_request("get", "/v1/fetch/public", params={"url": url})
return _page(data.get("url") or url, data.get("title") or "", data.get("content") or "", source_url=url)
return _per_url(urls, _fetch, "keenable")
# --- Round-robin ring + next-in-line failover (rate-limited free tiers) -------
_KEYLESS_RING = ("exa", "parallel", "firecrawl", "keenable")
# Late-bound lookups (not bare references) so ``patch.object(keyless_mcp, "<vendor>_search_keyless")``
# is honored at call time. Tests also ``setitem`` these dicts directly.
_KEYLESS_SEARCHERS: Dict[str, Callable[[str, int], Dict[str, Any]]] = {v: (lambda query, limit, _v=v: globals()[f"{_v}_search_keyless"](query, limit)) for v in _KEYLESS_RING}
_KEYLESS_EXTRACTORS: Dict[str, Callable[[List[str]], List[Dict[str, Any]]]] = {v: (lambda urls, _v=v: globals()[f"{_v}_extract_keyless"](urls)) for v in _KEYLESS_RING}
# Per-process round-robin cursor, seeded by the random session id so the fleet
# spreads across vendors; advances once per unpinned keyless request.
_ring_lock = threading.Lock()
_ring_cursor = int(_SESSION_ID, 16) % len(_KEYLESS_RING)
def _vendor_pinned(name: str) -> bool:
"""True when config explicitly routes web traffic to *name* (backend keys or a
``free`` tier pin). A pinned vendor starts every keyless request."""
if provider_tier(name) == "free":
return True
try:
return _web_config_selects(name)
except Exception as exc: # noqa: BLE001 β config layer optional
logger.debug("_vendor_pinned(%r) config read failed: %s", name, exc)
return False
def _ring_order(name: str) -> List[str]:
"""Vendor walk order: pinned β start at *name* (its ring position fixes the failover
succession); else round-robin from the cursor, advancing it per request. Vendors
pinned ``paid`` are excluded (explicit paid opts their free endpoint out)."""
global _ring_cursor
if _vendor_pinned(name):
start = _KEYLESS_RING.index(name) if name in _KEYLESS_RING else 0
else:
with _ring_lock:
start = _ring_cursor
_ring_cursor = (_ring_cursor + 1) % len(_KEYLESS_RING)
ordered = _KEYLESS_RING[start:] + _KEYLESS_RING[:start]
return [v for v in ordered if provider_tier(v) != "paid"]
_ALL_PAID_MSG = "All keyless web providers are pinned to paid tiers."
def _walk_ring(name: str, kind: str, call, throttled) -> tuple:
"""Call each vendor from :func:`_ring_order` until a result is not ``throttled``.
Returns ``(order, vendor, result, exhausted)``; ``order`` is empty (result None)
when every vendor is pinned paid."""
order = _ring_order(name)
vendor, result = None, None
for i, vendor in enumerate(order):
result = call(vendor)
if not throttled(result):
return order, vendor, result, False
if i + 1 < len(order):
logger.info("keyless %s %s throttled; failing over to %s", vendor, kind, order[i + 1])
return order, vendor, result, True
def search_with_failover(name: str, query: str, limit: int = 5) -> Dict[str, Any]:
"""Rate-limit-shaped errors advance to the next vendor, other errors stop the walk
(a malformed query fails everywhere). ``data.served_by`` is set when the serving
vendor differs from *name*."""
def _throttled(result: Dict[str, Any]) -> bool:
return not result.get("success") and _is_rate_limitish(result.get("error", ""))
order, vendor, result, exhausted = _walk_ring(name, "search", lambda v: _KEYLESS_SEARCHERS[v](query, limit), _throttled)
if not order:
return search_fail(_ALL_PAID_MSG)
if exhausted:
result["error"] = f"{result.get('error', '')} (all keyless vendors throttled: {', '.join(order)})"
elif result.get("success") and vendor != name:
result.setdefault("data", {})["served_by"] = vendor
return result
def extract_with_failover(name: str, urls: List[str]) -> List[Dict[str, Any]]:
"""Fails over only when EVERY url in a batch is rate-limit-shaped (partial failures
are page problems, returned as-is)."""
def _all_throttled(results: List[Dict[str, Any]]) -> bool:
return bool(results) and all(r.get("error", "") and _is_rate_limitish(r.get("error", "")) for r in results)
order, _vendor, results, _exhausted = _walk_ring(name, "extract", lambda v: _KEYLESS_EXTRACTORS[v](list(urls)), _all_throttled)
if not order:
return [_page_error(u, _ALL_PAID_MSG) for u in urls]
return results
|