From 0cc148ce844fb23cf8be4a6be3abb662eed34e8c Mon Sep 17 00:00:00 2001 From: James Liounis Date: Wed, 29 Apr 2026 20:20:19 +0000 Subject: [PATCH] feat(autogen-ext): add Perplexity model client + search tool Adds two new components to autogen-ext, mirroring the OpenAI / Anthropic / HTTP-tool patterns already in the package: - autogen_ext.models.perplexity.PerplexityChatCompletionClient: a thin wrapper around OpenAIChatCompletionClient targeting Perplexity's OpenAI-compatible endpoint (https://api.perplexity.ai). Reads PERPLEXITY_API_KEY (or PPLX_API_KEY) from the environment. - autogen_ext.tools.perplexity.PerplexitySearchTool: a BaseTool that calls POST /search and returns ranked web results (title, URL, snippet, optional date). Supports max_results, search_domain_filter (allow- or deny-list, prefix '-' to exclude), and search_recency_filter. Adds the [perplexity] extra to autogen-ext, a tutorial markdown page, and 13 mocked unit tests across both components. References: - Agent API: https://docs.perplexity.ai/docs/agent/quickstart - Search API: https://docs.perplexity.ai/docs/search/quickstart --- .../user-guide/agentchat-user-guide/index.md | 1 + .../agentchat-user-guide/tutorial/index.md | 7 + .../tutorial/perplexity.md | 75 +++++++ python/packages/autogen-ext/pyproject.toml | 6 + .../autogen_ext/models/perplexity/__init__.py | 6 + .../models/perplexity/_perplexity_client.py | 96 ++++++++ .../autogen_ext/tools/perplexity/__init__.py | 15 ++ .../perplexity/_perplexity_search_tool.py | 208 ++++++++++++++++++ .../models/test_perplexity_model_client.py | 67 ++++++ .../tools/test_perplexity_search_tool.py | 178 +++++++++++++++ 10 files changed, 659 insertions(+) create mode 100644 python/docs/src/user-guide/agentchat-user-guide/tutorial/perplexity.md create mode 100644 python/packages/autogen-ext/src/autogen_ext/models/perplexity/__init__.py create mode 100644 python/packages/autogen-ext/src/autogen_ext/models/perplexity/_perplexity_client.py create mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/perplexity/__init__.py create mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/perplexity/_perplexity_search_tool.py create mode 100644 python/packages/autogen-ext/tests/models/test_perplexity_model_client.py create mode 100644 python/packages/autogen-ext/tests/tools/test_perplexity_search_tool.py diff --git a/python/docs/src/user-guide/agentchat-user-guide/index.md b/python/docs/src/user-guide/agentchat-user-guide/index.md index 016111df7a30..9d879bd0cfa3 100644 --- a/python/docs/src/user-guide/agentchat-user-guide/index.md +++ b/python/docs/src/user-guide/agentchat-user-guide/index.md @@ -133,6 +133,7 @@ tutorial/teams tutorial/human-in-the-loop tutorial/termination tutorial/state +tutorial/perplexity ``` diff --git a/python/docs/src/user-guide/agentchat-user-guide/tutorial/index.md b/python/docs/src/user-guide/agentchat-user-guide/tutorial/index.md index b42d893b15af..b69112a6f1c4 100644 --- a/python/docs/src/user-guide/agentchat-user-guide/tutorial/index.md +++ b/python/docs/src/user-guide/agentchat-user-guide/tutorial/index.md @@ -77,4 +77,11 @@ Create your own agents Save and load agents and teams for persistent sessions ::: + +:::{grid-item-card} {fas}`magnifying-glass;pst-color-primary` Perplexity +:link: ./perplexity.html +:link-alt: Perplexity: Use the Perplexity model client and Search API tool + +Use the Perplexity model client and Search API tool +::: :::: diff --git a/python/docs/src/user-guide/agentchat-user-guide/tutorial/perplexity.md b/python/docs/src/user-guide/agentchat-user-guide/tutorial/perplexity.md new file mode 100644 index 000000000000..bf57ab0ad146 --- /dev/null +++ b/python/docs/src/user-guide/agentchat-user-guide/tutorial/perplexity.md @@ -0,0 +1,75 @@ +# Perplexity (experimental) + +[Perplexity](https://docs.perplexity.ai) provides LLM chat completions and a +real-time web search API. AutoGen ships two components for it in +`autogen_ext`: + +- {py:class}`~autogen_ext.models.perplexity.PerplexityChatCompletionClient` — + a chat-completion client (Perplexity's `/v1/chat/completions` endpoint is + OpenAI-compatible, so this client is a thin wrapper around + {py:class}`~autogen_ext.models.openai.OpenAIChatCompletionClient`). +- {py:class}`~autogen_ext.tools.perplexity.PerplexitySearchTool` — a `BaseTool` + that calls the + [Perplexity Search API](https://docs.perplexity.ai/docs/search/quickstart) + for ranked web results. + +Install the extra: + +```bash +pip install -U "autogen-ext[perplexity]" +``` + +Both components read the API key from the `api_key` argument, falling back +to the `PERPLEXITY_API_KEY` environment variable (or the `PPLX_API_KEY` +alias). Get a key from . + +## Chat completion client + +```python +import asyncio +from autogen_core.models import UserMessage +from autogen_ext.models.perplexity import PerplexityChatCompletionClient + + +async def main() -> None: + client = PerplexityChatCompletionClient(model="sonar") + result = await client.create( + [UserMessage(content="What changed in Python 3.13?", source="user")] + ) + print(result.content) + await client.close() + + +asyncio.run(main()) +``` + +See the [Perplexity Agent API quickstart](https://docs.perplexity.ai/docs/agent/quickstart) +for the list of available models. + +## Search tool + +```python +import asyncio +from autogen_agentchat.agents import AssistantAgent +from autogen_ext.models.openai import OpenAIChatCompletionClient +from autogen_ext.tools.perplexity import PerplexitySearchTool + + +async def main() -> None: + model_client = OpenAIChatCompletionClient(model="gpt-4o-mini") + search = PerplexitySearchTool() + agent = AssistantAgent("researcher", model_client=model_client, tools=[search]) + result = await agent.run(task="Summarize today's top AI news with sources.") + print(result.messages[-1].content) + + +asyncio.run(main()) +``` + +The tool accepts `query`, `max_results`, `search_domain_filter` +(allow- or deny-list — prefix a domain with `-` to exclude; do **not** mix +allow and deny in the same call), and `search_recency_filter` +(`hour` / `day` / `week` / `month` / `year`). See +[domain filters](https://docs.perplexity.ai/docs/search/filters/domain-filter) +and [date/recency filters](https://docs.perplexity.ai/docs/search/filters/date-time-filters) +for details. diff --git a/python/packages/autogen-ext/pyproject.toml b/python/packages/autogen-ext/pyproject.toml index 223b61070609..d5548f8afb27 100644 --- a/python/packages/autogen-ext/pyproject.toml +++ b/python/packages/autogen-ext/pyproject.toml @@ -31,6 +31,12 @@ azure = [ docker = ["docker~=7.0", "asyncio_atexit>=1.0.1"] ollama = ["ollama>=0.4.7", "tiktoken>=0.8.0"] openai = ["openai>=1.93", "tiktoken>=0.8.0", "aiofiles"] +perplexity = [ + "openai>=1.93", + "tiktoken>=0.8.0", + "aiofiles", + "httpx>=0.27.0", +] file-surfer = [ "autogen-agentchat==0.7.5", "magika>=0.6.1rc2", diff --git a/python/packages/autogen-ext/src/autogen_ext/models/perplexity/__init__.py b/python/packages/autogen-ext/src/autogen_ext/models/perplexity/__init__.py new file mode 100644 index 000000000000..e7ec2494c9af --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/models/perplexity/__init__.py @@ -0,0 +1,6 @@ +from ._perplexity_client import PERPLEXITY_BASE_URL, PerplexityChatCompletionClient + +__all__ = [ + "PERPLEXITY_BASE_URL", + "PerplexityChatCompletionClient", +] diff --git a/python/packages/autogen-ext/src/autogen_ext/models/perplexity/_perplexity_client.py b/python/packages/autogen-ext/src/autogen_ext/models/perplexity/_perplexity_client.py new file mode 100644 index 000000000000..02476e505844 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/models/perplexity/_perplexity_client.py @@ -0,0 +1,96 @@ +"""Perplexity chat completion client for AutoGen. + +Perplexity's chat completions endpoint is OpenAI-compatible, so this client +wraps :class:`~autogen_ext.models.openai.OpenAIChatCompletionClient` with the +Perplexity base URL and a sensible default ``model_info`` block, while reading +``PERPLEXITY_API_KEY`` (or ``PPLX_API_KEY``) from the environment. + +See https://docs.perplexity.ai/docs/agent/quickstart for endpoint details. +""" + +from __future__ import annotations + +import os +from typing import Any + +from autogen_core.models import ModelFamily, ModelInfo + +from ..openai import OpenAIChatCompletionClient + +PERPLEXITY_BASE_URL = "https://api.perplexity.ai" + +_DEFAULT_MODEL_INFO: ModelInfo = { + "vision": False, + "function_calling": True, + "json_output": True, + "family": ModelFamily.UNKNOWN, + "structured_output": True, +} + + +class PerplexityChatCompletionClient(OpenAIChatCompletionClient): + """Chat completion client for Perplexity. + + Wraps :class:`~autogen_ext.models.openai.OpenAIChatCompletionClient` with + Perplexity's OpenAI-compatible endpoint at ``https://api.perplexity.ai``. + + The API key is taken from the ``api_key`` argument, falling back to the + ``PERPLEXITY_API_KEY`` env var, then ``PPLX_API_KEY``. ``base_url`` defaults + to :data:`PERPLEXITY_BASE_URL` and can be overridden if you need to point + at an alternative gateway. + + To use this client, install the ``perplexity`` extra:: + + pip install -U "autogen-ext[perplexity]" + + Args: + model: The Perplexity model identifier (e.g. one of the chat-completion + models documented at https://docs.perplexity.ai/docs/agent/models). + api_key: API key. Defaults to ``PERPLEXITY_API_KEY`` or ``PPLX_API_KEY``. + base_url: API base URL. Defaults to ``https://api.perplexity.ai``. + model_info: Optional :class:`ModelInfo`. A reasonable default is supplied. + **kwargs: Forwarded to :class:`OpenAIChatCompletionClient`. + + Example: + .. code-block:: python + + import asyncio + from autogen_core.models import UserMessage + from autogen_ext.models.perplexity import PerplexityChatCompletionClient + + + async def main() -> None: + client = PerplexityChatCompletionClient(model="sonar") + result = await client.create([UserMessage(content="What is RAG?", source="user")]) + print(result.content) + await client.close() + + + asyncio.run(main()) + """ + + component_provider_override = "autogen_ext.models.perplexity.PerplexityChatCompletionClient" + + def __init__( + self, + model: str, + *, + api_key: str | None = None, + base_url: str | None = None, + model_info: ModelInfo | None = None, + **kwargs: Any, + ) -> None: + resolved_api_key = api_key or os.environ.get("PERPLEXITY_API_KEY") or os.environ.get("PPLX_API_KEY") + if not resolved_api_key: + raise ValueError( + "Perplexity API key not provided. Pass api_key=... or set the " + "PERPLEXITY_API_KEY (or PPLX_API_KEY) environment variable." + ) + + super().__init__( + model=model, + api_key=resolved_api_key, + base_url=base_url or PERPLEXITY_BASE_URL, + model_info=model_info or _DEFAULT_MODEL_INFO, + **kwargs, + ) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/perplexity/__init__.py b/python/packages/autogen-ext/src/autogen_ext/tools/perplexity/__init__.py new file mode 100644 index 000000000000..7c14adb42239 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/perplexity/__init__.py @@ -0,0 +1,15 @@ +from ._perplexity_search_tool import ( + PerplexitySearchResponse, + PerplexitySearchResult, + PerplexitySearchTool, + PerplexitySearchToolArgs, + PerplexitySearchToolConfig, +) + +__all__ = [ + "PerplexitySearchTool", + "PerplexitySearchToolArgs", + "PerplexitySearchToolConfig", + "PerplexitySearchResult", + "PerplexitySearchResponse", +] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/perplexity/_perplexity_search_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/perplexity/_perplexity_search_tool.py new file mode 100644 index 000000000000..964090109b86 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/perplexity/_perplexity_search_tool.py @@ -0,0 +1,208 @@ +"""Perplexity Search API tool for AutoGen. + +Calls ``POST https://api.perplexity.ai/search`` and returns ranked web results. +See https://docs.perplexity.ai/docs/search/quickstart and +https://docs.perplexity.ai/api-reference/search-post for the API contract. +""" + +from __future__ import annotations + +import os +from typing import Any, List, Literal, Optional + +import httpx +from autogen_core import CancellationToken, Component +from autogen_core.tools import BaseTool +from pydantic import BaseModel, Field, SecretStr +from typing_extensions import Self + +DEFAULT_BASE_URL = "https://api.perplexity.ai" +DEFAULT_TIMEOUT = 30.0 +DEFAULT_MAX_RESULTS = 5 + +RecencyFilter = Literal["hour", "day", "week", "month", "year"] + + +class PerplexitySearchResult(BaseModel): + """A single ranked result returned by the Perplexity Search API.""" + + title: str + url: str + snippet: str = "" + date: Optional[str] = None + + +class PerplexitySearchResponse(BaseModel): + """Response returned by :class:`PerplexitySearchTool`.""" + + results: List[PerplexitySearchResult] + + +class PerplexitySearchToolArgs(BaseModel): + """Arguments accepted by :class:`PerplexitySearchTool`.""" + + query: str = Field(..., description="The natural-language search query.") + max_results: int = Field( + default=DEFAULT_MAX_RESULTS, + ge=1, + le=20, + description="Maximum number of results to return.", + ) + search_domain_filter: Optional[List[str]] = Field( + default=None, + description=( + "Restrict results to (or away from) specific domains. Prefix a " + "domain with '-' to exclude it (e.g. '-pinterest.com'). Allow- " + "and deny-lists must NOT be mixed in a single call." + ), + ) + search_recency_filter: Optional[RecencyFilter] = Field( + default=None, + description="Restrict results to a recent time window: hour, day, week, month, or year.", + ) + + +class PerplexitySearchToolConfig(BaseModel): + """Config schema used by :class:`PerplexitySearchTool` for component (de)serialization.""" + + api_key: Optional[SecretStr] = None + base_url: str = DEFAULT_BASE_URL + default_max_results: int = DEFAULT_MAX_RESULTS + timeout: float = DEFAULT_TIMEOUT + + +class PerplexitySearchTool( + BaseTool[PerplexitySearchToolArgs, PerplexitySearchResponse], + Component[PerplexitySearchToolConfig], +): + """Search the web using the Perplexity Search API. + + Returns a list of ranked web results (title, URL, snippet, optional date). + Useful for grounding agents on up-to-date information. + + The API key is taken from ``api_key`` if provided, otherwise + ``PERPLEXITY_API_KEY`` (or ``PPLX_API_KEY``) from the environment. + + Install with:: + + pip install -U "autogen-ext[perplexity]" + + Args: + api_key: Perplexity API key. Falls back to ``PERPLEXITY_API_KEY`` / + ``PPLX_API_KEY`` env var if not provided. + base_url: Override the API base URL (defaults to ``https://api.perplexity.ai``). + default_max_results: Default cap on number of results when ``max_results`` + is not supplied in a call. + timeout: HTTP timeout in seconds. + + Example: + .. code-block:: python + + import asyncio + from autogen_core import CancellationToken + from autogen_ext.tools.perplexity import PerplexitySearchTool, PerplexitySearchToolArgs + + + async def main() -> None: + tool = PerplexitySearchTool() + results = await tool.run( + PerplexitySearchToolArgs(query="latest LLM benchmarks", max_results=3), + CancellationToken(), + ) + for r in results: + print(r.title, r.url) + + + asyncio.run(main()) + """ + + component_type = "tool" + component_provider_override = "autogen_ext.tools.perplexity.PerplexitySearchTool" + component_config_schema = PerplexitySearchToolConfig + + name = "perplexity_search" + description = "Search the web for up-to-date information using the Perplexity Search API." + + def __init__( + self, + *, + api_key: Optional[str] = None, + base_url: str = DEFAULT_BASE_URL, + default_max_results: int = DEFAULT_MAX_RESULTS, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + super().__init__( + args_type=PerplexitySearchToolArgs, + return_type=PerplexitySearchResponse, + name=self.name, + description=self.description, + ) + self._api_key = api_key or os.environ.get("PERPLEXITY_API_KEY") or os.environ.get("PPLX_API_KEY") + self._base_url = base_url.rstrip("/") + self._default_max_results = default_max_results + self._timeout = timeout + + def _to_config(self) -> PerplexitySearchToolConfig: + return PerplexitySearchToolConfig( + api_key=SecretStr(self._api_key) if self._api_key else None, + base_url=self._base_url, + default_max_results=self._default_max_results, + timeout=self._timeout, + ) + + @classmethod + def _from_config(cls, config: PerplexitySearchToolConfig) -> Self: + api_key = config.api_key.get_secret_value() if config.api_key is not None else None + return cls( + api_key=api_key, + base_url=config.base_url, + default_max_results=config.default_max_results, + timeout=config.timeout, + ) + + async def run( + self, + args: PerplexitySearchToolArgs, + cancellation_token: CancellationToken, + ) -> PerplexitySearchResponse: + if not self._api_key: + raise ValueError( + "Perplexity API key not provided. Pass api_key=... or set the " + "PERPLEXITY_API_KEY (or PPLX_API_KEY) environment variable." + ) + + payload: dict[str, Any] = { + "query": args.query, + "max_results": args.max_results or self._default_max_results, + } + if args.search_domain_filter: + payload["search_domain_filter"] = args.search_domain_filter + if args.search_recency_filter: + payload["search_recency_filter"] = args.search_recency_filter + + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) as client: + response = await client.post( + f"{self._base_url}/search", + headers=headers, + json=payload, + ) + response.raise_for_status() + data = response.json() + + items = data.get("results", []) or [] + return PerplexitySearchResponse( + results=[ + PerplexitySearchResult( + title=item.get("title", ""), + url=item.get("url", ""), + snippet=item.get("snippet", ""), + date=item.get("date"), + ) + for item in items + ] + ) diff --git a/python/packages/autogen-ext/tests/models/test_perplexity_model_client.py b/python/packages/autogen-ext/tests/models/test_perplexity_model_client.py new file mode 100644 index 000000000000..03898734cd4a --- /dev/null +++ b/python/packages/autogen-ext/tests/models/test_perplexity_model_client.py @@ -0,0 +1,67 @@ +"""Tests for PerplexityChatCompletionClient.""" + +from __future__ import annotations + +import pytest + +from autogen_ext.models.perplexity import ( + PERPLEXITY_BASE_URL, + PerplexityChatCompletionClient, +) + + +def test_requires_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + monkeypatch.delenv("PPLX_API_KEY", raising=False) + + with pytest.raises(ValueError, match="PERPLEXITY_API_KEY"): + PerplexityChatCompletionClient(model="sonar") + + +def test_picks_up_perplexity_api_key_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PERPLEXITY_API_KEY", "env-key-1") + monkeypatch.delenv("PPLX_API_KEY", raising=False) + + client = PerplexityChatCompletionClient(model="sonar") + + assert client._raw_config["api_key"] == "env-key-1" + assert client._raw_config["base_url"] == PERPLEXITY_BASE_URL + + +def test_falls_back_to_pplx_api_key_alias(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + monkeypatch.setenv("PPLX_API_KEY", "alias-key") + + client = PerplexityChatCompletionClient(model="sonar-pro") + + assert client._raw_config["api_key"] == "alias-key" + + +def test_explicit_api_key_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PERPLEXITY_API_KEY", "env-key") + + client = PerplexityChatCompletionClient(model="sonar", api_key="explicit-key") + + assert client._raw_config["api_key"] == "explicit-key" + + +def test_custom_base_url_is_respected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PERPLEXITY_API_KEY", "k") + + client = PerplexityChatCompletionClient( + model="sonar", + base_url="https://gateway.example.com/v1", + ) + + assert client._raw_config["base_url"] == "https://gateway.example.com/v1" + + +def test_default_model_info_is_populated(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PERPLEXITY_API_KEY", "k") + + client = PerplexityChatCompletionClient(model="sonar") + + info = client.model_info + assert info["function_calling"] is True + assert info["json_output"] is True + assert info["vision"] is False diff --git a/python/packages/autogen-ext/tests/tools/test_perplexity_search_tool.py b/python/packages/autogen-ext/tests/tools/test_perplexity_search_tool.py new file mode 100644 index 000000000000..e9be261e0790 --- /dev/null +++ b/python/packages/autogen-ext/tests/tools/test_perplexity_search_tool.py @@ -0,0 +1,178 @@ +"""Tests for PerplexitySearchTool.""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +from autogen_core import CancellationToken + +from autogen_ext.tools.perplexity import ( + PerplexitySearchResult, + PerplexitySearchTool, + PerplexitySearchToolArgs, +) + + +def _mock_transport( + captured: dict[str, Any], + *, + response_payload: dict[str, Any] | None = None, + status: int = 200, +) -> httpx.MockTransport: + if response_payload is None: + response_payload = { + "id": "abc", + "results": [ + { + "title": "Result A", + "url": "https://a.example.com", + "snippet": "snippet A", + "date": "2025-01-02", + }, + { + "title": "Result B", + "url": "https://b.example.com", + "snippet": "snippet B", + }, + ], + } + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + captured["json"] = request.read().decode("utf-8") if status == 200 else None + return httpx.Response(status, json=response_payload) + + return httpx.MockTransport(handler) + + +@pytest.mark.asyncio +async def test_run_requires_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + monkeypatch.delenv("PPLX_API_KEY", raising=False) + + tool = PerplexitySearchTool() + + with pytest.raises(ValueError, match="PERPLEXITY_API_KEY"): + await tool.run(PerplexitySearchToolArgs(query="hello"), CancellationToken()) + + +@pytest.mark.asyncio +async def test_run_returns_structured_results(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + transport = _mock_transport(captured) + + real_async_client = httpx.AsyncClient + + def fake_async_client(**kwargs: Any) -> httpx.AsyncClient: + return real_async_client(transport=transport) + + monkeypatch.setattr("autogen_ext.tools.perplexity._perplexity_search_tool.httpx.AsyncClient", fake_async_client) + + tool = PerplexitySearchTool(api_key="k") + response = await tool.run( + PerplexitySearchToolArgs(query="quantum computing", max_results=2), + CancellationToken(), + ) + + assert len(response.results) == 2 + assert all(isinstance(r, PerplexitySearchResult) for r in response.results) + assert response.results[0].title == "Result A" + assert response.results[0].url == "https://a.example.com" + assert response.results[0].date == "2025-01-02" + assert response.results[1].date is None + + +@pytest.mark.asyncio +async def test_run_sends_expected_payload(monkeypatch: pytest.MonkeyPatch) -> None: + import json + + captured: dict[str, Any] = {} + transport = _mock_transport(captured) + + real_async_client = httpx.AsyncClient + + def fake_async_client(**kwargs: Any) -> httpx.AsyncClient: + return real_async_client(transport=transport) + + monkeypatch.setattr("autogen_ext.tools.perplexity._perplexity_search_tool.httpx.AsyncClient", fake_async_client) + + tool = PerplexitySearchTool(api_key="my-key") + await tool.run( + PerplexitySearchToolArgs( + query="elections 2025", + max_results=4, + search_domain_filter=["nytimes.com", "-pinterest.com"], + search_recency_filter="week", + ), + CancellationToken(), + ) + + request = captured["request"] + assert str(request.url) == "https://api.perplexity.ai/search" + assert request.headers["Authorization"] == "Bearer my-key" + body = json.loads(captured["json"]) + assert body["query"] == "elections 2025" + assert body["max_results"] == 4 + assert body["search_domain_filter"] == ["nytimes.com", "-pinterest.com"] + assert body["search_recency_filter"] == "week" + + +@pytest.mark.asyncio +async def test_run_picks_up_pplx_api_key_alias(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + monkeypatch.setenv("PPLX_API_KEY", "alias-only") + + captured: dict[str, Any] = {} + transport = _mock_transport(captured, response_payload={"results": []}) + + real_async_client = httpx.AsyncClient + + def fake_async_client(**kwargs: Any) -> httpx.AsyncClient: + return real_async_client(transport=transport) + + monkeypatch.setattr("autogen_ext.tools.perplexity._perplexity_search_tool.httpx.AsyncClient", fake_async_client) + + tool = PerplexitySearchTool() + await tool.run(PerplexitySearchToolArgs(query="x"), CancellationToken()) + + assert captured["request"].headers["Authorization"] == "Bearer alias-only" + + +@pytest.mark.asyncio +async def test_run_raises_on_http_error(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + transport = _mock_transport(captured, response_payload={"error": "nope"}, status=401) + + real_async_client = httpx.AsyncClient + + def fake_async_client(**kwargs: Any) -> httpx.AsyncClient: + return real_async_client(transport=transport) + + monkeypatch.setattr("autogen_ext.tools.perplexity._perplexity_search_tool.httpx.AsyncClient", fake_async_client) + + tool = PerplexitySearchTool(api_key="k") + + with pytest.raises(httpx.HTTPStatusError): + await tool.run(PerplexitySearchToolArgs(query="x"), CancellationToken()) + + +def test_tool_metadata() -> None: + tool = PerplexitySearchTool(api_key="k") + assert tool.name == "perplexity_search" + assert "Perplexity Search API" in tool.description + assert "Sonar" not in tool.description + + +def test_to_and_from_config_round_trips(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False) + monkeypatch.delenv("PPLX_API_KEY", raising=False) + + tool = PerplexitySearchTool(api_key="round-trip-key", default_max_results=7, timeout=12.0) + config = tool.dump_component() + rebuilt = PerplexitySearchTool.load_component(config) + + assert rebuilt._api_key == "round-trip-key" + assert rebuilt._default_max_results == 7 + assert rebuilt._timeout == 12.0