mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-07 02:05:51 +00:00
feat(web_search): add olostep provider
Adds Olostep (https://www.olostep.com) as an optional web_search backend using the official olostep Python SDK (client.answers.create()). Changes: - pyproject.toml: adds olostep>=0.1.0 optional dependency - schema.py: adds olostep to provider comment in WebSearchConfig - web.py: adds _search_olostep() with lazy import and provider branching - docs/configuration.md: documents Olostep setup under web search config - tests: unit tests for the new provider Backward compatible: existing users see no behavior change unless they opt into provider: "olostep". No hard dependency at runtime path. Co-authored-by: umerkay <umerkk164@gmail.com>
This commit is contained in:
parent
0053e68423
commit
28f9bbff31
@ -546,6 +546,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
|||||||
| `tavily` | `apiKey` | `TAVILY_API_KEY` | No |
|
| `tavily` | `apiKey` | `TAVILY_API_KEY` | No |
|
||||||
| `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) |
|
| `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) |
|
||||||
| `kagi` | `apiKey` | `KAGI_API_KEY` | No |
|
| `kagi` | `apiKey` | `KAGI_API_KEY` | No |
|
||||||
|
| `olostep` | `apiKey` | `OLOSTEP_API_KEY` | No |
|
||||||
| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
|
| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
|
||||||
| `duckduckgo` (default) | — | — | Yes |
|
| `duckduckgo` (default) | — | — | Yes |
|
||||||
|
|
||||||
@ -605,6 +606,22 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Olostep:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"search": {
|
||||||
|
"provider": "olostep",
|
||||||
|
"apiKey": "YOUR_OLOSTEP_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also set `OLOSTEP_API_KEY` in the environment instead of storing it in config.
|
||||||
|
|
||||||
**SearXNG** (self-hosted, no API key needed):
|
**SearXNG** (self-hosted, no API key needed):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@ -119,6 +119,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 == "olostep":
|
||||||
|
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
||||||
|
return "olostep" if api_key else "duckduckgo"
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -134,6 +137,8 @@ class WebSearchTool(Tool):
|
|||||||
provider = self.config.provider.strip().lower() or "brave"
|
provider = self.config.provider.strip().lower() or "brave"
|
||||||
n = min(max(count or self.config.max_results, 1), 10)
|
n = min(max(count or self.config.max_results, 1), 10)
|
||||||
|
|
||||||
|
if provider == "olostep":
|
||||||
|
return await self._search_olostep(query, n)
|
||||||
if provider == "duckduckgo":
|
if provider == "duckduckgo":
|
||||||
return await self._search_duckduckgo(query, n)
|
return await self._search_duckduckgo(query, n)
|
||||||
elif provider == "tavily":
|
elif provider == "tavily":
|
||||||
@ -149,6 +154,58 @@ class WebSearchTool(Tool):
|
|||||||
else:
|
else:
|
||||||
return f"Error: unknown search provider '{provider}'"
|
return f"Error: unknown search provider '{provider}'"
|
||||||
|
|
||||||
|
async def _search_olostep(self, query: str, n: int) -> str:
|
||||||
|
try:
|
||||||
|
from olostep import AsyncOlostep, Olostep_BaseError
|
||||||
|
except ImportError:
|
||||||
|
return "Error: olostep package not installed. Run: pip install olostep"
|
||||||
|
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
|
||||||
|
return await self._search_duckduckgo(query, n)
|
||||||
|
try:
|
||||||
|
async with AsyncOlostep(api_key=api_key) as client:
|
||||||
|
if self.proxy:
|
||||||
|
transport = getattr(client, "_transport", None)
|
||||||
|
http_client = getattr(transport, "_client", None)
|
||||||
|
if transport is not None and isinstance(http_client, httpx.AsyncClient):
|
||||||
|
await http_client.aclose()
|
||||||
|
transport._client = httpx.AsyncClient( # type: ignore[attr-defined]
|
||||||
|
proxy=self.proxy,
|
||||||
|
headers=dict(http_client.headers),
|
||||||
|
timeout=http_client.timeout,
|
||||||
|
limits=httpx.Limits(
|
||||||
|
max_keepalive_connections=100,
|
||||||
|
max_connections=200,
|
||||||
|
),
|
||||||
|
http2=True,
|
||||||
|
)
|
||||||
|
result = await client.answers.create(task=query)
|
||||||
|
|
||||||
|
sources = getattr(result, "sources", None) or []
|
||||||
|
source_lines = []
|
||||||
|
for i, source in enumerate(sources[:n], 1):
|
||||||
|
if isinstance(source, dict):
|
||||||
|
title = source.get("title", "")
|
||||||
|
url = source.get("url", "")
|
||||||
|
else:
|
||||||
|
title = getattr(source, "title", "")
|
||||||
|
url = getattr(source, "url", "")
|
||||||
|
if title and url:
|
||||||
|
source_lines.append(f"{i}. {title} — {url}")
|
||||||
|
elif url:
|
||||||
|
source_lines.append(f"{i}. {url}")
|
||||||
|
elif title:
|
||||||
|
source_lines.append(f"{i}. {title}")
|
||||||
|
|
||||||
|
answer_text = getattr(result, "answer", "") or ""
|
||||||
|
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
|
||||||
|
return _format_results(query, items, n)
|
||||||
|
except Olostep_BaseError as e:
|
||||||
|
return f"Olostep search error: {type(e).__name__}: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Olostep search error: {type(e).__name__}: {e}"
|
||||||
|
|
||||||
async def _search_brave(self, query: str, n: int) -> str:
|
async def _search_brave(self, query: str, n: int) -> str:
|
||||||
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
|
|||||||
@ -181,7 +181,7 @@ class GatewayConfig(Base):
|
|||||||
class WebSearchConfig(Base):
|
class WebSearchConfig(Base):
|
||||||
"""Web search tool configuration."""
|
"""Web search tool configuration."""
|
||||||
|
|
||||||
provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina, kagi
|
provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina, kagi, olostep
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
base_url: str = "" # SearXNG base URL
|
base_url: str = "" # SearXNG base URL
|
||||||
max_results: int = 5
|
max_results: int = 5
|
||||||
|
|||||||
@ -92,6 +92,9 @@ langsmith = [
|
|||||||
pdf = [
|
pdf = [
|
||||||
"pymupdf>=1.25.0",
|
"pymupdf>=1.25.0",
|
||||||
]
|
]
|
||||||
|
olostep = [
|
||||||
|
"olostep>=0.1.0",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.0,<10.0.0",
|
"pytest>=9.0.0,<10.0.0",
|
||||||
"pytest-asyncio>=1.3.0,<2.0.0",
|
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||||
|
|||||||
@ -294,3 +294,82 @@ async def test_duckduckgo_timeout_returns_error(monkeypatch):
|
|||||||
result = await tool.execute(query="test")
|
result = await tool.execute(query="test")
|
||||||
gate.set()
|
gate.set()
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_olostep_search_formats_answer_and_sources(monkeypatch):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
calls: dict[str, str] = {}
|
||||||
|
|
||||||
|
class MockAsyncOlostep:
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
calls["api_key"] = api_key
|
||||||
|
self.answers = self
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create(self, task: str):
|
||||||
|
calls["task"] = task
|
||||||
|
return SimpleNamespace(
|
||||||
|
answer="Mocked Olostep answer",
|
||||||
|
sources=[SimpleNamespace(title="Example Source", url="https://example.com")],
|
||||||
|
)
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
fake_mod = types.ModuleType("olostep")
|
||||||
|
fake_mod.AsyncOlostep = MockAsyncOlostep
|
||||||
|
fake_mod.Olostep_BaseError = Exception
|
||||||
|
monkeypatch.setitem(sys.modules, "olostep", fake_mod)
|
||||||
|
|
||||||
|
tool = _tool(provider="olostep", api_key="olostep-key")
|
||||||
|
result = await tool.execute(query="test query")
|
||||||
|
|
||||||
|
assert calls["api_key"] == "olostep-key"
|
||||||
|
assert calls["task"] == "test query"
|
||||||
|
assert "Mocked Olostep answer" in result
|
||||||
|
assert "Example Source" in result
|
||||||
|
assert "https://example.com" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_olostep_missing_key_falls_back_to_duckduckgo(monkeypatch):
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
class MockDDGS:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def text(self, query, max_results=5):
|
||||||
|
return [{"title": "Fallback", "href": "https://ddg.example", "body": "fallback"}]
|
||||||
|
|
||||||
|
fake_mod = types.ModuleType("olostep")
|
||||||
|
fake_mod.AsyncOlostep = object
|
||||||
|
fake_mod.Olostep_BaseError = Exception
|
||||||
|
monkeypatch.setitem(sys.modules, "olostep", fake_mod)
|
||||||
|
|
||||||
|
monkeypatch.delenv("OLOSTEP_API_KEY", raising=False)
|
||||||
|
with patch("ddgs.DDGS", MockDDGS):
|
||||||
|
tool = _tool(provider="olostep", api_key="")
|
||||||
|
result = await tool.execute(query="test query")
|
||||||
|
|
||||||
|
assert "Fallback" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_olostep_package_missing_returns_install_hint(monkeypatch):
|
||||||
|
import sys
|
||||||
|
monkeypatch.delitem(sys.modules, "olostep", raising=False)
|
||||||
|
monkeypatch.setitem(sys.modules, "olostep", None)
|
||||||
|
tool = _tool(provider="olostep", api_key="olostep-key")
|
||||||
|
result = await tool.execute(query="test query")
|
||||||
|
|
||||||
|
assert result == "Error: olostep package not installed. Run: pip install olostep"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user