mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(community): migrate GroundRoute tools to async HTTP The GroundRoute provider sent its search and fetch requests 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 both tools coroutines over httpx.AsyncClient, resolve the API key and the configured max_results in a single config read, and keep that read off the event loop with asyncio.to_thread. * refactor(community): drop the dead GroundRoute API key wrapper The async migration resolved the key and max_results through _resolve_tool_settings, which left _get_api_key as a second seam around the same config read: only TestGetApiKey still exercised it. web_fetch now reads its key through the same resolver, and the tests point at _resolve_tool_settings. Also 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. --------- Co-authored-by: treasuresyq <309743290+treasuresyq@users.noreply.github.com>
180 lines
8.0 KiB
Python
180 lines
8.0 KiB
Python
"""GroundRoute web_search/web_fetch must contain malformed HTTP-success payloads."""
|
|
|
|
import json
|
|
import logging
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
_LOGGER_NAME = "deerflow.community.groundroute.tools"
|
|
_FORMAT_ERROR = "GroundRoute returned an unexpected response format"
|
|
_SEARCH_QUERY = "文档 search"
|
|
_FETCH_URL = "https://example.com/文档"
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_api_key_warned():
|
|
import deerflow.community.groundroute.tools as gr_mod
|
|
|
|
gr_mod._api_key_warned = set()
|
|
yield
|
|
gr_mod._api_key_warned = set()
|
|
|
|
|
|
def _invoker(monkeypatch, tool_name):
|
|
"""Patch the provider's httpx reference with a local MockTransport client factory."""
|
|
from deerflow.community.groundroute import tools
|
|
|
|
config = SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(model_extra={"api_key": "synthetic-test-key"}))
|
|
monkeypatch.setattr(tools, "get_app_config", lambda: config)
|
|
real_client = httpx.AsyncClient
|
|
|
|
async def invoke(payload, *, status_code=200):
|
|
requests = []
|
|
|
|
def respond(request):
|
|
requests.append(request)
|
|
return httpx.Response(status_code, content=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"})
|
|
|
|
transport = httpx.MockTransport(respond)
|
|
monkeypatch.setattr(
|
|
tools,
|
|
"httpx",
|
|
SimpleNamespace(AsyncClient=lambda **kwargs: real_client(transport=transport, **kwargs), HTTPStatusError=httpx.HTTPStatusError),
|
|
)
|
|
if tool_name == "web_search":
|
|
result = await tools.web_search_tool.ainvoke({"query": _SEARCH_QUERY})
|
|
else:
|
|
result = await tools.web_fetch_tool.ainvoke({"url": _FETCH_URL})
|
|
assert len(requests) == 1
|
|
return result
|
|
|
|
return invoke
|
|
|
|
|
|
@pytest.fixture
|
|
def invoke_search(monkeypatch):
|
|
return _invoker(monkeypatch, "web_search")
|
|
|
|
|
|
@pytest.fixture
|
|
def invoke_fetch(monkeypatch):
|
|
return _invoker(monkeypatch, "web_fetch")
|
|
|
|
|
|
async def _search(invoke_search, payload, *, status_code=200):
|
|
return json.loads(await invoke_search(payload, status_code=status_code))
|
|
|
|
|
|
def _error_logs(caplog):
|
|
return [record.getMessage() for record in caplog.records if record.name == _LOGGER_NAME and record.levelno == logging.ERROR]
|
|
|
|
|
|
def _assert_no_payload_values_leaked(caplog):
|
|
assert "文档 search" not in caplog.text
|
|
assert "example.com" not in caplog.text
|
|
assert "synthetic-test-key" not in caplog.text
|
|
assert "provider-private-payload" not in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [None, False, 0, "invalid", [], ["provider-private-payload"]])
|
|
async def test_malformed_top_level_payload_returns_format_error(invoke_search, payload, caplog):
|
|
assert await _search(invoke_search, payload) == {"error": _FORMAT_ERROR, "query": "文档 search"}
|
|
assert _error_logs(caplog) == [f"GroundRoute returned unexpected payload type: {type(payload).__name__}"]
|
|
_assert_no_payload_values_leaked(caplog)
|
|
|
|
|
|
@pytest.mark.parametrize("results", [False, 0, "", "provider-private-payload", {}, {"title": "provider-private-payload"}])
|
|
async def test_malformed_results_container_returns_format_error(invoke_search, results, caplog):
|
|
assert await _search(invoke_search, {"results": results}) == {"error": _FORMAT_ERROR, "query": "文档 search"}
|
|
assert _error_logs(caplog) == [f"GroundRoute returned unexpected 'results' payload type: {type(results).__name__}"]
|
|
_assert_no_payload_values_leaked(caplog)
|
|
|
|
|
|
@pytest.mark.parametrize("results", [[None], [False, 0, "provider-private-payload", []]])
|
|
async def test_all_malformed_entries_return_format_error(invoke_search, results, caplog):
|
|
assert await _search(invoke_search, {"results": results}) == {"error": _FORMAT_ERROR, "query": "文档 search"}
|
|
assert _error_logs(caplog) == ["GroundRoute returned 'results' with no usable result objects"]
|
|
_assert_no_payload_values_leaked(caplog)
|
|
|
|
|
|
async def test_mixed_entries_preserve_valid_results_in_order(invoke_search, caplog):
|
|
payload = {"results": [None, {"title": "文档", "url": "https://other.example/one", "snippet": "概述", "source_engine": "serper"}, "invalid", {}, [], {"title": "Second"}]}
|
|
assert await _search(invoke_search, payload) == [
|
|
{"title": "文档", "url": "https://other.example/one", "snippet": "概述", "source_engine": "serper"},
|
|
{"title": "", "url": "", "snippet": "", "source_engine": ""},
|
|
{"title": "Second", "url": "", "snippet": "", "source_engine": ""},
|
|
]
|
|
assert _error_logs(caplog) == []
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [{}, {"results": None}, {"request_id": "r1"}, {"results": []}])
|
|
async def test_missing_or_empty_results_keep_no_results_response(invoke_search, payload, caplog):
|
|
assert await _search(invoke_search, payload) == {"error": "No results found", "query": "文档 search"}
|
|
assert _error_logs(caplog) == []
|
|
|
|
|
|
async def test_valid_results_keep_existing_normalization(invoke_search, caplog):
|
|
payload = {"results": [{"title": "T", "url": "https://other.example", "snippet": "s", "source_engine": "exa"}, {}]}
|
|
assert await _search(invoke_search, payload) == [
|
|
{"title": "T", "url": "https://other.example", "snippet": "s", "source_engine": "exa"},
|
|
{"title": "", "url": "", "snippet": "", "source_engine": ""},
|
|
]
|
|
assert _error_logs(caplog) == []
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [None, False, [], "provider-private-payload", {"results": {"0": "provider-private-payload"}}, {"results": [None]}])
|
|
async def test_fetch_malformed_payload_returns_format_error(invoke_fetch, payload, caplog):
|
|
assert await invoke_fetch(payload) == "Error: GroundRoute returned an unexpected response format"
|
|
assert len(_error_logs(caplog)) == 1
|
|
_assert_no_payload_values_leaked(caplog)
|
|
|
|
|
|
async def test_fetch_missing_or_empty_results_keep_no_results_error(invoke_fetch, caplog):
|
|
assert await invoke_fetch({"results": []}) == "Error: No results found"
|
|
assert await invoke_fetch({}) == "Error: No results found"
|
|
assert _error_logs(caplog) == []
|
|
|
|
|
|
async def test_fetch_skips_malformed_entries_and_returns_the_first_usable_result(invoke_fetch):
|
|
assert await invoke_fetch({"results": [None, {"title": "Page", "content": "Body text", "url": "https://other.example"}]}) == "# Page\n\nBody text"
|
|
|
|
|
|
async def test_valid_fetch_keeps_existing_response(invoke_fetch, caplog):
|
|
assert await invoke_fetch({"results": [{"title": "Page", "content": "Body text"}]}) == "# Page\n\nBody text"
|
|
assert _error_logs(caplog) == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload", "expected"),
|
|
[
|
|
({"results": [{"title": "Page", "content": 12345}]}, "# Page\n\n12345"),
|
|
({"results": [{"title": "Page", "content": True}]}, "# Page\n\nTrue"),
|
|
({"results": [{"title": "Page", "content": {"a": 1}}]}, "# Page\n\n{'a': 1}"),
|
|
({"results": [{"title": "Page", "snippet": 6789}]}, "# Page\n\n6789"),
|
|
({"results": [{"title": None, "content": "Body text"}]}, "# \n\nBody text"),
|
|
],
|
|
)
|
|
async def test_fetch_coerces_non_string_fields(invoke_fetch, payload, expected, caplog):
|
|
assert await invoke_fetch(payload) == expected
|
|
assert _error_logs(caplog) == []
|
|
|
|
|
|
async def test_http_transport_patch_does_not_replace_global_client(invoke_search):
|
|
real_client = httpx.AsyncClient
|
|
await invoke_search({"results": []})
|
|
assert httpx.AsyncClient is real_client
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(204))) as client:
|
|
assert (await client.get("https://unrelated.invalid/probe")).status_code == 204
|
|
|
|
|
|
@pytest.mark.parametrize("status_code", [402, 503])
|
|
async def test_search_http_error_keeps_existing_structured_response(invoke_search, status_code):
|
|
assert await _search(invoke_search, {"error": "synthetic-denial"}, status_code=status_code) == {"error": f"GroundRoute API error: HTTP {status_code}", "query": "文档 search"}
|
|
|
|
|
|
async def test_fetch_http_error_keeps_existing_error_string(invoke_fetch):
|
|
assert await invoke_fetch({"error": "synthetic-denial"}, status_code=402) == "Error: GroundRoute API error: HTTP 402"
|