From ddb097a72f95b5e517f048b881bc6782f00c7985 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:00:49 +0800 Subject: [PATCH] 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 --- backend/docs/CONFIGURATION.md | 4 +- .../deerflow/community/brave/__init__.py | 4 +- .../harness/deerflow/community/brave/tools.py | 347 +++++++++++++++--- backend/tests/test_brave_tools.py | 336 ++++++++++++++++- backend/tests/test_doctor.py | 25 ++ config.example.yaml | 14 + frontend/src/content/en/harness/tools.mdx | 10 +- frontend/src/content/zh/harness/tools.mdx | 10 +- scripts/doctor.py | 1 + 9 files changed, 701 insertions(+), 50 deletions(-) diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 8302910dd..415e725ca 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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) diff --git a/backend/packages/harness/deerflow/community/brave/__init__.py b/backend/packages/harness/deerflow/community/brave/__init__.py index 876167859..9ec66554d 100644 --- a/backend/packages/harness/deerflow/community/brave/__init__.py +++ b/backend/packages/harness/deerflow/community/brave/__init__.py @@ -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"] diff --git a/backend/packages/harness/deerflow/community/brave/tools.py b/backend/packages/harness/deerflow/community/brave/tools.py index 48df4c03c..7852f4ba5 100644 --- a/backend/packages/harness/deerflow/community/brave/tools.py +++ b/backend/packages/harness/deerflow/community/brave/tools.py @@ -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) diff --git a/backend/tests/test_brave_tools.py b/backend/tests/test_brave_tools.py index c2c50d00f..27f3363fb 100644 --- a/backend/tests/test_brave_tools.py +++ b/backend/tests/test_brave_tools.py @@ -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 diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py index 4d2a3d0f9..e00e51e0f 100644 --- a/backend/tests/test_doctor.py +++ b/backend/tests/test_doctor.py @@ -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" diff --git a/config.example.yaml b/config.example.yaml index e4e45db6d..f6cf154c1 100644 --- a/config.example.yaml +++ b/config.example.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 diff --git a/frontend/src/content/en/harness/tools.mdx b/frontend/src/content/en/harness/tools.mdx index b08d61edb..5fc6dc1d8 100644 --- a/frontend/src/content/en/harness/tools.mdx +++ b/frontend/src/content/en/harness/tools.mdx @@ -265,7 +265,7 @@ set `allow_private_addresses: true` only when you intentionally target an intern ### Image search - + ```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. + +```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. + ## Tool groups diff --git a/frontend/src/content/zh/harness/tools.mdx b/frontend/src/content/zh/harness/tools.mdx index ed22d4a17..ba544febd 100644 --- a/frontend/src/content/zh/harness/tools.mdx +++ b/frontend/src/content/zh/harness/tools.mdx @@ -243,7 +243,7 @@ JavaScript 页面。默认会拒绝解析到私网、回环或云元数据地址 ### 图像搜索 - + ```yaml tools: @@ -267,6 +267,14 @@ tools: ``` 通过 Serper 获取 Google 图片结果。需要 [Serper](https://serper.dev) API Key,与 Serper `web_search` 工具复用同一个 `SERPER_API_KEY`。 + +```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`。 + ## 工具组 diff --git a/scripts/doctor.py b/scripts/doctor.py index 72ea0cb50..854e1e738 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -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", },