pulse-perplexity 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: pulse-perplexity
3
+ Version: 0.1.0
4
+ Summary: Perplexity AI adapter for PULSE Protocol — web search with citations via Sonar models
5
+ Author-email: PULSE Protocol Team <pulse@protocol.org>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/pulseprotocolorg-cyber/pulse-perplexity
8
+ Project-URL: Repository, https://github.com/pulseprotocolorg-cyber/pulse-perplexity
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: pulse-protocol>=0.5.0
22
+ Requires-Dist: requests>=2.28.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
26
+ Requires-Dist: black>=23.0; extra == "dev"
27
+ Requires-Dist: responses>=0.23.0; extra == "dev"
@@ -0,0 +1,28 @@
1
+ """
2
+ PULSE-Perplexity Adapter.
3
+
4
+ Web search with real-time citations via Perplexity Sonar API.
5
+
6
+ Setup:
7
+ export PERPLEXITY_API_KEY="pplx-..."
8
+ Get key at: https://www.perplexity.ai/settings/api
9
+
10
+ Example:
11
+ >>> import os
12
+ >>> from pulse_perplexity import PerplexityAdapter
13
+ >>> from pulse.message import PulseMessage
14
+
15
+ >>> adapter = PerplexityAdapter(api_key=os.environ["PERPLEXITY_API_KEY"])
16
+ >>> adapter.connect()
17
+ >>> response = adapter.send(PulseMessage(
18
+ ... action="ACT.SEARCH.WEB",
19
+ ... parameters={"query": "PULSE Protocol AI standard 2026"},
20
+ ... ))
21
+ >>> print(response.content["parameters"]["answer"])
22
+ >>> print(response.content["parameters"]["citations"])
23
+ """
24
+
25
+ from pulse_perplexity.adapter import PerplexityAdapter
26
+ from pulse_perplexity.version import __version__
27
+
28
+ __all__ = ["PerplexityAdapter", "__version__"]
@@ -0,0 +1,294 @@
1
+ """Perplexity AI adapter for PULSE Protocol.
2
+
3
+ Translates PULSE semantic messages to Perplexity Sonar API calls.
4
+ Returns answers with real-time web citations — unique capability
5
+ not available in standard LLM adapters.
6
+
7
+ Supported actions:
8
+ ACT.SEARCH.WEB — web search with cited answer (main use case)
9
+ ACT.QUERY.DATA — general question answering
10
+ ACT.ANALYZE.PATTERN — deep research synthesis
11
+ ACT.RESEARCH.TOPIC — long-form research report (sonar-deep-research)
12
+
13
+ Available models:
14
+ sonar — fast, cost-effective search (default)
15
+ sonar-pro — deeper context, 2× more sources
16
+ sonar-reasoning-pro — chain-of-thought reasoning
17
+ sonar-deep-research — hundreds of sources, long-form synthesis
18
+
19
+ Credentials:
20
+ Set PERPLEXITY_API_KEY environment variable.
21
+ Get key at: https://www.perplexity.ai/settings/api
22
+
23
+ Example:
24
+ >>> import os
25
+ >>> from pulse_perplexity import PerplexityAdapter
26
+ >>> from pulse.message import PulseMessage
27
+
28
+ >>> adapter = PerplexityAdapter(api_key=os.environ["PERPLEXITY_API_KEY"])
29
+ >>> adapter.connect()
30
+
31
+ >>> msg = PulseMessage(
32
+ ... action="ACT.SEARCH.WEB",
33
+ ... parameters={"query": "Latest PULSE Protocol news 2026"},
34
+ ... )
35
+ >>> response = adapter.send(msg)
36
+ >>> print(response.content["parameters"]["answer"])
37
+ >>> print(response.content["parameters"]["citations"])
38
+ """
39
+
40
+ import os
41
+ from typing import Any, Dict, List, Optional
42
+
43
+ import requests
44
+
45
+ from pulse.message import PulseMessage
46
+ from pulse.adapter import PulseAdapter, AdapterError, AdapterConnectionError
47
+
48
+
49
+ _API_BASE = "https://api.perplexity.ai"
50
+ _CHAT_ENDPOINT = "/chat/completions"
51
+
52
+ # Map PULSE actions to Sonar models and system prompts
53
+ _ACTION_CONFIG: Dict[str, Dict[str, str]] = {
54
+ "ACT.SEARCH.WEB": {
55
+ "model": "sonar",
56
+ "system": (
57
+ "You are a precise web search assistant. "
58
+ "Answer concisely with key facts. Always cite your sources."
59
+ ),
60
+ },
61
+ "ACT.QUERY.DATA": {
62
+ "model": "sonar-pro",
63
+ "system": (
64
+ "You are an expert analyst. "
65
+ "Provide a thorough, well-structured answer with citations."
66
+ ),
67
+ },
68
+ "ACT.ANALYZE.PATTERN": {
69
+ "model": "sonar-reasoning-pro",
70
+ "system": (
71
+ "You are a deep research analyst. Identify patterns, trends, and insights. "
72
+ "Use chain-of-thought reasoning. Cite all sources."
73
+ ),
74
+ },
75
+ "ACT.RESEARCH.TOPIC": {
76
+ "model": "sonar-deep-research",
77
+ "system": (
78
+ "You are a comprehensive research assistant. "
79
+ "Synthesize information from many sources into a detailed report."
80
+ ),
81
+ },
82
+ }
83
+
84
+
85
+ class PerplexityAdapter(PulseAdapter):
86
+ """PULSE adapter for Perplexity AI Sonar API.
87
+
88
+ Unique capability: every response includes real-time web citations.
89
+ Agents in your system get not just answers but verifiable sources.
90
+
91
+ Args:
92
+ api_key: Perplexity API key. Prefer ``os.environ["PERPLEXITY_API_KEY"]``.
93
+ model: Default Sonar model (overridable per message via ``parameters.model``).
94
+ search_recency: Filter results by time — "day", "week", "month", "year".
95
+ search_domain_filter: List of domains to restrict search to.
96
+ config: Extra adapter config.
97
+
98
+ Example:
99
+ >>> adapter = PerplexityAdapter(api_key=os.environ["PERPLEXITY_API_KEY"])
100
+ >>> adapter.connect()
101
+ >>> msg = PulseMessage(
102
+ ... action="ACT.SEARCH.WEB",
103
+ ... parameters={"query": "What is PULSE Protocol?"},
104
+ ... )
105
+ >>> response = adapter.send(msg)
106
+ >>> print(response.content["parameters"]["citations"])
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ api_key: Optional[str] = None,
112
+ model: str = "sonar",
113
+ search_recency: Optional[str] = None,
114
+ search_domain_filter: Optional[List[str]] = None,
115
+ config: Optional[Dict[str, Any]] = None,
116
+ ) -> None:
117
+ super().__init__(
118
+ name="perplexity",
119
+ base_url=_API_BASE,
120
+ config=config or {},
121
+ )
122
+ self._api_key = api_key or os.environ.get("PERPLEXITY_API_KEY", "")
123
+ self._default_model = model
124
+ self._search_recency = search_recency
125
+ self._search_domain_filter = search_domain_filter or []
126
+ self._session: Optional[requests.Session] = None
127
+
128
+ # ── Lifecycle ──────────────────────────────────────────────────────────
129
+
130
+ def connect(self) -> None:
131
+ """Initialize HTTP session with auth headers.
132
+
133
+ Raises:
134
+ AdapterConnectionError: If API key is missing.
135
+ """
136
+ if not self._api_key:
137
+ raise AdapterConnectionError(
138
+ "Perplexity API key required. "
139
+ "Set PERPLEXITY_API_KEY env var or pass api_key=. "
140
+ "Get key at https://www.perplexity.ai/settings/api"
141
+ )
142
+ self._session = requests.Session()
143
+ self._session.headers.update({
144
+ "Authorization": f"Bearer {self._api_key}",
145
+ "Content-Type": "application/json",
146
+ "Accept": "application/json",
147
+ })
148
+ self.connected = True
149
+
150
+ def disconnect(self) -> None:
151
+ """Close HTTP session."""
152
+ if self._session:
153
+ self._session.close()
154
+ self._session = None
155
+ self.connected = False
156
+
157
+ # ── PULSE Protocol Interface ───────────────────────────────────────────
158
+
159
+ def to_native(self, message: PulseMessage) -> Dict[str, Any]:
160
+ """Convert PULSE message to Perplexity API request.
161
+
162
+ Args:
163
+ message: PULSE message with action and parameters.
164
+
165
+ Returns:
166
+ Dict ready for Perplexity /chat/completions endpoint.
167
+
168
+ Raises:
169
+ AdapterError: If action unsupported or query missing.
170
+ """
171
+ action = message.content["action"]
172
+ params = message.content.get("parameters", {})
173
+
174
+ action_cfg = _ACTION_CONFIG.get(action)
175
+ if not action_cfg:
176
+ raise AdapterError(
177
+ f"Unsupported action '{action}'. "
178
+ f"Supported: {sorted(_ACTION_CONFIG.keys())}"
179
+ )
180
+
181
+ query = params.get("query") or params.get("text") or params.get("topic")
182
+ if not query:
183
+ raise AdapterError(
184
+ f"Parameter 'query' is required for {action}"
185
+ )
186
+
187
+ model = params.get("model", action_cfg["model"])
188
+ system_prompt = params.get("system_prompt", action_cfg["system"])
189
+
190
+ body: Dict[str, Any] = {
191
+ "model": model,
192
+ "messages": [
193
+ {"role": "system", "content": system_prompt},
194
+ {"role": "user", "content": query},
195
+ ],
196
+ "stream": False,
197
+ }
198
+
199
+ # Optional search filters
200
+ recency = params.get("search_recency", self._search_recency)
201
+ if recency:
202
+ body["search_recency_filter"] = recency
203
+
204
+ domain_filter = params.get("search_domain_filter", self._search_domain_filter)
205
+ if domain_filter:
206
+ body["search_domain_filter"] = domain_filter
207
+
208
+ if "max_tokens" in params:
209
+ body["max_tokens"] = params["max_tokens"]
210
+
211
+ if "temperature" in params:
212
+ body["temperature"] = params["temperature"]
213
+
214
+ return body
215
+
216
+ def call_api(self, native_request: Dict[str, Any]) -> Dict[str, Any]:
217
+ """Call Perplexity /chat/completions endpoint.
218
+
219
+ Args:
220
+ native_request: Request dict from to_native().
221
+
222
+ Returns:
223
+ Parsed response dict with answer, citations, usage.
224
+
225
+ Raises:
226
+ AdapterConnectionError: If Perplexity is unreachable.
227
+ AdapterError: On API error.
228
+ """
229
+ if not self._session:
230
+ self.connect()
231
+
232
+ try:
233
+ resp = self._session.post(
234
+ f"{self.base_url}{_CHAT_ENDPOINT}",
235
+ json=native_request,
236
+ timeout=120,
237
+ )
238
+ except requests.ConnectionError as e:
239
+ raise AdapterConnectionError(f"Cannot reach Perplexity API: {e}") from e
240
+ except requests.Timeout as e:
241
+ raise AdapterConnectionError("Perplexity API request timed out") from e
242
+
243
+ if resp.status_code == 401:
244
+ raise AdapterError("Invalid Perplexity API key. Check PERPLEXITY_API_KEY.")
245
+ if resp.status_code == 429:
246
+ raise AdapterError("Perplexity rate limit exceeded. Retry later.")
247
+ if resp.status_code >= 400:
248
+ raise AdapterError(
249
+ f"Perplexity API error {resp.status_code}: {resp.text[:200]}"
250
+ )
251
+
252
+ data = resp.json()
253
+ choice = data["choices"][0]
254
+
255
+ return {
256
+ "answer": choice["message"]["content"],
257
+ "model": data.get("model", native_request["model"]),
258
+ "citations": data.get("citations", []),
259
+ "finish_reason": choice.get("finish_reason", "stop"),
260
+ "usage": data.get("usage", {}),
261
+ }
262
+
263
+ def from_native(self, native_response: Dict[str, Any]) -> PulseMessage:
264
+ """Wrap Perplexity response in a PULSE message.
265
+
266
+ Args:
267
+ native_response: Dict from call_api().
268
+
269
+ Returns:
270
+ PULSE response message with answer, citations, model, usage.
271
+ """
272
+ return PulseMessage(
273
+ action="ACT.RESPOND",
274
+ parameters={
275
+ "status": "META.STATUS.SUCCESS",
276
+ "answer": native_response["answer"],
277
+ "citations": native_response["citations"],
278
+ "model": native_response["model"],
279
+ "finish_reason": native_response["finish_reason"],
280
+ "usage": native_response["usage"],
281
+ },
282
+ validate=False,
283
+ )
284
+
285
+ @property
286
+ def supported_actions(self) -> List[str]:
287
+ """PULSE actions this adapter handles."""
288
+ return sorted(_ACTION_CONFIG.keys())
289
+
290
+ def __repr__(self) -> str:
291
+ return (
292
+ f"PerplexityAdapter(model='{self._default_model}', "
293
+ f"connected={self.connected})"
294
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: pulse-perplexity
3
+ Version: 0.1.0
4
+ Summary: Perplexity AI adapter for PULSE Protocol — web search with citations via Sonar models
5
+ Author-email: PULSE Protocol Team <pulse@protocol.org>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/pulseprotocolorg-cyber/pulse-perplexity
8
+ Project-URL: Repository, https://github.com/pulseprotocolorg-cyber/pulse-perplexity
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: pulse-protocol>=0.5.0
22
+ Requires-Dist: requests>=2.28.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
26
+ Requires-Dist: black>=23.0; extra == "dev"
27
+ Requires-Dist: responses>=0.23.0; extra == "dev"
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ pulse_perplexity/__init__.py
3
+ pulse_perplexity/adapter.py
4
+ pulse_perplexity/version.py
5
+ pulse_perplexity.egg-info/PKG-INFO
6
+ pulse_perplexity.egg-info/SOURCES.txt
7
+ pulse_perplexity.egg-info/dependency_links.txt
8
+ pulse_perplexity.egg-info/requires.txt
9
+ pulse_perplexity.egg-info/top_level.txt
10
+ tests/test_adapter.py
@@ -0,0 +1,8 @@
1
+ pulse-protocol>=0.5.0
2
+ requests>=2.28.0
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ pytest-cov>=4.0
7
+ black>=23.0
8
+ responses>=0.23.0
@@ -0,0 +1 @@
1
+ pulse_perplexity
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pulse-perplexity"
7
+ version = "0.1.0"
8
+ description = "Perplexity AI adapter for PULSE Protocol — web search with citations via Sonar models"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ {name = "PULSE Protocol Team", email = "pulse@protocol.org"},
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
26
+ ]
27
+ dependencies = [
28
+ "pulse-protocol>=0.5.0",
29
+ "requests>=2.28.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.0",
35
+ "pytest-cov>=4.0",
36
+ "black>=23.0",
37
+ "responses>=0.23.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/pulseprotocolorg-cyber/pulse-perplexity"
42
+ Repository = "https://github.com/pulseprotocolorg-cyber/pulse-perplexity"
43
+
44
+ [tool.setuptools.packages.find]
45
+ include = ["pulse_perplexity*"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+
50
+ [tool.black]
51
+ line-length = 100
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,269 @@
1
+ """Tests for PerplexityAdapter — all HTTP calls mocked."""
2
+
3
+ import pytest
4
+ import responses as rsps_lib
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from pulse.message import PulseMessage
8
+ from pulse.adapter import AdapterError, AdapterConnectionError
9
+
10
+ from pulse_perplexity import PerplexityAdapter
11
+
12
+
13
+ API_URL = "https://api.perplexity.ai/chat/completions"
14
+
15
+
16
+ def make_api_response(answer="Paris", model="sonar", citations=None):
17
+ return {
18
+ "id": "req-123",
19
+ "model": model,
20
+ "choices": [{"message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}],
21
+ "citations": citations or ["https://example.com/1", "https://example.com/2"],
22
+ "usage": {"input_tokens": 10, "output_tokens": 50, "total_tokens": 60},
23
+ }
24
+
25
+
26
+ @pytest.fixture
27
+ def adapter():
28
+ a = PerplexityAdapter(api_key="pplx-test-key")
29
+ a.connect()
30
+ return a
31
+
32
+
33
+ # ── connect() ─────────────────────────────────────────────────────────────
34
+
35
+ def test_connect_raises_without_api_key():
36
+ a = PerplexityAdapter(api_key="")
37
+ with pytest.raises(AdapterConnectionError, match="PERPLEXITY_API_KEY"):
38
+ a.connect()
39
+
40
+
41
+ def test_connect_sets_auth_header(adapter):
42
+ assert "Authorization" in adapter._session.headers
43
+ assert adapter._session.headers["Authorization"] == "Bearer pplx-test-key"
44
+
45
+
46
+ def test_connect_sets_connected_flag(adapter):
47
+ assert adapter.connected is True
48
+
49
+
50
+ def test_disconnect_clears_session(adapter):
51
+ adapter.disconnect()
52
+ assert adapter._session is None
53
+ assert adapter.connected is False
54
+
55
+
56
+ # ── to_native() ───────────────────────────────────────────────────────────
57
+
58
+ def test_to_native_search_web(adapter):
59
+ msg = PulseMessage(
60
+ action="ACT.SEARCH.WEB",
61
+ parameters={"query": "What is PULSE Protocol?"},
62
+ validate=False,
63
+ )
64
+ native = adapter.to_native(msg)
65
+ assert native["model"] == "sonar"
66
+ assert native["messages"][-1]["content"] == "What is PULSE Protocol?"
67
+ assert native["stream"] is False
68
+
69
+
70
+ def test_to_native_research_uses_deep_model(adapter):
71
+ msg = PulseMessage(
72
+ action="ACT.RESEARCH.TOPIC",
73
+ parameters={"topic": "AI semantic protocols"},
74
+ validate=False,
75
+ )
76
+ native = adapter.to_native(msg)
77
+ assert native["model"] == "sonar-deep-research"
78
+
79
+
80
+ def test_to_native_model_override(adapter):
81
+ msg = PulseMessage(
82
+ action="ACT.SEARCH.WEB",
83
+ parameters={"query": "test", "model": "sonar-pro"},
84
+ validate=False,
85
+ )
86
+ native = adapter.to_native(msg)
87
+ assert native["model"] == "sonar-pro"
88
+
89
+
90
+ def test_to_native_search_recency_filter(adapter):
91
+ msg = PulseMessage(
92
+ action="ACT.SEARCH.WEB",
93
+ parameters={"query": "latest AI news", "search_recency": "day"},
94
+ validate=False,
95
+ )
96
+ native = adapter.to_native(msg)
97
+ assert native["search_recency_filter"] == "day"
98
+
99
+
100
+ def test_to_native_domain_filter(adapter):
101
+ msg = PulseMessage(
102
+ action="ACT.SEARCH.WEB",
103
+ parameters={"query": "q", "search_domain_filter": ["arxiv.org"]},
104
+ validate=False,
105
+ )
106
+ native = adapter.to_native(msg)
107
+ assert native["search_domain_filter"] == ["arxiv.org"]
108
+
109
+
110
+ def test_to_native_raises_without_query(adapter):
111
+ msg = PulseMessage(
112
+ action="ACT.SEARCH.WEB", parameters={}, validate=False
113
+ )
114
+ with pytest.raises(AdapterError, match="'query'"):
115
+ adapter.to_native(msg)
116
+
117
+
118
+ def test_to_native_raises_unsupported_action(adapter):
119
+ msg = PulseMessage(
120
+ action="ACT.STORE.SECRET", parameters={}, validate=False
121
+ )
122
+ with pytest.raises(AdapterError, match="Unsupported action"):
123
+ adapter.to_native(msg)
124
+
125
+
126
+ def test_to_native_accepts_text_param(adapter):
127
+ msg = PulseMessage(
128
+ action="ACT.QUERY.DATA",
129
+ parameters={"text": "Explain quantum computing"},
130
+ validate=False,
131
+ )
132
+ native = adapter.to_native(msg)
133
+ assert native["messages"][-1]["content"] == "Explain quantum computing"
134
+
135
+
136
+ # ── call_api() ────────────────────────────────────────────────────────────
137
+
138
+ @rsps_lib.activate
139
+ def test_call_api_success(adapter):
140
+ rsps_lib.add(rsps_lib.POST, API_URL, json=make_api_response("Paris is the capital"), status=200)
141
+ result = adapter.call_api({
142
+ "model": "sonar",
143
+ "messages": [{"role": "user", "content": "Capital of France?"}],
144
+ "stream": False,
145
+ })
146
+ assert result["answer"] == "Paris is the capital"
147
+ assert result["model"] == "sonar"
148
+ assert len(result["citations"]) == 2
149
+
150
+
151
+ @rsps_lib.activate
152
+ def test_call_api_returns_citations(adapter):
153
+ rsps_lib.add(rsps_lib.POST, API_URL, json=make_api_response(
154
+ citations=["https://wiki.org/France", "https://britannica.com/paris"]
155
+ ), status=200)
156
+ result = adapter.call_api({"model": "sonar", "messages": [], "stream": False})
157
+ assert "https://wiki.org/France" in result["citations"]
158
+
159
+
160
+ @rsps_lib.activate
161
+ def test_call_api_401_raises_auth_error(adapter):
162
+ rsps_lib.add(rsps_lib.POST, API_URL, json={"error": "unauthorized"}, status=401)
163
+ with pytest.raises(AdapterError, match="Invalid Perplexity API key"):
164
+ adapter.call_api({"model": "sonar", "messages": [], "stream": False})
165
+
166
+
167
+ @rsps_lib.activate
168
+ def test_call_api_429_raises_rate_limit(adapter):
169
+ rsps_lib.add(rsps_lib.POST, API_URL, json={"error": "rate limit"}, status=429)
170
+ with pytest.raises(AdapterError, match="rate limit"):
171
+ adapter.call_api({"model": "sonar", "messages": [], "stream": False})
172
+
173
+
174
+ @rsps_lib.activate
175
+ def test_call_api_500_raises_error(adapter):
176
+ rsps_lib.add(rsps_lib.POST, API_URL, json={"error": "server error"}, status=500)
177
+ with pytest.raises(AdapterError, match="500"):
178
+ adapter.call_api({"model": "sonar", "messages": [], "stream": False})
179
+
180
+
181
+ def test_call_api_connection_error(adapter):
182
+ with patch.object(adapter._session, "post", side_effect=Exception("connection refused")):
183
+ with pytest.raises(Exception):
184
+ adapter.call_api({"model": "sonar", "messages": [], "stream": False})
185
+
186
+
187
+ # ── from_native() ─────────────────────────────────────────────────────────
188
+
189
+ def test_from_native_wraps_in_pulse(adapter):
190
+ response = adapter.from_native({
191
+ "answer": "42",
192
+ "model": "sonar",
193
+ "citations": ["https://example.com"],
194
+ "finish_reason": "stop",
195
+ "usage": {"total_tokens": 60},
196
+ })
197
+ assert isinstance(response, PulseMessage)
198
+ p = response.content["parameters"]
199
+ assert p["answer"] == "42"
200
+ assert p["citations"] == ["https://example.com"]
201
+ assert p["status"] == "META.STATUS.SUCCESS"
202
+
203
+
204
+ # ── Full send() flow ──────────────────────────────────────────────────────
205
+
206
+ @rsps_lib.activate
207
+ def test_send_search_web(adapter):
208
+ rsps_lib.add(rsps_lib.POST, API_URL, json=make_api_response(
209
+ answer="PULSE Protocol is a semantic AI standard",
210
+ citations=["https://pulse-protocol.netlify.app"]
211
+ ), status=200)
212
+ msg = PulseMessage(
213
+ action="ACT.SEARCH.WEB",
214
+ parameters={"query": "What is PULSE Protocol?"},
215
+ validate=False,
216
+ )
217
+ response = adapter.send(msg)
218
+ p = response.content["parameters"]
219
+ assert "PULSE Protocol" in p["answer"]
220
+ assert "https://pulse-protocol.netlify.app" in p["citations"]
221
+ assert p["status"] == "META.STATUS.SUCCESS"
222
+
223
+
224
+ @rsps_lib.activate
225
+ def test_send_with_recency_filter(adapter):
226
+ rsps_lib.add(rsps_lib.POST, API_URL, json=make_api_response(), status=200)
227
+ msg = PulseMessage(
228
+ action="ACT.SEARCH.WEB",
229
+ parameters={"query": "AI news", "search_recency": "week"},
230
+ validate=False,
231
+ )
232
+ adapter.send(msg)
233
+ sent_body = rsps_lib.calls[0].request.body.decode()
234
+ assert "search_recency_filter" in sent_body
235
+ assert "week" in sent_body
236
+
237
+
238
+ # ── supported_actions & repr ──────────────────────────────────────────────
239
+
240
+ def test_supported_actions(adapter):
241
+ actions = adapter.supported_actions
242
+ assert "ACT.SEARCH.WEB" in actions
243
+ assert "ACT.QUERY.DATA" in actions
244
+ assert "ACT.ANALYZE.PATTERN" in actions
245
+ assert "ACT.RESEARCH.TOPIC" in actions
246
+ assert len(actions) == 4
247
+
248
+
249
+ def test_repr(adapter):
250
+ r = repr(adapter)
251
+ assert "PerplexityAdapter" in r
252
+ assert "sonar" in r
253
+
254
+
255
+ # ── Global search_recency on adapter level ────────────────────────────────
256
+
257
+ @rsps_lib.activate
258
+ def test_adapter_level_recency_filter():
259
+ a = PerplexityAdapter(api_key="pplx-test", search_recency="month")
260
+ a.connect()
261
+ rsps_lib.add(rsps_lib.POST, API_URL, json=make_api_response(), status=200)
262
+ msg = PulseMessage(
263
+ action="ACT.SEARCH.WEB",
264
+ parameters={"query": "test"},
265
+ validate=False,
266
+ )
267
+ a.send(msg)
268
+ sent_body = rsps_lib.calls[0].request.body.decode()
269
+ assert "month" in sent_body