Skip to content

Commit 6efc2ed

Browse files
committed
feat: add Perplexity Search component
Signed-off-by: James Liounis <james.liounis@perplexity.ai>
1 parent 7640ce6 commit 6efc2ed

4 files changed

Lines changed: 315 additions & 0 deletions

File tree

src/backend/tests/unit/components/perplexity/__init__.py

Whitespace-only changes.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
from lfx.components.perplexity.perplexity_search import PerplexitySearchComponent
5+
from lfx.schema import Data, DataFrame
6+
7+
from tests.base import ComponentTestBaseWithoutClient
8+
9+
10+
class TestPerplexitySearchComponent(ComponentTestBaseWithoutClient):
11+
@pytest.fixture
12+
def component_class(self):
13+
return PerplexitySearchComponent
14+
15+
@pytest.fixture
16+
def default_kwargs(self):
17+
return {
18+
"api_key": "test-key",
19+
"query": "what is langflow",
20+
"max_results": 3,
21+
}
22+
23+
@pytest.fixture
24+
def file_names_mapping(self):
25+
return []
26+
27+
@pytest.fixture
28+
def fake_search_response(self):
29+
return {
30+
"results": [
31+
{
32+
"title": "Result One",
33+
"url": "https://example.com/one",
34+
"snippet": "First snippet.",
35+
"date": "2025-01-01",
36+
"last_updated": "2025-01-02",
37+
},
38+
{
39+
"title": "Result Two",
40+
"url": "https://example.com/two",
41+
"snippet": "Second snippet.",
42+
"date": "2025-02-01",
43+
"last_updated": "2025-02-02",
44+
},
45+
],
46+
"id": "abc",
47+
"server_time": "2025-03-01T00:00:00Z",
48+
}
49+
50+
def test_component_initialization(self, component_class):
51+
component = component_class()
52+
frontend_node = component.to_frontend_node()
53+
node_data = frontend_node["data"]["node"]
54+
55+
assert node_data["display_name"] == "Perplexity Search API"
56+
assert node_data["icon"] == "Perplexity"
57+
template = node_data["template"]
58+
for input_name in ("api_key", "query", "max_results", "search_recency_filter", "country", "search_mode"):
59+
assert input_name in template
60+
61+
@patch("lfx.components.perplexity.perplexity_search.httpx.Client")
62+
def test_fetch_content_success(self, mock_client_cls, component_class, default_kwargs, fake_search_response):
63+
mock_response = MagicMock()
64+
mock_response.json.return_value = fake_search_response
65+
mock_response.raise_for_status.return_value = None
66+
mock_client = MagicMock()
67+
mock_client.post.return_value = mock_response
68+
mock_client_cls.return_value.__enter__.return_value = mock_client
69+
70+
component = component_class(**default_kwargs)
71+
results = component.fetch_content()
72+
73+
assert isinstance(results, list)
74+
assert len(results) == 2
75+
assert isinstance(results[0], Data)
76+
assert results[0].data["title"] == "Result One"
77+
assert results[0].data["url"] == "https://example.com/one"
78+
assert results[0].text == "First snippet."
79+
80+
# Verify the request was sent with the correct attribution header and payload.
81+
call_args = mock_client.post.call_args
82+
assert call_args.args[0] == "https://api.perplexity.ai/search"
83+
sent_headers = call_args.kwargs["headers"]
84+
assert sent_headers["Authorization"] == "Bearer test-key"
85+
assert sent_headers["Content-Type"] == "application/json"
86+
assert "X-Pplx-Integration" in sent_headers
87+
assert sent_headers["X-Pplx-Integration"].startswith("langflow/")
88+
sent_payload = call_args.kwargs["json"]
89+
assert sent_payload == {"query": "what is langflow", "max_results": 3}
90+
91+
@patch("lfx.components.perplexity.perplexity_search.httpx.Client")
92+
def test_fetch_content_optional_filters(self, mock_client_cls, component_class, fake_search_response):
93+
mock_response = MagicMock()
94+
mock_response.json.return_value = fake_search_response
95+
mock_response.raise_for_status.return_value = None
96+
mock_client = MagicMock()
97+
mock_client.post.return_value = mock_response
98+
mock_client_cls.return_value.__enter__.return_value = mock_client
99+
100+
component = component_class(
101+
api_key="test-key",
102+
query="langflow",
103+
max_results=10,
104+
search_recency_filter="week",
105+
country="US",
106+
search_mode="academic",
107+
)
108+
component.fetch_content()
109+
110+
sent_payload = mock_client.post.call_args.kwargs["json"]
111+
assert sent_payload["query"] == "langflow"
112+
assert sent_payload["max_results"] == 10
113+
assert sent_payload["search_recency_filter"] == "week"
114+
assert sent_payload["country"] == "US"
115+
assert sent_payload["search_mode"] == "academic"
116+
117+
@patch("lfx.components.perplexity.perplexity_search.httpx.Client")
118+
def test_max_results_clamped(self, mock_client_cls, component_class, fake_search_response):
119+
mock_response = MagicMock()
120+
mock_response.json.return_value = fake_search_response
121+
mock_response.raise_for_status.return_value = None
122+
mock_client = MagicMock()
123+
mock_client.post.return_value = mock_response
124+
mock_client_cls.return_value.__enter__.return_value = mock_client
125+
126+
component = component_class(api_key="test-key", query="langflow", max_results=999)
127+
component.fetch_content()
128+
129+
sent_payload = mock_client.post.call_args.kwargs["json"]
130+
assert sent_payload["max_results"] == 20
131+
132+
def test_missing_query_returns_error(self, component_class):
133+
component = component_class(api_key="test-key", query="")
134+
results = component.fetch_content()
135+
assert len(results) == 1
136+
assert "error" in results[0].data
137+
138+
@patch("lfx.components.perplexity.perplexity_search.httpx.Client")
139+
def test_fetch_content_dataframe(self, mock_client_cls, component_class, default_kwargs, fake_search_response):
140+
mock_response = MagicMock()
141+
mock_response.json.return_value = fake_search_response
142+
mock_response.raise_for_status.return_value = None
143+
mock_client = MagicMock()
144+
mock_client.post.return_value = mock_response
145+
mock_client_cls.return_value.__enter__.return_value = mock_client
146+
147+
component = component_class(**default_kwargs)
148+
df = component.fetch_content_dataframe()
149+
assert isinstance(df, DataFrame)
150+
assert len(df) == 2
151+
152+
@pytest.mark.asyncio
153+
async def test_latest_version(self, component_class, default_kwargs):
154+
"""Override to skip network call."""
155+
component = component_class(**default_kwargs)
156+
assert component is not None

src/lfx/src/lfx/components/perplexity/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66

77
if TYPE_CHECKING:
88
from .perplexity import PerplexityComponent
9+
from .perplexity_search import PerplexitySearchComponent
910

1011
_dynamic_imports = {
1112
"PerplexityComponent": "perplexity",
13+
"PerplexitySearchComponent": "perplexity_search",
1214
}
1315

1416
__all__ = [
1517
"PerplexityComponent",
18+
"PerplexitySearchComponent",
1619
]
1720

1821

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import importlib.metadata
2+
3+
import httpx
4+
5+
from lfx.custom.custom_component.component import Component
6+
from lfx.inputs.inputs import DropdownInput, IntInput, MessageTextInput, SecretStrInput, StrInput
7+
from lfx.log.logger import logger
8+
from lfx.schema.data import Data
9+
from lfx.schema.dataframe import DataFrame
10+
from lfx.template.field.base import Output
11+
12+
PERPLEXITY_SEARCH_URL = "https://api.perplexity.ai/search"
13+
INTEGRATION_SLUG = "langflow"
14+
DEFAULT_TIMEOUT = 90.0
15+
16+
17+
def _get_integration_header() -> str:
18+
"""Return the X-Pplx-Integration header value for outgoing requests."""
19+
for package in ("langflow", "langflow-base", "lfx"):
20+
try:
21+
version = importlib.metadata.version(package)
22+
except importlib.metadata.PackageNotFoundError:
23+
continue
24+
return f"{INTEGRATION_SLUG}/{version}"
25+
return f"{INTEGRATION_SLUG}/unknown"
26+
27+
28+
class PerplexitySearchComponent(Component):
29+
display_name = "Perplexity Search API"
30+
description = (
31+
"Search the web with the Perplexity Search API. Returns ranked, "
32+
"LLM-friendly results with snippets, URLs, and freshness metadata."
33+
)
34+
documentation = "https://docs.perplexity.ai/api-reference/search-post"
35+
icon = "Perplexity"
36+
name = "PerplexitySearch"
37+
38+
inputs = [
39+
SecretStrInput(
40+
name="api_key",
41+
display_name="Perplexity API Key",
42+
required=True,
43+
info="Your Perplexity API key.",
44+
),
45+
MessageTextInput(
46+
name="query",
47+
display_name="Search Query",
48+
info="The search query to send to Perplexity.",
49+
tool_mode=True,
50+
),
51+
IntInput(
52+
name="max_results",
53+
display_name="Max Results",
54+
info="Maximum number of search results to return (1-20).",
55+
value=5,
56+
),
57+
DropdownInput(
58+
name="search_recency_filter",
59+
display_name="Search Recency Filter",
60+
info="Restrict results to content published within the given window.",
61+
options=["hour", "day", "week", "month", "year"],
62+
value=None,
63+
advanced=True,
64+
),
65+
StrInput(
66+
name="country",
67+
display_name="Country",
68+
info="ISO 3166-1 alpha-2 country code to bias results (e.g. US, GB).",
69+
advanced=True,
70+
),
71+
DropdownInput(
72+
name="search_mode",
73+
display_name="Search Mode",
74+
info="Search mode: web (default), academic, or sec.",
75+
options=["web", "academic", "sec"],
76+
value=None,
77+
advanced=True,
78+
),
79+
]
80+
81+
outputs = [
82+
Output(display_name="Search Results", name="results", method="fetch_content"),
83+
Output(display_name="Table", name="dataframe", method="fetch_content_dataframe"),
84+
]
85+
86+
def _build_payload(self) -> dict:
87+
max_results = self.max_results or 5
88+
max_results = max(1, min(int(max_results), 20))
89+
payload: dict = {"query": self.query, "max_results": max_results}
90+
if self.search_recency_filter:
91+
payload["search_recency_filter"] = self.search_recency_filter
92+
if self.country:
93+
payload["country"] = self.country
94+
if self.search_mode:
95+
payload["search_mode"] = self.search_mode
96+
return payload
97+
98+
def fetch_content(self) -> list[Data]:
99+
if not self.query:
100+
error_message = "Query is required."
101+
logger.error(error_message)
102+
return [Data(text=error_message, data={"error": error_message})]
103+
104+
headers = {
105+
"Authorization": f"Bearer {self.api_key}",
106+
"Content-Type": "application/json",
107+
"X-Pplx-Integration": _get_integration_header(),
108+
}
109+
payload = self._build_payload()
110+
111+
try:
112+
with httpx.Client(timeout=DEFAULT_TIMEOUT) as client:
113+
response = client.post(PERPLEXITY_SEARCH_URL, json=payload, headers=headers)
114+
response.raise_for_status()
115+
search_results = response.json()
116+
except httpx.TimeoutException:
117+
error_message = f"Request timed out ({DEFAULT_TIMEOUT}s). Please try again or adjust parameters."
118+
logger.error(error_message)
119+
return [Data(text=error_message, data={"error": error_message})]
120+
except httpx.HTTPStatusError as exc:
121+
error_message = f"HTTP error occurred: {exc.response.status_code} - {exc.response.text}"
122+
logger.error(error_message)
123+
return [Data(text=error_message, data={"error": error_message})]
124+
except httpx.RequestError as exc:
125+
error_message = f"Request error occurred: {exc}"
126+
logger.error(error_message)
127+
return [Data(text=error_message, data={"error": error_message})]
128+
except ValueError as exc:
129+
error_message = f"Invalid response format: {exc}"
130+
logger.error(error_message)
131+
return [Data(text=error_message, data={"error": error_message})]
132+
133+
data_results: list[Data] = []
134+
for result in search_results.get("results", []):
135+
snippet = result.get("snippet", "") or ""
136+
data_results.append(
137+
Data(
138+
text=snippet,
139+
data={
140+
"title": result.get("title"),
141+
"url": result.get("url"),
142+
"snippet": snippet,
143+
"date": result.get("date"),
144+
"last_updated": result.get("last_updated"),
145+
},
146+
)
147+
)
148+
149+
if not data_results:
150+
data_results.append(Data(text="No results found.", data={"results": []}))
151+
152+
self.status = data_results
153+
return data_results
154+
155+
def fetch_content_dataframe(self) -> DataFrame:
156+
return DataFrame(self.fetch_content())

0 commit comments

Comments
 (0)