diff --git a/contributors/emails/alex@thealexferrari.com b/contributors/emails/alex@thealexferrari.com new file mode 100644 index 0000000000000..c624062c8a960 --- /dev/null +++ b/contributors/emails/alex@thealexferrari.com @@ -0,0 +1 @@ +alexferrari88 diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py index dbb4585694284..7dc5eaf36c1b2 100644 --- a/plugins/web/parallel/provider.py +++ b/plugins/web/parallel/provider.py @@ -1,7 +1,7 @@ """Parallel.ai web search (sync ``Parallel`` SDK) + async extract (``AsyncParallel``). Env: ``PARALLEL_API_KEY`` (https://parallel.ai), optional -``PARALLEL_SEARCH_MODE`` = agentic (default) | fast | one-shot. +``PARALLEL_SEARCH_MODE`` accepts v1 modes and legacy names with their Beta semantics. """ from __future__ import annotations @@ -37,9 +37,24 @@ def _get_async_client() -> Any: return _client("_async_parallel_client", "AsyncParallel") +_V1_SEARCH_MODES = {"turbo", "fast", "basic", "advanced"} +_SEARCH_MODE_ALIASES = { + "agentic": "advanced", + "one-shot": "basic", + "fast": "basic", + "v1-fast": "fast", +} + + def _resolve_search_mode() -> str: + """Translate configured modes to their semantically equivalent v1 value. + + Bare ``fast`` retains its legacy Beta meaning (v1 ``basic``). The new v1 + ``fast`` mode is available only through the explicit ``v1-fast`` alias. + """ mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip() - return mode if mode in {"fast", "one-shot", "agentic"} else "agentic" + mode = _SEARCH_MODE_ALIASES.get(mode, mode) + return mode if mode in _V1_SEARCH_MODES else "advanced" class ParallelWebSearchProvider(BaseWebSearchProvider): @@ -57,7 +72,12 @@ def _body() -> Dict[str, Any]: return keyless_search("Parallel", "parallel", query, limit, logger) mode = _resolve_search_mode() logger.info("Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit) - response = _get_sync_client().beta.search(search_queries=[query], objective=query, mode=mode, max_results=min(limit, SEARCH_LIMIT_CAP)) + response = _get_sync_client().search( + search_queries=[query], + objective=query, + mode=mode, + advanced_settings={"max_results": min(limit, SEARCH_LIMIT_CAP)}, + ) return search_ok([ web_hit(r.url or "", r.title or "", " ".join(r.excerpts or []), i + 1) for i, r in enumerate(response.results or []) @@ -71,7 +91,10 @@ async def _body() -> List[Dict[str, Any]]: # Keyless ring is blocking HTTP — hop off the event loop. return await asyncio.to_thread(keyless_extract, "Parallel", "parallel", urls, logger) logger.info("Parallel extract: %d URL(s)", len(urls)) - response = await _get_async_client().beta.extract(urls=urls, full_content=True) + response = await _get_async_client().extract( + urls=urls, + advanced_settings={"full_content": True}, + ) results = [document(r.url or "", r.title or "", r.full_content or "\n\n".join(r.excerpts or [])) for r in response.results or []] return results + [ {**page_error(e.url or "", e.content or e.error_type or "extraction failed"), "metadata": {"sourceURL": e.url or ""}} diff --git a/pyproject.toml b/pyproject.toml index 12c672ff3cdac..8ff6068c63225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,7 +186,7 @@ anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452 # search provider (configured via `hermes tools` or config.yaml). exa = ["exa-py==2.10.2"] firecrawl = ["firecrawl-py==4.17.0"] -parallel-web = ["parallel-web==0.4.2"] +parallel-web = ["parallel-web==1.3.0"] # Image generation backends fal = ["fal-client==0.13.1"] # Edge TTS — default TTS provider but still optional (users can pick diff --git a/tests/plugins/web/test_parallel_provider.py b/tests/plugins/web/test_parallel_provider.py new file mode 100644 index 0000000000000..b37e00b6cf36a --- /dev/null +++ b/tests/plugins/web/test_parallel_provider.py @@ -0,0 +1,203 @@ +"""Regression tests for the keyed Parallel GA/v1 provider path.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import patch + +import httpx +import pytest +from parallel import Parallel + +from plugins.web.parallel.provider import ( + ParallelWebSearchProvider, + _resolve_search_mode, +) + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + (None, "advanced"), + ("not-a-mode", "advanced"), + ("agentic", "advanced"), + ("one-shot", "basic"), + ("fast", "basic"), + ("basic", "basic"), + ("advanced", "advanced"), + ("turbo", "turbo"), + ("v1-fast", "fast"), + ], +) +def test_search_mode_preserves_legacy_semantics_and_explicit_v1_modes( + monkeypatch: pytest.MonkeyPatch, + configured: str | None, + expected: str, +) -> None: + if configured is None: + monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False) + else: + monkeypatch.setenv("PARALLEL_SEARCH_MODE", configured) + + assert _resolve_search_mode() == expected + + +def test_search_uses_v1_client_and_preserves_normalized_result_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict] = [] + + class FakeClient: + def search(self, **kwargs): + calls.append(kwargs) + return SimpleNamespace( + results=[ + SimpleNamespace( + url="https://docs.parallel.ai", + title="Parallel docs", + excerpts=["First excerpt", "second excerpt"], + ) + ] + ) + + monkeypatch.setenv("PARALLEL_API_KEY", "test-key") + monkeypatch.setenv("PARALLEL_SEARCH_MODE", "one-shot") + with ( + patch( + "plugins.web.parallel.provider._get_sync_client", + return_value=FakeClient(), + ), + patch("tools.interrupt.is_interrupted", return_value=False), + ): + result = ParallelWebSearchProvider().search("Parallel SDK", limit=27) + + assert calls == [ + { + "search_queries": ["Parallel SDK"], + "objective": "Parallel SDK", + "mode": "basic", + "advanced_settings": {"max_results": 20}, + } + ] + assert result == { + "success": True, + "data": { + "web": [ + { + "url": "https://docs.parallel.ai", + "title": "Parallel docs", + "description": "First excerpt second excerpt", + "position": 1, + } + ] + }, + } + + +def test_search_serializes_v1_request_through_real_sdk( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "results": [], + "search_id": "search_test", + "session_id": "session_test", + }, + ) + + transport = httpx.MockTransport(handle) + http_client = httpx.Client(transport=transport) + client = Parallel(api_key="test-key", http_client=http_client) + monkeypatch.setenv("PARALLEL_API_KEY", "test-key") + monkeypatch.setenv("PARALLEL_SEARCH_MODE", "agentic") + + try: + with ( + patch( + "plugins.web.parallel.provider._get_sync_client", + return_value=client, + ), + patch("tools.interrupt.is_interrupted", return_value=False), + ): + result = ParallelWebSearchProvider().search("migration contract", limit=7) + finally: + client.close() + + assert result == {"success": True, "data": {"web": []}} + assert len(requests) == 1 + assert requests[0].url.path == "/v1/search" + payload = json.loads(requests[0].content) + assert payload["search_queries"] == ["migration contract"] + assert payload["mode"] == "advanced" + assert payload["advanced_settings"]["max_results"] == 7 + + +@pytest.mark.asyncio +async def test_extract_uses_v1_client_and_preserves_per_url_result_shapes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict] = [] + + class FakeAsyncClient: + async def extract(self, **kwargs): + calls.append(kwargs) + return SimpleNamespace( + results=[ + SimpleNamespace( + url="https://example.com/ok", + title="Example", + full_content="Full content", + excerpts=["fallback excerpt"], + ) + ], + errors=[ + SimpleNamespace( + url="https://example.com/missing", + content="not found", + error_type="http_error", + ) + ], + ) + + urls = ["https://example.com/ok", "https://example.com/missing"] + monkeypatch.setenv("PARALLEL_API_KEY", "test-key") + with ( + patch( + "plugins.web.parallel.provider._get_async_client", + return_value=FakeAsyncClient(), + ), + patch("tools.interrupt.is_interrupted", return_value=False), + ): + result = await ParallelWebSearchProvider().extract(urls) + + assert calls == [ + { + "urls": urls, + "advanced_settings": {"full_content": True}, + } + ] + assert result == [ + { + "url": "https://example.com/ok", + "title": "Example", + "content": "Full content", + "raw_content": "Full content", + "metadata": { + "sourceURL": "https://example.com/ok", + "title": "Example", + }, + }, + { + "url": "https://example.com/missing", + "title": "", + "content": "", + "error": "not found", + "metadata": {"sourceURL": "https://example.com/missing"}, + }, + ] diff --git a/tests/tools/test_web_keyless_fallback.py b/tests/tools/test_web_keyless_fallback.py index 1de9fba14bdca..526891404b073 100644 --- a/tests/tools/test_web_keyless_fallback.py +++ b/tests/tools/test_web_keyless_fallback.py @@ -10,7 +10,8 @@ """ import json -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch import pytest @@ -199,13 +200,44 @@ def test_parallel_keyed_path_skips_keyless(self, monkeypatch): lambda name: "sk-real" if name == "PARALLEL_API_KEY" else "", ) provider = ParallelWebSearchProvider() - with patch.object(keyless_mcp, "parallel_search_keyless") as keyless, \ + with patch.object(keyless_mcp, "search_with_failover") as ring, \ patch("plugins.web.parallel.provider._get_sync_client") as client: - client.return_value.beta.search.return_value.results = [] + client.return_value.search.return_value.results = [] out = provider.search("q") - keyless.assert_not_called() + client.return_value.search.assert_called_once_with( + search_queries=["q"], + objective="q", + mode="advanced", + advanced_settings={"max_results": 5}, + ) + client.return_value.beta.search.assert_not_called() + ring.assert_not_called() assert out["success"] is True + @pytest.mark.asyncio + async def test_parallel_keyed_extract_skips_keyless(self, monkeypatch): + monkeypatch.setattr( + "agent.web_search_provider.get_provider_env", + lambda name: "sk-real" if name == "PARALLEL_API_KEY" else "", + ) + provider = ParallelWebSearchProvider() + client = AsyncMock() + client.extract.return_value = SimpleNamespace(results=[], errors=[]) + urls = ["https://example.com/article"] + with patch.object(keyless_mcp, "extract_with_failover") as ring, \ + patch( + "plugins.web.parallel.provider._get_async_client", + return_value=client, + ): + out = await provider.extract(urls) + client.extract.assert_awaited_once_with( + urls=urls, + advanced_settings={"full_content": True}, + ) + client.beta.extract.assert_not_called() + ring.assert_not_called() + assert out == [] + def test_keyless_disabled_falls_through_to_key_error(self, monkeypatch): monkeypatch.setattr(registry, "_keyless_tier_enabled", lambda: False) provider = ParallelWebSearchProvider() @@ -238,12 +270,21 @@ def test_tier_free_forces_keyless_even_with_key(self, monkeypatch): def test_tier_paid_forces_keyed_without_key(self, monkeypatch): monkeypatch.setattr(keyless_mcp, "provider_tier", lambda name: "paid") provider = ParallelWebSearchProvider() - with patch.object(keyless_mcp, "parallel_search_keyless") as keyless: + with patch.object(keyless_mcp, "search_with_failover") as ring: out = provider.search("q") - keyless.assert_not_called() + ring.assert_not_called() assert out["success"] is False assert "PARALLEL_API_KEY" in out["error"] + @pytest.mark.asyncio + async def test_tier_paid_extract_without_key_skips_keyless(self, monkeypatch): + monkeypatch.setattr(keyless_mcp, "provider_tier", lambda name: "paid") + provider = ParallelWebSearchProvider() + with patch.object(keyless_mcp, "extract_with_failover") as ring: + out = await provider.extract(["https://example.com/article"]) + ring.assert_not_called() + assert "PARALLEL_API_KEY" in out[0]["error"] + def test_tier_paid_disables_keyless_availability(self, monkeypatch): monkeypatch.setattr(keyless_mcp, "provider_tier", lambda name: "paid") assert ParallelWebSearchProvider().is_keyless_available() is False diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index ffccf19761b43..e2cd3f0e3f270 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -48,7 +48,7 @@ # ─── Web search backends ─────────────────────────────────────────────── "search.exa": ("exa-py==2.10.2",), "search.firecrawl": ("firecrawl-py==4.17.0",), - "search.parallel": ("parallel-web==0.4.2",), + "search.parallel": ("parallel-web==1.3.0",), # ─── Monitoring ───────────────────────────────────────────────────────── # OTLP export; tracks the `otlp` extra. diff --git a/uv.lock b/uv.lock index ec0c477bd2c60..e2ff1d9075d0f 100644 --- a/uv.lock +++ b/uv.lock @@ -1999,7 +1999,7 @@ requires-dist = [ { name = "opentelemetry-sdk", marker = "extra == 'otlp'", specifier = "==1.39.1" }, { name = "openwakeword", marker = "extra == 'wake'", specifier = "==0.6.0" }, { name = "packaging", specifier = "==26.0" }, - { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, + { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==1.3.0" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "pillow", specifier = "==12.3.0" }, { name = "prompt-toolkit", specifier = "==3.0.52" }, @@ -3193,7 +3193,7 @@ wheels = [ [[package]] name = "parallel-web" -version = "0.4.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3203,9 +3203,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/50/fb9b28a679e01682006b5259abff96de3d16e114e9447a7793fec31715de/parallel_web-0.4.2.tar.gz", hash = "sha256:599b5a8f387dc35c7dc8c81e372eadf6958a40acacea58bf170dfc663c003da7", size = 140026, upload-time = "2026-03-09T22:24:35.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/76/ac6596ce1ec2f105e2288d53e022f3cda65600e8c03970b3a6c248bce604/parallel_web-1.3.0.tar.gz", hash = "sha256:a0aaa45535ac907b0eb94f636f6a0c142f8b1658add0510f7dd9a26e8a81b4ce", size = 161808, upload-time = "2026-08-12T21:45:02.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" }, + { url = "https://files.pythonhosted.org/packages/67/35/e753a70f15300f93dae065b5348016fce13f29f5cf5263919d8b33128af1/parallel_web-1.3.0-py3-none-any.whl", hash = "sha256:dc7e93ee500e6773141e8de2e04ad468a39a21cefafa2aff1d833c6e5566690d", size = 175849, upload-time = "2026-08-12T21:45:00.821Z" }, ] [[package]] diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index cccec83cfb00c..8823e769f4e9f 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2474,7 +2474,7 @@ web: **Self-hosted Firecrawl:** Set `FIRECRAWL_API_URL` to point at your own instance. When a custom URL is set, the API key becomes optional (set `USE_DB_AUTHENTICATION=*** on the server to disable auth). -**Parallel search modes:** Set `PARALLEL_SEARCH_MODE` to control search behavior — `fast`, `one-shot`, or `agentic` (default: `agentic`). +**Parallel search modes:** Set `PARALLEL_SEARCH_MODE` to control search behavior. Legacy `agentic` (the default) maps to v1 `advanced`; legacy `one-shot` and `fast` map to v1 `basic`. Explicit v1 `basic`, `advanced`, and `turbo` values pass through. Use `v1-fast` to select the new v1 `fast` mode, which has no Beta equivalent. **Exa:** Set `EXA_API_KEY` in `~/.hermes/.env`. Supports `category` filtering (`company`, `research paper`, `news`, `people`, `personal site`, `pdf`) and domain/date filters. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index f72df7043a5dd..023da9873a4d9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -1520,7 +1520,7 @@ web: **自托管 Firecrawl:** 设置 `FIRECRAWL_API_URL` 指向您自己的实例。设置自定义 URL 后,API 密钥变为可选(在服务器上设置 `USE_DB_AUTHENTICATION=***` 以禁用认证)。 -**Parallel 搜索模式:** 设置 `PARALLEL_SEARCH_MODE` 控制搜索行为 —— `fast`、`one-shot` 或 `agentic`(默认:`agentic`)。 +**Parallel 搜索模式:** 设置 `PARALLEL_SEARCH_MODE` 控制搜索行为。旧版 `agentic`(默认值)映射到 v1 `advanced`;旧版 `one-shot` 和 `fast` 映射到 v1 `basic`。显式 v1 值 `basic`、`advanced` 和 `turbo` 会直接传递。使用 `v1-fast` 选择全新的 v1 `fast` 模式,该模式在 Beta 中没有对应项。 **Exa:** 在 `~/.hermes/.env` 中设置 `EXA_API_KEY`。支持 `category` 过滤(`company`、`research paper`、`news`、`people`、`personal site`、`pdf`)和域名/日期过滤器。