mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
feat(community): add Brave image search community tool (#3866)
* Add Brave image search community tool * fix(community): length-cap Brave web_search queries Apply _clean_query in web_search_tool so over-long queries are trimmed to Brave's 400-char limit before the API call, matching image_search_tool and avoiding HTTP 422 from the Brave Search API. * fix(community): harden Brave image search SSRF guard and dimension mapping Address PR review findings: - Catch ValueError from urlparse so a malformed bracketed-IPv6 URL skips one item instead of crashing the whole image_search call - Reject IPv6 literals embedding a non-global IPv4 (IPv4-mapped, 6to4, NAT64, IPv4-compatible), closing the loopback/private SSRF bypass - Report width/height from the dict of the URL actually returned, so a surviving thumbnail no longer reports the dropped original's dimensions --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
co-authored by
Willem Jiang
parent
3748344303
commit
ddb097a72f
@@ -237,7 +237,7 @@ tools:
|
||||
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute)
|
||||
- `web_fetch` - Fetch web pages (Jina AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless)
|
||||
- `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless)
|
||||
- `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper)
|
||||
- `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper, Brave)
|
||||
- `ls` - List directory contents
|
||||
- `read_file` - Read file contents
|
||||
- `write_file` - Write file contents
|
||||
@@ -509,7 +509,7 @@ models:
|
||||
- `MIMO_API_KEY` - Xiaomi MiMo API key
|
||||
- `NOVITA_API_KEY` - Novita API key (OpenAI-compatible endpoint)
|
||||
- `TAVILY_API_KEY` - Tavily search API key
|
||||
- `BRAVE_SEARCH_API_KEY` - Brave Search API key
|
||||
- `BRAVE_SEARCH_API_KEY` - Brave Search API key for `web_search` and `image_search`
|
||||
- `SERPER_API_KEY` - Serper (Google Search/Images API) key for `web_search` and `image_search`
|
||||
- `GROUNDROUTE_API_KEY` - GroundRoute meta-search API key for `web_search` and `web_fetch` (routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing)
|
||||
- `BROWSERLESS_TOKEN` - Browserless Cloud token for `web_capture` (optional for self-hosted Browserless)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .tools import web_search_tool
|
||||
from .tools import image_search_tool, web_search_tool
|
||||
|
||||
__all__ = ["web_search_tool"]
|
||||
__all__ = ["image_search_tool", "web_search_tool"]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
Web Search Tool - Search the web using the Brave Search API.
|
||||
Web and image search tools powered by the Brave Search API.
|
||||
|
||||
Brave Search provides web results from an independent search index via a
|
||||
REST API. An API key is required. Sign up at https://brave.com/search/api/
|
||||
to get one.
|
||||
Brave Search provides web and image results from an independent search index
|
||||
via a REST API. An API key is required. Sign up at
|
||||
https://brave.com/search/api/ to get one.
|
||||
|
||||
Unlike the DuckDuckGo ``backend: brave`` option (which scrapes results via the
|
||||
DDGS aggregator), this provider calls the official Brave Search API directly,
|
||||
@@ -13,6 +13,8 @@ giving structured results, authenticated quota, and a documented SLA.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
@@ -21,23 +23,36 @@ from deerflow.config import get_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
|
||||
_BRAVE_WEB_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
|
||||
_BRAVE_IMAGES_ENDPOINT = "https://api.search.brave.com/res/v1/images/search"
|
||||
_DEFAULT_MAX_RESULTS = 5
|
||||
# Brave Search API caps the `count` parameter at 20 results per request.
|
||||
_BRAVE_MAX_COUNT = 20
|
||||
_api_key_warned = False
|
||||
_BRAVE_WEB_MAX_COUNT = 20
|
||||
# Brave Image Search supports larger batches than web search.
|
||||
_BRAVE_IMAGE_MAX_COUNT = 200
|
||||
# NAT64 well-known prefix (RFC 6052): IPv6 literals embedding an IPv4 address.
|
||||
_NAT64_PREFIX = ip_network("64:ff9b::/96")
|
||||
_api_key_warned: set[str] = set()
|
||||
|
||||
|
||||
def _get_api_key() -> str | None:
|
||||
config = get_app_config().get_tool_config("web_search")
|
||||
def _get_api_key(tool_name: str = "web_search") -> str | None:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
if config is not None:
|
||||
api_key = (config.model_extra or {}).get("api_key")
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
return api_key
|
||||
return os.getenv("BRAVE_SEARCH_API_KEY")
|
||||
return api_key.strip()
|
||||
env_key = os.getenv("BRAVE_SEARCH_API_KEY")
|
||||
if isinstance(env_key, str) and env_key.strip():
|
||||
return env_key.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_max_results(value: object, *, default: int = _DEFAULT_MAX_RESULTS) -> int:
|
||||
def _coerce_max_results(
|
||||
value: object,
|
||||
*,
|
||||
default: int = _DEFAULT_MAX_RESULTS,
|
||||
max_allowed: int = _BRAVE_WEB_MAX_COUNT,
|
||||
) -> int:
|
||||
try:
|
||||
coerced = int(value)
|
||||
except (TypeError, ValueError):
|
||||
@@ -48,7 +63,170 @@ def _coerce_max_results(value: object, *, default: int = _DEFAULT_MAX_RESULTS) -
|
||||
)
|
||||
coerced = default
|
||||
|
||||
return max(1, min(coerced, _BRAVE_MAX_COUNT))
|
||||
return max(1, min(coerced, max_allowed))
|
||||
|
||||
|
||||
def _clean_query(query: str, *, max_length: int = 400) -> str:
|
||||
query = query.strip()
|
||||
if len(query) > max_length:
|
||||
query = query[:max_length]
|
||||
return query
|
||||
|
||||
|
||||
def _missing_key_error(query: str, tool_name: str) -> str:
|
||||
if tool_name not in _api_key_warned:
|
||||
_api_key_warned.add(tool_name)
|
||||
logger.warning(
|
||||
"Brave Search API key is not set for '%s'. Set BRAVE_SEARCH_API_KEY in your environment or provide api_key in config.yaml. Sign up at https://brave.com/search/api/",
|
||||
tool_name,
|
||||
)
|
||||
return json.dumps(
|
||||
{"error": "BRAVE_SEARCH_API_KEY is not configured", "query": query},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _unexpected_format_error(query: str, *, service_name: str = "Brave Search") -> str:
|
||||
return json.dumps(
|
||||
{"error": f"{service_name} returned an unexpected response format", "query": query},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _decode_ipv4(host: str) -> IPv4Address | None:
|
||||
"""Decode obfuscated IPv4 literals that ``ip_address`` rejects.
|
||||
|
||||
Mirrors the permissive ``inet_aton`` parsing many HTTP clients use, so that
|
||||
integer (``2130706433``), hex (``0x7f000001``) and octal (``0177.0.0.1``)
|
||||
encodings of an address are recognized.
|
||||
"""
|
||||
parts = host.split(".")
|
||||
if not 1 <= len(parts) <= 4:
|
||||
return None
|
||||
|
||||
values: list[int] = []
|
||||
for part in parts:
|
||||
if not part:
|
||||
return None
|
||||
try:
|
||||
if part.startswith(("0x", "0X")):
|
||||
values.append(int(part, 16))
|
||||
elif part.startswith("0") and len(part) > 1:
|
||||
values.append(int(part, 8))
|
||||
else:
|
||||
values.append(int(part, 10))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
*leading, last = values
|
||||
for value in leading:
|
||||
if not 0 <= value <= 0xFF:
|
||||
return None
|
||||
max_last = (1 << (8 * (4 - len(leading)))) - 1
|
||||
if not 0 <= last <= max_last:
|
||||
return None
|
||||
|
||||
result = 0
|
||||
for value in leading:
|
||||
result = (result << 8) | value
|
||||
result = (result << (8 * (4 - len(leading)))) | last
|
||||
return IPv4Address(result)
|
||||
|
||||
|
||||
def _is_url_present(value: object) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def _embedded_ipv4(ip: IPv6Address) -> IPv4Address | None:
|
||||
"""Extract an IPv4 address embedded in an IPv6 literal, if any.
|
||||
|
||||
Covers IPv4-mapped (``::ffff:a.b.c.d``), 6to4 (``2002::/16``), NAT64
|
||||
(``64:ff9b::/96``), and IPv4-compatible (``::a.b.c.d``) forms. These all
|
||||
smuggle a v4 destination through the IPv6 path, where ``is_global`` on the
|
||||
v6 literal alone would otherwise report a loopback/private target as safe.
|
||||
"""
|
||||
if ip.ipv4_mapped is not None:
|
||||
return ip.ipv4_mapped
|
||||
if ip.sixtofour is not None:
|
||||
return ip.sixtofour
|
||||
if ip in _NAT64_PREFIX:
|
||||
return IPv4Address(int(ip) & 0xFFFFFFFF)
|
||||
# IPv4-compatible ``::a.b.c.d`` (high 96 bits zero, excluding ::/:: 1).
|
||||
packed = int(ip)
|
||||
if packed >> 32 == 0 and packed > 1:
|
||||
return IPv4Address(packed & 0xFFFFFFFF)
|
||||
return None
|
||||
|
||||
|
||||
def _safe_public_url(value: object) -> str:
|
||||
"""Return ``value`` only if it is a safe, public http(s) URL, else "".
|
||||
|
||||
This is a best-effort SSRF guard that rejects non-http(s) schemes,
|
||||
``localhost``, and private/non-global IP literals (including obfuscated
|
||||
decimal/hex/octal encodings and IPv6 literals embedding a non-global IPv4).
|
||||
It only inspects the URL string and cannot catch public hostnames that
|
||||
resolve to internal IPs; any consumer that actually downloads these URLs
|
||||
must re-validate the resolved IP at fetch time.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
url = value.strip()
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return ""
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
|
||||
return ""
|
||||
|
||||
host = parsed.hostname.lower().rstrip(".")
|
||||
if not host:
|
||||
return ""
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
return ""
|
||||
|
||||
try:
|
||||
ip = ip_address(host)
|
||||
except ValueError:
|
||||
ip = _decode_ipv4(host)
|
||||
if ip is None:
|
||||
return url
|
||||
if isinstance(ip, IPv6Address):
|
||||
embedded = _embedded_ipv4(ip)
|
||||
if embedded is not None and not embedded.is_global:
|
||||
return ""
|
||||
return url if ip.is_global else ""
|
||||
|
||||
|
||||
def _brave_get(
|
||||
endpoint: str,
|
||||
api_key: str,
|
||||
query: str,
|
||||
params: dict[str, object],
|
||||
*,
|
||||
service_name: str,
|
||||
) -> tuple[dict | None, str | None]:
|
||||
headers = {
|
||||
"X-Subscription-Token": api_key,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
try:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
response = client.get(endpoint, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
logger.error("%s returned an unexpected payload type: %s", service_name, type(data).__name__)
|
||||
return None, _unexpected_format_error(query, service_name=service_name)
|
||||
return data, None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("%s API returned HTTP %s: %s", service_name, e.response.status_code, e.response.text)
|
||||
return None, json.dumps(
|
||||
{"error": f"{service_name} API error: HTTP {e.response.status_code}", "query": query},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("%s request failed: %s: %s", service_name, type(e).__name__, e)
|
||||
return None, json.dumps({"error": str(e), "query": query}, ensure_ascii=False)
|
||||
|
||||
|
||||
@tool("web_search", parse_docstring=True)
|
||||
@@ -59,44 +237,22 @@ def web_search_tool(query: str, max_results: int = 5) -> str:
|
||||
query: Search keywords describing what you want to find. Be specific for better results.
|
||||
max_results: Maximum number of search results to return. Default is 5.
|
||||
"""
|
||||
global _api_key_warned
|
||||
|
||||
config = get_app_config().get_tool_config("web_search")
|
||||
if config is not None and "max_results" in (config.model_extra or {}):
|
||||
max_results = config.model_extra["max_results"]
|
||||
|
||||
count = _coerce_max_results(max_results)
|
||||
count = _coerce_max_results(max_results, max_allowed=_BRAVE_WEB_MAX_COUNT)
|
||||
query = _clean_query(query)
|
||||
|
||||
api_key = _get_api_key()
|
||||
api_key = _get_api_key("web_search")
|
||||
if not api_key:
|
||||
if not _api_key_warned:
|
||||
_api_key_warned = True
|
||||
logger.warning("Brave Search API key is not set. Set BRAVE_SEARCH_API_KEY in your environment or provide api_key in config.yaml. Sign up at https://brave.com/search/api/")
|
||||
return json.dumps(
|
||||
{"error": "BRAVE_SEARCH_API_KEY is not configured", "query": query},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return _missing_key_error(query, "web_search")
|
||||
|
||||
headers = {
|
||||
"X-Subscription-Token": api_key,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
params = {"q": query, "count": count, "text_decorations": False}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
response = client.get(_BRAVE_ENDPOINT, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Brave Search API returned HTTP {e.response.status_code}: {e.response.text}")
|
||||
return json.dumps(
|
||||
{"error": f"Brave Search API error: HTTP {e.response.status_code}", "query": query},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Brave search failed: {type(e).__name__}: {e}")
|
||||
return json.dumps({"error": str(e), "query": query}, ensure_ascii=False)
|
||||
data, error_json = _brave_get(_BRAVE_WEB_ENDPOINT, api_key, query, params, service_name="Brave Search")
|
||||
if error_json is not None:
|
||||
return error_json
|
||||
|
||||
web_results = (data.get("web") or {}).get("results", [])
|
||||
if not web_results:
|
||||
@@ -117,3 +273,110 @@ def web_search_tool(query: str, max_results: int = 5) -> str:
|
||||
"results": normalized_results,
|
||||
}
|
||||
return json.dumps(output, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
@tool("image_search", parse_docstring=True)
|
||||
def image_search_tool(query: str, max_results: int = 5) -> str:
|
||||
"""Search for images online using Brave Image Search. Use this tool BEFORE image generation to find reference images for characters, portraits, objects, scenes, or any content requiring visual accuracy.
|
||||
|
||||
The returned image URLs can be used as reference images in image generation to significantly improve quality.
|
||||
|
||||
Args:
|
||||
query: Search keywords describing the images you want to find. Be specific for better results.
|
||||
max_results: Maximum number of images to return. Default is 5, capped at 200.
|
||||
"""
|
||||
config = get_app_config().get_tool_config("image_search")
|
||||
extra = (config.model_extra or {}) if config is not None else {}
|
||||
if "max_results" in extra:
|
||||
max_results = extra["max_results"]
|
||||
count = _coerce_max_results(max_results, max_allowed=_BRAVE_IMAGE_MAX_COUNT)
|
||||
query = _clean_query(query)
|
||||
|
||||
api_key = _get_api_key("image_search")
|
||||
if not api_key:
|
||||
return _missing_key_error(query, "image_search")
|
||||
|
||||
params: dict[str, object] = {"q": query, "count": count}
|
||||
for key in ("country", "search_lang", "safesearch", "spellcheck"):
|
||||
if key in extra:
|
||||
params[key] = extra[key]
|
||||
|
||||
data, error_json = _brave_get(
|
||||
_BRAVE_IMAGES_ENDPOINT,
|
||||
api_key,
|
||||
query,
|
||||
params,
|
||||
service_name="Brave Image Search",
|
||||
)
|
||||
if error_json is not None:
|
||||
return error_json
|
||||
|
||||
images = data.get("results")
|
||||
if images is None:
|
||||
images = []
|
||||
if not isinstance(images, list):
|
||||
logger.error("Brave Image Search returned unexpected 'results' payload type: %s", type(images).__name__)
|
||||
return _unexpected_format_error(query, service_name="Brave Image Search")
|
||||
if not images:
|
||||
return json.dumps({"error": "No images found", "query": query}, ensure_ascii=False)
|
||||
|
||||
normalized_results = []
|
||||
for item in images:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
thumbnail = item.get("thumbnail") if isinstance(item.get("thumbnail"), dict) else {}
|
||||
properties = item.get("properties") if isinstance(item.get("properties"), dict) else {}
|
||||
raw_image = properties.get("url")
|
||||
raw_thumb = thumbnail.get("src")
|
||||
raw_source = item.get("url")
|
||||
|
||||
safe_image = _safe_public_url(raw_image)
|
||||
safe_thumb = _safe_public_url(raw_thumb)
|
||||
safe_source = _safe_public_url(raw_source)
|
||||
|
||||
# Surface a URL and remember which dict it came from, so the reported
|
||||
# width/height describe the URL we actually return rather than a
|
||||
# dropped one.
|
||||
if safe_image:
|
||||
image_url, image_dims = safe_image, properties
|
||||
elif not _is_url_present(raw_image):
|
||||
image_url, image_dims = safe_thumb, thumbnail
|
||||
else:
|
||||
image_url, image_dims = "", {}
|
||||
|
||||
if safe_thumb:
|
||||
thumbnail_url, thumb_dims = safe_thumb, thumbnail
|
||||
elif not _is_url_present(raw_thumb):
|
||||
thumbnail_url, thumb_dims = safe_image, properties
|
||||
else:
|
||||
thumbnail_url, thumb_dims = "", {}
|
||||
|
||||
if not image_url and not thumbnail_url:
|
||||
continue
|
||||
|
||||
dims = image_dims if image_url else thumb_dims
|
||||
|
||||
normalized_results.append(
|
||||
{
|
||||
"title": item.get("title", ""),
|
||||
"image_url": image_url,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"source_url": safe_source,
|
||||
"source": item.get("source", ""),
|
||||
"width": dims.get("width"),
|
||||
"height": dims.get("height"),
|
||||
}
|
||||
)
|
||||
if len(normalized_results) >= count:
|
||||
break
|
||||
|
||||
if not normalized_results:
|
||||
return json.dumps({"error": "No safe image URLs found", "query": query}, ensure_ascii=False)
|
||||
|
||||
output = {
|
||||
"query": query,
|
||||
"total_results": len(normalized_results),
|
||||
"results": normalized_results,
|
||||
"usage_hint": "Use the 'image_url' values as reference images in image generation. Download them first if needed.",
|
||||
}
|
||||
return json.dumps(output, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -12,9 +12,9 @@ def reset_api_key_warned():
|
||||
"""Reset the module-level warning flag before each test."""
|
||||
import deerflow.community.brave.tools as brave_mod
|
||||
|
||||
brave_mod._api_key_warned = False
|
||||
brave_mod._api_key_warned = set()
|
||||
yield
|
||||
brave_mod._api_key_warned = False
|
||||
brave_mod._api_key_warned = set()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -42,6 +42,13 @@ def _make_brave_response(results: list) -> MagicMock:
|
||||
return mock_resp
|
||||
|
||||
|
||||
def _make_brave_images_response(results: list | object) -> MagicMock:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"results": results}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
return mock_resp
|
||||
|
||||
|
||||
def _count_aware_get(results: list):
|
||||
"""Mimic Brave returning at most `count` results for the request."""
|
||||
|
||||
@@ -52,6 +59,16 @@ def _count_aware_get(results: list):
|
||||
return _get
|
||||
|
||||
|
||||
def _image_count_aware_get(results: list):
|
||||
"""Mimic Brave Image Search returning at most `count` results for the request."""
|
||||
|
||||
def _get(url, **kwargs):
|
||||
count = kwargs["params"]["count"]
|
||||
return _make_brave_images_response(results[:count])
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
class TestGetApiKey:
|
||||
def test_returns_config_key_when_present(self):
|
||||
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||
@@ -63,6 +80,17 @@ class TestGetApiKey:
|
||||
|
||||
assert _get_api_key() == "from-config"
|
||||
|
||||
def test_reads_config_for_requested_tool_name(self):
|
||||
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||
tool_config = MagicMock()
|
||||
tool_config.model_extra = {"api_key": "image-key"}
|
||||
mock.return_value.get_tool_config.return_value = tool_config
|
||||
|
||||
from deerflow.community.brave.tools import _get_api_key
|
||||
|
||||
assert _get_api_key("image_search") == "image-key"
|
||||
mock.return_value.get_tool_config.assert_called_with("image_search")
|
||||
|
||||
def test_falls_back_to_env_when_config_key_empty(self):
|
||||
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||
tool_config = MagicMock()
|
||||
@@ -326,6 +354,23 @@ class TestWebSearchTool:
|
||||
assert params["q"] == "hello world"
|
||||
assert params["count"] == 5
|
||||
|
||||
def test_long_query_is_truncated_to_brave_limit(self, mock_config_with_key):
|
||||
results = [{"title": "T", "url": "https://x.com", "description": "D"}]
|
||||
mock_resp = _make_brave_response(results)
|
||||
|
||||
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||
mock_get = mock_client_cls.return_value.__enter__.return_value.get
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
from deerflow.community.brave.tools import web_search_tool
|
||||
|
||||
result = web_search_tool.invoke({"query": "a" * 500})
|
||||
parsed = json.loads(result)
|
||||
params = mock_get.call_args.kwargs["params"]
|
||||
|
||||
assert len(params["q"]) == 400
|
||||
assert parsed["query"] == "a" * 400
|
||||
|
||||
def test_uses_env_key_when_config_absent(self):
|
||||
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||
mock.return_value.get_tool_config.return_value = None
|
||||
@@ -358,3 +403,290 @@ class TestWebSearchTool:
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["results"][0] == {"title": "", "url": "", "content": ""}
|
||||
|
||||
|
||||
class TestSafePublicUrl:
|
||||
def test_https_public_hostname_passes(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("https://example.com/i.jpg") == "https://example.com/i.jpg"
|
||||
|
||||
def test_non_http_scheme_is_filtered(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("file:///etc/passwd") == ""
|
||||
|
||||
def test_localhost_is_filtered(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://localhost/i.jpg") == ""
|
||||
|
||||
def test_private_ip_is_filtered(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://10.0.0.1/i.jpg") == ""
|
||||
|
||||
def test_obfuscated_loopback_ip_is_filtered(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://2130706433/i.jpg") == ""
|
||||
|
||||
def test_malformed_ipv6_url_does_not_raise(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://[::1/i.jpg") == ""
|
||||
|
||||
def test_nat64_embedded_loopback_is_filtered(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://[64:ff9b::127.0.0.1]/i.jpg") == ""
|
||||
|
||||
def test_ipv4_compatible_embedded_private_is_filtered(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://[::10.0.0.1]/i.jpg") == ""
|
||||
|
||||
def test_ipv4_mapped_loopback_is_filtered(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://[::ffff:127.0.0.1]/i.jpg") == ""
|
||||
|
||||
def test_sixtofour_loopback_is_filtered(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://[2002:7f00:1::]/i.jpg") == ""
|
||||
|
||||
def test_global_ipv6_passes(self):
|
||||
from deerflow.community.brave.tools import _safe_public_url
|
||||
|
||||
assert _safe_public_url("http://[2001:4860:4860::8888]/i.jpg") == "http://[2001:4860:4860::8888]/i.jpg"
|
||||
|
||||
|
||||
class TestImageSearchTool:
|
||||
def test_basic_image_search_returns_normalized_results(self, mock_config_with_key):
|
||||
results = [
|
||||
{
|
||||
"title": "Mountain",
|
||||
"url": "https://example.com/mountain-page",
|
||||
"source": "example.com",
|
||||
"thumbnail": {"src": "https://imgs.search.brave.com/thumb.jpg", "width": 500, "height": 320},
|
||||
"properties": {"url": "https://cdn.example.com/mountain.jpg", "width": 1920, "height": 1080},
|
||||
},
|
||||
{
|
||||
"title": "Forest",
|
||||
"url": "https://example.org/forest-page",
|
||||
"thumbnail": {"src": "https://imgs.search.brave.com/forest.jpg"},
|
||||
"properties": {"url": "https://cdn.example.org/forest.jpg"},
|
||||
},
|
||||
]
|
||||
mock_resp = _make_brave_images_response(results)
|
||||
|
||||
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.__enter__.return_value.get.return_value = mock_resp
|
||||
|
||||
from deerflow.community.brave.tools import image_search_tool
|
||||
|
||||
result = image_search_tool.invoke({"query": "mountain landscape"})
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["query"] == "mountain landscape"
|
||||
assert parsed["total_results"] == 2
|
||||
assert parsed["results"][0] == {
|
||||
"title": "Mountain",
|
||||
"image_url": "https://cdn.example.com/mountain.jpg",
|
||||
"thumbnail_url": "https://imgs.search.brave.com/thumb.jpg",
|
||||
"source_url": "https://example.com/mountain-page",
|
||||
"source": "example.com",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
}
|
||||
assert "usage_hint" in parsed
|
||||
|
||||
def test_image_search_sends_brave_image_params_from_config(self):
|
||||
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
||||
tool_config = MagicMock()
|
||||
tool_config.model_extra = {
|
||||
"api_key": "test-key",
|
||||
"max_results": "250",
|
||||
"country": "JP",
|
||||
"search_lang": "ja",
|
||||
"safesearch": "off",
|
||||
"spellcheck": False,
|
||||
}
|
||||
mock.return_value.get_tool_config.return_value = tool_config
|
||||
|
||||
results = [
|
||||
{
|
||||
"title": f"R{i}",
|
||||
"url": f"https://example.com/{i}",
|
||||
"thumbnail": {"src": f"https://imgs.search.brave.com/{i}.jpg"},
|
||||
"properties": {"url": f"https://cdn.example.com/{i}.jpg"},
|
||||
}
|
||||
for i in range(250)
|
||||
]
|
||||
|
||||
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||
mock_get = mock_client_cls.return_value.__enter__.return_value.get
|
||||
mock_get.side_effect = _image_count_aware_get(results)
|
||||
|
||||
from deerflow.community.brave.tools import image_search_tool
|
||||
|
||||
result = image_search_tool.invoke({"query": "sakura", "max_results": 5})
|
||||
parsed = json.loads(result)
|
||||
call = mock_get.call_args
|
||||
params = call.kwargs["params"]
|
||||
|
||||
assert call.args[0] == "https://api.search.brave.com/res/v1/images/search"
|
||||
assert params["q"] == "sakura"
|
||||
assert params["count"] == 200
|
||||
assert params["country"] == "JP"
|
||||
assert params["search_lang"] == "ja"
|
||||
assert params["safesearch"] == "off"
|
||||
assert params["spellcheck"] is False
|
||||
assert mock_get.call_count == 1
|
||||
assert parsed["total_results"] == 200
|
||||
|
||||
def test_image_search_filters_unsafe_image_urls_but_keeps_safe_thumbnail(self, mock_config_with_key):
|
||||
results = [
|
||||
{
|
||||
"title": "Unsafe original",
|
||||
"url": "http://localhost/page",
|
||||
"thumbnail": {"src": "https://imgs.search.brave.com/thumb.jpg"},
|
||||
"properties": {"url": "http://127.0.0.1/image.jpg"},
|
||||
},
|
||||
{
|
||||
"title": "Fully unsafe",
|
||||
"url": "http://10.0.0.1/page",
|
||||
"thumbnail": {"src": "http://0177.0.0.1/thumb.jpg"},
|
||||
"properties": {"url": "http://2130706433/image.jpg"},
|
||||
},
|
||||
]
|
||||
|
||||
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.__enter__.return_value.get.return_value = _make_brave_images_response(results)
|
||||
|
||||
from deerflow.community.brave.tools import image_search_tool
|
||||
|
||||
result = image_search_tool.invoke({"query": "unsafe"})
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["total_results"] == 1
|
||||
assert parsed["results"][0]["title"] == "Unsafe original"
|
||||
assert parsed["results"][0]["image_url"] == ""
|
||||
assert parsed["results"][0]["thumbnail_url"] == "https://imgs.search.brave.com/thumb.jpg"
|
||||
assert parsed["results"][0]["source_url"] == ""
|
||||
|
||||
def test_image_search_falls_back_when_only_one_image_url_is_present(self, mock_config_with_key):
|
||||
results = [
|
||||
{
|
||||
"title": "Only thumbnail",
|
||||
"thumbnail": {"src": "https://imgs.search.brave.com/only-thumb.jpg"},
|
||||
"properties": {},
|
||||
},
|
||||
{
|
||||
"title": "Only original",
|
||||
"thumbnail": {},
|
||||
"properties": {"url": "https://cdn.example.com/original.jpg"},
|
||||
},
|
||||
]
|
||||
|
||||
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.__enter__.return_value.get.return_value = _make_brave_images_response(results)
|
||||
|
||||
from deerflow.community.brave.tools import image_search_tool
|
||||
|
||||
result = image_search_tool.invoke({"query": "fallback"})
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["total_results"] == 2
|
||||
assert parsed["results"][0]["image_url"] == "https://imgs.search.brave.com/only-thumb.jpg"
|
||||
assert parsed["results"][0]["thumbnail_url"] == "https://imgs.search.brave.com/only-thumb.jpg"
|
||||
assert parsed["results"][1]["image_url"] == "https://cdn.example.com/original.jpg"
|
||||
assert parsed["results"][1]["thumbnail_url"] == "https://cdn.example.com/original.jpg"
|
||||
|
||||
def test_image_search_reports_thumbnail_dimensions_when_original_dropped(self, mock_config_with_key):
|
||||
"""When only the thumbnail URL survives, width/height must describe it, not the dropped original."""
|
||||
results = [
|
||||
{
|
||||
"title": "Unsafe original with dims",
|
||||
"url": "https://example.com/page",
|
||||
"thumbnail": {"src": "https://imgs.search.brave.com/thumb.jpg", "width": 300, "height": 200},
|
||||
"properties": {"url": "http://127.0.0.1/image.jpg", "width": 1920, "height": 1080},
|
||||
},
|
||||
]
|
||||
|
||||
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.__enter__.return_value.get.return_value = _make_brave_images_response(results)
|
||||
|
||||
from deerflow.community.brave.tools import image_search_tool
|
||||
|
||||
result = image_search_tool.invoke({"query": "dims"})
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["total_results"] == 1
|
||||
entry = parsed["results"][0]
|
||||
assert entry["image_url"] == ""
|
||||
assert entry["thumbnail_url"] == "https://imgs.search.brave.com/thumb.jpg"
|
||||
assert entry["width"] == 300
|
||||
assert entry["height"] == 200
|
||||
|
||||
def test_image_search_missing_api_key_returns_error_json(self, mock_config_no_key):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
from deerflow.community.brave.tools import image_search_tool
|
||||
|
||||
result = image_search_tool.invoke({"query": "test"})
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["error"] == "BRAVE_SEARCH_API_KEY is not configured"
|
||||
assert parsed["query"] == "test"
|
||||
|
||||
def test_image_search_missing_api_key_logs_warning_once_per_tool(self, mock_config_no_key, caplog):
|
||||
import logging
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
from deerflow.community.brave.tools import image_search_tool, web_search_tool
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="deerflow.community.brave.tools"):
|
||||
web_search_tool.invoke({"query": "q1"})
|
||||
image_search_tool.invoke({"query": "q2"})
|
||||
image_search_tool.invoke({"query": "q3"})
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert len(warnings) == 2
|
||||
assert any("web_search" in r.message for r in warnings)
|
||||
assert any("image_search" in r.message for r in warnings)
|
||||
|
||||
def test_image_search_http_error_returns_structured_error(self, mock_config_with_key):
|
||||
mock_error_response = MagicMock()
|
||||
mock_error_response.status_code = 403
|
||||
mock_error_response.text = "Forbidden"
|
||||
|
||||
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.__enter__.return_value.get.side_effect = httpx.HTTPStatusError("403", request=MagicMock(), response=mock_error_response)
|
||||
|
||||
from deerflow.community.brave.tools import image_search_tool
|
||||
|
||||
result = image_search_tool.invoke({"query": "test"})
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["error"] == "Brave Image Search API error: HTTP 403"
|
||||
assert parsed["query"] == "test"
|
||||
|
||||
def test_image_search_unexpected_results_format_returns_error(self, mock_config_with_key):
|
||||
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
||||
mock_client_cls.return_value.__enter__.return_value.get.return_value = _make_brave_images_response({"not": "a list"})
|
||||
|
||||
from deerflow.community.brave.tools import image_search_tool
|
||||
|
||||
result = image_search_tool.invoke({"query": "test"})
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["error"] == "Brave Image Search returned an unexpected response format"
|
||||
assert parsed["query"] == "test"
|
||||
|
||||
|
||||
def test_package_exports_image_search_tool():
|
||||
from deerflow.community.brave import image_search_tool
|
||||
from deerflow.community.brave.tools import image_search_tool as direct_image_search_tool
|
||||
|
||||
assert image_search_tool is direct_image_search_tool
|
||||
|
||||
@@ -422,6 +422,31 @@ class TestCheckImageSearch:
|
||||
assert result.status == "warn"
|
||||
assert "SERPER_API_KEY" in (result.fix or "")
|
||||
|
||||
def test_brave_image_search_with_key_ok(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.brave.tools:image_search_tool\n")
|
||||
result = doctor.check_image_search(cfg)
|
||||
assert result.status == "ok"
|
||||
assert "brave" in result.detail
|
||||
|
||||
def test_brave_image_search_without_key_warns(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.brave.tools:image_search_tool\n")
|
||||
result = doctor.check_image_search(cfg)
|
||||
assert result.status == "warn"
|
||||
assert "BRAVE_SEARCH_API_KEY" in (result.fix or "")
|
||||
|
||||
def test_brave_image_search_inline_api_key_warns(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\ntools:\n - name: image_search\n use: deerflow.community.brave.tools:image_search_tool\n api_key: inline-key\n")
|
||||
result = doctor.check_image_search(cfg)
|
||||
assert result.status == "warn"
|
||||
assert "literal api_key set in config" in result.detail
|
||||
assert "BRAVE_SEARCH_API_KEY" in (result.fix or "")
|
||||
|
||||
def test_infoquest_with_key_ok(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("INFOQUEST_API_KEY", "test-key")
|
||||
cfg = tmp_path / "config.yaml"
|
||||
|
||||
@@ -766,6 +766,20 @@ tools:
|
||||
# max_results: 5 # capped at 10 by the Serper provider
|
||||
# # api_key: $SERPER_API_KEY # Optional explicit env-var reference
|
||||
|
||||
# Image search tool (uses Brave Image Search API, requires BRAVE_SEARCH_API_KEY)
|
||||
# Brave provides independent image results and works alongside the Brave web search tool.
|
||||
# Note: set BRAVE_SEARCH_API_KEY in your environment before starting the app.
|
||||
# Avoid putting literal API keys in config.yaml; use the $VAR form instead.
|
||||
# - name: image_search
|
||||
# group: web
|
||||
# use: deerflow.community.brave.tools:image_search_tool
|
||||
# max_results: 5 # capped at 200 by Brave Image Search
|
||||
# # country: US
|
||||
# # search_lang: en
|
||||
# # safesearch: strict
|
||||
# # spellcheck: true
|
||||
# # api_key: $BRAVE_SEARCH_API_KEY # Optional explicit env-var reference
|
||||
|
||||
# File operations tools
|
||||
- name: ls
|
||||
group: file:read
|
||||
|
||||
@@ -265,7 +265,7 @@ set `allow_private_addresses: true` only when you intentionally target an intern
|
||||
|
||||
### Image search
|
||||
|
||||
<Tabs items={["DuckDuckGo (default)", "InfoQuest", "Serper"]}>
|
||||
<Tabs items={["DuckDuckGo (default)", "InfoQuest", "Serper", "Brave"]}>
|
||||
<Tabs.Tab>
|
||||
```yaml
|
||||
tools:
|
||||
@@ -289,6 +289,14 @@ tools:
|
||||
```
|
||||
Google Images results via Serper. Requires a [Serper](https://serper.dev) API key. Reuses `SERPER_API_KEY` with the Serper `web_search` tool.
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab>
|
||||
```yaml
|
||||
tools:
|
||||
- use: deerflow.community.brave.tools:image_search_tool
|
||||
api_key: $BRAVE_SEARCH_API_KEY
|
||||
```
|
||||
Independent image results via Brave Image Search. Requires a [Brave Search API](https://brave.com/search/api/) key. Reuses `BRAVE_SEARCH_API_KEY` with the Brave `web_search` tool.
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
|
||||
## Tool groups
|
||||
|
||||
@@ -243,7 +243,7 @@ JavaScript 页面。默认会拒绝解析到私网、回环或云元数据地址
|
||||
|
||||
### 图像搜索
|
||||
|
||||
<Tabs items={["DuckDuckGo(默认)", "InfoQuest", "Serper"]}>
|
||||
<Tabs items={["DuckDuckGo(默认)", "InfoQuest", "Serper", "Brave"]}>
|
||||
<Tabs.Tab>
|
||||
```yaml
|
||||
tools:
|
||||
@@ -267,6 +267,14 @@ tools:
|
||||
```
|
||||
通过 Serper 获取 Google 图片结果。需要 [Serper](https://serper.dev) API Key,与 Serper `web_search` 工具复用同一个 `SERPER_API_KEY`。
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab>
|
||||
```yaml
|
||||
tools:
|
||||
- use: deerflow.community.brave.tools:image_search_tool
|
||||
api_key: $BRAVE_SEARCH_API_KEY
|
||||
```
|
||||
通过 Brave Image Search 获取独立图片结果。需要 [Brave Search API](https://brave.com/search/api/) Key,与 Brave `web_search` 工具复用同一个 `BRAVE_SEARCH_API_KEY`。
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
|
||||
## 工具组
|
||||
|
||||
@@ -479,6 +479,7 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
|
||||
"fastcrw": "CRW_API_KEY",
|
||||
},
|
||||
"image_search": {
|
||||
"brave": "BRAVE_SEARCH_API_KEY",
|
||||
"infoquest": "INFOQUEST_API_KEY",
|
||||
"serper": "SERPER_API_KEY",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user