mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 07:41:20 +00:00
* fix(web-fetch): stop sending .env.example placeholder keys to providers make config and make setup copy .env.example into .env, and the template set nine search/fetch provider keys to example values such as JINA_API_KEY=your-jina-api-key. The default keyless Jina Reader sent that text as a Bearer key, so every web_fetch on a fresh install failed with 401 while make doctor reported web fetch as configured. - .env.example comments those nine lines out, like every other credential in the template already is. - provider_credentials.resolve_api_key reads a your-...-key/-token/-secret value as unset, so existing .env files that still hold the placeholders recover without editing, for every provider that resolves keys there. * chore: leave the changelog entry to merge time Per CONTRIBUTING.md (#8202), maintainers add changelog entries when they merge a pull request. * fix(web-search): route every provider key and doctor through one placeholder rule Review follow-up for #8206: - provider_credentials exposes is_template_placeholder(), the single definition of an .env.example example value. key/token/secret must be a whole hyphen-separated word, so a real value such as your-monkey or your-passkey is no longer dropped. - Serper, Serply, Sofya, SpecStream, Unbrowse, Webz, Brave, Tencent WSA and You.com resolve config and environment keys through resolve_api_key instead of private strip-and-send copies, so an old .env that still holds the template value no longer sends it. - make doctor asks the same predicate: a placeholder key is reported as still holding the .env.example value instead of "set", and a literal placeholder in config.yaml falls back to the environment key as the runtime does. - A census test keeps credential environment reads under community/ wrapped in resolve_api_key. * fix(web-search): match shipped placeholders exactly and mirror Browserless precedence in doctor Second review follow-up for #8206: - is_template_placeholder() matches exactly the your-... values that .env.example ships instead of a pattern, so a token an operator chose (a self-hosted Browserless TOKEN such as your-review-team-token) is sent again. A test keeps the set equal to the template. - make doctor mirrors Browserless: a configured token field is authoritative, blank or not, and BROWSERLESS_TOKEN is read only when the field is omitted. A blank or placeholder token now warns instead of reporting the variable, cross-checked against the client. - The keyless Jina and You.com providers stay ok but name JINA_API_KEY or YDC_API_KEY when a placeholder there is ignored, and a literal placeholder api_key in config.yaml is named when no env key backs it. * fix(doctor): match each web provider's key precedence; drop the dead Jina api_key from docs Found while re-checking #8206 against every provider's runtime: - Browserless used as web_fetch had no doctor key check; it now gets the same authoritative token handling as web_capture. - InfoQuest reads only INFOQUEST_API_KEY, so doctor no longer counts an api_key field in its tool config as configured. - GroundRoute needs a key for every call but had no doctor entry, so a missing key reported ok. - You.com names a placeholder api_key in config that it ignores. - The Jina web_fetch docs snippet carried api_key: $JINA_API_KEY, which the tool never reads. With JINA_API_KEY now commented out in .env.example, that line made config loading fail on a fresh install. - The provider_credentials docstring now scopes doctor's use of the predicate to web provider keys, and the scripts guide no longer says .env.example ships live placeholder keys.
278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""Offline Webz request/response contract using the real httpx serializer."""
|
|
|
|
import asyncio
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
import yaml
|
|
from httpx import AsyncClient as RealAsyncClient
|
|
|
|
|
|
@pytest.fixture
|
|
def webz(monkeypatch):
|
|
import deerflow.community.webz.tools as module
|
|
|
|
options = {"api_key": "synthetic-key"}
|
|
monkeypatch.setattr(module, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(model_extra=options)))
|
|
monkeypatch.delenv("WEBZ_API_KEY", raising=False)
|
|
requests = []
|
|
response = {"results": []}
|
|
status = [200]
|
|
client_class = httpx.AsyncClient
|
|
|
|
async def handler(request):
|
|
requests.append(request)
|
|
return httpx.Response(status[0], json=response)
|
|
|
|
monkeypatch.setattr(module.httpx, "AsyncClient", lambda **kwargs: client_class(transport=httpx.MockTransport(handler), **kwargs))
|
|
return module, options, requests, response, status
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_filters_and_normalized_results(webz, monkeypatch):
|
|
module, _, requests, response, _ = webz
|
|
monkeypatch.setenv("WEBZ_API_KEY", "unused-environment-key")
|
|
response["results"] = [
|
|
{
|
|
"score": 8.5,
|
|
"article": {"title": "News", "url": "https://example.com/news", "published_at": "2026-09-20T12:00:00Z", "summary": "Summary"},
|
|
"chunk": {"text": "Matching passage"},
|
|
"metadata": {"domain": "example.com", "language": "english", "country": "US"},
|
|
}
|
|
]
|
|
result = json.loads(
|
|
await module.web_search_tool.ainvoke(
|
|
{
|
|
"query": "renewable energy",
|
|
"max_results": 3,
|
|
"published_from": "2026-09-01",
|
|
"published_to": "2026-09-20",
|
|
"language": ["english"],
|
|
"country": ["US"],
|
|
"source": ["example.com"],
|
|
"sentiment": ["positive"],
|
|
"category": ["Environment"],
|
|
}
|
|
)
|
|
)
|
|
request = requests[0]
|
|
assert str(request.url) == "https://api.webz.io/api/news/context"
|
|
assert request.headers["authorization"] == "Bearer synthetic-key"
|
|
assert json.loads(request.content) == {
|
|
"query": "renewable energy",
|
|
"k": 3,
|
|
"filters": {"published_from": "2026-09-01", "published_to": "2026-09-20", "language": ["english"], "country": ["US"], "domain": ["example.com"], "sentiment": ["positive"], "category": ["Environment"]},
|
|
}
|
|
assert result == {
|
|
"query": "renewable energy",
|
|
"returned_results": 1,
|
|
"results": [{"title": "News", "url": "https://example.com/news", "content": "Matching passage", "published_at": "2026-09-20T12:00:00Z", "source": {"domain": "example.com", "language": "english", "country": "US"}}],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(("window", "start"), [("day", "2026-09-23"), ("week", "2026-09-17"), ("month", "2026-08-25"), ("year", "2025-09-24")])
|
|
async def test_relative_window_uses_utc_and_explicit_lower_bound_wins(webz, monkeypatch, window, start):
|
|
module, _, requests, _, _ = webz
|
|
|
|
class Clock(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return cls(2026, 9, 24, 12, tzinfo=UTC)
|
|
|
|
monkeypatch.setattr(module, "datetime", Clock)
|
|
await module.web_search_tool.ainvoke({"query": "news", "time_range": window})
|
|
assert json.loads(requests[-1].content)["filters"] == {"published_from": start}
|
|
await module.web_search_tool.ainvoke({"query": "news", "time_range": window, "published_from": "2026-09-22"})
|
|
assert json.loads(requests[-1].content)["filters"] == {"published_from": "2026-09-22"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_environment_key_configured_count_and_no_implicit_filters(webz, monkeypatch):
|
|
module, options, requests, _, _ = webz
|
|
options.update(api_key=" ", max_results="1000")
|
|
monkeypatch.setenv("WEBZ_API_KEY", " env-key ")
|
|
assert json.loads(await module.web_search_tool.ainvoke({"query": " news "})) == {"query": "news", "returned_results": 0, "results": []}
|
|
assert requests[0].headers["authorization"] == "Bearer env-key"
|
|
assert json.loads(requests[0].content) == {"query": "news", "k": 100}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_key_never_makes_request(webz):
|
|
module, options, requests, _, _ = webz
|
|
options.clear()
|
|
assert "WEBZ_API_KEY" in json.loads(await module.web_search_tool.ainvoke({"query": "news"}))["error"]
|
|
assert not requests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_env_template_placeholder_is_not_sent_as_a_key(webz, monkeypatch):
|
|
"""An ``.env`` made from the old template still holds ``WEBZ_API_KEY=your-webz-api-key``."""
|
|
module, options, requests, _, _ = webz
|
|
options.update(api_key="your-webz-api-key")
|
|
monkeypatch.setenv("WEBZ_API_KEY", "your-webz-api-key")
|
|
assert "WEBZ_API_KEY" in json.loads(await module.web_search_tool.ainvoke({"query": "news"}))["error"]
|
|
assert not requests
|
|
|
|
monkeypatch.setenv("WEBZ_API_KEY", "env-key")
|
|
await module.web_search_tool.ainvoke({"query": "news"})
|
|
assert requests[0].headers["authorization"] == "Bearer env-key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("arguments", [{"query": " "}, {"query": "x" * 751}, {"query": "word " * 101}, {"query": "news", "published_from": "yesterday"}, {"query": "news", "published_from": "2026-09-20", "published_to": "2026-09-01"}])
|
|
async def test_invalid_input_never_makes_request(webz, arguments):
|
|
module, _, requests, _, _ = webz
|
|
assert "error" in json.loads(await module.web_search_tool.ainvoke(arguments))
|
|
assert not requests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status_code", [302, 401, 429, 500])
|
|
async def test_http_errors_do_not_echo_provider_body_or_credentials(webz, status_code, caplog):
|
|
module, _, requests, response, status = webz
|
|
response.update(error="synthetic-key")
|
|
status[0] = status_code
|
|
result = await module.web_search_tool.ainvoke({"query": "news"})
|
|
assert str(status_code) in json.loads(result)["error"]
|
|
assert "synthetic-key" not in result + caplog.text
|
|
assert len(requests) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("payload", [{}, {"results": None}, {"results": [None]}, {"results": [{"article": []}]}])
|
|
async def test_malformed_payload_is_an_error(webz, payload, caplog):
|
|
module, _, _, response, _ = webz
|
|
response.clear()
|
|
response.update(payload)
|
|
assert "error" in json.loads(await module.web_search_tool.ainvoke({"query": "news"}))
|
|
assert "Webz returned an unexpected response" in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancellation_propagates(webz, monkeypatch):
|
|
module, _, _, _, _ = webz
|
|
|
|
async def cancelled(*args, **kwargs):
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(RealAsyncClient, "post", cancelled)
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await module.web_search_tool.ainvoke({"query": "news"})
|
|
|
|
|
|
def test_setup_wizard_offers_webz_with_env_key():
|
|
from wizard.providers import SEARCH_PROVIDERS
|
|
from wizard.writer import build_minimal_config
|
|
|
|
provider = next(p for p in SEARCH_PROVIDERS if p.name == "webz")
|
|
assert provider.env_var == "WEBZ_API_KEY"
|
|
config = build_minimal_config(provider_use="langchain_openai:ChatOpenAI", model_name="dummy", display_name="Dummy", api_key_field="api_key", env_var="OPENAI_API_KEY", search_use=provider.use, search_extra_config=provider.extra_config)
|
|
assert next(t for t in yaml.safe_load(config)["tools"] if t["name"] == "web_search")["use"] == "deerflow.community.webz.tools:web_search_tool"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("exception", [httpx.ReadTimeout("synthetic-key"), httpx.ConnectError("synthetic-key")])
|
|
async def test_transport_failures_are_sanitized(webz, monkeypatch, exception):
|
|
module, _, _, _, _ = webz
|
|
|
|
async def fail(*args, **kwargs):
|
|
raise exception
|
|
|
|
monkeypatch.setattr(RealAsyncClient, "post", fail)
|
|
result = await module.web_search_tool.ainvoke({"query": "news"})
|
|
assert "error" in json.loads(result)
|
|
assert "synthetic-key" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_summary_fallback_and_output_limit(webz):
|
|
module, _, _, response, _ = webz
|
|
response["results"] = [{"article": {"title": "News", "url": "https://example.com", "summary": "Summary"}}] * 3
|
|
response["total_results"] = 3
|
|
result = json.loads(await module.web_search_tool.ainvoke({"query": "news", "max_results": 1}))
|
|
assert result["returned_results"] == len(result["results"]) == 1
|
|
assert "total_results" not in result
|
|
assert result["results"][0]["content"] == "Summary"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("value, expected", [(None, 5), (True, 5), (False, 5), ("invalid", 5), (3.5, 5), (-3.5, 5), (float("inf"), 5), (float("nan"), 5), (3.0, 3), (-1, 1), ("12", 12), (200, 100)])
|
|
async def test_configured_result_count(webz, value, expected):
|
|
module, options, requests, _, _ = webz
|
|
options["max_results"] = value
|
|
await module.web_search_tool.ainvoke({"query": "news"})
|
|
assert json.loads(requests[0].content)["k"] == expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("configured, arguments, expected", [(5, {"max_results": 20}, 20), (20, {"max_results": 5}, 5), ("invalid", {"max_results": 3}, 3), (20, {}, 20), (20, {"max_results": None}, 20)])
|
|
async def test_call_count_overrides_configuration_unless_omitted(webz, configured, arguments, expected):
|
|
module, options, requests, _, _ = webz
|
|
options["max_results"] = configured
|
|
await module.web_search_tool.ainvoke({"query": "news", **arguments})
|
|
assert json.loads(requests[0].content)["k"] == expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_configured_count_defaults_to_five(webz):
|
|
module, _, requests, _, _ = webz
|
|
await module.web_search_tool.ainvoke({"query": "news"})
|
|
assert json.loads(requests[0].content)["k"] == 5
|
|
|
|
|
|
def test_doctor_recognizes_webz_credentials(tmp_path, monkeypatch):
|
|
import doctor
|
|
|
|
config = tmp_path / "config.yaml"
|
|
config.write_text("tools:\n - name: web_search\n use: deerflow.community.webz.tools:web_search_tool\n", encoding="utf-8")
|
|
monkeypatch.delenv("WEBZ_API_KEY", raising=False)
|
|
missing = doctor.check_web_search(config)
|
|
assert missing.status == "warn"
|
|
assert "WEBZ_API_KEY" in missing.detail
|
|
monkeypatch.setenv("WEBZ_API_KEY", "synthetic-key")
|
|
present = doctor.check_web_search(config)
|
|
assert present.status == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("bad_item", [None, {"article": []}, {"article": {}, "chunk": None}, {"article": {}, "metadata": "synthetic-key"}, {"article": {"title": ["synthetic-key"]}}])
|
|
async def test_malformed_item_preserves_valid_neighbors(webz, bad_item, caplog):
|
|
module, _, _, response, _ = webz
|
|
response["results"] = [
|
|
{"article": {"title": "First", "summary": "First summary"}},
|
|
bad_item,
|
|
{"article": {"title": "Last", "summary": "Last summary"}},
|
|
]
|
|
result = json.loads(await module.web_search_tool.ainvoke({"query": "news", "max_results": 3}))
|
|
assert result["returned_results"] == 2
|
|
assert [item["title"] for item in result["results"]] == ["First", "Last"]
|
|
assert "Skipping malformed Webz result at index 1" in caplog.text
|
|
assert "synthetic-key" not in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_json_is_logged_without_response_body(webz, monkeypatch, caplog):
|
|
module, _, _, _, _ = webz
|
|
|
|
async def invalid_json(*args, **kwargs):
|
|
return httpx.Response(200, text="synthetic-key", request=httpx.Request("POST", "https://api.webz.io/api/news/context"))
|
|
|
|
monkeypatch.setattr(RealAsyncClient, "post", invalid_json)
|
|
result = await module.web_search_tool.ainvoke({"query": "news"})
|
|
assert "unexpected response" in json.loads(result)["error"]
|
|
assert "Webz returned an unexpected response" in caplog.text
|
|
assert "synthetic-key" not in result + caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_configured_count_logs_value(webz, caplog):
|
|
module, options, requests, _, _ = webz
|
|
options["max_results"] = 3.5
|
|
await module.web_search_tool.ainvoke({"query": "news"})
|
|
assert json.loads(requests[0].content)["k"] == 5
|
|
assert "max_results=3.5" in caplog.text
|