diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx
index 247c0994ec..e964c87f97 100644
--- a/docs/advanced/configuration.mdx
+++ b/docs/advanced/configuration.mdx
@@ -137,11 +137,29 @@ affecting the agents that do the actual testing.
- API key for Exa. Enables real-time web search through the Exa `/search` endpoint. Exa also powers the `web_get_contents` tool, which fetches the full text of a page through the Exa `/contents` endpoint. This is the preferred web search provider.
+ API key for Exa. Enables real-time web search through the Exa `/search` endpoint. Exa powers the `web_get_contents` tool through the Exa `/contents` endpoint unless Parallel is pinned. This is the preferred web search provider.
- Web search provider: `auto`, `perplexity`, or `exa`. With `auto`, Strix uses Exa when `EXA_API_KEY` is set, and Perplexity otherwise. Set an explicit provider to pin one when you configure both keys.
+ Web search provider: `auto`, `perplexity`, `exa`, or `parallel`. With `auto`, Strix uses Exa when `EXA_API_KEY` is set, and Perplexity otherwise. Set an explicit provider to pin one. Parallel is opt-in and must be selected explicitly.
+
+
+
+ API key for [Parallel Search](https://docs.parallel.ai/search/search-quickstart).
+ Set `STRIX_WEB_SEARCH_PROVIDER=parallel` to use it for the built-in `web_search` tool.
+ Requests use `/v1/search` in `fast` mode, returning up to five source URLs with
+ relevant excerpts. Excerpts are shortened to keep output within 12,000 characters
+ plus a truncation notice. Source titles and URLs are always preserved, even if
+ those headers alone exceed the budget.
+ Queries may contain up to 5,000 characters; the full query is the search objective,
+ with its first 200 characters also supplied as a search query.
+ This uses the authenticated API, not the free Search MCP. When Parallel is pinned,
+ `web_get_contents` uses the Parallel `/v1/extract` endpoint with the same key.
+
+ ```bash
+ export STRIX_WEB_SEARCH_PROVIDER="parallel"
+ export PARALLEL_API_KEY="your-parallel-api-key"
+ ```
@@ -235,7 +253,7 @@ strix --target ./app --config /path/to/config.json
export STRIX_LLM="openrouter/z-ai/glm-5.3"
export LLM_API_KEY="sk-..."
-# Optional: Enable web search (Exa preferred, Perplexity supported)
+# Optional: Enable web search (Exa preferred; Perplexity and opt-in Parallel supported)
export EXA_API_KEY="..."
export PERPLEXITY_API_KEY="pplx-..."
diff --git a/docs/tools/overview.mdx b/docs/tools/overview.mdx
index 466d91af63..d04ff0be46 100644
--- a/docs/tools/overview.mdx
+++ b/docs/tools/overview.mdx
@@ -28,6 +28,6 @@ Strix agents use specialized tools to test your applications like a real penetra
| -------------- | ---------------------------------------- |
| Python Runtime | Write and execute custom exploit scripts |
| File Editor | Read and modify source code |
-| Web Search | Real-time OSINT with Exa or Perplexity |
+| Web Search | Real-time OSINT with Exa, Perplexity, or Parallel |
| Notes | Document findings during the scan |
| Reporting | Generate vulnerability reports with PoCs |
diff --git a/strix/config/settings.py b/strix/config/settings.py
index 9b216aa99a..122f234fab 100644
--- a/strix/config/settings.py
+++ b/strix/config/settings.py
@@ -179,7 +179,7 @@ class TelemetrySettings(BaseSettings):
run_record_timings: bool = Field(default=False, alias="STRIX_RUN_RECORD_TIMINGS")
-WebSearchProvider = Literal["auto", "perplexity", "exa"]
+WebSearchProvider = Literal["auto", "perplexity", "exa", "parallel"]
ExaSearchType = Literal["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"]
@@ -196,6 +196,11 @@ class IntegrationSettings(BaseSettings):
alias="EXA_API_KEY",
repr=False,
)
+ parallel_api_key: str | None = Field(
+ default=None,
+ alias="PARALLEL_API_KEY",
+ repr=False,
+ )
web_search_provider: WebSearchProvider = Field(
default="auto",
alias="STRIX_WEB_SEARCH_PROVIDER",
@@ -216,6 +221,11 @@ class IntegrationSettings(BaseSettings):
repr=False,
)
+ @field_validator("web_search_provider", "exa_search_type", mode="before")
+ @classmethod
+ def _normalize_case(cls, value: object) -> object:
+ return _lowercase(value)
+
class ViewerSettings(BaseSettings):
model_config = _BASE_CONFIG
diff --git a/strix/interface/environment.py b/strix/interface/environment.py
index 522067df84..fe293c439a 100644
--- a/strix/interface/environment.py
+++ b/strix/interface/environment.py
@@ -26,6 +26,8 @@ def _missing_web_search_vars(integrations: IntegrationSettings) -> list[str]:
return [] if integrations.exa_api_key else ["EXA_API_KEY"]
if integrations.web_search_provider == "perplexity":
return [] if integrations.perplexity_api_key else ["PERPLEXITY_API_KEY"]
+ if integrations.web_search_provider == "parallel":
+ return [] if integrations.parallel_api_key else ["PARALLEL_API_KEY"]
if integrations.exa_api_key or integrations.perplexity_api_key:
return []
return ["EXA_API_KEY", "PERPLEXITY_API_KEY"]
@@ -34,8 +36,8 @@ def _missing_web_search_vars(integrations: IntegrationSettings) -> list[str]:
def validate_environment() -> None:
logger.info("Validating environment")
console = Console()
- missing_required_vars = []
- missing_optional_vars = []
+ missing_required_vars: list[str] = []
+ missing_optional_vars: list[str] = []
settings = load_settings()
@@ -111,6 +113,10 @@ def validate_environment() -> None:
" - API key for Exa web search (enables real-time research)\n",
style="white",
)
+ elif var == "PARALLEL_API_KEY":
+ error_text.append("• ", style="white")
+ error_text.append("PARALLEL_API_KEY", style="bold cyan")
+ error_text.append(" - API key for Parallel web search\n", style="white")
elif var == "STRIX_REASONING_EFFORT":
error_text.append("• ", style="white")
error_text.append("STRIX_REASONING_EFFORT", style="bold cyan")
@@ -137,6 +143,10 @@ def validate_environment() -> None:
)
elif var == "EXA_API_KEY":
error_text.append("export EXA_API_KEY='your-exa-key-here'\n", style="dim white")
+ elif var == "PARALLEL_API_KEY":
+ error_text.append(
+ "export PARALLEL_API_KEY='your-parallel-key-here'\n", style="dim white"
+ )
elif var == "STRIX_REASONING_EFFORT":
error_text.append(
"export STRIX_REASONING_EFFORT='high'\n",
diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py
index f525b1ea49..bc477d630c 100644
--- a/strix/tools/web_search/tool.py
+++ b/strix/tools/web_search/tool.py
@@ -1,4 +1,4 @@
-"""Security-focused web research tools (Exa or Perplexity)."""
+"""Security-focused web research tools (Exa, Perplexity, or Parallel)."""
from __future__ import annotations
@@ -133,6 +133,98 @@ def _exa_content(api_key: str, query: str, search_type: str, num_results: int) -
return "\n\n".join(blocks)
+_PARALLEL_MAX_CHARS = 12000
+
+
+def _parallel_result_content(results: list[tuple[str, str]]) -> str:
+ header_chars = sum(len(header) for header, _ in results) + 2 * (len(results) - 1)
+ remaining = max(0, _PARALLEL_MAX_CHARS - header_chars)
+ excerpts = [excerpt.rstrip() for _, excerpt in results]
+ allocations = [0] * len(results)
+ nonempty = [index for index, excerpt in enumerate(excerpts) if excerpt]
+ full_cost = sum(len(excerpts[index]) + 2 for index in nonempty)
+
+ if full_cost <= remaining:
+ for index in nonempty:
+ allocations[index] = len(excerpts[index])
+ else:
+ selected = nonempty[: min(len(nonempty), remaining // 3)]
+ content_budget = remaining - 2 * len(selected)
+ pending = set(selected)
+ while pending:
+ share = content_budget // len(pending)
+ satisfied = [index for index in pending if len(excerpts[index]) <= share]
+ if not satisfied:
+ base, extra = divmod(content_budget, len(pending))
+ for offset, index in enumerate(sorted(pending)):
+ allocations[index] = base + (offset < extra)
+ break
+ for index in satisfied:
+ allocations[index] = len(excerpts[index])
+ content_budget -= allocations[index]
+ pending.remove(index)
+
+ blocks: list[str] = []
+ for index, (header, _) in enumerate(results):
+ excerpt = excerpts[index][: allocations[index]].rstrip()
+ blocks.append(f"{header}\n\n{excerpt}" if excerpt else header)
+ truncated = any(
+ allocation < len(excerpt) for allocation, excerpt in zip(allocations, excerpts, strict=True)
+ )
+ content = "\n\n".join(blocks)
+ if truncated:
+ content += "\n[excerpts truncated to preserve source links]"
+ return content
+
+
+def _parallel_content(api_key: str, query: str) -> str:
+ with requests.post(
+ "https://api.parallel.ai/v1/search",
+ headers={"x-api-key": api_key, "Content-Type": "application/json"},
+ json={
+ "objective": query,
+ "search_queries": [query[:200]],
+ "mode": "fast",
+ "max_chars_total": _PARALLEL_MAX_CHARS,
+ "advanced_settings": {"max_results": 5},
+ },
+ timeout=300,
+ ) as response:
+ response.raise_for_status()
+ body = response.json()
+ if not isinstance(body, dict):
+ raise TypeError("Parallel response must be an object")
+ results: list[Any] | None = cast("dict[str, Any]", body).get("results")
+ if not isinstance(results, list):
+ raise TypeError("Parallel response has no results list")
+ if not results:
+ return "No web search results found."
+
+ blocks: list[tuple[str, str]] = []
+ for item in results:
+ if not isinstance(item, dict):
+ continue
+ result = cast("dict[str, Any]", item)
+ raw_url = result.get("url")
+ if not isinstance(raw_url, str) or not (url := raw_url.strip()):
+ continue
+ raw_title = result.get("title")
+ title = raw_title.strip() if isinstance(raw_title, str) else ""
+ header = f"### {title or url}\n{url}"
+ parts: list[str] = []
+ excerpts: list[Any] | None = result.get("excerpts")
+ if isinstance(excerpts, list):
+ parts.extend(
+ text.strip() for text in excerpts if isinstance(text, str) and text.strip()
+ )
+ blocks.append((header, "\n\n".join(parts)))
+ if len(blocks) == 5:
+ break
+ if not blocks:
+ raise ValueError("Parallel response has no usable results")
+ return _parallel_result_content(blocks)
+
+
def _normalize_url(url: str) -> str:
"""Canonical form for matching: case-fold scheme and host only, drop a trailing slash."""
parts = urlsplit(url.strip())
@@ -161,6 +253,61 @@ def _exa_page_text(api_key: str, urls: list[str]) -> tuple[str, set[str]]:
return "\n\n".join(blocks), fetched
+def _parallel_page_text(api_key: str, urls: list[str]) -> tuple[str, set[str]]:
+ """Fetch page text and report which of the requested URLs Parallel returned."""
+ headers = {"x-api-key": api_key, "Content-Type": "application/json"}
+ payload = {
+ "urls": urls,
+ "advanced_settings": {"full_content": {"max_chars_per_result": _EXA_PAGE_MAX_CHARS}},
+ }
+ with requests.post(
+ "https://api.parallel.ai/v1/extract",
+ headers=headers,
+ json=payload,
+ timeout=300,
+ ) as response:
+ response.raise_for_status()
+ body = response.json()
+ if not isinstance(body, dict):
+ raise TypeError("Parallel Extract response must be an object")
+ results: list[Any] | None = cast("dict[str, Any]", body).get("results")
+ if not isinstance(results, list):
+ raise TypeError("Parallel Extract response has no results list")
+
+ blocks: list[str] = []
+ fetched: set[str] = set()
+ for item in results:
+ if not isinstance(item, dict):
+ continue
+ result = cast("dict[str, Any]", item)
+ raw_url = result.get("url")
+ if not isinstance(raw_url, str) or not (url := raw_url.strip()):
+ continue
+ raw_content = result.get("full_content")
+ content = raw_content.strip() if isinstance(raw_content, str) else ""
+ if not content:
+ excerpts: list[Any] | None = result.get("excerpts")
+ if isinstance(excerpts, list):
+ content = "\n\n".join(
+ excerpt.strip()
+ for excerpt in excerpts
+ if isinstance(excerpt, str) and excerpt.strip()
+ )
+ if not content:
+ continue
+ if len(content) > _EXA_PAGE_MAX_CHARS:
+ content = (
+ f"{content[:_EXA_PAGE_MAX_CHARS]}\n[truncated at {_EXA_PAGE_MAX_CHARS} characters]"
+ )
+ raw_title = result.get("title")
+ title = raw_title.strip() if isinstance(raw_title, str) else ""
+ blocks.append(f"### {title or url}\n{url}\n\n{content}")
+ fetched.add(_normalize_url(url))
+ if not blocks:
+ raise ValueError("Parallel Extract returned no page contents")
+ return "\n\n".join(blocks), fetched
+
+
def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs its own return
integrations: Any,
) -> tuple[str, str] | dict[str, Any]:
@@ -177,6 +324,10 @@ def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs i
if not exa_key:
return _not_configured_error("EXA_API_KEY")
return ("exa", exa_key)
+ if provider == "parallel":
+ if not integrations.parallel_api_key:
+ return _not_configured_error("PARALLEL_API_KEY")
+ return ("parallel", integrations.parallel_api_key)
if exa_key:
return ("exa", exa_key)
@@ -216,7 +367,7 @@ def _guarded_call[T]( # noqa: PLR0911 - each error class needs its own sanitize
except requests.exceptions.RequestException:
logger.exception("%s network error", tool)
return {"success": False, "error": f"{tool} network error. Try again later"}
- except (KeyError, IndexError, ValueError):
+ except (KeyError, IndexError, TypeError, ValueError):
logger.exception("%s response shape unexpected", tool)
return {"success": False, "error": f"{tool} returned an unexpected response. Try again"}
except Exception:
@@ -225,7 +376,8 @@ def _guarded_call[T]( # noqa: PLR0911 - each error class needs its own sanitize
def _do_search(query: str) -> dict[str, Any]:
- if not query or not query.strip():
+ query = query.strip()
+ if not query:
return {"success": False, "error": "Query cannot be empty"}
integrations = load_settings().integrations
@@ -233,9 +385,16 @@ def _do_search(query: str) -> dict[str, Any]:
if isinstance(resolved, dict):
return resolved
provider, api_key = resolved
+ if provider == "parallel" and len(query) > 5000:
+ return {
+ "success": False,
+ "error": "Parallel search queries must be at most 5000 characters",
+ }
logger.info("web_search provider=%s query (len=%d): %s", provider, len(query), query[:120])
def fetch() -> str:
+ if provider == "parallel":
+ return _parallel_content(api_key, query)
if provider == "exa":
return _exa_content(
api_key,
@@ -248,7 +407,10 @@ def fetch() -> str:
outcome = _guarded_call(
"Web search",
(
- "Web search rejected the query. Refine it "
+ "Parallel rejected the request. Check PARALLEL_API_KEY, account credits, "
+ "rate limits, and query length"
+ if provider == "parallel"
+ else "Web search rejected the query. Refine it "
"(more specific, shorter, no unusual characters) and retry"
),
fetch,
@@ -263,7 +425,9 @@ def fetch() -> str:
}
-def _do_get_contents(urls: list[str]) -> dict[str, Any]:
+def _do_get_contents( # noqa: PLR0911 - validation and provider errors return directly
+ urls: list[str],
+) -> dict[str, Any]:
cleaned = [url.strip() for url in urls if url and url.strip()]
if not cleaned:
return {"success": False, "error": "Provide at least one URL"}
@@ -274,9 +438,6 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
}
integrations = load_settings().integrations
- api_key = integrations.exa_api_key
- if not api_key:
- return _not_configured_error("EXA_API_KEY")
if integrations.web_search_provider == "perplexity":
logger.warning("web_get_contents invoked while the provider is pinned to Perplexity")
return {
@@ -288,11 +449,26 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
),
}
- logger.info("web_get_contents urls=%d", len(cleaned))
+ provider = integrations.web_search_provider
+ if provider == "parallel":
+ api_key = integrations.parallel_api_key
+ if not api_key:
+ return _not_configured_error("PARALLEL_API_KEY")
+ page_fetch = _parallel_page_text
+ source_name = "Parallel Extract"
+ else:
+ api_key = integrations.exa_api_key
+ if not api_key:
+ return _not_configured_error("EXA_API_KEY")
+ provider = "exa"
+ page_fetch = _exa_page_text
+ source_name = "Exa"
+
+ logger.info("web_get_contents provider=%s urls=%d", provider, len(cleaned))
outcome = _guarded_call(
"Page fetch",
"Page fetch was rejected. Check the URLs are complete, public, and correctly formed",
- lambda: _exa_page_text(api_key, cleaned),
+ lambda: page_fetch(api_key, cleaned),
)
if isinstance(outcome, dict):
return outcome
@@ -301,7 +477,7 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
result: dict[str, Any] = {
"success": True,
"urls": [url for url in cleaned if url not in missing],
- "provider": "exa",
+ "provider": provider,
"content": content,
}
if missing:
@@ -310,7 +486,8 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
)
result["failed_urls"] = missing
result["warning"] = (
- f"Exa returned no content for {len(missing)} of {len(cleaned)} requested URLs. "
+ f"{source_name} returned no content for {len(missing)} of {len(cleaned)} requested "
+ "URLs. "
"Those pages are missing from the content below"
)
return result
@@ -318,7 +495,7 @@ def _do_get_contents(urls: list[str]) -> dict[str, Any]:
@function_tool(timeout=330)
async def web_search(ctx: RunContextWrapper, query: str) -> str:
- """Real-time web search (Exa or Perplexity) — your primary research tool.
+ """Real-time web search (Exa, Perplexity, or Parallel) — your primary research tool.
Use it liberally for anything that's not in your training data:
@@ -354,6 +531,11 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str:
full page text when a summary is not enough. With Perplexity you get
a single synthesized cited answer.
+ With Parallel you get ranked titles, source URLs, and relevant excerpts,
+ capped to keep the response concise. These are source excerpts, not a
+ synthesized answer. ``web_get_contents`` uses Parallel Extract when the
+ operator pins Parallel.
+
**Good example queries** (each is a full sentence, names a
version/product, and asks one concrete thing):
@@ -385,7 +567,7 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str:
@function_tool(timeout=330)
async def web_get_contents(ctx: RunContextWrapper, urls: list[str]) -> str:
- """Fetch the full, cleaned text of specific web pages (Exa only).
+ """Fetch the full, cleaned text of specific web pages (Exa or Parallel Extract).
Use this as the drill-down step after ``web_search``: when a result's
summary is not enough, pass that result's URL here to read the whole
@@ -396,9 +578,10 @@ async def web_get_contents(ctx: RunContextWrapper, urls: list[str]) -> str:
the few URLs worth reading in full — each page can be large, so avoid
fetching many pages you do not need.
- This tool needs the Exa provider (``EXA_API_KEY``). When the operator
- pins the provider to Perplexity, it returns an error and you should
- use ``web_search`` instead.
+ This tool uses Parallel Extract (``PARALLEL_API_KEY``) when the operator
+ pins Parallel. Otherwise it uses Exa (``EXA_API_KEY``). When the operator
+ pins Perplexity, it returns an error before checking any Exa key; use
+ ``web_search`` instead.
Some pages block extraction. When a page returns no content, the
result lists it under ``failed_urls`` and the ``content`` field holds
diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py
index 463853d222..0a42d11c6c 100644
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -31,6 +31,7 @@
"LLM_TIMEOUT",
"PERPLEXITY_API_KEY",
"EXA_API_KEY",
+ "PARALLEL_API_KEY",
"STRIX_WEB_SEARCH_PROVIDER",
# RuntimeSettings
"STRIX_IMAGE",
@@ -99,6 +100,30 @@ def test_read_json_overrides_maps_exa_and_provider(tmp_path: Path) -> None:
}
+def test_parallel_settings_persistence_and_env_precedence(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ target = tmp_path / "cli-config.json"
+ loader.apply_config_override(target)
+ monkeypatch.setenv("STRIX_WEB_SEARCH_PROVIDER", "parallel")
+ monkeypatch.setenv("PARALLEL_API_KEY", "parallel-file-key")
+ loader.persist_current()
+ assert target.stat().st_mode & 0o777 == 0o600
+
+ monkeypatch.delenv("PARALLEL_API_KEY")
+ monkeypatch.delenv("STRIX_WEB_SEARCH_PROVIDER")
+ loader.apply_config_override(target)
+ integrations = loader.load_settings().integrations
+ assert integrations.web_search_provider == "parallel"
+ assert integrations.parallel_api_key == "parallel-file-key"
+ assert "parallel-file-key" not in repr(integrations)
+
+ monkeypatch.setenv("PARALLEL_API_KEY", "parallel-env-key")
+ loader.apply_config_override(target)
+ assert loader.load_settings().integrations.parallel_api_key == "parallel-env-key"
+
+
def test_read_json_overrides_skips_keys_already_in_environ(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git a/tests/test_web_search.py b/tests/test_web_search.py
index 6aff26ba14..5f65c593a0 100644
--- a/tests/test_web_search.py
+++ b/tests/test_web_search.py
@@ -1,8 +1,8 @@
-"""Tests for web_search/web_get_contents provider selection and the Exa backend."""
+"""Tests for web_search/web_get_contents provider selection and backends."""
from __future__ import annotations
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
import pytest
import requests
@@ -34,6 +34,19 @@ def json(self) -> dict[str, Any]:
return self._body
+@pytest.fixture(autouse=True)
+def _isolate_search_settings(monkeypatch: pytest.MonkeyPatch) -> None:
+ for key in (
+ "PERPLEXITY_API_KEY",
+ "EXA_API_KEY",
+ "PARALLEL_API_KEY",
+ "STRIX_WEB_SEARCH_PROVIDER",
+ "STRIX_EXA_SEARCH_TYPE",
+ "STRIX_EXA_NUM_RESULTS",
+ ):
+ monkeypatch.delenv(key, raising=False)
+
+
def test_auto_prefers_exa_when_both_keys_set() -> None:
integrations = IntegrationSettings(PERPLEXITY_API_KEY="pk", EXA_API_KEY="ek")
assert tool._resolve_provider(integrations) == ("exa", "ek")
@@ -167,6 +180,9 @@ class _Settings:
({"STRIX_WEB_SEARCH_PROVIDER": "exa", "EXA_API_KEY": "ek"}, []),
({"STRIX_WEB_SEARCH_PROVIDER": "perplexity", "EXA_API_KEY": "ek"}, ["PERPLEXITY_API_KEY"]),
({"STRIX_WEB_SEARCH_PROVIDER": "perplexity", "PERPLEXITY_API_KEY": "pk"}, []),
+ ({"STRIX_WEB_SEARCH_PROVIDER": "parallel", "EXA_API_KEY": "ek"}, ["PARALLEL_API_KEY"]),
+ ({"STRIX_WEB_SEARCH_PROVIDER": "parallel", "PARALLEL_API_KEY": "key"}, []),
+ ({"PARALLEL_API_KEY": "key"}, ["EXA_API_KEY", "PERPLEXITY_API_KEY"]),
],
)
def test_environment_validation_follows_provider_rules(
@@ -284,12 +300,67 @@ class _Settings:
assert "EXA_API_KEY" in result["error"]
-def test_do_get_contents_refuses_a_perplexity_pinned_provider(
+def test_do_get_contents_uses_parallel_extract_when_provider_is_pinned(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _Settings:
integrations = IntegrationSettings(
EXA_API_KEY="ek",
+ PARALLEL_API_KEY="parallel-key",
+ STRIX_WEB_SEARCH_PROVIDER="parallel",
+ )
+
+ monkeypatch.setattr(tool, "load_settings", _Settings)
+ monkeypatch.setattr(
+ tool,
+ "_parallel_page_text",
+ lambda *_a: ("parallel page", {"https://ex.example"}),
+ raising=False,
+ )
+ monkeypatch.setattr(
+ tool,
+ "_exa_page_text",
+ lambda *_a: pytest.fail("Pinned Parallel must not send URLs to Exa"),
+ )
+
+ result = tool._do_get_contents(["https://ex.example"])
+
+ assert result == {
+ "success": True,
+ "urls": ["https://ex.example"],
+ "provider": "parallel",
+ "content": "parallel page",
+ }
+
+
+def test_do_get_contents_pinned_parallel_requires_parallel_key(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ class _Settings:
+ integrations = IntegrationSettings(
+ EXA_API_KEY="ek",
+ STRIX_WEB_SEARCH_PROVIDER="parallel",
+ )
+
+ monkeypatch.setattr(tool, "load_settings", _Settings)
+ monkeypatch.setattr(
+ tool,
+ "_exa_page_text",
+ lambda *_a: pytest.fail("Pinned Parallel must not fall back to Exa"),
+ )
+
+ result = tool._do_get_contents(["https://ex.example"])
+
+ assert result["success"] is False
+ assert "PARALLEL_API_KEY" in result["error"]
+ assert "EXA_API_KEY" not in result["error"]
+
+
+def test_do_get_contents_refuses_a_perplexity_pinned_provider(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ class _Settings:
+ integrations = IntegrationSettings(
PERPLEXITY_API_KEY="pk",
STRIX_WEB_SEARCH_PROVIDER="perplexity",
)
@@ -300,6 +371,7 @@ class _Settings:
assert result["success"] is False
assert "web_search" in result["error"]
+ assert "EXA_API_KEY" not in result["error"]
def test_do_get_contents_returns_page_text(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -399,6 +471,162 @@ def boom(*_args: Any, **_kwargs: Any) -> None:
assert "ek" not in result["error"]
+def test_parallel_page_text_requests_full_content_and_renders_pages(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: dict[str, Any] = {}
+
+ def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
+ captured.update(url=url, **kwargs)
+ return _FakeResponse(
+ {
+ "results": [
+ {
+ "url": "https://vendor.example/advisory",
+ "title": " Vendor advisory ",
+ "excerpts": ["Focused excerpt."],
+ "full_content": " Full advisory body. ",
+ },
+ {
+ "url": "https://vendor.example/docs",
+ "title": None,
+ "excerpts": [" Excerpt fallback. "],
+ "full_content": None,
+ },
+ {"url": " ", "excerpts": ["Malformed."]},
+ ],
+ "errors": [{"url": "https://blocked.example", "error_type": "fetch_error"}],
+ }
+ )
+
+ monkeypatch.setattr(requests, "post", fake_post)
+
+ content, fetched = tool._parallel_page_text(
+ "parallel-key",
+ ["https://vendor.example/advisory", "https://vendor.example/docs"],
+ )
+
+ assert captured["url"] == "https://api.parallel.ai/v1/extract"
+ assert captured["headers"] == {
+ "x-api-key": "parallel-key",
+ "Content-Type": "application/json",
+ }
+ assert captured["json"] == {
+ "urls": ["https://vendor.example/advisory", "https://vendor.example/docs"],
+ "advanced_settings": {"full_content": {"max_chars_per_result": tool._EXA_PAGE_MAX_CHARS}},
+ }
+ assert captured["timeout"] == 300
+ assert content == (
+ "### Vendor advisory\nhttps://vendor.example/advisory\n\nFull advisory body.\n\n"
+ "### https://vendor.example/docs\nhttps://vendor.example/docs\n\nExcerpt fallback."
+ )
+ assert fetched == {
+ "https://vendor.example/advisory",
+ "https://vendor.example/docs",
+ }
+
+
+@pytest.mark.parametrize(
+ ("body", "exception", "message"),
+ [
+ (cast("Any", []), TypeError, "must be an object"),
+ ({"results": []}, ValueError, "no page contents"),
+ (
+ {
+ "results": [
+ {
+ "url": "https://empty.example",
+ "full_content": " ",
+ "excerpts": [" "],
+ }
+ ]
+ },
+ ValueError,
+ "no page contents",
+ ),
+ ],
+)
+def test_parallel_page_text_rejects_empty_or_contentless_responses(
+ monkeypatch: pytest.MonkeyPatch,
+ body: Any,
+ exception: type[Exception],
+ message: str,
+) -> None:
+ monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body))
+
+ with pytest.raises(exception, match=message):
+ tool._parallel_page_text("parallel-key", ["https://empty.example"])
+
+
+def test_parallel_page_text_truncates_a_long_page(monkeypatch: pytest.MonkeyPatch) -> None:
+ body = "A" * (tool._EXA_PAGE_MAX_CHARS + 500)
+ monkeypatch.setattr(
+ requests,
+ "post",
+ lambda *_a, **_kw: _FakeResponse(
+ {
+ "results": [
+ {
+ "url": "https://long.example",
+ "full_content": body,
+ "excerpts": [],
+ }
+ ]
+ }
+ ),
+ )
+
+ content, fetched = tool._parallel_page_text("parallel-key", ["https://long.example"])
+
+ assert content.count("A") == tool._EXA_PAGE_MAX_CHARS
+ assert f"[truncated at {tool._EXA_PAGE_MAX_CHARS} characters]" in content
+ assert fetched == {"https://long.example"}
+
+
+def test_do_get_contents_reports_parallel_extract_partial_failures(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ class _Settings:
+ integrations = IntegrationSettings(
+ PARALLEL_API_KEY="parallel-key",
+ STRIX_WEB_SEARCH_PROVIDER="parallel",
+ )
+
+ monkeypatch.setattr(tool, "load_settings", _Settings)
+ monkeypatch.setattr(
+ requests,
+ "post",
+ lambda *_a, **_kw: _FakeResponse(
+ {
+ "results": [
+ {
+ "url": "https://ok.example/",
+ "full_content": "Page body.",
+ "excerpts": [],
+ }
+ ],
+ "errors": [
+ {
+ "url": "https://blocked.example",
+ "error_type": "fetch_error",
+ "http_status_code": 403,
+ "content": None,
+ }
+ ],
+ }
+ ),
+ )
+
+ result = tool._do_get_contents(["https://ok.example", "https://blocked.example"])
+
+ assert result["success"] is True
+ assert result["provider"] == "parallel"
+ assert result["urls"] == ["https://ok.example"]
+ assert result["failed_urls"] == ["https://blocked.example"]
+ assert "Parallel Extract" in result["warning"]
+ assert "1 of 2" in result["warning"]
+
+
def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch) -> None:
class _Settings:
integrations = IntegrationSettings(EXA_API_KEY="ek")
@@ -414,3 +642,313 @@ class _Settings:
"provider": "exa",
"content": "answer",
}
+
+
+@pytest.mark.parametrize(
+ ("provider", "keys", "expected"),
+ [
+ ("parallel", {"PARALLEL_API_KEY": "key", "EXA_API_KEY": "ek"}, ("parallel", "key")),
+ ("parallel", {"EXA_API_KEY": "ek"}, "PARALLEL_API_KEY"),
+ ("auto", {"PARALLEL_API_KEY": "key", "EXA_API_KEY": "ek"}, ("exa", "ek")),
+ ("auto", {"PARALLEL_API_KEY": "key", "PERPLEXITY_API_KEY": "pk"}, ("perplexity", "pk")),
+ ("auto", {"PARALLEL_API_KEY": "key"}, "EXA_API_KEY or PERPLEXITY_API_KEY"),
+ ],
+)
+def test_parallel_selection_is_opt_in(
+ provider: str,
+ keys: dict[str, str],
+ expected: tuple[str, str] | str,
+) -> None:
+ integrations = IntegrationSettings.model_validate(
+ {"STRIX_WEB_SEARCH_PROVIDER": provider, **keys}
+ )
+ result = tool._resolve_provider(integrations)
+ if isinstance(expected, str):
+ assert isinstance(result, dict)
+ assert result["success"] is False
+ assert expected in result["error"]
+ else:
+ assert result == expected
+
+
+def test_web_search_provider_and_exa_type_are_case_insensitive() -> None:
+ integrations = IntegrationSettings.model_validate(
+ {
+ "STRIX_WEB_SEARCH_PROVIDER": " PARALLEL ",
+ "STRIX_EXA_SEARCH_TYPE": "DEEP-REASONING",
+ }
+ )
+
+ assert integrations.web_search_provider == "parallel"
+ assert integrations.exa_search_type == "deep-reasoning"
+
+
+@pytest.fixture
+def _parallel_settings(monkeypatch: pytest.MonkeyPatch) -> None:
+ class _Settings:
+ integrations = IntegrationSettings(
+ STRIX_WEB_SEARCH_PROVIDER="parallel",
+ PARALLEL_API_KEY="test-parallel-secret",
+ )
+
+ monkeypatch.setattr(tool, "load_settings", _Settings)
+
+
+@pytest.mark.usefixtures("_parallel_settings")
+def test_parallel_request_and_tool_response(monkeypatch: pytest.MonkeyPatch) -> None:
+ captured: dict[str, Any] = {}
+
+ def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
+ captured.update(url=url, **kwargs)
+ return _FakeResponse(
+ {
+ "results": [
+ {
+ "title": "Vendor advisory",
+ "url": "https://vendor.example/advisory",
+ "excerpts": [" Affected versions. ", "Fixed versions."],
+ },
+ {"title": None, "url": "https://vendor.example/docs", "excerpts": []},
+ {"title": "Missing URL"},
+ "malformed entry",
+ ]
+ }
+ )
+
+ monkeypatch.setattr(requests, "post", fake_post)
+ query = ("Find affected versions and mitigations in the vendor advisory. " * 4).strip()
+ result = tool._do_search(query)
+ assert captured["url"] == "https://api.parallel.ai/v1/search"
+ assert captured["headers"] == {
+ "x-api-key": "test-parallel-secret",
+ "Content-Type": "application/json",
+ }
+ assert captured["json"] == {
+ "objective": query,
+ "search_queries": [query[:200]],
+ "mode": "fast",
+ "max_chars_total": 12000,
+ "advanced_settings": {"max_results": 5},
+ }
+ assert captured["timeout"] == 300
+ assert result == {
+ "success": True,
+ "query": query,
+ "provider": "parallel",
+ "content": "### Vendor advisory\nhttps://vendor.example/advisory\n\n"
+ "Affected versions.\n\nFixed versions.\n\n"
+ "### https://vendor.example/docs\nhttps://vendor.example/docs",
+ }
+
+
+@pytest.mark.usefixtures("_parallel_settings")
+def test_parallel_search_strips_query_before_validation_and_request(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: dict[str, Any] = {}
+
+ def fake_post(_url: str, **kwargs: Any) -> _FakeResponse:
+ captured["json"] = kwargs["json"]
+ return _FakeResponse({"results": [{"url": "https://vendor.example"}]})
+
+ monkeypatch.setattr(requests, "post", fake_post)
+ result = tool._do_search(" " * 250 + "vendor advisory ")
+
+ assert result["success"] is True
+ assert result["query"] == "vendor advisory"
+ assert captured["json"]["objective"] == "vendor advisory"
+ assert captured["json"]["search_queries"] == ["vendor advisory"]
+
+
+@pytest.mark.usefixtures("_parallel_settings")
+@pytest.mark.parametrize(
+ ("body", "success", "text"),
+ [
+ ({"results": []}, True, "No web search results found."),
+ ({}, False, "unexpected response"),
+ ({"results": "invalid"}, False, "unexpected response"),
+ ({"results": [{"title": "No URL"}]}, False, "unexpected response"),
+ ],
+)
+def test_parallel_empty_or_malformed_results(
+ monkeypatch: pytest.MonkeyPatch,
+ body: dict[str, Any],
+ success: bool,
+ text: str,
+) -> None:
+ monkeypatch.setattr(requests, "post", lambda *_a, **_kw: _FakeResponse(body))
+ result = tool._do_search("vendor advisory")
+ assert result["success"] is success
+ assert text in result["content" if success else "error"]
+
+
+def test_parallel_caps_output(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(
+ requests,
+ "post",
+ lambda *_a, **_kw: _FakeResponse(
+ {
+ "results": [{"url": "https://vendor.example", "excerpts": ["x" * 20000]}],
+ }
+ ),
+ )
+ result = tool._parallel_content("key", "vendor advisory")
+ assert result.startswith("### https://vendor.example\nhttps://vendor.example")
+ notice = "\n[excerpts truncated to preserve source links]"
+ assert result.endswith(notice)
+ assert len(result) == 12000 + len(notice)
+
+
+def test_parallel_filters_before_capping_results(monkeypatch: pytest.MonkeyPatch) -> None:
+ malformed: list[Any] = [
+ "not an object",
+ {"url": " ", "title": "Blank URL"},
+ {"title": "Missing URL"},
+ ]
+ usable = [
+ {
+ "url": f" https://vendor.example/{index} ",
+ "title": " " if index == 0 else f" Result {index} ",
+ "excerpts": [f"Excerpt {index}."],
+ }
+ for index in range(6)
+ ]
+ monkeypatch.setattr(
+ requests,
+ "post",
+ lambda *_a, **_kw: _FakeResponse({"results": [*malformed, *usable]}),
+ )
+
+ content = tool._parallel_content("key", "vendor advisories")
+
+ for index in range(5):
+ assert f"https://vendor.example/{index}" in content
+ assert "https://vendor.example/5" not in content
+ assert "### https://vendor.example/0\nhttps://vendor.example/0" in content
+ assert "Blank URL" not in content
+
+
+def test_parallel_spreads_excerpt_budget_and_strips_trailing_whitespace(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ headers = ["### First\nhttps://first.example", "### Second\nhttps://second.example"]
+ header_text = "\n\n".join(headers)
+ monkeypatch.setattr(tool, "_PARALLEL_MAX_CHARS", len(header_text) + 24)
+
+ content = tool._parallel_result_content(
+ [(headers[0], "a" * 40 + "\n\n"), (headers[1], "b" * 40 + "\n\n")]
+ )
+
+ assert f"{headers[0]}\n\na" in content
+ assert f"{headers[1]}\n\nb" in content
+ assert "\n\n\n\n### Second" not in content
+ assert content.endswith("[excerpts truncated to preserve source links]")
+
+
+def test_parallel_redistributes_unused_excerpt_budget() -> None:
+ results = [
+ ("### Long\nhttps://long.example", "a" * 3500),
+ *[(f"### Short {index}\nhttps://short{index}.example", "b" * 100) for index in range(4)],
+ ]
+
+ content = tool._parallel_result_content(results)
+
+ assert "a" * 3500 in content
+ assert content.count("b" * 100) == 4
+ assert "[excerpts truncated" not in content
+ assert len(content) <= tool._PARALLEL_MAX_CHARS
+
+
+def test_parallel_strips_trailing_excerpt_whitespace() -> None:
+ content = tool._parallel_result_content(
+ [("### Result\nhttps://result.example", "Complete excerpt.\n\n")]
+ )
+
+ assert content == "### Result\nhttps://result.example\n\nComplete excerpt."
+
+
+@pytest.mark.parametrize("first_excerpt_size", [11920, 20000])
+def test_parallel_truncation_keeps_all_source_headers(
+ monkeypatch: pytest.MonkeyPatch,
+ first_excerpt_size: int,
+) -> None:
+ results = [
+ {
+ "title": f"Vendor advisory {index}",
+ "url": f"https://vendor.example/advisory/{index}/" + "a" * 100,
+ "excerpts": ["x" * (first_excerpt_size if index == 0 else 2000)],
+ }
+ for index in range(5)
+ ]
+ monkeypatch.setattr(
+ requests,
+ "post",
+ lambda *_a, **_kw: _FakeResponse({"results": results}),
+ )
+ content = tool._parallel_content("key", "vendor advisories")
+ for result in results:
+ assert f"### {result['title']}\n{result['url']}" in content
+ notice = "\n[excerpts truncated to preserve source links]"
+ assert content.endswith(notice)
+ assert len(content) <= 12000 + len(notice)
+
+
+@pytest.mark.parametrize("spare_chars", [-1, 0, 1, 2, 3])
+def test_parallel_keeps_headers_when_no_excerpt_fits(
+ monkeypatch: pytest.MonkeyPatch,
+ spare_chars: int,
+) -> None:
+ headers = ["### First\nhttps://first.example", "### Second\nhttps://second.example"]
+ header_text = "\n\n".join(headers)
+ monkeypatch.setattr(tool, "_PARALLEL_MAX_CHARS", len(header_text) + spare_chars)
+ content = tool._parallel_result_content([(header, "excerpt") for header in headers])
+ assert all(header in content for header in headers)
+ notice = "\n[excerpts truncated to preserve source links]"
+ assert content.endswith(notice)
+ assert len(content) <= max(len(header_text), tool._PARALLEL_MAX_CHARS) + len(notice)
+
+
+@pytest.mark.usefixtures("_parallel_settings")
+@pytest.mark.parametrize("status", [401, 402, 422, 429, 503])
+def test_parallel_http_failures_are_sanitized(monkeypatch: pytest.MonkeyPatch, status: int) -> None:
+ def fail(*_args: Any, **_kwargs: Any) -> _FakeResponse:
+ response = requests.Response()
+ response.status_code = status
+ raise requests.HTTPError("test-parallel-secret: upstream body", response=response)
+
+ monkeypatch.setattr(requests, "post", fail)
+ result = tool._do_search("vendor advisory")
+ assert result["success"] is False
+ assert "test-parallel-secret" not in result["error"]
+ assert "upstream body" not in result["error"]
+ assert ("PARALLEL_API_KEY" if status < 500 else "unavailable") in result["error"]
+
+
+@pytest.mark.usefixtures("_parallel_settings")
+@pytest.mark.parametrize(
+ ("exception", "message"),
+ [(requests.Timeout, "timed out"), (requests.ConnectionError, "network error")],
+)
+def test_parallel_network_failures(
+ monkeypatch: pytest.MonkeyPatch,
+ exception: type[requests.RequestException],
+ message: str,
+) -> None:
+ def fail(*_args: Any, **_kwargs: Any) -> _FakeResponse:
+ raise exception("upstream error")
+
+ monkeypatch.setattr(requests, "post", fail)
+ result = tool._do_search("vendor advisory")
+ assert result["success"] is False
+ assert message in result["error"]
+
+
+@pytest.mark.usefixtures("_parallel_settings")
+def test_parallel_rejects_oversized_objective_before_http(monkeypatch: pytest.MonkeyPatch) -> None:
+ def unexpected_call(*_args: Any, **_kwargs: Any) -> _FakeResponse:
+ pytest.fail("Invalid input must not make a request")
+
+ monkeypatch.setattr(requests, "post", unexpected_call)
+ result = tool._do_search("x" * 5001)
+ assert result["success"] is False
+ assert "5000" in result["error"]