mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(tools): make the search coercion guards catch OverflowError
The community search tools coerce max_results (and Sofya's content
limit) from YAML config or tool parameters through guards that catch
only TypeError and ValueError. `int(float('inf'))` raises OverflowError
instead, so a YAML `.inf` (or a JSON `Infinity`) value still crashes
the coercion - SearXNG's search even fails wholesale with
'cannot convert float infinity to integer'.
Catch OverflowError alongside them so an infinite value falls back to
the default like every other invalid input. NaN is already covered by
ValueError.
Signed-off-by: yetuge <2219677952@qq.com>
* test(tools): move the GroundRoute inf test to module scope
The regression test was appended inside TestWebSearchTool, ending the
class early and turning its last two methods into uncollected nested
functions. Move it after TestWebFetchTool so pytest collects all 20
tests again.
Signed-off-by: yetuge <2219677952@qq.com>
---------
Signed-off-by: yetuge <2219677952@qq.com>
741 lines
31 KiB
Python
741 lines
31 KiB
Python
"""Unit tests for the Brave Search community web search tool."""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
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 = set()
|
|
yield
|
|
brave_mod._api_key_warned = set()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_with_key():
|
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "test-brave-key", "max_results": 5}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_no_key():
|
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
yield mock
|
|
|
|
|
|
def _make_brave_response(results: list) -> MagicMock:
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"web": {"results": results}}
|
|
mock_resp.raise_for_status = 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."""
|
|
|
|
def _get(url, **kwargs):
|
|
count = kwargs["params"]["count"]
|
|
return _make_brave_response(results[:count])
|
|
|
|
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:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "from-config"}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
|
|
from deerflow.community.brave.tools import _get_api_key
|
|
|
|
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()
|
|
tool_config.model_extra = {"api_key": " "}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True):
|
|
from deerflow.community.brave.tools import _get_api_key
|
|
|
|
assert _get_api_key() == "env-key"
|
|
|
|
def test_falls_back_to_env_when_no_config(self):
|
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
|
mock.return_value.get_tool_config.return_value = None
|
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-only"}, clear=True):
|
|
from deerflow.community.brave.tools import _get_api_key
|
|
|
|
assert _get_api_key() == "env-only"
|
|
|
|
def test_ignores_legacy_brave_api_key(self):
|
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
|
mock.return_value.get_tool_config.return_value = None
|
|
with patch.dict("os.environ", {"BRAVE_API_KEY": "legacy"}, clear=True):
|
|
from deerflow.community.brave.tools import _get_api_key
|
|
|
|
assert _get_api_key() is None
|
|
|
|
def test_returns_none_when_no_key_anywhere(self):
|
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
|
mock.return_value.get_tool_config.return_value = None
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.brave.tools import _get_api_key
|
|
|
|
assert _get_api_key() is None
|
|
|
|
def test_model_extra_none_does_not_crash(self):
|
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = None
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True):
|
|
from deerflow.community.brave.tools import _get_api_key
|
|
|
|
assert _get_api_key() == "env-key"
|
|
|
|
|
|
class TestWebSearchTool:
|
|
def test_basic_search_returns_normalized_results(self, mock_config_with_key):
|
|
results = [
|
|
{"title": "Result 1", "url": "https://example.com/1", "description": "Desc 1"},
|
|
{"title": "Result 2", "url": "https://example.com/2", "description": "Desc 2"},
|
|
]
|
|
mock_resp = _make_brave_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 web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "python tutorial"})
|
|
parsed = json.loads(result)
|
|
|
|
assert parsed["query"] == "python tutorial"
|
|
assert parsed["total_results"] == 2
|
|
assert parsed["results"][0]["title"] == "Result 1"
|
|
assert parsed["results"][0]["url"] == "https://example.com/1"
|
|
assert parsed["results"][0]["content"] == "Desc 1"
|
|
|
|
def test_respects_max_results_from_config(self, mock_config_with_key):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"max_results": 3,
|
|
}
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)]
|
|
|
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results)
|
|
|
|
from deerflow.community.brave.tools import web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test"})
|
|
parsed = json.loads(result)
|
|
|
|
assert parsed["total_results"] == 3
|
|
assert len(parsed["results"]) == 3
|
|
|
|
def test_max_results_parameter_accepted(self, mock_config_no_key):
|
|
"""Tool accepts max_results as a call parameter when config does not override it."""
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)]
|
|
|
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-key"}, clear=True):
|
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results)
|
|
|
|
from deerflow.community.brave.tools import web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test", "max_results": 2})
|
|
parsed = json.loads(result)
|
|
|
|
assert parsed["total_results"] == 2
|
|
|
|
def test_config_max_results_overrides_parameter(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": 3}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)]
|
|
|
|
with patch("deerflow.community.brave.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.get.side_effect = _count_aware_get(results)
|
|
|
|
from deerflow.community.brave.tools import web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test", "max_results": 8})
|
|
parsed = json.loads(result)
|
|
|
|
assert parsed["total_results"] == 3
|
|
|
|
def test_max_results_string_from_env_is_coerced_and_clamped(self):
|
|
"""Env-sourced max_results is a string and must be coerced and clamped to 20."""
|
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "test-key", "max_results": "50"}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(30)]
|
|
|
|
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 = _count_aware_get(results)
|
|
|
|
from deerflow.community.brave.tools import web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test"})
|
|
parsed = json.loads(result)
|
|
params = mock_get.call_args.kwargs["params"]
|
|
|
|
assert params["count"] == 20
|
|
assert parsed["total_results"] == 20
|
|
|
|
def test_invalid_max_results_falls_back_to_default(self, caplog):
|
|
with patch("deerflow.community.brave.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "test-key", "max_results": "abc"}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "description": f"D{i}"} for i in range(10)]
|
|
|
|
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 = _count_aware_get(results)
|
|
|
|
from deerflow.community.brave.tools import web_search_tool
|
|
|
|
with caplog.at_level("WARNING", logger="deerflow.community.brave.tools"):
|
|
result = web_search_tool.invoke({"query": "test"})
|
|
parsed = json.loads(result)
|
|
params = mock_get.call_args.kwargs["params"]
|
|
|
|
assert params["count"] == 5
|
|
assert parsed["total_results"] == 5
|
|
assert any("Invalid Brave Search max_results" in record.message for record in caplog.records)
|
|
|
|
def test_empty_results_returns_error_json(self, mock_config_with_key):
|
|
mock_resp = _make_brave_response([])
|
|
|
|
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 web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "no results"})
|
|
parsed = json.loads(result)
|
|
|
|
assert parsed["error"] == "No results found"
|
|
assert parsed["query"] == "no results"
|
|
|
|
def test_missing_web_key_returns_error_json(self, mock_config_with_key):
|
|
"""A response without a `web` block should be treated as no results."""
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
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 web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test"})
|
|
parsed = json.loads(result)
|
|
|
|
assert parsed["error"] == "No results found"
|
|
|
|
def test_missing_api_key_returns_error_json(self, mock_config_no_key):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.brave.tools import web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test"})
|
|
parsed = json.loads(result)
|
|
|
|
assert "error" in parsed
|
|
assert "BRAVE_SEARCH_API_KEY" in parsed["error"]
|
|
|
|
def test_missing_api_key_logs_warning_once(self, mock_config_no_key, caplog):
|
|
import logging
|
|
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.brave.tools import web_search_tool
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.brave.tools"):
|
|
web_search_tool.invoke({"query": "q1"})
|
|
web_search_tool.invoke({"query": "q2"})
|
|
|
|
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
assert len(warnings) == 1
|
|
|
|
def test_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 web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test"})
|
|
parsed = json.loads(result)
|
|
|
|
assert "error" in parsed
|
|
assert "403" in parsed["error"]
|
|
|
|
def test_network_exception_returns_error_json(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.side_effect = Exception("timeout")
|
|
|
|
from deerflow.community.brave.tools import web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test"})
|
|
parsed = json.loads(result)
|
|
|
|
assert "error" in parsed
|
|
|
|
def test_sends_correct_headers_and_params(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
|
|
|
|
web_search_tool.invoke({"query": "hello world"})
|
|
|
|
call_kwargs = mock_get.call_args
|
|
headers = call_kwargs.kwargs["headers"]
|
|
params = call_kwargs.kwargs["params"]
|
|
|
|
assert headers["X-Subscription-Token"] == "test-brave-key"
|
|
assert params["q"] == "hello world"
|
|
assert params["count"] == 5
|
|
|
|
@pytest.mark.parametrize(
|
|
("time_range", "expected_freshness"),
|
|
[
|
|
("day", "pd"),
|
|
("week", "pw"),
|
|
("month", "pm"),
|
|
("year", "py"),
|
|
],
|
|
)
|
|
def test_maps_time_range_to_freshness(self, mock_config_with_key, time_range: str, expected_freshness: str):
|
|
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
|
|
|
|
web_search_tool.invoke({"query": "latest releases", "time_range": time_range})
|
|
|
|
params = mock_get.call_args.kwargs["params"]
|
|
|
|
assert params["freshness"] == expected_freshness
|
|
|
|
def test_omits_freshness_without_time_range(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
|
|
|
|
web_search_tool.invoke({"query": "stable documentation"})
|
|
|
|
params = mock_get.call_args.kwargs["params"]
|
|
|
|
assert "freshness" not in params
|
|
|
|
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
|
|
with patch.dict("os.environ", {"BRAVE_SEARCH_API_KEY": "env-only-key"}, clear=True):
|
|
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
|
|
|
|
web_search_tool.invoke({"query": "env key test"})
|
|
headers = mock_get.call_args.kwargs["headers"]
|
|
|
|
assert headers["X-Subscription-Token"] == "env-only-key"
|
|
|
|
def test_partial_fields_in_result(self, mock_config_with_key):
|
|
"""Missing title/url/description should default to empty string."""
|
|
results = [{}]
|
|
mock_resp = _make_brave_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 web_search_tool
|
|
|
|
result = web_search_tool.invoke({"query": "test"})
|
|
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
|
|
|
|
|
|
def test_coerce_max_results_inf_falls_back_to_default():
|
|
"""A YAML `.inf` max_results must fall back to the default, not crash."""
|
|
import deerflow.community.brave.tools as brave_mod
|
|
|
|
assert brave_mod._coerce_max_results(float("inf")) == brave_mod._DEFAULT_MAX_RESULTS
|