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.
- pulse_perplexity-0.1.0/PKG-INFO +27 -0
- pulse_perplexity-0.1.0/pulse_perplexity/__init__.py +28 -0
- pulse_perplexity-0.1.0/pulse_perplexity/adapter.py +294 -0
- pulse_perplexity-0.1.0/pulse_perplexity/version.py +1 -0
- pulse_perplexity-0.1.0/pulse_perplexity.egg-info/PKG-INFO +27 -0
- pulse_perplexity-0.1.0/pulse_perplexity.egg-info/SOURCES.txt +10 -0
- pulse_perplexity-0.1.0/pulse_perplexity.egg-info/dependency_links.txt +1 -0
- pulse_perplexity-0.1.0/pulse_perplexity.egg-info/requires.txt +8 -0
- pulse_perplexity-0.1.0/pulse_perplexity.egg-info/top_level.txt +1 -0
- pulse_perplexity-0.1.0/pyproject.toml +51 -0
- pulse_perplexity-0.1.0/setup.cfg +4 -0
- pulse_perplexity-0.1.0/tests/test_adapter.py +269 -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|