deer-flow/backend/tests/test_youcom_tools.py
treasuresyq 6caa5addf3
feat(community): migrate You.com tools to httpx.AsyncClient (#7186)
* 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>
2026-10-23 17:59:14 +08:00

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