149 lines
5.8 KiB
Python
149 lines
5.8 KiB
Python
"""Tavily web search + content extraction (``/search``, ``/extract``; sync httpx).
|
|
|
|
Env: ``TAVILY_API_KEY`` (https://app.tavily.com/home, optional), ``TAVILY_BASE_URL``.
|
|
Keyed requests use ``Authorization: Bearer``; without a key the request is
|
|
keyless (``X-Tavily-Access-Mode: keyless``). Tavily is NOT in the zero-config
|
|
keyless ring — keyless access is opt-in by selecting Tavily in ``hermes tools``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import httpx
|
|
|
|
from plugins.web._common import (
|
|
SEARCH_LIMIT_CAP,
|
|
BaseWebSearchProvider,
|
|
document,
|
|
extract_fail,
|
|
http_status_detail,
|
|
provider_env,
|
|
run_extract,
|
|
run_search,
|
|
search_fail,
|
|
search_ok,
|
|
use_keyless,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CLIENT_NAME = "hermes-agent"
|
|
|
|
_SEARCH_PAYLOAD = {"include_raw_content": False, "include_images": False}
|
|
|
|
|
|
def _tavily_headers(api_key: str) -> Dict[str, str]:
|
|
headers = {"X-Client-Name": _CLIENT_NAME}
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
else:
|
|
headers["X-Tavily-Access-Mode"] = "keyless"
|
|
return headers
|
|
|
|
|
|
def _tavily_request(endpoint: str, payload: Dict[str, Any], *, api_key: Optional[str] = None) -> Dict[str, Any]:
|
|
"""POST to Tavily and return parsed JSON.
|
|
|
|
``api_key=None`` reads ``TAVILY_API_KEY``; pass ``""`` to force the keyless
|
|
header even when a key exists (``web.provider_tier.tavily: free``). Non-2xx
|
|
raises ValueError with the body so Tavily's rate-limit/upgrade text reaches the model.
|
|
"""
|
|
if api_key is None:
|
|
api_key = provider_env("TAVILY_API_KEY")
|
|
base_url = provider_env("TAVILY_BASE_URL") or "https://api.tavily.com"
|
|
url = f"{base_url}/{endpoint.lstrip('/')}"
|
|
logger.info("Tavily %s request to %s", endpoint, url)
|
|
|
|
response = httpx.post(url, json=payload, timeout=60, headers=_tavily_headers(api_key))
|
|
if response.status_code >= 400:
|
|
raise ValueError(http_status_detail(response))
|
|
return response.json()
|
|
|
|
|
|
def _normalize_tavily_search_results(response: Dict[str, Any]) -> Dict[str, Any]:
|
|
# title-first key order is Tavily's historical wire shape (differs from web_hit).
|
|
return search_ok([
|
|
{"title": r.get("title", ""), "url": r.get("url", ""), "description": r.get("content", ""), "position": i + 1}
|
|
for i, r in enumerate(response.get("results", []))
|
|
])
|
|
|
|
|
|
def _normalize_tavily_documents(response: Dict[str, Any], fallback_url: str = "") -> List[Dict[str, Any]]:
|
|
"""Map ``/extract`` to documents; ``failed_results`` / ``failed_urls`` become ``error`` entries."""
|
|
documents: List[Dict[str, Any]] = []
|
|
for result in response.get("results", []):
|
|
url = result.get("url", fallback_url)
|
|
raw = result.get("raw_content", "") or result.get("content", "")
|
|
documents.append(document(url, result.get("title", ""), raw))
|
|
for fail in response.get("failed_results", []):
|
|
url = fail.get("url", fallback_url)
|
|
documents.append(_failed_document(url, fail.get("error", "extraction failed")))
|
|
for fail_url in response.get("failed_urls", []):
|
|
documents.append(_failed_document(str(fail_url), "extraction failed"))
|
|
return documents
|
|
|
|
|
|
def _failed_document(url: str, error: str) -> Dict[str, Any]:
|
|
return {"url": url, "title": "", "content": "", "raw_content": "", "error": error, "metadata": {"sourceURL": url}}
|
|
|
|
|
|
def _missing_key_error(action: str) -> str:
|
|
return (
|
|
"TAVILY_API_KEY is not set. Get a key at https://app.tavily.com/home "
|
|
f"or select Tavily in `hermes tools` for opt-in keyless {action}."
|
|
)
|
|
|
|
|
|
class TavilyWebSearchProvider(BaseWebSearchProvider):
|
|
"""Tavily search + extract provider (keyed, or opt-in keyless)."""
|
|
|
|
NAME = "tavily"
|
|
DISPLAY_NAME = "Tavily"
|
|
KEY_ENV = "TAVILY_API_KEY"
|
|
EXTRACT = True
|
|
KEYLESS = True
|
|
|
|
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
|
|
def _body() -> Dict[str, Any]:
|
|
api_key = provider_env("TAVILY_API_KEY")
|
|
force_keyless = use_keyless("tavily", api_key)
|
|
if not force_keyless and not api_key:
|
|
return search_fail(_missing_key_error("search"))
|
|
|
|
logger.info("Tavily %ssearch: '%s' (limit=%d)", "keyless " if force_keyless else "", query, limit)
|
|
payload = {"query": query, "max_results": min(limit, SEARCH_LIMIT_CAP), **_SEARCH_PAYLOAD}
|
|
return _normalize_tavily_search_results(
|
|
_tavily_request("search", payload, api_key="" if force_keyless else api_key)
|
|
)
|
|
|
|
return run_search("Tavily", logger, _body)
|
|
|
|
def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]:
|
|
def _body() -> List[Dict[str, Any]]:
|
|
api_key = provider_env("TAVILY_API_KEY")
|
|
force_keyless = use_keyless("tavily", api_key)
|
|
if not force_keyless and not api_key:
|
|
return extract_fail(urls, _missing_key_error("extract"))
|
|
|
|
logger.info("Tavily %sextract: %d URL(s)", "keyless " if force_keyless else "", len(urls))
|
|
raw = _tavily_request("extract", {"urls": urls, "include_images": False}, api_key="" if force_keyless else api_key)
|
|
return _normalize_tavily_documents(raw, fallback_url=urls[0] if urls else "")
|
|
|
|
return run_extract("Tavily", logger, urls, _body)
|
|
|
|
def get_setup_schema(self) -> Dict[str, Any]:
|
|
return {
|
|
"name": "Tavily",
|
|
"badge": "free · key optional",
|
|
"tag": "Search + extract. Opt-in keyless; set TAVILY_API_KEY for higher limits.",
|
|
"env_vars": [
|
|
{
|
|
"key": "TAVILY_API_KEY",
|
|
"prompt": "Tavily API key (optional — keyless works when Tavily is selected)",
|
|
"url": "https://app.tavily.com/home",
|
|
},
|
|
],
|
|
}
|