mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* feat(community): migrate You.com tools to async HTTP The You.com provider sent its search request through a sync httpx.Client. A sync LangChain tool invoked through ainvoke runs on the default executor, so the request held one of that pool's threads for the whole network round-trip, and several providers queried in parallel queued behind each other. Make the tool a coroutine over httpx.AsyncClient, resolve the API key and the configured max_results in a single config read, and move both disk reads off the event loop: the tool config, and the User-Agent, which reads the installed package metadata the first time it is needed. * refactor(community): drop the dead You.com API key wrapper The async migration resolved the key and max_results through _resolve_tool_settings, which left _get_api_key with no production caller: only TestGetApiKey still exercised it. Keeping a second seam around the same config read lets it drift from the real call path, so drop the wrapper and point the tests at _resolve_tool_settings. Also keep the hot path off the executor: _user_agent is cached, so only the first call reads the installed package metadata. Later calls are in-memory hits and now skip the to_thread round-trip. Finally, record which httpx client factory the provider reaches for in the runtime anchor. MockTransport runs its handler on the calling thread without real blocking I/O, so a provider that stayed async but sent the request through a sync client would satisfy the thread assertion; the recorded factory is what catches that partial regression. * test(community): align You.com URL safety stub with async tool The URL safety matrix still patched the removed _get_api_key seam and invoked the You.com tool synchronously. Mock the awaited search with AsyncMock and call the tool through ainvoke so URL screening remains covered after the async migration. --------- Co-authored-by: treasuresyq <309743290+treasuresyq@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
393 lines
16 KiB
Python
393 lines
16 KiB
Python
"""Unit tests for the You.com community web search tool."""
|
|
|
|
import json
|
|
import logging
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
KEYED_ENDPOINT = "https://api.you.com/v1/search"
|
|
KEYLESS_ENDPOINT = "https://api.you.com/v1/agents/search"
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_with_key():
|
|
with patch("deerflow.community.youcom.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "test-ydc-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.youcom.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_response(payload: object) -> MagicMock:
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = payload
|
|
mock_resp.raise_for_status = MagicMock()
|
|
return mock_resp
|
|
|
|
|
|
def _patch_get(mock_resp: MagicMock):
|
|
"""Patch httpx.AsyncClient so the context-managed .get returns mock_resp."""
|
|
patcher = patch("deerflow.community.youcom.tools.httpx.AsyncClient")
|
|
mock_client_cls = patcher.start()
|
|
mock_client_cls.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_resp)
|
|
return patcher, mock_client_cls
|
|
|
|
|
|
def _get_call(mock_client_cls):
|
|
return mock_client_cls.return_value.__aenter__.return_value.get.call_args
|
|
|
|
|
|
def _web(**overrides) -> dict:
|
|
result = {"url": "https://ex.com/a", "title": "A", "description": "d1", "snippets": ["s1", "s2"]}
|
|
result.update(overrides)
|
|
return result
|
|
|
|
|
|
class TestResolveToolSettings:
|
|
def test_returns_config_key_when_present(self):
|
|
with patch("deerflow.community.youcom.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.youcom.tools import _resolve_tool_settings
|
|
|
|
assert _resolve_tool_settings("web_search")[0] == "from-config"
|
|
|
|
def test_falls_back_to_env_when_config_key_blank(self):
|
|
with patch("deerflow.community.youcom.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", {"YDC_API_KEY": "env-key"}, clear=True):
|
|
from deerflow.community.youcom.tools import _resolve_tool_settings
|
|
|
|
assert _resolve_tool_settings("web_search")[0] == "env-key"
|
|
|
|
def test_returns_none_when_no_key_anywhere(self):
|
|
"""No key is not an error: it selects the keyless endpoint."""
|
|
with patch("deerflow.community.youcom.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.youcom.tools import _resolve_tool_settings
|
|
|
|
assert _resolve_tool_settings("web_search")[0] is None
|
|
|
|
|
|
class TestEndpointSelection:
|
|
async def test_configured_key_uses_keyed_endpoint_with_header(self, mock_config_with_key):
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test"})
|
|
call = _get_call(mock_client_cls)
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert call.args[0] == KEYED_ENDPOINT
|
|
assert call.kwargs["headers"]["X-API-Key"] == "test-ydc-key"
|
|
assert call.kwargs["params"] == {"query": "test", "count": 5}
|
|
|
|
async def test_no_key_uses_keyless_endpoint_without_auth_header(self, mock_config_no_key):
|
|
"""The keyless endpoint rejects an auth header, so none may be sent."""
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test"})
|
|
call = _get_call(mock_client_cls)
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert call.args[0] == KEYLESS_ENDPOINT
|
|
assert "X-API-Key" not in call.kwargs["headers"]
|
|
|
|
async def test_env_key_uses_keyed_endpoint(self, mock_config_no_key):
|
|
with patch.dict("os.environ", {"YDC_API_KEY": "env-key"}, clear=True):
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test"})
|
|
call = _get_call(mock_client_cls)
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert call.args[0] == KEYED_ENDPOINT
|
|
assert call.kwargs["headers"]["X-API-Key"] == "env-key"
|
|
|
|
@pytest.mark.parametrize("with_key", [True, False])
|
|
async def test_identifies_deerflow_in_user_agent(self, with_key, mock_config_no_key):
|
|
"""Both endpoints identify DeerFlow so You.com can attribute the traffic."""
|
|
env = {"YDC_API_KEY": "k"} if with_key else {}
|
|
with patch.dict("os.environ", env, clear=True):
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test"})
|
|
call = _get_call(mock_client_cls)
|
|
finally:
|
|
patcher.stop()
|
|
|
|
user_agent = call.kwargs["headers"]["User-Agent"]
|
|
assert user_agent.startswith("deerflow-harness/")
|
|
assert "youdotcom-integration/bytedance-deer-flow" in user_agent
|
|
|
|
|
|
class TestWebSearchTool:
|
|
async def test_returns_normalized_list_with_joined_snippets(self, mock_config_with_key):
|
|
payload = {
|
|
"results": {
|
|
"web": [
|
|
_web(url="https://ex.com/a", title="A", snippets=["s1", "s2"]),
|
|
_web(url="https://ex.com/b", title="B", snippets=["s3"]),
|
|
]
|
|
}
|
|
}
|
|
patcher, _ = _patch_get(_make_response(payload))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "vector databases"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert parsed == [
|
|
{"title": "A", "url": "https://ex.com/a", "snippet": "s1\ns2"},
|
|
{"title": "B", "url": "https://ex.com/b", "snippet": "s3"},
|
|
]
|
|
|
|
async def test_falls_back_to_description_when_no_snippets(self, mock_config_with_key):
|
|
"""News hits carry only a description; web hits with empty snippets do too."""
|
|
payload = {
|
|
"results": {
|
|
"web": [_web(snippets=[], description="web-desc")],
|
|
"news": [{"url": "https://n.com", "title": "N", "description": "news-desc"}],
|
|
}
|
|
}
|
|
patcher, _ = _patch_get(_make_response(payload))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "news"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert [r["snippet"] for r in parsed] == ["web-desc", "news-desc"]
|
|
|
|
async def test_web_and_news_merged_and_trimmed_to_count(self, mock_config_with_key):
|
|
"""`count` applies per section, so the merged list can overshoot it."""
|
|
payload = {
|
|
"results": {
|
|
"web": [_web(url=f"https://w{i}.com", title=f"W{i}") for i in range(5)],
|
|
"news": [{"url": f"https://n{i}.com", "title": f"N{i}", "description": "d"} for i in range(5)],
|
|
}
|
|
}
|
|
patcher, _ = _patch_get(_make_response(payload))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert len(parsed) == 5
|
|
assert [r["title"] for r in parsed] == ["W0", "W1", "W2", "W3", "W4"]
|
|
|
|
async def test_agent_max_results_is_honored_over_config(self, mock_config_with_key):
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test", "max_results": 20})
|
|
params = _get_call(mock_client_cls).kwargs["params"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert params["count"] == 20
|
|
|
|
async def test_config_max_results_used_when_caller_omits(self, mock_config_with_key):
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test"})
|
|
params = _get_call(mock_client_cls).kwargs["params"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert params["count"] == 5
|
|
|
|
@pytest.mark.parametrize(("max_results", "expected"), [(0, 5), (-2, 5), (1, 1), (100, 100), ("500", 100)])
|
|
async def test_max_results_clamped_to_provider_bounds(self, max_results, expected):
|
|
with patch("deerflow.community.youcom.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "k", "max_results": max_results}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test"})
|
|
params = _get_call(mock_client_cls).kwargs["params"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert params["count"] == expected
|
|
|
|
async def test_invalid_max_results_falls_back_to_default_with_warning(self, caplog):
|
|
with patch("deerflow.community.youcom.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "k", "max_results": "not-a-number"}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.community.youcom.tools"):
|
|
await web_search_tool.ainvoke({"query": "test"})
|
|
params = _get_call(mock_client_cls).kwargs["params"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert params["count"] == 5
|
|
assert any("Invalid You.com max_results" in record.message for record in caplog.records)
|
|
|
|
async def test_empty_results_returns_error_json(self, mock_config_with_key):
|
|
patcher, _ = _patch_get(_make_response({"results": {"web": []}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "no results"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert parsed["error"] == "No results found"
|
|
assert parsed["query"] == "no results"
|
|
|
|
async def test_missing_results_section_returns_error_json(self, mock_config_with_key):
|
|
patcher, _ = _patch_get(_make_response({"metadata": {"latency": 0.1}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert parsed["error"] == "No results found"
|
|
|
|
async def test_non_dict_payload_returns_format_error(self, mock_config_with_key):
|
|
patcher, _ = _patch_get(_make_response(["not", "a", "dict"]))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert "unexpected response format" in parsed["error"]
|
|
|
|
async def test_partial_fields_default_to_empty_string(self, mock_config_with_key):
|
|
patcher, _ = _patch_get(_make_response({"results": {"web": [{}]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert parsed[0] == {"title": "", "url": "", "snippet": ""}
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "hint"),
|
|
[
|
|
(401, "invalid or expired YDC_API_KEY"),
|
|
(402, "You.com credit balance depleted"),
|
|
(429, "rate limit reached — set YDC_API_KEY for higher limits"),
|
|
(500, None),
|
|
],
|
|
)
|
|
async def test_http_error_returns_structured_error(self, status, hint, mock_config_with_key):
|
|
mock_resp = MagicMock()
|
|
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
str(status),
|
|
request=MagicMock(),
|
|
response=MagicMock(status_code=status, text="boom"),
|
|
)
|
|
patcher, _ = _patch_get(mock_resp)
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert str(status) in parsed["error"]
|
|
if hint:
|
|
assert hint in parsed["error"]
|
|
|
|
async def test_network_exception_returns_error_json(self, mock_config_with_key):
|
|
patcher, mock_client_cls = _patch_get(MagicMock())
|
|
mock_client_cls.return_value.__aenter__.return_value.get.side_effect = Exception("timeout")
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
parsed = json.loads(await web_search_tool.ainvoke({"query": "test"}))
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert parsed["error"] == "timeout"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
[float("inf"), True, 3.5],
|
|
ids=["yaml-inf", "bool", "non-integral-float"],
|
|
)
|
|
async def test_invalid_max_results_shapes_fall_back_to_default(raw):
|
|
"""Invalid max_results shapes fall back to the default, never crash or truncate.
|
|
|
|
These pin the shared-owner bar for this provider: a YAML ``.inf`` makes
|
|
``int()`` raise ``OverflowError``, a YAML ``true`` is a bool (never 1),
|
|
and ``3.5`` must not silently truncate to 3. The shared
|
|
``coerce_max_results`` owns the rejection profile; this provider only
|
|
adds the 1-100 cap on top.
|
|
"""
|
|
with patch("deerflow.community.youcom.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "***", "max_results": raw}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
patcher, mock_client_cls = _patch_get(_make_response({"results": {"web": [_web()]}}))
|
|
try:
|
|
from deerflow.community.youcom.tools import web_search_tool
|
|
|
|
await web_search_tool.ainvoke({"query": "test"})
|
|
params = _get_call(mock_client_cls).kwargs["params"]
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert params["count"] == 5
|
|
|
|
|
|
def test_package_exports_web_search_tool():
|
|
from deerflow.community.youcom import web_search_tool
|
|
from deerflow.community.youcom.tools import web_search_tool as direct_web_search_tool
|
|
|
|
assert web_search_tool is direct_web_search_tool
|