Ryker_Feng ddb097a72f
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>
2026-07-02 11:00:49 +08:00

383 lines
13 KiB
Python

"""
Web and image search tools powered by the Brave Search API.
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,
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
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
_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_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(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.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,
max_allowed: int = _BRAVE_WEB_MAX_COUNT,
) -> int:
try:
coerced = int(value)
except (TypeError, ValueError):
logger.warning(
"Invalid Brave Search max_results=%r; using default %s",
value,
default,
)
coerced = default
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)
def web_search_tool(query: str, max_results: int = 5) -> str:
"""Search the web for information using Brave Search.
Args:
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.
"""
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, max_allowed=_BRAVE_WEB_MAX_COUNT)
query = _clean_query(query)
api_key = _get_api_key("web_search")
if not api_key:
return _missing_key_error(query, "web_search")
params = {"q": query, "count": count, "text_decorations": 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:
return json.dumps({"error": "No results found", "query": query}, ensure_ascii=False)
normalized_results = [
{
"title": r.get("title", ""),
"url": r.get("url", ""),
"content": r.get("description", ""),
}
for r in web_results
]
output = {
"query": query,
"total_results": len(normalized_results),
"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)