From 3822cbb1602ef636cdf1c74675c5eb86027e0c23 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Fri, 4 Sep 2026 11:52:15 +0000 Subject: [PATCH] feat(web_search): add Exa as a web search provider alongside Perplexity --- README.md | 3 +- docs/advanced/configuration.mdx | 11 ++- docs/tools/overview.mdx | 2 +- strix/config/settings.py | 12 +++ strix/interface/environment.py | 14 +++- strix/tools/web_search/tool.py | 107 +++++++++++++++++++----- tests/test_config_loader.py | 13 +++ tests/test_web_search.py | 141 ++++++++++++++++++++++++++++++++ 8 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 tests/test_web_search.py diff --git a/README.md b/README.md index e71e2c1d..8b6d329d 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,8 @@ export LLM_API_KEY="your-api-key" # Optional export LLM_API_BASE="your-api-base-url" # if using a local model, e.g. Ollama, LMStudio -export PERPLEXITY_API_KEY="your-api-key" # for search capabilities +export EXA_API_KEY="your-api-key" # for search capabilities (preferred) +export PERPLEXITY_API_KEY="your-api-key" # alternative search provider ``` > [!NOTE] diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index a6a46f36..9152c37b 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -80,6 +80,14 @@ affecting the agents that do the actual testing. API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research. + + API key for Exa. Enables real-time web search through the Exa `/answer` endpoint. 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. + + Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://`), and Postman environments (`postman://?env=`) to resolve collection variables. Not needed when passing a local collection export file. @@ -159,7 +167,8 @@ 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 +# Optional: Enable web search (Exa preferred, Perplexity supported) +export EXA_API_KEY="..." export PERPLEXITY_API_KEY="pplx-..." # Optional: Custom timeouts diff --git a/docs/tools/overview.mdx b/docs/tools/overview.mdx index 4a5db0a0..466d91af 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 via Perplexity | +| Web Search | Real-time OSINT with Exa or Perplexity | | 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 42a2c97e..e0aaa409 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -120,6 +120,9 @@ class TelemetrySettings(BaseSettings): enabled: bool = Field(default=True, alias="STRIX_TELEMETRY") +WebSearchProvider = Literal["auto", "perplexity", "exa"] + + class IntegrationSettings(BaseSettings): model_config = _BASE_CONFIG @@ -128,6 +131,15 @@ class IntegrationSettings(BaseSettings): alias="PERPLEXITY_API_KEY", repr=False, ) + exa_api_key: str | None = Field( + default=None, + alias="EXA_API_KEY", + repr=False, + ) + web_search_provider: WebSearchProvider = Field( + default="auto", + alias="STRIX_WEB_SEARCH_PROVIDER", + ) postman_api_key: str | None = Field( default=None, alias="POSTMAN_API_KEY", diff --git a/strix/interface/environment.py b/strix/interface/environment.py index bcf765df..e0491b16 100644 --- a/strix/interface/environment.py +++ b/strix/interface/environment.py @@ -46,7 +46,8 @@ def validate_environment() -> None: if not settings.llm.api_base: missing_optional_vars.append("LLM_API_BASE") - if not settings.integrations.perplexity_api_key: + if not settings.integrations.perplexity_api_key and not settings.integrations.exa_api_key: + missing_optional_vars.append("EXA_API_KEY") missing_optional_vars.append("PERPLEXITY_API_KEY") if missing_required_vars: @@ -89,7 +90,14 @@ def validate_environment() -> None: error_text.append("• ", style="white") error_text.append("PERPLEXITY_API_KEY", style="bold cyan") error_text.append( - " - API key for Perplexity AI web search (enables real-time research)\n", + " - API key for Perplexity AI web search (alternative to Exa)\n", + style="white", + ) + elif var == "EXA_API_KEY": + error_text.append("• ", style="white") + error_text.append("EXA_API_KEY", style="bold cyan") + error_text.append( + " - API key for Exa web search (enables real-time research)\n", style="white", ) elif var == "STRIX_REASONING_EFFORT": @@ -116,6 +124,8 @@ def validate_environment() -> None: error_text.append( "export PERPLEXITY_API_KEY='your-perplexity-key-here'\n", style="dim white" ) + elif var == "EXA_API_KEY": + error_text.append("export EXA_API_KEY='your-exa-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 796a950e..5f6dc3e9 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -1,11 +1,11 @@ -"""``web_search`` — Perplexity-backed security-focused web search.""" +"""``web_search`` — security-focused web search (Exa or Perplexity).""" from __future__ import annotations import asyncio import json import logging -from typing import Any +from typing import Any, cast import requests from agents import RunContextWrapper, function_tool @@ -41,22 +41,7 @@ Structure your response to be comprehensive yet concise, emphasizing the most cr security implications and details.""" -def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error class needs its own sanitized return - if not query or not query.strip(): - return {"success": False, "error": "Query cannot be empty"} - - api_key = load_settings().integrations.perplexity_api_key - if not api_key: - logger.warning("web_search invoked without PERPLEXITY_API_KEY configured") - return { - "success": False, - "error": ( - "Web search is not configured for this scan " - "(operator needs to set PERPLEXITY_API_KEY). Proceed without it" - ), - } - logger.info("web_search query (len=%d): %s", len(query), query[:120]) - +def _perplexity_content(api_key: str, query: str) -> str: url = "https://api.perplexity.ai/chat/completions" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} payload = { @@ -66,11 +51,88 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas {"role": "user", "content": query}, ], } + with requests.post(url, headers=headers, json=payload, timeout=300) as response: + response.raise_for_status() + return str(response.json()["choices"][0]["message"]["content"]) + + +def _exa_content(api_key: str, query: str) -> str: + url = "https://api.exa.ai/answer" + headers = {"x-api-key": api_key, "Content-Type": "application/json"} + payload = { + "query": f"{_SYSTEM_PROMPT}\n\n{query}", + "text": True, + } + with requests.post(url, headers=headers, json=payload, timeout=300) as response: + response.raise_for_status() + body: dict[str, Any] = response.json() + answer = str(body.get("answer") or "").strip() + citations: list[Any] = body.get("citations") or [] + lines: list[str] = [] + for citation in citations: + if not isinstance(citation, dict): + continue + entry = cast("dict[str, Any]", citation) + citation_url = str(entry.get("url") or entry.get("id") or "") + if not citation_url: + continue + title = str(entry.get("title") or citation_url) + lines.append(f"- {title}: {citation_url}") + if lines: + return answer + "\n\nSources:\n" + "\n".join(lines) + return answer + + +def _resolve_provider( # noqa: PLR0911 - each provider/missing-key case needs its own return + integrations: Any, +) -> tuple[str, str] | dict[str, Any]: + """Pick the search provider and its key, or return a sanitized error dict.""" + provider = integrations.web_search_provider + perplexity_key = integrations.perplexity_api_key + exa_key = integrations.exa_api_key + + if provider == "perplexity": + if not perplexity_key: + return _not_configured_error("PERPLEXITY_API_KEY") + return ("perplexity", perplexity_key) + if provider == "exa": + if not exa_key: + return _not_configured_error("EXA_API_KEY") + return ("exa", exa_key) + + if exa_key: + return ("exa", exa_key) + if perplexity_key: + return ("perplexity", perplexity_key) + return _not_configured_error("EXA_API_KEY or PERPLEXITY_API_KEY") + + +def _not_configured_error(missing: str) -> dict[str, Any]: + logger.warning("web_search invoked without %s configured", missing) + return { + "success": False, + "error": ( + "Web search is not configured for this scan " + f"(operator needs to set {missing}). Proceed without it" + ), + } + + +def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error class needs its own sanitized return + if not query or not query.strip(): + return {"success": False, "error": "Query cannot be empty"} + + resolved = _resolve_provider(load_settings().integrations) + if isinstance(resolved, dict): + return resolved + provider, api_key = resolved + logger.info("web_search provider=%s query (len=%d): %s", provider, len(query), query[:120]) try: - with requests.post(url, headers=headers, json=payload, timeout=300) as response: - response.raise_for_status() - content = response.json()["choices"][0]["message"]["content"] + if provider == "exa": + content = _exa_content(api_key, query) + else: + content = _perplexity_content(api_key, query) except requests.exceptions.Timeout: logger.warning("web_search timed out") return { @@ -114,13 +176,14 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas return { "success": True, "query": query, + "provider": provider, "content": content, } @function_tool(timeout=330) async def web_search(ctx: RunContextWrapper, query: str) -> str: - """Real-time web search via Perplexity — your primary research tool. + """Real-time web search (Exa or Perplexity) — your primary research tool. Use it liberally for anything that's not in your training data: diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index d4bc7ba5..d083236a 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -30,6 +30,8 @@ _LLM_ENV_KEYS = [ "STRIX_FORCE_REQUIRED_TOOL_CHOICE", "LLM_TIMEOUT", "PERPLEXITY_API_KEY", + "EXA_API_KEY", + "STRIX_WEB_SEARCH_PROVIDER", # RuntimeSettings "STRIX_IMAGE", "STRIX_RUNTIME_BACKEND", @@ -80,6 +82,17 @@ def test_read_json_overrides_maps_to_nested_settings(tmp_path: Path) -> None: } +def test_read_json_overrides_maps_exa_and_provider(tmp_path: Path) -> None: + path = tmp_path / "cli-config.json" + path.write_text( + json.dumps({"env": {"EXA_API_KEY": "exa-key", "STRIX_WEB_SEARCH_PROVIDER": "exa"}}), + encoding="utf-8", + ) + assert loader._read_json_overrides(path) == { + "integrations": {"exa_api_key": "exa-key", "web_search_provider": "exa"}, + } + + 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 new file mode 100644 index 00000000..98eb4af4 --- /dev/null +++ b/tests/test_web_search.py @@ -0,0 +1,141 @@ +"""Tests for web_search provider selection and the Exa backend.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import requests + +from strix.config.settings import IntegrationSettings +from strix.tools.web_search import tool + + +if TYPE_CHECKING: + from typing import Self + + import pytest + + +class _FakeResponse: + def __init__(self, body: dict[str, Any]) -> None: + self._body = body + self.headers: dict[str, str] = {} + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_exc: object) -> None: + return None + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, Any]: + return self._body + + +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") + + +def test_auto_falls_back_to_perplexity_when_only_perplexity_is_set() -> None: + integrations = IntegrationSettings(PERPLEXITY_API_KEY="pk") + assert tool._resolve_provider(integrations) == ("perplexity", "pk") + + +def test_explicit_exa_ignores_a_configured_perplexity_key() -> None: + integrations = IntegrationSettings( + PERPLEXITY_API_KEY="pk", + EXA_API_KEY="ek", + STRIX_WEB_SEARCH_PROVIDER="exa", + ) + assert tool._resolve_provider(integrations) == ("exa", "ek") + + +def test_explicit_perplexity_ignores_a_configured_exa_key() -> None: + integrations = IntegrationSettings( + PERPLEXITY_API_KEY="pk", + EXA_API_KEY="ek", + STRIX_WEB_SEARCH_PROVIDER="perplexity", + ) + assert tool._resolve_provider(integrations) == ("perplexity", "pk") + + +def test_explicit_exa_without_a_key_names_only_exa() -> None: + integrations = IntegrationSettings( + PERPLEXITY_API_KEY="pk", + STRIX_WEB_SEARCH_PROVIDER="exa", + ) + resolved = tool._resolve_provider(integrations) + assert isinstance(resolved, dict) + assert resolved["success"] is False + assert "EXA_API_KEY" in resolved["error"] + assert "PERPLEXITY_API_KEY" not in resolved["error"] + + +def test_no_keys_names_both_providers() -> None: + resolved = tool._resolve_provider(IntegrationSettings()) + assert isinstance(resolved, dict) + assert "EXA_API_KEY or PERPLEXITY_API_KEY" in resolved["error"] + + +def test_exa_content_appends_citation_sources(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + captured["url"] = url + captured["headers"] = kwargs["headers"] + captured["json"] = kwargs["json"] + return _FakeResponse( + { + "answer": "CVE-2024-0001 affects it.", + "citations": [ + {"url": "https://nvd.example/cve", "title": "NVD entry"}, + {"id": "https://blog.example/post"}, + "not-a-dict", + {"title": "no url"}, + ], + } + ) + + monkeypatch.setattr(requests, "post", fake_post) + + content = tool._exa_content("ek", "OpenSSH 7.4 RCE?") + + assert captured["url"] == "https://api.exa.ai/answer" + assert captured["headers"]["x-api-key"] == "ek" + assert "OpenSSH 7.4 RCE?" in captured["json"]["query"] + assert content == ( + "CVE-2024-0001 affects it.\n\nSources:\n" + "- NVD entry: https://nvd.example/cve\n" + "- https://blog.example/post: https://blog.example/post" + ) + + +def test_exa_content_without_citations_returns_the_answer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + requests, + "post", + lambda *_a, **_kw: _FakeResponse({"answer": " Nothing found. "}), + ) + assert tool._exa_content("ek", "q") == "Nothing found." + + +def test_do_search_reports_the_provider_it_used(monkeypatch: pytest.MonkeyPatch) -> None: + class _Settings: + integrations = IntegrationSettings(EXA_API_KEY="ek") + + monkeypatch.setattr(tool, "load_settings", _Settings) + monkeypatch.setattr(tool, "_exa_content", lambda *_a: "answer") + + result = tool._do_search("OpenSSH 7.4 RCE?") + + assert result == { + "success": True, + "query": "OpenSSH 7.4 RCE?", + "provider": "exa", + "content": "answer", + }