mirror of
https://github.com/usestrix/strix.git
synced 2026-11-14 09:01:25 +00:00
Merge e36603632e into cb581bf93d
This commit is contained in:
commit
bbb009caff
7 changed files with 811 additions and 27 deletions
|
|
@ -137,11 +137,29 @@ affecting the agents that do the actual testing.
|
|||
</ParamField>
|
||||
|
||||
<ParamField path="EXA_API_KEY" type="string">
|
||||
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.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_WEB_SEARCH_PROVIDER" default="auto" type="string">
|
||||
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.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="PARALLEL_API_KEY" type="string">
|
||||
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"
|
||||
```
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_EXA_SEARCH_TYPE" default="auto" type="string">
|
||||
|
|
@ -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-..."
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 _do_search(query: str) -> dict[str, Any]:
|
|||
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 _do_search(query: str) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ _LLM_ENV_KEYS = [
|
|||
"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:
|
||||
|
|
|
|||
|
|
@ -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 @@ class _FakeResponse:
|
|||
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 @@ def test_do_search_reports_empty_exa_results_as_unexpected(
|
|||
({"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 @@ def test_do_get_contents_needs_an_exa_key(monkeypatch: pytest.MonkeyPatch) -> No
|
|||
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 @@ def test_do_get_contents_refuses_a_perplexity_pinned_provider(
|
|||
|
||||
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 test_do_get_contents_sanitizes_a_network_error(monkeypatch: pytest.MonkeyPat
|
|||
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 @@ def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch)
|
|||
"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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue