mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 15:24:06 +00:00
Add Exa web search provider
This commit is contained in:
parent
5d91d59cf7
commit
31bfec58d0
@ -300,6 +300,9 @@ class WebSearchTool(Tool):
|
|||||||
if provider == "kagi":
|
if provider == "kagi":
|
||||||
api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "")
|
||||||
return "kagi" if api_key else "duckduckgo"
|
return "kagi" if api_key else "duckduckgo"
|
||||||
|
if provider == "exa":
|
||||||
|
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "")
|
||||||
|
return "exa" if api_key else "duckduckgo"
|
||||||
if provider == "olostep":
|
if provider == "olostep":
|
||||||
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
||||||
return "olostep" if api_key else "duckduckgo"
|
return "olostep" if api_key else "duckduckgo"
|
||||||
@ -356,6 +359,8 @@ class WebSearchTool(Tool):
|
|||||||
return await self._search_brave(query, n)
|
return await self._search_brave(query, n)
|
||||||
elif provider == "kagi":
|
elif provider == "kagi":
|
||||||
return await self._search_kagi(query, n)
|
return await self._search_kagi(query, n)
|
||||||
|
elif provider == "exa":
|
||||||
|
return await self._search_exa(query, n)
|
||||||
else:
|
else:
|
||||||
return f"Error: unknown search provider '{provider}'"
|
return f"Error: unknown search provider '{provider}'"
|
||||||
|
|
||||||
@ -542,6 +547,56 @@ class WebSearchTool(Tool):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
async def _search_exa(self, query: str, n: int) -> str:
|
||||||
|
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("EXA_API_KEY not set, falling back to DuckDuckGo")
|
||||||
|
return await self._search_duckduckgo(query, n)
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"User-Agent": self.user_agent,
|
||||||
|
}
|
||||||
|
body = {
|
||||||
|
"query": query,
|
||||||
|
"numResults": n,
|
||||||
|
"contents": {"highlights": True},
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
||||||
|
r = await client.post(
|
||||||
|
"https://api.exa.ai/search",
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
timeout=float(self.config.timeout),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
items = []
|
||||||
|
for result in r.json().get("results", []):
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
continue
|
||||||
|
highlights = result.get("highlights") or []
|
||||||
|
if isinstance(highlights, list):
|
||||||
|
content = "\n".join(str(highlight) for highlight in highlights if highlight)
|
||||||
|
else:
|
||||||
|
content = str(highlights)
|
||||||
|
if not content:
|
||||||
|
content = str(result.get("summary") or result.get("text") or "")[:500]
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": result.get("title", ""),
|
||||||
|
"url": result.get("url", ""),
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _format_results(query, items, n)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 429:
|
||||||
|
return "Error: Exa search rate limited. Try again later or reduce search frequency."
|
||||||
|
return f"Error: Exa search failed ({e.response.status_code}): {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: Exa search failed: {e}"
|
||||||
|
|
||||||
async def _search_volcengine(
|
async def _search_volcengine(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
|
|||||||
@ -78,6 +78,7 @@ _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
|||||||
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
|
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
|
||||||
{"name": "jina", "label": "Jina", "credential": "api_key"},
|
{"name": "jina", "label": "Jina", "credential": "api_key"},
|
||||||
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
|
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
|
||||||
|
{"name": "exa", "label": "Exa", "credential": "api_key"},
|
||||||
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
||||||
{"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"},
|
{"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1699,6 +1699,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert body["web"]["fetch"]["use_jina_reader"] is True
|
assert body["web"]["fetch"]["use_jina_reader"] is True
|
||||||
search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]}
|
search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]}
|
||||||
assert search_providers["duckduckgo"]["credential"] == "none"
|
assert search_providers["duckduckgo"]["credential"] == "none"
|
||||||
|
assert search_providers["exa"]["credential"] == "api_key"
|
||||||
assert search_providers["volcengine"]["credential"] == "api_key"
|
assert search_providers["volcengine"]["credential"] == "api_key"
|
||||||
assert search_providers["searxng"]["credential"] == "base_url"
|
assert search_providers["searxng"]["credential"] == "base_url"
|
||||||
assert body["image_generation"]["enabled"] is False
|
assert body["image_generation"]["enabled"] is False
|
||||||
|
|||||||
@ -291,6 +291,71 @@ async def test_kagi_search(monkeypatch):
|
|||||||
assert "ignored related search" not in result
|
assert "ignored related search" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exa_search(monkeypatch):
|
||||||
|
async def mock_post(self, url, **kw):
|
||||||
|
assert url == "https://api.exa.ai/search"
|
||||||
|
assert kw["headers"]["x-api-key"] == "exa-key"
|
||||||
|
assert kw["headers"]["User-Agent"] == "nanobot-search-test"
|
||||||
|
assert kw["json"] == {
|
||||||
|
"query": "test",
|
||||||
|
"numResults": 2,
|
||||||
|
"contents": {"highlights": True},
|
||||||
|
}
|
||||||
|
return _response(json={
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"title": "Exa Result",
|
||||||
|
"url": "https://exa.ai",
|
||||||
|
"highlights": ["Relevant Exa highlight"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||||
|
tool = _tool(provider="exa", api_key="exa-key", user_agent="nanobot-search-test")
|
||||||
|
result = await tool.execute(query="test", count=2)
|
||||||
|
|
||||||
|
assert "Exa Result" in result
|
||||||
|
assert "https://exa.ai" in result
|
||||||
|
assert "Relevant Exa highlight" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exa_search_uses_env_api_key(monkeypatch):
|
||||||
|
async def mock_post(self, url, **kw):
|
||||||
|
assert kw["headers"]["x-api-key"] == "env-exa-key"
|
||||||
|
return _response(json={
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"title": "Env Exa Result",
|
||||||
|
"url": "https://exa.ai/env",
|
||||||
|
"summary": "Summary fallback",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||||
|
monkeypatch.setenv("EXA_API_KEY", "env-exa-key")
|
||||||
|
tool = _tool(provider="exa", api_key="")
|
||||||
|
result = await tool.execute(query="test", count=1)
|
||||||
|
|
||||||
|
assert "Env Exa Result" in result
|
||||||
|
assert "Summary fallback" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exa_search_http_error(monkeypatch):
|
||||||
|
async def mock_post(self, url, **kw):
|
||||||
|
return _response(status=401, json={"error": "invalid key"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||||
|
tool = _tool(provider="exa", api_key="bad-exa-key")
|
||||||
|
result = await tool.execute(query="test")
|
||||||
|
|
||||||
|
assert "Error: Exa search failed (401)" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unknown_provider():
|
async def test_unknown_provider():
|
||||||
tool = _tool(provider="unknown")
|
tool = _tool(provider="unknown")
|
||||||
@ -377,6 +442,23 @@ async def test_kagi_fallback_to_duckduckgo_when_no_key(monkeypatch):
|
|||||||
assert "Fallback" in result
|
assert "Fallback" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exa_fallback_to_duckduckgo_when_no_key(monkeypatch):
|
||||||
|
class MockDDGS:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def text(self, query, max_results=5):
|
||||||
|
return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr("ddgs.DDGS", MockDDGS)
|
||||||
|
monkeypatch.delenv("EXA_API_KEY", raising=False)
|
||||||
|
|
||||||
|
tool = _tool(provider="exa", api_key="")
|
||||||
|
result = await tool.execute(query="test")
|
||||||
|
assert "Fallback" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_jina_search_uses_path_encoded_query(monkeypatch):
|
async def test_jina_search_uses_path_encoded_query(monkeypatch):
|
||||||
calls = {}
|
calls = {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user