deer-flow/backend/tests/test_groundroute_response_shapes.py
treasuresyq 8f9409bccc
feat(community): migrate GroundRoute tools to httpx.AsyncClient (#7183)
* 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>
2026-10-21 23:08:27 +08:00

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"