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:
Ryker_Feng
2026-07-02 11:00:49 +08:00
committed by GitHub
co-authored by Willem Jiang
parent 3748344303
commit ddb097a72f
9 changed files with 701 additions and 50 deletions
+2 -2
View File
@@ -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)
+334 -2
View File
@@ -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
+25
View File
@@ -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"
+14
View File
@@ -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
+9 -1
View File
@@ -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
+9 -1
View File
@@ -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>
## 工具组
+1
View File
@@ -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",
},