diff --git a/plugins/web/_360_search/__init__.py b/plugins/web/_360_search/__init__.py new file mode 100644 index 0000000000000..98e9d327cd880 --- /dev/null +++ b/plugins/web/_360_search/__init__.py @@ -0,0 +1 @@ +"""360 Search (好搜) web search provider plugin.""" diff --git a/plugins/web/_360_search/plugin.yaml b/plugins/web/_360_search/plugin.yaml new file mode 100644 index 0000000000000..71e3992c9f167 --- /dev/null +++ b/plugins/web/_360_search/plugin.yaml @@ -0,0 +1,7 @@ +name: web-360-search +version: 1.0.0 +description: "360 Search (Haosou) — Chinese search engine. NOTE: no public developer API available." +author: Hermes Community +kind: backend +provides_web_providers: + - 360-search diff --git a/plugins/web/_360_search/provider.py b/plugins/web/_360_search/provider.py new file mode 100644 index 0000000000000..9b5d1fa0af6c2 --- /dev/null +++ b/plugins/web/_360_search/provider.py @@ -0,0 +1,45 @@ +"""360 Search (Haosou) — Chinese search engine. NOTE: no public developer API available.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class Three60SearchWebSearchProvider(WebSearchProvider): + """360 Search (好搜) — unavailable tier search provider.""" + + @property + def name(self) -> str: + return "360-search" + + @property + def display_name(self) -> str: + return "360 Search (好搜)" + + def is_available(self) -> bool: + """No public API available — always unavailable.""" + return False + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + return {"success": False, "error": "360 Search (好搜) does not offer a public search API. Use Baidu or Bocha for Chinese-language search."} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "360 Search (好搜)", + "badge": "unavailable", + "tag": "No public API available. Listed for registry completeness.", + "env_vars": [ + ], + } diff --git a/plugins/web/baidu/__init__.py b/plugins/web/baidu/__init__.py new file mode 100644 index 0000000000000..10f4321f62e61 --- /dev/null +++ b/plugins/web/baidu/__init__.py @@ -0,0 +1 @@ +"""Baidu Search web search provider plugin.""" diff --git a/plugins/web/baidu/plugin.yaml b/plugins/web/baidu/plugin.yaml new file mode 100644 index 0000000000000..6b698394c771f --- /dev/null +++ b/plugins/web/baidu/plugin.yaml @@ -0,0 +1,7 @@ +name: web-baidu +version: 1.0.0 +description: "Baidu AI Search API — 100 queries/day free. Requires BAIDU_API_KEY from https://ai.baidu.com." +author: Hermes Community +kind: backend +provides_web_providers: + - baidu diff --git a/plugins/web/baidu/provider.py b/plugins/web/baidu/provider.py new file mode 100644 index 0000000000000..47a5044009691 --- /dev/null +++ b/plugins/web/baidu/provider.py @@ -0,0 +1,65 @@ +"""Baidu AI Search API — 100 queries/day free. Requires BAIDU_API_KEY from https://ai.baidu.com.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class BaiduWebSearchProvider(WebSearchProvider): + """Baidu Search — free tier search provider.""" + + @property + def name(self) -> str: + return "baidu" + + @property + def display_name(self) -> str: + return "Baidu Search" + + def is_available(self) -> bool: + """Return True when BAIDU_API_KEY is set.""" + return bool(os.getenv("BAIDU_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + import requests + api_key = os.getenv("BAIDU_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "BAIDU_API_KEY not set. Get a key at https://ai.baidu.com."} + try: + r = requests.post("https://api.baidu.com/search/v1/websearch", params={"q": query, "topn": min(limit, 50)}, headers={"Authorization": f"Bearer {api_key}"}, timeout=10) + r.raise_for_status() + data = r.json() + raw = data.get("results", [])[:limit] + results = [ + {"title": str(i.get("title", "")), "url": str(i.get("url", "")), + "description": str(i.get("summary", "")), "position": n + 1} + for n, i in enumerate(raw) + ] + return {"success": True, "data": {"web": results}} + except Exception as e: + return {"success": False, "error": str(e)} + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Baidu Search", + "badge": "free", + "tag": "100 queries/day free tier, native Chinese content.", + "env_vars": [ + { + "key": "BAIDU_API_KEY", + "prompt": "Baidu Search API key", + "url": "https://ai.baidu.com", + }, + ], + } diff --git a/plugins/web/bocha/__init__.py b/plugins/web/bocha/__init__.py new file mode 100644 index 0000000000000..942e9655dd6e6 --- /dev/null +++ b/plugins/web/bocha/__init__.py @@ -0,0 +1 @@ +"""Bocha (博查) web search provider plugin.""" diff --git a/plugins/web/bocha/plugin.yaml b/plugins/web/bocha/plugin.yaml new file mode 100644 index 0000000000000..7b79c51047a9e --- /dev/null +++ b/plugins/web/bocha/plugin.yaml @@ -0,0 +1,7 @@ +name: web-bocha +version: 1.0.0 +description: "Bochaa AI Search API — leading Chinese search for AI. Free for personal use. Requires BOCHA_API_KEY from https://open.bochaai.com." +author: Hermes Community +kind: backend +provides_web_providers: + - bocha diff --git a/plugins/web/bocha/provider.py b/plugins/web/bocha/provider.py new file mode 100644 index 0000000000000..fbd534dfe5bff --- /dev/null +++ b/plugins/web/bocha/provider.py @@ -0,0 +1,65 @@ +"""Bochaa AI Search API — leading Chinese search for AI. Free for personal use. Requires BOCHA_API_KEY from https://open.bochaai.com.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class BochaWebSearchProvider(WebSearchProvider): + """Bocha (博查) — free tier search provider.""" + + @property + def name(self) -> str: + return "bocha" + + @property + def display_name(self) -> str: + return "Bocha (博查)" + + def is_available(self) -> bool: + """Return True when BOCHA_API_KEY is set.""" + return bool(os.getenv("BOCHA_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + import requests + api_key = os.getenv("BOCHA_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "BOCHA_API_KEY not set. Get a key at https://open.bochaai.com."} + try: + r = requests.post("https://api.bochaai.com/v1/web-search", json={"query": query, "count": min(limit, 20), "summary": True}, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, timeout=10) + r.raise_for_status() + data = r.json() + raw = list(((data or {}).get("webPages", []) or {}).get("value", []))[:limit] + results = [ + {"title": str(i.get("name", "")), "url": str(i.get("url", "")), + "description": str(i.get("summary", i.get("snippet", ""))), "position": n + 1} + for n, i in enumerate(raw) + ] + return {"success": True, "data": {"web": results}} + except Exception as e: + return {"success": False, "error": str(e)} + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Bocha (博查)", + "badge": "free", + "tag": "Free for personal use, best Chinese content quality. 1,000 free queries starter pack.", + "env_vars": [ + { + "key": "BOCHA_API_KEY", + "prompt": "Bocha (博查) API key", + "url": "https://open.bochaai.com", + }, + ], + } diff --git a/plugins/web/google-cse/__init__.py b/plugins/web/google-cse/__init__.py new file mode 100644 index 0000000000000..830ac75bc661f --- /dev/null +++ b/plugins/web/google-cse/__init__.py @@ -0,0 +1 @@ +"""Google CSE web search provider plugin.""" diff --git a/plugins/web/google-cse/plugin.yaml b/plugins/web/google-cse/plugin.yaml new file mode 100644 index 0000000000000..0b47287862baa --- /dev/null +++ b/plugins/web/google-cse/plugin.yaml @@ -0,0 +1,7 @@ +name: web-google-cse +version: 1.0.0 +description: "Google Custom Search JSON API — requires GCP project + API key + SE ID. NOTE: blocked by GFW in China." +author: Hermes Community +kind: backend +provides_web_providers: + - google-cse diff --git a/plugins/web/google-cse/provider.py b/plugins/web/google-cse/provider.py new file mode 100644 index 0000000000000..d9144634c76a4 --- /dev/null +++ b/plugins/web/google-cse/provider.py @@ -0,0 +1,67 @@ +"""Google Custom Search JSON API — requires GCP project + API key + SE ID. NOTE: blocked by GFW in China.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class GoogleCseWebSearchProvider(WebSearchProvider): + """Google CSE — free tier search provider.""" + + @property + def name(self) -> str: + return "google-cse" + + @property + def display_name(self) -> str: + return "Google CSE" + + def is_available(self) -> bool: + """Return True when GOOGLE_CSE_API_KEY is set.""" + return bool(os.getenv("GOOGLE_CSE_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + import requests + api_key = os.getenv("GOOGLE_CSE_API_KEY", "").strip() + cx = os.getenv("GOOGLE_CSE_CX", "").strip() + if not api_key or not cx: + return {"success": False, "error": "GOOGLE_CSE_API_KEY or GOOGLE_CSE_CX not set."} + try: + r = requests.get("https://customsearch.googleapis.com/customsearch/v1", + params={"q": query, "num": min(limit, 10), "key": api_key, "cx": cx}, timeout=10) + r.raise_for_status() + data = r.json() + raw = data.get("items", [])[:limit] + results = [ + {"title": str(i.get("title", "")), "url": str(i.get("link", "")), + "description": str(i.get("snippet", "")), "position": n + 1} + for n, i in enumerate(raw) + ] + return {"success": True, "data": {"web": results}} + except Exception as e: + return {"success": False, "error": str(e)} + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Google CSE", + "badge": "free", + "tag": "100 queries/day free tier. Requires GCP setup. NOT accessible from mainland China without proxy.", + "env_vars": [ + { + "key": "GOOGLE_CSE_API_KEY", + "prompt": "Google CSE API key", + "url": "https://developers.google.com/custom-search/v1/overview", + }, + ], + } diff --git a/plugins/web/jina/__init__.py b/plugins/web/jina/__init__.py new file mode 100644 index 0000000000000..f39d51a4c87e7 --- /dev/null +++ b/plugins/web/jina/__init__.py @@ -0,0 +1 @@ +"""Jina AI web search provider plugin.""" diff --git a/plugins/web/jina/plugin.yaml b/plugins/web/jina/plugin.yaml new file mode 100644 index 0000000000000..3ba9a3ff727b8 --- /dev/null +++ b/plugins/web/jina/plugin.yaml @@ -0,0 +1,7 @@ +name: web-jina +version: 1.0.0 +description: "Jina AI Search — full-page content extraction. 10M tokens free for new users. Requires JINA_API_KEY from https://jina.ai. NOTE: blocked by GFW in China." +author: Hermes Community +kind: backend +provides_web_providers: + - jina diff --git a/plugins/web/jina/provider.py b/plugins/web/jina/provider.py new file mode 100644 index 0000000000000..41818a0129d00 --- /dev/null +++ b/plugins/web/jina/provider.py @@ -0,0 +1,68 @@ +"""Jina AI Search — full-page content extraction. 10M tokens free for new users. Requires JINA_API_KEY from https://jina.ai. NOTE: blocked by GFW in China.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class JinaWebSearchProvider(WebSearchProvider): + """Jina AI — free tier search provider.""" + + @property + def name(self) -> str: + return "jina" + + @property + def display_name(self) -> str: + return "Jina AI" + + def is_available(self) -> bool: + """Return True when JINA_API_KEY is set.""" + return bool(os.getenv("JINA_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + import requests + api_key = os.getenv("JINA_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "JINA_API_KEY not set. Get a key at https://jina.ai (10M free tokens for new users)."} + try: + import urllib.parse + r = requests.get(f"https://s.jina.ai/{urllib.parse.quote(query)}", + headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"}, + timeout=15) + r.raise_for_status() + data = r.json() + raw = data.get("data", [])[:limit] + results = [ + {"title": str(i.get("title", "")), "url": str(i.get("url", "")), + "description": str(i.get("description", i.get("content", "")))[:500], "position": n + 1} + for n, i in enumerate(raw) + ] + return {"success": True, "data": {"web": results}} + except Exception as e: + return {"success": False, "error": str(e)} + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Jina AI", + "badge": "free", + "tag": "10M tokens free for new users. NOT accessible from mainland China without proxy.", + "env_vars": [ + { + "key": "JINA_API_KEY", + "prompt": "Jina AI API key", + "url": "https://jina.ai", + }, + ], + } diff --git a/plugins/web/qiniu-baidu/__init__.py b/plugins/web/qiniu-baidu/__init__.py new file mode 100644 index 0000000000000..cca756928d093 --- /dev/null +++ b/plugins/web/qiniu-baidu/__init__.py @@ -0,0 +1 @@ +"""Qiniu Baidu Search web search provider plugin.""" diff --git a/plugins/web/qiniu-baidu/plugin.yaml b/plugins/web/qiniu-baidu/plugin.yaml new file mode 100644 index 0000000000000..e5f5daa739270 --- /dev/null +++ b/plugins/web/qiniu-baidu/plugin.yaml @@ -0,0 +1,7 @@ +name: web-qiniu-baidu +version: 1.0.0 +description: "Qiniu Cloud Baidu Search — OpenAI-compatible Baidu Search API. Requires QINIU_API_KEY from https://qiniu.com/ai/models." +author: Hermes Community +kind: backend +provides_web_providers: + - qiniu-baidu diff --git a/plugins/web/qiniu-baidu/provider.py b/plugins/web/qiniu-baidu/provider.py new file mode 100644 index 0000000000000..44cbc096e1369 --- /dev/null +++ b/plugins/web/qiniu-baidu/provider.py @@ -0,0 +1,65 @@ +"""Qiniu Cloud Baidu Search — OpenAI-compatible Baidu Search API. Requires QINIU_API_KEY from https://qiniu.com/ai/models.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class QiniuBaiduWebSearchProvider(WebSearchProvider): + """Qiniu Baidu Search — free tier search provider.""" + + @property + def name(self) -> str: + return "qiniu-baidu" + + @property + def display_name(self) -> str: + return "Qiniu Baidu Search" + + def is_available(self) -> bool: + """Return True when QINIU_API_KEY is set.""" + return bool(os.getenv("QINIU_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + import requests + api_key = os.getenv("QINIU_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "QINIU_API_KEY not set. Get a key at https://qiniu.com/ai/models."} + try: + r = requests.post("https://api.qnaigc.com/v1/search/web", json={"query": query, "max_results": min(limit, 50), "search_type": "web"}, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, timeout=10) + r.raise_for_status() + data = r.json() + raw = list(((data or {}).get("data", []) or {}).get("results", []))[:limit] + results = [ + {"title": str(i.get("title", "")), "url": str(i.get("url", "")), + "description": str(i.get("snippet", "")), "position": n + 1} + for n, i in enumerate(raw) + ] + return {"success": True, "data": {"web": results}} + except Exception as e: + return {"success": False, "error": str(e)} + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Qiniu Baidu Search", + "badge": "free", + "tag": "300万 new-user tokens, OpenAI-compatible interface wrapping Baidu.", + "env_vars": [ + { + "key": "QINIU_API_KEY", + "prompt": "Qiniu Baidu Search API key", + "url": "https://qiniu.com/ai/models", + }, + ], + } diff --git a/plugins/web/serpapi/__init__.py b/plugins/web/serpapi/__init__.py new file mode 100644 index 0000000000000..1a76718243fa9 --- /dev/null +++ b/plugins/web/serpapi/__init__.py @@ -0,0 +1 @@ +"""SerpAPI web search provider plugin.""" diff --git a/plugins/web/serpapi/plugin.yaml b/plugins/web/serpapi/plugin.yaml new file mode 100644 index 0000000000000..7afa551c336e5 --- /dev/null +++ b/plugins/web/serpapi/plugin.yaml @@ -0,0 +1,7 @@ +name: web-serpapi +version: 1.0.0 +description: "SerpAPI — multi-engine (Google, Bing, Baidu, YouTube). 100 queries/month free. Requires SERPAPI_API_KEY from https://serpapi.com." +author: Hermes Community +kind: backend +provides_web_providers: + - serpapi diff --git a/plugins/web/serpapi/provider.py b/plugins/web/serpapi/provider.py new file mode 100644 index 0000000000000..e91a97acc8068 --- /dev/null +++ b/plugins/web/serpapi/provider.py @@ -0,0 +1,65 @@ +"""SerpAPI — multi-engine (Google, Bing, Baidu, YouTube). 100 queries/month free. Requires SERPAPI_API_KEY from https://serpapi.com.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class SerpapiWebSearchProvider(WebSearchProvider): + """SerpAPI — free tier search provider.""" + + @property + def name(self) -> str: + return "serpapi" + + @property + def display_name(self) -> str: + return "SerpAPI" + + def is_available(self) -> bool: + """Return True when SERPAPI_API_KEY is set.""" + return bool(os.getenv("SERPAPI_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + import requests + api_key = os.getenv("SERPAPI_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "SERPAPI_API_KEY not set. Get a key at https://serpapi.com."} + try: + r = requests.get("https://serpapi.com/search", params={"q": query, "num": min(limit, 100), "engine": "google", "api_key": api_key}, timeout=10) + r.raise_for_status() + data = r.json() + raw = data.get("organic_results", [])[:limit] + results = [ + {"title": str(i.get("title", "")), "url": str(i.get("link", "")), + "description": str(i.get("snippet", "")), "position": n + 1} + for n, i in enumerate(raw) + ] + return {"success": True, "data": {"web": results}} + except Exception as e: + return {"success": False, "error": str(e)} + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "SerpAPI", + "badge": "free", + "tag": "100 queries/month free tier, multi-engine (Google/Bing/Baidu/YouTube).", + "env_vars": [ + { + "key": "SERPAPI_API_KEY", + "prompt": "SerpAPI API key", + "url": "https://serpapi.com", + }, + ], + } diff --git a/plugins/web/serper/__init__.py b/plugins/web/serper/__init__.py new file mode 100644 index 0000000000000..cdc04942abc9b --- /dev/null +++ b/plugins/web/serper/__init__.py @@ -0,0 +1 @@ +"""Serper (Google Search) web search provider plugin.""" diff --git a/plugins/web/serper/plugin.yaml b/plugins/web/serper/plugin.yaml new file mode 100644 index 0000000000000..6d9cc6e5bfdef --- /dev/null +++ b/plugins/web/serper/plugin.yaml @@ -0,0 +1,7 @@ +name: web-serper +version: 1.0.0 +description: "Serper.dev — Google Search API. 2,500 free queries (no credit card). Requires SERPER_API_KEY from https://serper.dev." +author: Hermes Community +kind: backend +provides_web_providers: + - serper diff --git a/plugins/web/serper/provider.py b/plugins/web/serper/provider.py new file mode 100644 index 0000000000000..1b07373617cab --- /dev/null +++ b/plugins/web/serper/provider.py @@ -0,0 +1,65 @@ +"""Serper.dev — Google Search API. 2,500 free queries (no credit card). Requires SERPER_API_KEY from https://serper.dev.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class SerperWebSearchProvider(WebSearchProvider): + """Serper (Google Search) — free tier search provider.""" + + @property + def name(self) -> str: + return "serper" + + @property + def display_name(self) -> str: + return "Serper (Google Search)" + + def is_available(self) -> bool: + """Return True when SERPER_API_KEY is set.""" + return bool(os.getenv("SERPER_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + import requests + api_key = os.getenv("SERPER_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "SERPER_API_KEY not set. Get a key at https://serper.dev."} + try: + r = requests.post("https://google.serper.dev/search", json={"q": query, "num": min(limit, 100)}, headers={"X-API-KEY": api_key}, timeout=10) + r.raise_for_status() + data = r.json() + raw = data.get("organic", [])[:limit] + results = [ + {"title": str(i.get("title", "")), "url": str(i.get("link", "")), + "description": str(i.get("snippet", "")), "position": n + 1} + for n, i in enumerate(raw) + ] + return {"success": True, "data": {"web": results}} + except Exception as e: + return {"success": False, "error": str(e)} + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Serper (Google Search)", + "badge": "free", + "tag": "2,500 free queries/month, no credit card — Google SERP backed.", + "env_vars": [ + { + "key": "SERPER_API_KEY", + "prompt": "Serper (Google Search) API key", + "url": "https://serper.dev", + }, + ], + } diff --git a/plugins/web/sogou/__init__.py b/plugins/web/sogou/__init__.py new file mode 100644 index 0000000000000..65909be2aa461 --- /dev/null +++ b/plugins/web/sogou/__init__.py @@ -0,0 +1 @@ +"""Sogou (搜狗) web search provider plugin.""" diff --git a/plugins/web/sogou/plugin.yaml b/plugins/web/sogou/plugin.yaml new file mode 100644 index 0000000000000..e3b0c2924f40a --- /dev/null +++ b/plugins/web/sogou/plugin.yaml @@ -0,0 +1,7 @@ +name: web-sogou +version: 1.0.0 +description: "Sogou Search — Chinese search engine. NOTE: no public developer API available." +author: Hermes Community +kind: backend +provides_web_providers: + - sogou diff --git a/plugins/web/sogou/provider.py b/plugins/web/sogou/provider.py new file mode 100644 index 0000000000000..95325e86d324b --- /dev/null +++ b/plugins/web/sogou/provider.py @@ -0,0 +1,45 @@ +"""Sogou Search — Chinese search engine. NOTE: no public developer API available.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class SogouWebSearchProvider(WebSearchProvider): + """Sogou (搜狗) — unavailable tier search provider.""" + + @property + def name(self) -> str: + return "sogou" + + @property + def display_name(self) -> str: + return "Sogou (搜狗)" + + def is_available(self) -> bool: + """No public API available — always unavailable.""" + return False + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + return {"success": False, "error": "Sogou (搜狗) does not offer a public search API. Use Baidu or Bocha for Chinese-language search."} + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Sogou (搜狗)", + "badge": "unavailable", + "tag": "No public API available. Listed for registry completeness.", + "env_vars": [ + ], + } diff --git a/tools/web_tools.py b/tools/web_tools.py index 133489b0a8923..e90309bda590e 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -149,7 +149,9 @@ def _get_backend() -> str: keys manually without running setup. """ configured = (_load_web_config().get("backend") or "").lower().strip() - if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}: + if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai", + "serper", "baidu", "bocha", "qiniu-baidu", "serpapi", + "jina", "google-cse", "sogou", "360-search"}: return configured # Fallback for manual / legacy config — pick the highest-priority @@ -168,6 +170,18 @@ def _get_backend() -> str: ("searxng", _has_env("SEARXNG_URL")), ("brave-free", _has_env("BRAVE_SEARCH_API_KEY")), ("ddgs", _ddgs_package_importable()), + # New free providers — ordered by composite quality score + # (see docs: multi-source search API ranking) + ("serper", _check_provider_available("serper")), + ("baidu", _check_provider_available("baidu")), + ("bocha", _check_provider_available("bocha")), + ("qiniu-baidu", _check_provider_available("qiniu-baidu")), + ("serpapi", _check_provider_available("serpapi")), + ("jina", _check_provider_available("jina")), + ("google-cse", _check_provider_available("google-cse")), + # Registry-only entries (no public API, always unavailable) + ("sogou", _check_provider_available("sogou")), + ("360-search", _check_provider_available("360-search")), ) for backend, available in backend_candidates: if available: @@ -176,6 +190,17 @@ def _get_backend() -> str: return "firecrawl" # default (backward compat) +def _check_provider_available(provider_name: str) -> bool: + """Check whether a web search provider plugin is available. + + Used by ``backend_candidates`` to probe providers dynamically. + Falls back to ``_is_backend_available`` for unified behaviour. + """ + _ensure_web_plugins_loaded() + return _is_backend_available(provider_name) + + + def _get_search_backend() -> str: """Determine which backend to use for web_search specifically. @@ -240,9 +265,123 @@ def _is_backend_available(backend: str) -> bool: return has_xai_credentials() except Exception: return False + + # Generic probe for plugin-based backends — serper, baidu, bocha, + # qiniu-baidu, serpapi, jina, google-cse, sogou, 360-search, etc. + try: + from agent.web_search_registry import get_provider + provider = get_provider(backend) + if provider is not None: + return provider.is_available() + except Exception: + pass + return False +# ─── Fallback Chain & Multi-Source Search ──────────────────────────────────── + +def _get_valid_engine_names() -> set[str]: + """Return the set of currently-registered search provider names. + + Used by ``web_search_tool`` to validate the ``search_engine`` parameter. + Dynamically sourced from the provider registry so adding a new provider + automatically extends the valid choices — no hardcoded list. + """ + try: + from agent.web_search_registry import list_provider_names + return set(list_provider_names()) + except Exception: + return set() + + +def _get_fallback_chain() -> list[str]: + """Return an ordered list of backends to try when ``search_engine == "auto"``. + + Priority: + 1. ``web.backend`` — explicit choice from config (always first) + 2. ``web.fallback_backends`` — user-configured ordered list + 3. All other registered providers not already in the chain + """ + cfg = _load_web_config() + + # Build unique-ordered chain. Explicit backend goes first, then + # user-configured fallback list, then auto-discovered remainder. + chain: list[str] = [] + + primary = (cfg.get("backend") or "").lower().strip() + if primary: + chain.append(primary) + + user_fbs = cfg.get("fallback_backends", []) + if isinstance(user_fbs, str): + user_fbs = [b.strip() for b in user_fbs.split(",") if b.strip()] + for b in user_fbs: + if b not in chain: + chain.append(b) + + # Append remaining registered providers (those not already listed). + # The registry returns providers in registration order, which for + # bundled plugins is the backend_candidates tuple order. + try: + from agent.web_search_registry import list_provider_names + for name in list_provider_names(): + if name not in chain: + chain.append(name) + except Exception: + pass + + return chain + + +def _search_with_fallback( + query: str, + limit: int, + chain: list[str], +) -> tuple[dict | None, list[str]]: + """Try each backend in *chain* until one succeeds. + + Returns ``(success_result, error_trace)``. A backend is skipped + when it is not found, not available, returns an error, or returns + zero results. ``success_result`` is ``None`` when all backends fail. + """ + from agent.web_search_registry import get_provider + + errors: list[str] = [] + for name in chain: + provider = get_provider(name) + if provider is None or not provider.supports_search(): + errors.append(f"{name}: not found or search unsupported") + continue + if not provider.is_available(): + errors.append(f"{name}: not available (missing key or package)") + continue + + try: + result = provider.search(query, limit) + except Exception as exc: + errors.append(f"{name}: {exc}") + continue + + if not isinstance(result, dict): + errors.append(f"{name}: unexpected response type {type(result).__name__}") + continue + if not result.get("success"): + errors.append(f"{name}: {result.get('error', 'unknown error')}") + continue + if not len(result.get("data", {}).get("web", [])): + errors.append(f"{name}: returned 0 results") + continue + + # Success — return immediately + return result, errors + + return None, errors + + +# ─── Tool Functions ────────────────────────────────────────────────────────── + + def _ddgs_package_importable() -> bool: """Return True when the ``ddgs`` Python package can be imported. @@ -784,12 +923,14 @@ def _ensure_web_plugins_loaded() -> None: logger.warning("Web plugin discovery failed (non-fatal): %s", exc) -def web_search_tool(query: str, limit: int = 5) -> str: +def web_search_tool(query: str, limit: int = 5, search_engine: str = "auto") -> str: """ - Search the web for information using available search API backend. + Search the web for information using available search API backends. This function provides a generic interface for web search that can work - with multiple backends (Parallel or Firecrawl). + with multiple backends. When ``search_engine`` is ``"auto"`` (default), + backends from ``web.fallback_backends`` are tried in order until one + succeeds. Explicit engine names run a single backend with no fallback. Note: This function returns search result metadata only (URLs, titles, descriptions). Use web_extract_tool to get full content from specific URLs. @@ -797,6 +938,10 @@ def web_search_tool(query: str, limit: int = 5) -> str: Args: query (str): The search query to look up limit (int): Maximum number of results to return (default: 5) + search_engine (str): Which provider to use. ``"auto"`` walks the + fallback chain. Any registered provider name (e.g. ``"baidu"``, + ``"serper"``, ``"ddgs"``) runs that single backend. Defaults to + ``"auto"``. Returns: str: JSON string containing search results with the following structure: @@ -814,9 +959,6 @@ def web_search_tool(query: str, limit: int = 5) -> str: ] } } - - Raises: - Exception: If search fails or API key is not set """ try: limit = int(limit) @@ -840,38 +982,52 @@ def web_search_tool(query: str, limit: int = 5) -> str: if is_interrupted(): return tool_error("Interrupted", success=False) - # Dispatch through the web search registry. All 7 providers - # (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl) - # now live as plugins; the dispatcher is just a registry lookup + - # delegation. Sync only — every provider's search() is sync. _ensure_web_plugins_loaded() from agent.web_search_registry import ( - get_active_search_provider, get_provider as _wsp_get_provider, ) - backend = _get_search_backend() - provider = _wsp_get_provider(backend) if backend else None - if provider is None or not provider.supports_search(): - # Fall back to availability-walked active provider when the - # configured backend isn't a registered search provider (typo, - # uninstalled plugin, or capability mismatch). - provider = get_active_search_provider() - - if provider is None: + # --- Explicit engine requested --- + if search_engine and search_engine != "auto": + provider = _wsp_get_provider(search_engine) + if provider is None: + valid = _get_valid_engine_names() + return tool_error( + f"Unknown search engine: '{search_engine}'. " + f"Valid values: {', '.join(repr(e) for e in sorted(valid))}" + ) + if not provider.supports_search(): + return tool_error( + f"Provider '{search_engine}' does not support search." + ) + logger.info( + "Web search via %s (explicit): '%s' (limit: %d)", + provider.name, query, limit, + ) + response_data = provider.search(query, limit) + debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) + result_json = json.dumps(response_data, indent=2, ensure_ascii=False) + debug_call_data["final_response_size"] = len(result_json) + _debug.log_call("web_search_tool", debug_call_data) + _debug.save() + return result_json + + # --- Auto mode: fallback chain --- + chain = _get_fallback_chain() + response_data, errors = _search_with_fallback(query, limit, chain) + + if response_data is None: + summary = "; ".join(errors[-3:]) if errors else "All backends exhausted" response_data = { "success": False, "error": ( - "No web search provider configured. " - "Run `hermes tools` to set one up." + f"No web search backend available. Tried: " + f"{', '.join(chain)}. Last errors: {summary}" ), } - else: - logger.info( - "Web search via %s: '%s' (limit: %d)", - provider.name, query, limit, - ) - response_data = provider.search(query, limit) + elif errors: + # Attach fallback trace for observability + response_data.setdefault("_fallback_trace", errors) debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) result_json = json.dumps(response_data, indent=2, ensure_ascii=False)