diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index e168bbf400..0072c314ea 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -97,6 +97,7 @@ ExaWebSearchTool, FirecrawlExtractWebPageTool, FirecrawlWebSearchTool, + SerplyWebSearchTool, TavilyExtractWebPageTool, TavilyWebSearchTool, normalize_legacy_web_search_config, @@ -145,6 +146,7 @@ "web_search_bocha", "web_search_brave", "web_search_exa", + "web_search_serply", } ) WEB_SEARCH_CITATION_PROMPT = ( @@ -1285,6 +1287,8 @@ async def _apply_web_search_tools( elif provider == "exa": req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaWebSearchTool)) req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaGetContentsTool)) + elif provider == "serply": + req.func_tool.add_tool(tool_mgr.get_builtin_tool(SerplyWebSearchTool)) def _apply_web_search_citation_prompt( diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 7d449c60e8..e526df8f7b 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -123,6 +123,7 @@ "websearch_baidu_app_builder_key": "", "websearch_firecrawl_key": [], "websearch_exa_key": [], + "websearch_serply_key": [], "web_search_link": False, "display_reasoning_text": False, "identifier": False, @@ -3567,6 +3568,7 @@ "brave", "firecrawl", "exa", + "serply", ], "condition": { "provider_settings.web_search": True, @@ -3637,6 +3639,17 @@ "provider_settings.web_search": True, }, }, + "provider_settings.websearch_serply_key": { + "description": "Serply API Key", + "type": "list", + "items": {"type": "string"}, + "hint": "可添加多个 Key 进行轮询。Get a key at https://serply.io", + "secret": True, + "condition": { + "provider_settings.websearch_provider": "serply", + "provider_settings.web_search": True, + }, + }, "provider_settings.web_search_link": { "description": "显示来源引用", "type": "bool", diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index 0d85c40dc6..49369dcc71 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -23,6 +23,7 @@ "firecrawl_extract_web_page", "web_search_exa", "exa_get_contents", + "web_search_serply", ] _TAVILY_WEB_SEARCH_TOOL_CONFIG = { "provider_settings.web_search": True, @@ -48,6 +49,16 @@ "provider_settings.web_search": True, "provider_settings.websearch_provider": "exa", } +_SERPLY_WEB_SEARCH_TOOL_CONFIG = { + "provider_settings.web_search": True, + "provider_settings.websearch_provider": "serply", +} +# Serply search vertical -> (endpoint, key of the result list in the response). +_SERPLY_SEARCH_ENDPOINTS = { + "web": ("https://api.serply.io/v1/search/", "results"), + "news": ("https://api.serply.io/v1/news/", "entries"), + "scholar": ("https://api.serply.io/v1/scholar/", "articles"), +} @std_dataclass @@ -110,6 +121,7 @@ async def get(self, provider_settings: dict) -> str: _BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave") _FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl") _EXA_KEY_ROTATOR = _KeyRotator("websearch_exa_key", "Exa") +_SERPLY_KEY_ROTATOR = _KeyRotator("websearch_serply_key", "Serply") def normalize_legacy_web_search_config(cfg) -> None: @@ -134,6 +146,7 @@ def normalize_legacy_web_search_config(cfg) -> None: "websearch_brave_key", "websearch_firecrawl_key", "websearch_exa_key", + "websearch_serply_key", ): value = provider_settings.get(setting_name) if isinstance(value, str): @@ -1240,12 +1253,154 @@ async def call(self, context, **kwargs) -> ToolExecResult: return ret or "Error: Exa get contents does not return any results." +async def _serply_search( + provider_settings: dict, + search_type: str, + params: dict, +) -> list[SearchResult]: + """Call the Serply search API with API key failover. + + Args: + provider_settings: Provider settings containing Serply API keys. + search_type: Search vertical, one of the keys of _SERPLY_SEARCH_ENDPOINTS. + params: Query parameters for the Serply endpoint. + + Returns: + Normalized search results. + + Raises: + ValueError: If Serply API keys are not configured. + Exception: If the request fails after all retryable keys are exhausted, + or if a non-retryable HTTP error is returned. + """ + keys = provider_settings.get("websearch_serply_key", []) + if not keys: + raise ValueError("Error: Serply API key is not configured in AstrBot.") + + url, results_key = _SERPLY_SEARCH_ENDPOINTS[search_type] + last_error = None + for _ in range(len(keys)): + serply_key = await _SERPLY_KEY_ROTATOR.get(provider_settings) + header = { + "Accept": "application/json", + "X-Api-Key": serply_key, + } + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get( + url, + params=params, + headers=header, + ) as response: + if response.status == 200: + data = await response.json() + return [ + SearchResult( + title=item.get("title", ""), + url=item.get("link", ""), + # News entries carry an HTML summary; use source and date instead. + snippet=item.get("description") + or ( + f"{(item.get('source') or {}).get('title', '')} " + f"{item.get('published', '')}" + ).strip(), + ) + for item in data.get(results_key, []) + if item.get("link") + ] + reason = await response.text() + if response.status in _RETRYABLE_HTTP_STATUSES: + last_error = Exception( + f"Serply web search failed: {reason}, status: {response.status}", + ) + continue + raise Exception( + f"Serply web search failed: {reason}, status: {response.status}", + ) + + if last_error is not None: + raise last_error + raise Exception("Serply web search failed with all configured keys.") + + +@builtin_tool(config=_SERPLY_WEB_SEARCH_TOOL_CONFIG) +@pydantic_dataclass +class SerplyWebSearchTool(FunctionTool[AstrAgentContext]): + """Web search tool powered by the Serply API (Google web, news and scholar).""" + + name: str = "web_search_serply" + description: str = ( + "A web search tool powered by Serply, which returns live Google search results. " + "Supports Google web search as well as the Google News and Google Scholar verticals." + ) + parameters: dict = Field( + default_factory=lambda: { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Required. Search query."}, + "num_results": { + "type": "integer", + "description": "Optional. Number of results to return. Range: 1-100. Default is 10.", + }, + "search_type": { + "type": "string", + "description": ( + 'Optional. Search vertical. One of "web", "news", "scholar". ' + 'Default is "web". Use "news" for recent news coverage and ' + '"scholar" for academic papers.' + ), + }, + "gl": { + "type": "string", + "description": 'Optional. Country code for region-specific results, for example "us" or "cn".', + }, + "hl": { + "type": "string", + "description": 'Optional. Google interface language code, for example "en" or "zh-cn".', + }, + }, + "required": ["query"], + } + ) + + async def call(self, context, **kwargs) -> ToolExecResult: + _, provider_settings, _ = _get_runtime(context) + if not provider_settings.get("websearch_serply_key", []): + return "Error: Serply API key is not configured in AstrBot." + + try: + num_results = int(kwargs.get("num_results", 10)) + except (TypeError, ValueError): + num_results = 10 + if num_results < 1: + num_results = 1 + if num_results > 100: + num_results = 100 + + search_type = kwargs.get("search_type", "web") + if search_type not in _SERPLY_SEARCH_ENDPOINTS: + search_type = "web" + + params: dict = {"q": kwargs["query"], "num": num_results} + if kwargs.get("gl"): + params["gl"] = kwargs["gl"] + if kwargs.get("hl"): + params["hl"] = kwargs["hl"] + + # The news and scholar feeds do not honor `num`, so cap client-side. + results = await _serply_search(provider_settings, search_type, params) + results = results[:num_results] + if not results: + return "Error: Serply web search does not return any results." + return _search_result_payload(results) + + __all__ = [ "BaiduWebSearchTool", "BochaWebSearchTool", "BraveWebSearchTool", "ExaGetContentsTool", "ExaWebSearchTool", + "SerplyWebSearchTool", "TavilyExtractWebPageTool", "TavilyWebSearchTool", "WEB_SEARCH_TOOL_NAMES", diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 15a54640ba..a6cfd80c73 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -143,6 +143,10 @@ "websearch_exa_key": { "description": "Exa API Key", "hint": "Multiple keys can be added for rotation. Get a key at https://dashboard.exa.ai" + }, + "websearch_serply_key": { + "description": "Serply API Key", + "hint": "Multiple keys can be added for rotation. Get a key at https://serply.io" } } }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index f6d22a7152..7ba7b1436b 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -143,6 +143,10 @@ "websearch_exa_key": { "description": "API-ключ Exa", "hint": "Можно добавить несколько ключей для ротации. Получить ключ: https://dashboard.exa.ai" + }, + "websearch_serply_key": { + "description": "API-ключ Serply", + "hint": "Можно добавить несколько ключей для ротации. Получить ключ: https://serply.io" } } }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 0e314cc115..d1439eba16 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -145,6 +145,10 @@ "websearch_exa_key": { "description": "Exa API Key", "hint": "可添加多个 Key 进行轮询。获取 Key: https://dashboard.exa.ai" + }, + "websearch_serply_key": { + "description": "Serply API Key", + "hint": "可添加多个 Key 进行轮询。获取 Key: https://serply.io" } } }, diff --git a/docs/en/use/websearch.md b/docs/en/use/websearch.md index 798df2dcaa..889c50946d 100644 --- a/docs/en/use/websearch.md +++ b/docs/en/use/websearch.md @@ -14,11 +14,11 @@ When using a large language model that supports function calling with the web se And other prompts with search intent to trigger the model to invoke the search tool. -AstrBot currently supports 6 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, and `Exa`. +AstrBot currently supports 7 web search providers: `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, `Exa`, and `Serply`. ![image](https://files.astrbot.app/docs/source/images/websearch/image.png) -Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, or `Exa`. +Go to `Configuration`, scroll down to find Web Search, where you can select `Tavily`, `BoCha`, `Baidu AI Search`, `Brave`, `Firecrawl`, `Exa`, or `Serply`. ### Tavily @@ -44,6 +44,10 @@ Go to [Firecrawl](https://firecrawl.dev) to get an API Key, then fill it in the Go to [Exa](https://dashboard.exa.ai) to get an API Key, then fill it in the corresponding configuration item. Exa is an AI-native search engine that supports keyword and semantic search with category filters, domain restrictions, and date ranges. +### Serply + +Go to [Serply](https://serply.io) to get an API Key, then fill it in the corresponding configuration item. Serply returns live Google search results and also exposes the Google News and Google Scholar verticals through the `search_type` parameter of the search tool. See the [Serply API docs](https://serply.io/docs) for details. + If you use Tavily as your web search source, you will get a better experience optimization on AstrBot ChatUI, including citation source display and more: ![](https://files.astrbot.app/docs/source/images/websearch/image1.png) diff --git a/docs/zh/use/websearch.md b/docs/zh/use/websearch.md index c3b7f48a42..c6b6864cc3 100644 --- a/docs/zh/use/websearch.md +++ b/docs/zh/use/websearch.md @@ -13,11 +13,11 @@ AstrBot 内置的网页搜索功能依赖大模型提供 `函数调用` 能力 等等带有搜索意味的提示让大模型触发调用搜索工具。 -AstrBot 当前支持 6 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl`、`Exa`。 +AstrBot 当前支持 7 种网页搜索源接入方式:`Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl`、`Exa`、`Serply`。 ![image](https://files.astrbot.app/docs/source/images/websearch/image.png) -进入 `配置`,下拉找到网页搜索,您可选择 `Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl` 或 `Exa`。 +进入 `配置`,下拉找到网页搜索,您可选择 `Tavily`、`BoCha`、`百度 AI 搜索`、`Brave`、`Firecrawl`、`Exa` 或 `Serply`。 ### Tavily @@ -43,6 +43,10 @@ AstrBot 当前支持 6 种网页搜索源接入方式:`Tavily`、`BoCha`、` 前往 [Exa](https://dashboard.exa.ai) 获取 API Key,然后填写在相应的配置项。Exa 是一个 AI 原生搜索引擎,支持关键词和语义搜索,提供分类过滤、域名限制和日期范围等高级搜索功能。 +### Serply + +前往 [Serply](https://serply.io) 获取 API Key,然后填写在相应的配置项。Serply 返回实时的 Google 搜索结果,并可通过搜索工具的 `search_type` 参数使用 Google 新闻和 Google 学术两个垂直搜索。详情参见 [Serply API 文档](https://serply.io/docs)。 + 如果您使用 Tavily 作为网页搜索源,在 AstrBot ChatUI 上将会获得更好的体验优化,包括引用来源展示等: ![](https://files.astrbot.app/docs/source/images/websearch/image1.png) diff --git a/tests/unit/test_web_search_tools.py b/tests/unit/test_web_search_tools.py index fc8d1bb56a..1896a405f7 100644 --- a/tests/unit/test_web_search_tools.py +++ b/tests/unit/test_web_search_tools.py @@ -425,11 +425,13 @@ def _resetKeyRotators(): tools._BOCHA_KEY_ROTATOR.index = 0 tools._BRAVE_KEY_ROTATOR.index = 0 tools._FIRECRAWL_KEY_ROTATOR.index = 0 + tools._SERPLY_KEY_ROTATOR.index = 0 yield tools._TAVILY_KEY_ROTATOR.index = 0 tools._BOCHA_KEY_ROTATOR.index = 0 tools._BRAVE_KEY_ROTATOR.index = 0 tools._FIRECRAWL_KEY_ROTATOR.index = 0 + tools._SERPLY_KEY_ROTATOR.index = 0 # --------------------------------------------------------------------------- @@ -462,7 +464,11 @@ async def test_tavily_search_key_failover_on_quota_exceeded_432( status=200, jsonData={ "results": [ - {"title": "AstrBot", "url": "https://example.com", "content": "OK"} + { + "title": "AstrBot", + "url": "https://example.com", + "content": "OK", + } ] }, ), @@ -500,7 +506,11 @@ async def test_tavily_search_key_failover_on_rate_limited_429( status=200, jsonData={ "results": [ - {"title": "RateLimitOK", "url": "https://example2.com", "content": "OK"} + { + "title": "RateLimitOK", + "url": "https://example2.com", + "content": "OK", + } ] }, ), @@ -805,3 +815,248 @@ def fake_client_session(*, trust_env): {"websearch_exa_key": ["exa-key"]}, {"ids": ["https://example.com"]}, ) + + +# --- Serply tests --- + + +class _FakeSerplySession: + """Return the next response for each get() call and record the requests.""" + + def __init__(self, responses: list): + self.responses = responses + self.cursor = 0 + self.trust_env = None + self.calls: list[dict] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, url, params, headers): + resp = self.responses[self.cursor] + self.cursor = (self.cursor + 1) % len(self.responses) + self.calls.append({"url": url, "params": params, "headers": headers}) + return resp + + +def test_normalize_legacy_web_search_config_migrates_serply_key(): + config = _FakeConfig({"provider_settings": {"websearch_serply_key": "serply-key"}}) + + tools.normalize_legacy_web_search_config(config) + + assert config["provider_settings"]["websearch_serply_key"] == ["serply-key"] + assert config.saved is True + + +@pytest.mark.asyncio +async def test_serply_search_tool_maps_results(monkeypatch): + async def fake_serply_search(provider_settings, search_type, params): + assert provider_settings["websearch_serply_key"] == ["serply-key"] + assert search_type == "web" + assert params == {"q": "AstrBot", "num": 5, "gl": "us"} + return [ + tools.SearchResult( + title="AstrBot", + url="https://example.com", + snippet="AI Agent Assistant", + ) + ] + + monkeypatch.setattr(tools, "_serply_search", fake_serply_search) + tool = tools.SerplyWebSearchTool() + context = _context_with_provider_settings({"websearch_serply_key": ["serply-key"]}) + + result = await tool.call(context, query="AstrBot", num_results=5, gl="us") + + parsed = json.loads(result) + assert parsed["results"][0]["title"] == "AstrBot" + assert parsed["results"][0]["url"] == "https://example.com" + assert parsed["results"][0]["snippet"] == "AI Agent Assistant" + + +@pytest.mark.asyncio +async def test_serply_search_tool_falls_back_to_web_and_caps_results(monkeypatch): + async def fake_serply_search(provider_settings, search_type, params): + assert search_type == "web" + assert params["num"] == 1 + return [ + tools.SearchResult(title="A", url="https://a.example", snippet="a"), + tools.SearchResult(title="B", url="https://b.example", snippet="b"), + ] + + monkeypatch.setattr(tools, "_serply_search", fake_serply_search) + tool = tools.SerplyWebSearchTool() + context = _context_with_provider_settings({"websearch_serply_key": ["serply-key"]}) + + result = await tool.call( + context, query="AstrBot", num_results=0, search_type="images" + ) + + assert [item["title"] for item in json.loads(result)["results"]] == ["A"] + + +@pytest.mark.asyncio +async def test_serply_search_tool_returns_error_without_key(): + tool = tools.SerplyWebSearchTool() + context = _context_with_provider_settings({"websearch_serply_key": []}) + + result = await tool.call(context, query="AstrBot") + + assert result == "Error: Serply API key is not configured in AstrBot." + + +@pytest.mark.asyncio +async def test_serply_search_raw_api_call(monkeypatch): + session = _FakeSerplySession( + [ + _FakeFirecrawlResponse( + status=200, + json_data={ + "results": [ + { + "title": "AstrBot", + "link": "https://example.com", + "description": "AI Agent Assistant", + }, + {"title": "No link", "description": "dropped"}, + ], + }, + ) + ] + ) + + def fake_client_session(*, trust_env): + session.trust_env = trust_env + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + results = await tools._serply_search( + {"websearch_serply_key": ["serply-key"]}, + "web", + {"q": "AstrBot", "num": 10}, + ) + + assert session.trust_env is True + assert session.calls == [ + { + "url": "https://api.serply.io/v1/search/", + "params": {"q": "AstrBot", "num": 10}, + "headers": {"Accept": "application/json", "X-Api-Key": "serply-key"}, + } + ] + assert results == [ + tools.SearchResult( + title="AstrBot", url="https://example.com", snippet="AI Agent Assistant" + ) + ] + + +@pytest.mark.asyncio +async def test_serply_search_news_vertical_maps_entries(monkeypatch): + session = _FakeSerplySession( + [ + _FakeFirecrawlResponse( + status=200, + json_data={ + "entries": [ + { + "title": "AstrBot 4.0 released", + "link": "https://news.example.com/astrbot", + "summary": 'AstrBot 4.0 released', + "source": { + "href": "https://news.example.com", + "title": "Example News", + }, + "published": "Fri, 07 Aug 2026 07:00:00 GMT", + } + ], + }, + ) + ] + ) + + def fake_client_session(*, trust_env): + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + results = await tools._serply_search( + {"websearch_serply_key": ["serply-key"]}, + "news", + {"q": "AstrBot", "num": 10}, + ) + + assert session.calls[0]["url"] == "https://api.serply.io/v1/news/" + assert results == [ + tools.SearchResult( + title="AstrBot 4.0 released", + url="https://news.example.com/astrbot", + snippet="Example News Fri, 07 Aug 2026 07:00:00 GMT", + ) + ] + + +@pytest.mark.asyncio +async def test_serply_search_key_failover_on_unauthorized_401(monkeypatch): + session = _FakeSerplySession( + [ + _FakeFirecrawlResponse(status=401, text_data="Invalid API key"), + _FakeFirecrawlResponse( + status=200, + json_data={ + "results": [ + { + "title": "AstrBot", + "link": "https://example.com", + "description": "ok", + } + ] + }, + ), + ] + ) + + def fake_client_session(*, trust_env): + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + results = await tools._serply_search( + {"websearch_serply_key": ["bad-key", "good-key"]}, + "web", + {"q": "AstrBot", "num": 10}, + ) + + assert [call["headers"]["X-Api-Key"] for call in session.calls] == [ + "bad-key", + "good-key", + ] + assert results[0].url == "https://example.com" + + +@pytest.mark.asyncio +async def test_serply_search_raises_on_server_error_without_failover(monkeypatch): + session = _FakeSerplySession( + [_FakeFirecrawlResponse(status=500, text_data="Internal Server Error")] + ) + + def fake_client_session(*, trust_env): + return session + + monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session) + + with pytest.raises( + Exception, + match="Serply web search failed: Internal Server Error, status: 500", + ): + await tools._serply_search( + {"websearch_serply_key": ["key-1", "key-2"]}, + "web", + {"q": "AstrBot"}, + ) + + assert len(session.calls) == 1