Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors/emails/alex@thealexferrari.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alexferrari88
31 changes: 27 additions & 4 deletions plugins/web/parallel/provider.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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 [])
Expand All @@ -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 ""}}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
203 changes: 203 additions & 0 deletions tests/plugins/web/test_parallel_provider.py
Original file line number Diff line number Diff line change
@@ -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"},
},
]
53 changes: 47 additions & 6 deletions tests/tools/test_web_keyless_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"""

import json
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch

import pytest

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tools/lazy_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading