Source code for ralph.mcp.websearch.backends.exa
"""Exa web-search backend.
Implements the ``ExaBackend`` dataclass that wraps the ``exa-py`` Python SDK to
deliver web-search results via the Exa API. Requires ``pip install
ralph-workflow[web-search]`` (or ``pip install exa-py``) at runtime; importing
this module without the SDK installed is safe, but calling ``search`` raises
``WebSearchError``.
API key resolution:
- Pass ``api_key`` directly, or
- set ``api_key_env`` to an environment variable name that holds the key
(resolved via ``ralph.mcp.websearch.secrets.resolve_secret``).
Typical usage (from ``ralph.config.mcp_models`` backend selection)::
backend = ExaBackend(api_key_env="EXA_API_KEY")
results = backend.search("async Python tutorial", limit=5)
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from importlib import import_module
from typing import TYPE_CHECKING, cast
from ..secrets import resolve_secret
from .base import SearchResult, WebSearchError
if TYPE_CHECKING:
from typing import Protocol
class _ExaSearchResponse(Protocol):
results: Iterable[object]
class _ExaClient(Protocol):
def search(self, query: str, *, num_results: int) -> _ExaSearchResponse: ...
class _ExaType(Protocol):
def __call__(self, *, api_key: str) -> _ExaClient: ...
[docs]
@dataclass(frozen=True)
class ExaBackend:
"""Backend powered by the Exa Python SDK."""
api_key: str | None = None
api_key_env: str | None = None
def search(self, query: str, *, limit: int = 10) -> list[SearchResult]:
exa_type = _load_exa_type()
resolved_key = resolve_secret(self.api_key, self.api_key_env)
try:
response = exa_type(api_key=resolved_key).search(query, num_results=limit)
except Exception as exc:
raise WebSearchError("exa search failed") from exc
return _normalize_results(response)
def _load_exa_type() -> _ExaType:
try:
module = import_module("exa_py")
except ImportError as exc:
raise WebSearchError(
"backend 'exa' requires 'pip install ralph-workflow[web-search]'"
) from exc
exa_type = cast("object | None", getattr(module, "Exa", None))
if exa_type is None:
raise WebSearchError("exa backend is unavailable")
return cast("_ExaType", exa_type)
def _normalize_results(response: _ExaSearchResponse) -> list[SearchResult]:
results: list[SearchResult] = []
for item in response.results:
payload = _object_payload(item)
title = _string_value(payload, "title")
url = _string_value(payload, "url")
snippet = _string_value(payload, "text")
if not title or not url:
continue
results.append(SearchResult(title=title, url=url, snippet=snippet))
return results
def _object_payload(value: object) -> Mapping[str, object]:
if isinstance(value, Mapping):
return value
try:
payload = cast("dict[str, object]", vars(value))
except TypeError:
return {}
return payload
def _string_value(payload: Mapping[str, object], *keys: str) -> str:
for key in keys:
value = payload.get(key)
if isinstance(value, str) and value:
return value
return ""
__all__ = ["ExaBackend"]