revert(web): remove keyless Parallel search fallback (#46350)
Remove the free Parallel Search MCP path and restore the keyed Parallel backend behavior from before it was introduced. Also drops the keyless fallback registration/display labeling tests and returns the Parallel SDK pin to the prior version.
This commit is contained in:
@@ -12,7 +12,6 @@ from agent.display import (
|
||||
set_tool_preview_max_len,
|
||||
_render_inline_unified_diff,
|
||||
_summarize_rendered_diff_sections,
|
||||
_used_free_parallel,
|
||||
render_edit_diff_with_delta,
|
||||
)
|
||||
|
||||
@@ -172,46 +171,6 @@ class TestCuteToolMessagePreviewLength:
|
||||
assert "[error]" not in line
|
||||
|
||||
|
||||
class TestWebProviderLabel:
|
||||
"""The free-path "Parallel search"/"Parallel fetch" verb labeling."""
|
||||
|
||||
def test_free_search_verb_is_parallel(self):
|
||||
result = json.dumps({"success": True, "data": {"web": []}, "provider": "parallel"})
|
||||
line = get_cute_tool_message("web_search", {"query": "hello"}, 0.1, result=result)
|
||||
assert "Parallel search" in line
|
||||
assert "hello" in line
|
||||
|
||||
def test_paid_search_verb_is_plain(self):
|
||||
result = json.dumps({"success": True, "data": {"web": [{"url": "u"}]}})
|
||||
line = get_cute_tool_message("web_search", {"query": "hi"}, 0.1, result=result)
|
||||
assert "Parallel" not in line
|
||||
assert "search" in line
|
||||
|
||||
def test_missing_result_verb_is_plain(self):
|
||||
line = get_cute_tool_message("web_search", {"query": "hello"}, 0.1)
|
||||
assert "Parallel" not in line
|
||||
assert "search" in line
|
||||
|
||||
def test_helper_is_parallel_free_specific(self):
|
||||
# Only Parallel's free MCP path marks results; nothing else does.
|
||||
assert _used_free_parallel(json.dumps({"provider": "parallel"})) is True
|
||||
assert _used_free_parallel(json.dumps({"provider": "exa"})) is False
|
||||
assert _used_free_parallel(json.dumps({"provider": "firecrawl"})) is False
|
||||
assert _used_free_parallel(json.dumps({"success": True, "data": {}})) is False
|
||||
assert _used_free_parallel('not json') is False
|
||||
assert _used_free_parallel(None) is False
|
||||
|
||||
def test_free_extract_verb_is_parallel(self):
|
||||
result = json.dumps({"results": [{"url": "u", "content": "x"}], "provider": "parallel"})
|
||||
line = get_cute_tool_message("web_extract", {"urls": ["https://a.test"]}, 0.1, result=result)
|
||||
assert "Parallel fetch" in line
|
||||
|
||||
def test_paid_extract_verb_is_plain(self):
|
||||
result = json.dumps({"results": [{"url": "u", "content": "x"}]})
|
||||
line = get_cute_tool_message("web_extract", {"urls": ["https://a.test"]}, 0.1, result=result)
|
||||
assert "Parallel" not in line
|
||||
|
||||
|
||||
class TestEditDiffPreview:
|
||||
def test_extract_edit_diff_for_patch(self):
|
||||
diff = extract_edit_diff("patch", '{"success": true, "diff": "--- a/x\\n+++ b/x\\n"}')
|
||||
|
||||
@@ -975,19 +975,6 @@ def test_toolset_has_keys_treats_no_key_providers_as_configured():
|
||||
assert _toolset_has_keys("computer_use", config) is True
|
||||
|
||||
|
||||
def test_web_no_prompt_when_usable_keyless():
|
||||
"""Fresh install: web works via the free Parallel MCP, so enabling the web
|
||||
toolset should not force provider setup."""
|
||||
with patch("tools.web_tools.check_web_api_key", return_value=True):
|
||||
assert _toolset_needs_configuration_prompt("web", {}) is False
|
||||
|
||||
|
||||
def test_web_no_prompt_when_extract_backend_is_extract_capable():
|
||||
with patch("tools.web_tools.check_web_api_key", return_value=True):
|
||||
cfg = {"web": {"extract_backend": "parallel"}}
|
||||
assert _toolset_needs_configuration_prompt("web", cfg) is False
|
||||
|
||||
|
||||
def test_computer_use_needs_configuration_when_cua_driver_post_setup_pending():
|
||||
"""No-key providers can still need setup when their post_setup is unsatisfied.
|
||||
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
"""Keyless Parallel search via the free hosted Search MCP.
|
||||
|
||||
Covers the transport added in ``plugins/web/parallel/provider.py`` that lets
|
||||
``web_search`` work with no ``PARALLEL_API_KEY``:
|
||||
|
||||
- ``_mcp_headers`` — Bearer attached only when a key is held
|
||||
- ``_decode_mcp_envelope`` — plain-JSON and SSE (``data:``) response bodies
|
||||
- ``_mcp_payload`` — structuredContent preferred, text-block JSON fallback, errors
|
||||
- ``_mcp_web_search`` — full handshake (mocked transport) → standard search shape
|
||||
- ``ParallelWebSearchProvider.search`` — keyless path routes to the MCP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.web.parallel.provider as pp
|
||||
|
||||
|
||||
# ─── _mcp_headers ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestMcpHeaders:
|
||||
def test_anonymous_has_no_authorization(self):
|
||||
h = pp._mcp_headers(session_id=None, api_key=None)
|
||||
assert "Authorization" not in h
|
||||
assert h["Accept"] == "application/json, text/event-stream"
|
||||
assert "Mcp-Session-Id" not in h
|
||||
|
||||
def test_user_agent_is_generic_not_hermes(self):
|
||||
# Telemetry policy: no third-party usage attribution without opt-in.
|
||||
# The UA must be set (not python-httpx default) but must not name
|
||||
# hermes, on both the anonymous and keyed paths.
|
||||
for ua in (
|
||||
pp._mcp_headers(session_id=None, api_key=None)["User-Agent"],
|
||||
pp._mcp_headers(session_id="sid", api_key="pk-live")["User-Agent"],
|
||||
):
|
||||
assert ua == f"{pp._MCP_CLIENT_NAME}/{pp._MCP_CLIENT_VERSION}"
|
||||
assert "hermes" not in ua.lower()
|
||||
|
||||
def test_session_id_and_bearer_when_present(self):
|
||||
h = pp._mcp_headers(session_id="sid-123", api_key="pk-live")
|
||||
assert h["Mcp-Session-Id"] == "sid-123"
|
||||
assert h["Authorization"] == "Bearer pk-live"
|
||||
|
||||
|
||||
# ─── SSE / JSON-RPC parsing ──────────────────────────────────────────────────
|
||||
|
||||
class TestMcpResponseParsing:
|
||||
def test_plain_json_matched_by_id(self):
|
||||
body = '{"jsonrpc":"2.0","id":"abc","result":{"ok":true}}'
|
||||
assert pp._mcp_response_envelope(body, "abc")["result"]["ok"] is True
|
||||
|
||||
def test_sse_selects_response_for_request_id_skipping_notifications(self):
|
||||
# A progress notification (no id) precedes the real result; an unrelated
|
||||
# response id is also present. We must pick the one matching our id.
|
||||
body = (
|
||||
'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"p":1}}\n\n'
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":"other","result":{"ok":false}}\n\n'
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":"req-1","result":{"ok":true}}\n\n'
|
||||
)
|
||||
env = pp._mcp_response_envelope(body, "req-1")
|
||||
assert env["result"]["ok"] is True
|
||||
|
||||
def test_sse_multiline_data_concatenated(self):
|
||||
body = 'data: {"jsonrpc":"2.0","id":"x",\ndata: "result":{"n":42}}\n\n'
|
||||
assert pp._mcp_response_envelope(body, "x")["result"]["n"] == 42
|
||||
|
||||
def test_falls_back_to_last_result_when_id_absent(self):
|
||||
body = '{"jsonrpc":"2.0","id":"server-chose","result":{"ok":true}}'
|
||||
# request id doesn't match, but there's a single result → use it
|
||||
assert pp._mcp_response_envelope(body, "mismatch")["result"]["ok"] is True
|
||||
|
||||
def test_empty_body(self):
|
||||
assert pp._mcp_response_envelope("", "x") == {}
|
||||
assert pp._mcp_response_envelope(" ", "x") == {}
|
||||
|
||||
def test_batched_json_array_flattened(self):
|
||||
# Streamable HTTP may batch messages into a JSON array.
|
||||
body = ('[{"jsonrpc":"2.0","method":"notifications/progress"},'
|
||||
'{"jsonrpc":"2.0","id":"req-9","result":{"ok":true}}]')
|
||||
assert pp._mcp_response_envelope(body, "req-9")["result"]["ok"] is True
|
||||
|
||||
def test_batched_sse_data_array_flattened(self):
|
||||
body = 'data: [{"jsonrpc":"2.0","id":"a","result":{"n":1}}]\n\n'
|
||||
assert pp._mcp_response_envelope(body, "a")["result"]["n"] == 1
|
||||
|
||||
|
||||
# ─── _mcp_payload ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMcpPayload:
|
||||
def test_prefers_structured_content(self):
|
||||
env = {"result": {"structuredContent": {"results": [{"url": "u"}]},
|
||||
"content": [{"type": "text", "text": "ignored"}]}}
|
||||
assert pp._mcp_payload(env) == {"results": [{"url": "u"}]}
|
||||
|
||||
def test_parses_text_block_json(self):
|
||||
inner = {"search_id": "s1", "results": [{"url": "u", "title": "t"}]}
|
||||
env = {"result": {"content": [{"type": "text", "text": json.dumps(inner)}]}}
|
||||
assert pp._mcp_payload(env)["search_id"] == "s1"
|
||||
|
||||
def test_raises_on_jsonrpc_error(self):
|
||||
with pytest.raises(RuntimeError, match="Parallel MCP error"):
|
||||
pp._mcp_payload({"error": {"code": -32000, "message": "boom"}})
|
||||
|
||||
def test_raises_on_tool_iserror(self):
|
||||
with pytest.raises(RuntimeError, match="Parallel MCP tool error"):
|
||||
pp._mcp_payload({"result": {"isError": True, "content": []}})
|
||||
|
||||
|
||||
# ─── _mcp_web_search (mocked transport) ──────────────────────────────────────
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, *, text="", headers=None):
|
||||
self.text = text
|
||||
self.headers = headers or {}
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Stands in for httpx.Client: replays init → ack → tools/call."""
|
||||
|
||||
def __init__(self, search_payload, init_session_id="server-sid"):
|
||||
self._search_payload = search_payload
|
||||
self._init_session_id = init_session_id
|
||||
self.calls = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def post(self, url, headers=None, json=None):
|
||||
self.calls.append({"headers": headers, "json": json})
|
||||
req = json or {}
|
||||
method = req.get("method")
|
||||
req_id = req.get("id")
|
||||
if method == "initialize":
|
||||
# Echo the request id, as the real server does.
|
||||
return _FakeResponse(
|
||||
text=json_dumps({"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"protocolVersion": "2099-01-01"}}),
|
||||
headers=(
|
||||
{"mcp-session-id": self._init_session_id}
|
||||
if self._init_session_id is not None
|
||||
else {}
|
||||
),
|
||||
)
|
||||
if method == "notifications/initialized":
|
||||
return _FakeResponse(text="")
|
||||
# tools/call
|
||||
envelope = {"jsonrpc": "2.0", "id": req_id, "result": {
|
||||
"content": [{"type": "text", "text": json_dumps(self._search_payload)}],
|
||||
}}
|
||||
return _FakeResponse(text=json_dumps(envelope))
|
||||
|
||||
|
||||
def json_dumps(obj):
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
class TestMcpWebSearch:
|
||||
def _payload(self, n):
|
||||
return {"search_id": "s", "results": [
|
||||
{"url": f"https://ex/{i}", "title": f"t{i}",
|
||||
"excerpts": [f"a{i}", f"b{i}"]}
|
||||
for i in range(n)
|
||||
]}
|
||||
|
||||
def test_returns_standard_shape_and_handshake(self):
|
||||
fake = _FakeClient(self._payload(3))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("hello", limit=5, api_key=None)
|
||||
|
||||
assert out["success"] is True
|
||||
# Free-tier results credit Parallel.
|
||||
assert "Parallel" in out["attribution"]
|
||||
web = out["data"]["web"]
|
||||
assert [r["position"] for r in web] == [1, 2, 3]
|
||||
assert web[0]["url"] == "https://ex/0"
|
||||
assert web[0]["description"] == "a0 b0" # excerpts joined
|
||||
# handshake order
|
||||
methods = [c["json"].get("method") for c in fake.calls]
|
||||
assert methods == ["initialize", "notifications/initialized", "tools/call"]
|
||||
# session id from the initialize response header is reused
|
||||
assert fake.calls[-1]["headers"]["Mcp-Session-Id"] == "server-sid"
|
||||
|
||||
def test_stateless_server_no_session_header_not_invented(self):
|
||||
# A stateless Streamable-HTTP server may omit mcp-session-id on
|
||||
# initialize; we must NOT invent one (sending an unissued session id can
|
||||
# get follow-up requests rejected). The follow-ups carry no header.
|
||||
fake = _FakeClient(self._payload(1), init_session_id=None)
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("hello", limit=5, api_key=None)
|
||||
assert out["success"] is True
|
||||
follow_ups = [c for c in fake.calls if c["json"].get("method") != "initialize"]
|
||||
assert follow_ups, "expected notifications/initialized + tools/call"
|
||||
assert all("Mcp-Session-Id" not in c["headers"] for c in follow_ups)
|
||||
# anonymous → no Authorization on any call
|
||||
assert all("Authorization" not in c["headers"] for c in fake.calls)
|
||||
# tools/call mirrors query into objective + search_queries
|
||||
args = fake.calls[-1]["json"]["params"]["arguments"]
|
||||
assert args["objective"] == "hello"
|
||||
assert args["search_queries"] == ["hello"]
|
||||
|
||||
def test_limit_is_applied_client_side(self):
|
||||
fake = _FakeClient(self._payload(10))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("q", limit=2, api_key=None)
|
||||
assert len(out["data"]["web"]) == 2
|
||||
|
||||
def test_bearer_attached_when_key_present(self):
|
||||
fake = _FakeClient(self._payload(1))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
pp._mcp_web_search("q", limit=1, api_key="pk-live")
|
||||
assert all(c["headers"]["Authorization"] == "Bearer pk-live" for c in fake.calls)
|
||||
|
||||
def test_negotiated_protocol_version_echoed_post_init(self):
|
||||
fake = _FakeClient(self._payload(1))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
pp._mcp_web_search("q", limit=1, api_key=None)
|
||||
# initialize request doesn't carry the (not-yet-negotiated) version...
|
||||
assert "MCP-Protocol-Version" not in fake.calls[0]["headers"]
|
||||
# ...but notifications/initialized and tools/call echo the negotiated one.
|
||||
assert fake.calls[1]["headers"]["MCP-Protocol-Version"] == "2099-01-01"
|
||||
assert fake.calls[-1]["headers"]["MCP-Protocol-Version"] == "2099-01-01"
|
||||
|
||||
|
||||
# ─── provider.search keyless routing ─────────────────────────────────────────
|
||||
|
||||
class TestProviderKeylessSearch:
|
||||
def test_search_without_key_uses_mcp(self, monkeypatch):
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake(query, limit, api_key):
|
||||
captured.update(query=query, limit=limit, api_key=api_key)
|
||||
return {"success": True, "data": {"web": []}}
|
||||
|
||||
monkeypatch.setattr(pp, "_mcp_web_search", _fake)
|
||||
out = pp.ParallelWebSearchProvider().search("kittens", limit=4)
|
||||
assert out["success"] is True
|
||||
assert captured == {"query": "kittens", "limit": 4, "api_key": None}
|
||||
|
||||
def test_is_available_reflects_key(self, monkeypatch):
|
||||
# is_available() gates the registry's active-provider walk + picker, so
|
||||
# it's key-based (keyless dispatch is handled by _get_backend, not this).
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
assert pp.ParallelWebSearchProvider().is_available() is False
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "k")
|
||||
assert pp.ParallelWebSearchProvider().is_available() is True
|
||||
|
||||
|
||||
# ─── web_fetch (keyless extract) ─────────────────────────────────────────────
|
||||
|
||||
class TestMcpWebFetch:
|
||||
def _payload(self, urls):
|
||||
return {"extract_id": "e1", "results": [
|
||||
{"url": u, "title": f"T{i}", "publish_date": None,
|
||||
"excerpts": [f"chunk-a-{i}", f"chunk-b-{i}"]}
|
||||
for i, u in enumerate(urls)
|
||||
]}
|
||||
|
||||
def test_maps_to_extract_shape(self):
|
||||
urls = ["https://a.test", "https://b.test"]
|
||||
fake = _FakeClient(self._payload(urls))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(urls, api_key=None)
|
||||
assert [r["url"] for r in out] == urls
|
||||
assert out[0]["content"] == "chunk-a-0\n\nchunk-b-0"
|
||||
assert out[0]["raw_content"] == out[0]["content"]
|
||||
assert out[0]["metadata"] == {"sourceURL": "https://a.test", "title": "T0"}
|
||||
# tools/call targeted web_fetch, requesting full page bodies.
|
||||
args = fake.calls[-1]["json"]["params"]
|
||||
assert args["name"] == "web_fetch"
|
||||
assert args["arguments"]["urls"] == urls
|
||||
assert args["arguments"]["full_content"] is True
|
||||
assert args["arguments"]["session_id"].startswith(f"{pp._MCP_CLIENT_NAME}-")
|
||||
|
||||
def test_prefers_full_content_over_excerpts(self):
|
||||
payload = {"results": [
|
||||
{"url": "https://a.test", "title": "T",
|
||||
"excerpts": ["snippet"], "full_content": "the entire page body"},
|
||||
]}
|
||||
fake = _FakeClient(payload)
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(["https://a.test"], api_key=None)
|
||||
assert out[0]["content"] == "the entire page body"
|
||||
|
||||
def test_missing_url_becomes_error_entry(self):
|
||||
# Server returns only one of the two requested URLs.
|
||||
fake = _FakeClient(self._payload(["https://a.test"]))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(["https://a.test", "https://missing.test"], api_key=None)
|
||||
assert len(out) == 2
|
||||
missing = [r for r in out if r["url"] == "https://missing.test"][0]
|
||||
assert "error" in missing
|
||||
assert missing["content"] == ""
|
||||
|
||||
def test_preserves_order_and_duplicate_inputs(self):
|
||||
# MCP returns each unique URL once; output must still be one row per
|
||||
# input, in order, including the duplicate.
|
||||
fake = _FakeClient(self._payload(["https://a.test", "https://b.test"]))
|
||||
urls = ["https://b.test", "https://a.test", "https://b.test"]
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(urls, api_key=None)
|
||||
assert [r["url"] for r in out] == urls # one row per input, in order
|
||||
assert all("error" not in r for r in out) # all three resolved
|
||||
|
||||
def test_extract_without_key_uses_web_fetch(self, monkeypatch):
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake(urls, api_key):
|
||||
captured.update(urls=list(urls), api_key=api_key)
|
||||
return [{"url": urls[0], "title": "", "content": "x",
|
||||
"raw_content": "x", "metadata": {}}]
|
||||
|
||||
monkeypatch.setattr(pp, "_mcp_web_fetch", _fake)
|
||||
out = asyncio.run(pp.ParallelWebSearchProvider().extract(["https://x.test"]))
|
||||
assert out[0]["content"] == "x"
|
||||
assert captured == {"urls": ["https://x.test"], "api_key": None}
|
||||
|
||||
|
||||
# ─── keyed v1 REST search ────────────────────────────────────────────────────
|
||||
|
||||
class TestKeyedV1Search:
|
||||
def test_passes_max_results_and_omits_branding(self, monkeypatch):
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "pk-live")
|
||||
monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False)
|
||||
captured = {}
|
||||
|
||||
class _Res:
|
||||
def __init__(self, url):
|
||||
self.url, self.title, self.excerpts = url, "T", ["x"]
|
||||
|
||||
class _Resp:
|
||||
results = [_Res(f"https://r/{i}") for i in range(10)]
|
||||
|
||||
class _Client:
|
||||
def search(self, **kw):
|
||||
captured.update(kw)
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(pp, "_get_sync_client", lambda: _Client())
|
||||
out = pp.ParallelWebSearchProvider().search("q", limit=7)
|
||||
|
||||
assert out["success"] is True
|
||||
# honors the caller's limit via advanced_settings.max_results
|
||||
assert captured["advanced_settings"] == {"max_results": 7}
|
||||
assert captured["mode"] == "advanced" # v1 default
|
||||
assert captured["session_id"].startswith(f"{pp._MCP_CLIENT_NAME}-") # per-call id
|
||||
assert len(out["data"]["web"]) == 7 # client-side slice
|
||||
# paid path: no free-tier attribution, no [Parallel] label signal
|
||||
assert "attribution" not in out
|
||||
assert "provider" not in out
|
||||
|
||||
|
||||
# ─── v1 search mode mapping ──────────────────────────────────────────────────
|
||||
|
||||
class TestResolveSearchMode:
|
||||
@pytest.mark.parametrize("env,expected", [
|
||||
(None, "advanced"), # default
|
||||
("advanced", "advanced"),
|
||||
("basic", "basic"),
|
||||
("fast", "basic"), # legacy → basic
|
||||
("one-shot", "basic"), # legacy → basic
|
||||
("agentic", "advanced"), # legacy → advanced
|
||||
("garbage", "advanced"), # invalid → default
|
||||
("BASIC", "basic"), # case-insensitive
|
||||
])
|
||||
def test_mode_mapping(self, monkeypatch, env, expected):
|
||||
if env is None:
|
||||
monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("PARALLEL_SEARCH_MODE", env)
|
||||
assert pp._resolve_search_mode() == expected
|
||||
@@ -193,16 +193,11 @@ class TestIsAvailable:
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_parallel_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""is_available() is key-based — it gates the registry's active-provider
|
||||
walk/picker. (Keyless search/extract still work via the free MCP through
|
||||
_get_backend's terminal default, independent of this flag.)
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
@@ -427,33 +422,17 @@ class TestErrorResponseShapes:
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_parallel_extract_keyless_uses_mcp_web_fetch(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Without a key, extract routes to the free MCP web_fetch tool rather
|
||||
than erroring. The MCP transport is mocked so the test stays offline."""
|
||||
def test_parallel_extract_returns_per_url_errors_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
import plugins.web.parallel.provider as parallel_provider
|
||||
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake_fetch(urls, api_key):
|
||||
captured["urls"] = list(urls)
|
||||
captured["api_key"] = api_key
|
||||
return [{"url": urls[0], "title": "Example", "content": "body",
|
||||
"raw_content": "body", "metadata": {"sourceURL": urls[0]}}]
|
||||
|
||||
monkeypatch.setattr(parallel_provider, "_mcp_web_fetch", _fake_fetch)
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
result = asyncio.run(p.extract(["https://example.com"]))
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert "error" in result[0]
|
||||
assert result[0]["url"] == "https://example.com"
|
||||
assert result[0]["content"] == "body"
|
||||
assert captured == {"urls": ["https://example.com"], "api_key": None}
|
||||
|
||||
def test_firecrawl_extract_returns_per_url_errors_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Regression: the keyless Parallel web default must survive a failed sweep.
|
||||
|
||||
``web_search`` / ``web_extract`` are documented to work out of the box with
|
||||
zero setup via the bundled keyless Parallel free-MCP backend. That guarantee
|
||||
only holds if the bundled ``plugins/web/*`` providers are registered in
|
||||
``agent.web_search_registry``. The dispatch triggers the general plugin sweep
|
||||
(:func:`hermes_cli.plugins._ensure_plugins_discovered`) to do that — but the
|
||||
sweep can finish without registering them (its exception swallowed as a
|
||||
warning, a packaged layout where it ran before the bundled tree was
|
||||
importable, or a stale empty-discovery cache). When that happened, *both*
|
||||
tools dead-ended on "No web {search,extract} provider configured" even though
|
||||
no setup should be needed.
|
||||
|
||||
These tests pin the invariant that :func:`tools.web_tools._ensure_web_plugins_loaded`
|
||||
guarantees the keyless default is registered regardless of the sweep's outcome,
|
||||
and that the direct-registration fallback honors an explicit ``plugins.disabled``
|
||||
entry. Real imports from the bundled plugin modules — no provider mocking.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.web_search_registry as reg
|
||||
import hermes_cli.plugins as plugins
|
||||
from tools import web_tools
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
reg._reset_for_tests()
|
||||
yield
|
||||
reg._reset_for_tests()
|
||||
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise RuntimeError("discovery boom")
|
||||
|
||||
|
||||
def test_keyless_default_registered_when_discovery_raises(monkeypatch):
|
||||
"""A swallowed discovery failure must not strand the keyless default."""
|
||||
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", _boom)
|
||||
assert reg.get_provider("parallel") is None
|
||||
|
||||
web_tools._ensure_web_plugins_loaded()
|
||||
|
||||
parallel = reg.get_provider("parallel")
|
||||
assert parallel is not None, "keyless Parallel default not restored"
|
||||
# It is the universal keyless default precisely because it does both.
|
||||
assert parallel.supports_search()
|
||||
assert parallel.supports_extract()
|
||||
|
||||
|
||||
def test_fallback_registers_full_bundled_set(monkeypatch):
|
||||
"""The fix covers the whole bundled provider class, not just parallel."""
|
||||
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", _boom)
|
||||
|
||||
web_tools._ensure_web_plugins_loaded()
|
||||
|
||||
names = {p.name for p in reg.list_providers()}
|
||||
# Every bundled backend a user might have configured should be reachable
|
||||
# again, so an explicit ``web.extract_backend: firecrawl`` etc. resolves.
|
||||
for expected in ("parallel", "firecrawl", "tavily", "exa"):
|
||||
assert expected in names, f"{expected} missing after fallback"
|
||||
|
||||
|
||||
def test_fallback_honors_explicit_disable(monkeypatch):
|
||||
"""A backend the user turned off via plugins.disabled stays off."""
|
||||
monkeypatch.setattr(plugins, "_get_disabled_plugins", lambda: {"web-parallel"})
|
||||
|
||||
web_tools._register_bundled_web_providers_directly()
|
||||
|
||||
names = {p.name for p in reg.list_providers()}
|
||||
assert "parallel" not in names, "explicit disable was ignored"
|
||||
# Other bundled backends are unaffected by the parallel disable.
|
||||
assert "tavily" in names
|
||||
|
||||
|
||||
def test_fallback_is_noop_when_discovery_already_registered(monkeypatch):
|
||||
"""Healthy path: don't pay for the direct sweep when parallel is present."""
|
||||
# Pretend the general sweep already registered the keyless default.
|
||||
import importlib
|
||||
|
||||
class _Ctx:
|
||||
def register_web_search_provider(self, provider):
|
||||
reg.register_provider(provider)
|
||||
|
||||
importlib.import_module("plugins.web.parallel").register(_Ctx())
|
||||
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", lambda *a, **k: None)
|
||||
|
||||
calls = {"n": 0}
|
||||
real = web_tools._register_bundled_web_providers_directly
|
||||
|
||||
def _spy():
|
||||
calls["n"] += 1
|
||||
real()
|
||||
|
||||
monkeypatch.setattr(web_tools, "_register_bundled_web_providers_directly", _spy)
|
||||
web_tools._ensure_web_plugins_loaded()
|
||||
|
||||
assert calls["n"] == 0, "direct-registration ran on the healthy path"
|
||||
@@ -167,21 +167,6 @@ class TestPerCapabilityBackendSelection:
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
|
||||
assert web_tools._get_search_backend() == "tavily"
|
||||
|
||||
def test_explicit_extract_backend_honored_when_unavailable(self, monkeypatch):
|
||||
"""An explicit per-capability backend is honored even with no creds, so
|
||||
its setup error surfaces instead of silently rerouting to the keyless
|
||||
Parallel default (which would send user URLs to a different provider)."""
|
||||
from tools import web_tools
|
||||
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {
|
||||
"extract_backend": "firecrawl",
|
||||
})
|
||||
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False, raising=False)
|
||||
# Resolves to firecrawl (not parallel) despite firecrawl being unavailable.
|
||||
assert web_tools._get_extract_backend() == "firecrawl"
|
||||
|
||||
def test_falls_back_to_generic_backend_when_extract_backend_empty(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
|
||||
@@ -192,7 +177,7 @@ class TestPerCapabilityBackendSelection:
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "test-key")
|
||||
assert web_tools._get_extract_backend() == "parallel"
|
||||
|
||||
def test_explicit_search_backend_honored_when_unavailable(self, monkeypatch):
|
||||
def test_search_backend_ignored_when_not_available(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {
|
||||
@@ -201,10 +186,8 @@ class TestPerCapabilityBackendSelection:
|
||||
})
|
||||
monkeypatch.delenv("EXA_API_KEY", raising=False)
|
||||
monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-key")
|
||||
# The explicit per-capability choice (exa) is honored even though it's
|
||||
# unavailable, so its setup error surfaces — we don't silently reroute
|
||||
# to the shared backend (or the keyless Parallel default).
|
||||
assert web_tools._get_search_backend() == "exa"
|
||||
# Should fall back to firecrawl since exa isn't configured
|
||||
assert web_tools._get_search_backend() == "firecrawl"
|
||||
|
||||
def test_fully_backward_compatible_with_web_backend_only(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
@@ -308,55 +291,26 @@ class TestUnconfiguredErrorEnvelopeParity:
|
||||
):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
def test_extract_empty_urls_does_not_raise(self, monkeypatch):
|
||||
"""Regression: empty (or fully SSRF-blocked) URL sets skip the dispatch
|
||||
branch; the free-Parallel flag must still be initialized so the tool
|
||||
returns an error envelope instead of UnboundLocalError."""
|
||||
import asyncio
|
||||
from tools import web_tools
|
||||
self._clear_web_creds(monkeypatch)
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
|
||||
out = asyncio.run(web_tools.web_extract_tool([], "markdown"))
|
||||
# The key assertion is that it returns a normal error envelope (a
|
||||
# string) rather than raising UnboundLocalError.
|
||||
assert isinstance(out, str)
|
||||
result = json.loads(out)
|
||||
assert "error" in result
|
||||
|
||||
def test_unconfigured_search_falls_back_to_free_parallel(self, monkeypatch):
|
||||
"""``web_search_tool`` with no creds routes to Parallel's free Search
|
||||
MCP rather than erroring. The MCP transport is mocked so the test
|
||||
stays offline; we assert dispatch landed on parallel and returned the
|
||||
standard search envelope.
|
||||
def test_unconfigured_search_emits_top_level_error(self, monkeypatch):
|
||||
"""``web_search_tool`` with no creds returns ``{"error": "Error searching web: ..."}``
|
||||
— matching main's ``tool_error()`` envelope, not a per-result shape.
|
||||
"""
|
||||
from tools import web_tools
|
||||
import plugins.web.parallel.provider as parallel_provider
|
||||
|
||||
self._clear_web_creds(monkeypatch)
|
||||
# Reset firecrawl client cache so the unconfigured state is re-evaluated
|
||||
monkeypatch.setattr(web_tools, "_firecrawl_client", None, raising=False)
|
||||
monkeypatch.setattr(web_tools, "_firecrawl_client_config", None, raising=False)
|
||||
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False)
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_mcp(query, limit, api_key):
|
||||
captured["query"] = query
|
||||
captured["api_key"] = api_key
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"web": [
|
||||
{"url": "https://example.com", "title": "Example",
|
||||
"description": "hit", "position": 1},
|
||||
]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(parallel_provider, "_mcp_web_search", _fake_mcp)
|
||||
|
||||
result = json.loads(web_tools.web_search_tool("hello world", limit=3))
|
||||
assert result.get("success") is True, f"expected success, got {result}"
|
||||
assert result["data"]["web"][0]["url"] == "https://example.com"
|
||||
# Keyless path: dispatched to parallel with no Bearer token.
|
||||
assert captured == {"query": "hello world", "api_key": None}
|
||||
assert "error" in result, f"expected top-level 'error' key, got {result}"
|
||||
# ``Error searching web:`` prefix comes from web_tools' top-level except handler
|
||||
assert "Error searching web:" in result["error"]
|
||||
assert "FIRECRAWL_API_KEY" in result["error"]
|
||||
# No per-result burying
|
||||
assert "results" not in result
|
||||
|
||||
|
||||
class TestDispatchersTriggerPluginDiscovery:
|
||||
|
||||
@@ -190,11 +190,7 @@ class TestDDGSBackendWiring:
|
||||
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True)
|
||||
assert web_tools._get_backend() == "exa"
|
||||
|
||||
def test_auto_detect_prefers_keyless_parallel_over_ddgs(self, monkeypatch):
|
||||
# With no credentials, keyless Parallel is the auto-detect default even
|
||||
# when the ddgs package is installed — ddgs is search-only (can't
|
||||
# extract), so Parallel is preferred so both search and extract work.
|
||||
# ddgs remains reachable via an explicit web.backend=ddgs.
|
||||
def test_auto_detect_picks_ddgs_as_last_resort(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
|
||||
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PARALLEL_API_KEY",
|
||||
@@ -202,7 +198,7 @@ class TestDDGSBackendWiring:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
|
||||
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True)
|
||||
assert web_tools._get_backend() == "parallel"
|
||||
assert web_tools._get_backend() == "ddgs"
|
||||
|
||||
def test_check_web_api_key_true_when_ddgs_configured(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
|
||||
@@ -313,9 +313,7 @@ class TestCheckWebApiKey:
|
||||
)
|
||||
assert web_tools.check_web_api_key() is True
|
||||
|
||||
def test_no_credentials_usable_via_free_parallel(self, monkeypatch):
|
||||
"""No credentials → check_web_api_key True: the keyless Parallel free MCP
|
||||
services calls, so web is usable out of the box."""
|
||||
def test_no_credentials_fails(self, monkeypatch):
|
||||
from tools import web_tools
|
||||
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})
|
||||
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
|
||||
@@ -327,7 +325,7 @@ class TestCheckWebApiKey:
|
||||
monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False)
|
||||
monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False)
|
||||
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False)
|
||||
assert web_tools.check_web_api_key() is True
|
||||
assert web_tools.check_web_api_key() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -384,14 +384,12 @@ class TestBackendSelection:
|
||||
patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
|
||||
assert _get_backend() == "firecrawl"
|
||||
|
||||
def test_fallback_no_keys_defaults_to_parallel(self):
|
||||
"""No credentials, no config → 'parallel' (free Search MCP works
|
||||
keyless). Selection is purely credential-based."""
|
||||
def test_fallback_no_keys_defaults_to_firecrawl(self):
|
||||
"""No keys, no config → 'firecrawl' (will fail at client init)."""
|
||||
from tools.web_tools import _get_backend
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \
|
||||
patch("tools.web_tools._ddgs_package_importable", return_value=False):
|
||||
assert _get_backend() == "parallel"
|
||||
assert _get_backend() == "firecrawl"
|
||||
|
||||
def test_invalid_config_falls_through_to_fallback(self):
|
||||
"""web.backend=invalid → ignored, uses key-based fallback."""
|
||||
@@ -626,73 +624,9 @@ class TestCheckWebApiKey:
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_no_keys_usable_via_free_parallel(self):
|
||||
"""No credentials → check_web_api_key True: selection resolves to the
|
||||
keyless Parallel free MCP, which genuinely services calls (web works out
|
||||
of the box). check_web_api_key is a usability probe, not a key check."""
|
||||
def test_no_keys_returns_false(self):
|
||||
from tools.web_tools import check_web_api_key
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \
|
||||
patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
for k in ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL",
|
||||
"TAVILY_API_KEY", "EXA_API_KEY", "SEARXNG_URL", "BRAVE_SEARCH_API_KEY"):
|
||||
os.environ.pop(k, None)
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_typo_extract_backend_not_masked_by_parallel(self):
|
||||
"""A typo'd per-capability backend is honored (so dispatch errors)
|
||||
rather than silently falling through to keyless Parallel."""
|
||||
from tools.web_tools import _get_extract_backend, check_web_api_key
|
||||
with patch("tools.web_tools._load_web_config",
|
||||
return_value={"extract_backend": "parrallel"}):
|
||||
assert _get_extract_backend() == "parrallel" # not "parallel"
|
||||
assert check_web_api_key() is False # unknown → unusable
|
||||
|
||||
def test_keyless_parallel_unusable_when_provider_disabled(self):
|
||||
"""If the bundled web-parallel provider is disabled/unregistered, the
|
||||
keyless free-MCP path must NOT report web as usable — otherwise setup is
|
||||
skipped but web tools fail at runtime with no provider."""
|
||||
from tools.web_tools import check_web_api_key
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch("tools.web_tools._parallel_provider_registered", return_value=False), \
|
||||
patch("tools.web_tools._is_tool_gateway_ready", return_value=False), \
|
||||
patch("tools.web_tools.check_firecrawl_api_key", return_value=False), \
|
||||
patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
for var in (
|
||||
"PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL",
|
||||
"TAVILY_API_KEY", "EXA_API_KEY", "BRAVE_SEARCH_API_KEY", "SEARXNG_URL",
|
||||
):
|
||||
os.environ.pop(var, None)
|
||||
assert check_web_api_key() is False
|
||||
|
||||
def test_extract_autodetect_skips_search_only_for_keyless_parallel(self):
|
||||
"""A search-only env credential (SEARXNG_URL) must not shadow the keyless
|
||||
Parallel free-MCP extract fallback: extract auto-detect skips search-only
|
||||
backends, so _get_extract_backend resolves to parallel (which can fetch),
|
||||
while search auto-detect still prefers the configured searxng."""
|
||||
from tools.web_tools import _get_extract_backend, _get_search_backend
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
for var in (
|
||||
"PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL",
|
||||
"TAVILY_API_KEY", "EXA_API_KEY", "BRAVE_SEARCH_API_KEY",
|
||||
):
|
||||
os.environ.pop(var, None)
|
||||
os.environ["SEARXNG_URL"] = "http://localhost:8080"
|
||||
with patch("tools.web_tools._is_tool_gateway_ready", return_value=False):
|
||||
assert _get_search_backend() == "searxng"
|
||||
assert _get_extract_backend() == "parallel"
|
||||
|
||||
def test_configured_but_unavailable_backend_reports_unusable(self):
|
||||
"""An explicitly configured backend with no creds (exa, no key) →
|
||||
check_web_api_key False so diagnostics flag the misconfiguration —
|
||||
even though the tools stay registered."""
|
||||
from tools.web_tools import check_web_api_key
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("EXA_API_KEY", None)
|
||||
with patch("tools.web_tools._ddgs_package_importable", return_value=False):
|
||||
assert check_web_api_key() is False
|
||||
|
||||
def test_both_keys_returns_true(self):
|
||||
@@ -756,18 +690,12 @@ class TestCheckWebApiKey:
|
||||
|
||||
assert refresh_calls == []
|
||||
|
||||
def test_web_tools_registered_even_when_configured_backend_unavailable(self):
|
||||
# Registration is unconditional (web_tools_registered) so an explicitly
|
||||
# configured but unavailable backend (exa without EXA_API_KEY) keeps the
|
||||
# tools registered to surface exa's setup error at call time — while the
|
||||
# readiness probe (check_web_api_key) honestly reports not-configured.
|
||||
from tools.web_tools import web_tools_registered, check_web_api_key
|
||||
assert web_tools_registered() is True
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("EXA_API_KEY", None)
|
||||
assert web_tools_registered() is True
|
||||
assert check_web_api_key() is False
|
||||
def test_configured_backend_must_match_available_provider(self):
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is False
|
||||
|
||||
def test_configured_firecrawl_backend_accepts_managed_gateway(self):
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
|
||||
|
||||
Reference in New Issue
Block a user