mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 16:37:55 +00:00
* 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>
383 lines
13 KiB
Python
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)
|