返回 last30days-skill
test_perplexity.py
根目录 / tests / test_perplexity.py
1 import unittest
2 from unittest.mock import patch
3
4 from lib import perplexity
5
6
7 class PerplexityProviderTests(unittest.TestCase):
8 def test_direct_perplexity_key_wins_and_parses_search_results(self):
9 response = {
10 "choices": [{"message": {"content": "Direct synthesis"}}],
11 "citations": ["https://example.com/a"],
12 "search_results": [
13 {
14 "title": "Example A",
15 "url": "https://example.com/a",
16 "date": "2026-06-01",
17 "snippet": "Direct source snippet",
18 "source": "web",
19 }
20 ],
21 }
22 with patch("lib.perplexity.http.post", return_value=response) as post:
23 items, artifact = perplexity.search(
24 "test topic",
25 ("2026-05-01", "2026-06-01"),
26 {
27 "PERPLEXITY_API_KEY": "pplx-test",
28 "OPENROUTER_API_KEY": "or-test",
29 },
30 )
31
32 url, payload = post.call_args.args[:2]
33 headers = post.call_args.kwargs["headers"]
34 self.assertEqual(perplexity.PERPLEXITY_URL, url)
35 self.assertEqual("Bearer pplx-test", headers["Authorization"])
36 self.assertEqual("sonar-pro", payload["model"])
37 self.assertEqual(
38 "05/01/2026",
39 payload["web_search_options"]["search_after_date_filter"],
40 )
41 self.assertEqual(
42 "06/01/2026",
43 payload["web_search_options"]["search_before_date_filter"],
44 )
45 self.assertEqual("perplexity", artifact["provider"])
46 self.assertEqual("sonar-pro", artifact["model"])
47 self.assertEqual("Example A", items[1]["title"])
48 self.assertEqual("Direct source snippet", items[1]["snippet"])
49
50 def test_direct_model_config_selects_supported_sonar_model(self):
51 response = {
52 "choices": [{"message": {"content": "Reasoned synthesis"}}],
53 "citations": [],
54 "search_results": [],
55 }
56 with patch("lib.perplexity.http.post", return_value=response) as post:
57 _, artifact = perplexity.search(
58 "test topic",
59 ("2026-05-01", "2026-06-01"),
60 {
61 "PERPLEXITY_API_KEY": "pplx-test",
62 "LAST30DAYS_PERPLEXITY_MODEL": "sonar-reasoning-pro",
63 "LAST30DAYS_PERPLEXITY_REASONING_EFFORT": "high",
64 },
65 )
66
67 payload = post.call_args.args[1]
68 self.assertEqual("sonar-reasoning-pro", payload["model"])
69 self.assertEqual("high", payload["reasoning_effort"])
70 self.assertEqual("sonar-reasoning-pro", artifact["model"])
71
72 def test_openrouter_fallback_uses_openrouter_models_and_annotations(self):
73 response = {
74 "choices": [
75 {
76 "message": {
77 "content": "OpenRouter synthesis",
78 "annotations": [
79 {
80 "url_citation": {
81 "url": "https://example.com/b",
82 "title": "Example B",
83 }
84 }
85 ],
86 }
87 }
88 ],
89 }
90 with patch("lib.perplexity.http.post", return_value=response) as post:
91 items, artifact = perplexity.search(
92 "test topic",
93 ("2026-05-01", "2026-06-01"),
94 {"OPENROUTER_API_KEY": "or-test"},
95 deep=True,
96 )
97
98 url, payload = post.call_args.args[:2]
99 headers = post.call_args.kwargs["headers"]
100 self.assertEqual(perplexity.OPENROUTER_URL, url)
101 self.assertEqual("Bearer or-test", headers["Authorization"])
102 self.assertEqual("perplexity/sonar-deep-research", payload["model"])
103 self.assertEqual(120, post.call_args.kwargs["timeout"])
104 self.assertEqual("openrouter", artifact["provider"])
105 self.assertEqual("perplexity/sonar-deep-research", artifact["model"])
106 self.assertEqual("Example B", items[1]["title"])
107
108 def test_search_api_mode_returns_ranked_rows_with_filters(self):
109 response = {
110 "id": "search-1",
111 "server_time": "2026-06-01T00:00:00Z",
112 "results": [
113 {
114 "title": "Ranked result",
115 "url": "https://example.com/ranked",
116 "snippet": "Search API snippet",
117 "date": "2026-05-15",
118 "last_updated": "2026-05-20",
119 }
120 ],
121 }
122 config = {
123 "PERPLEXITY_API_KEY": "pplx-test",
124 "LAST30DAYS_PERPLEXITY_MODE": "search",
125 "LAST30DAYS_PERPLEXITY_MAX_RESULTS": "3",
126 "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE": "low",
127 "LAST30DAYS_PERPLEXITY_COUNTRY": "us",
128 "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER": "example.com,example.org",
129 "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER": "en",
130 "LAST30DAYS_PERPLEXITY_RECENCY_FILTER": "year",
131 }
132 with patch("lib.perplexity.http.post", return_value=response) as post:
133 items, artifact = perplexity.search(
134 "test topic",
135 ("2026-05-01", "2026-06-01"),
136 config,
137 )
138
139 url, payload = post.call_args.args[:2]
140 self.assertEqual(perplexity.PERPLEXITY_SEARCH_URL, url)
141 self.assertEqual("test topic", payload["query"])
142 self.assertEqual(3, payload["max_results"])
143 self.assertEqual("low", payload["search_context_size"])
144 self.assertEqual("US", payload["country"])
145 self.assertEqual(["example.com", "example.org"], payload["search_domain_filter"])
146 self.assertEqual("05/01/2026", payload["search_after_date_filter"])
147 self.assertEqual("06/01/2026", payload["search_before_date_filter"])
148 self.assertNotIn("search_recency_filter", payload)
149 self.assertEqual("search", artifact["mode"])
150 self.assertEqual("Ranked result", items[0]["title"])
151 self.assertEqual("2026-05-20", items[0]["metadata"]["last_updated"])
152
153 def test_search_api_keeps_recency_filter_when_no_exact_dates_are_available(self):
154 payload = perplexity._build_search_payload(
155 "test topic",
156 ("not-a-date", "also-not-a-date"),
157 {"LAST30DAYS_PERPLEXITY_RECENCY_FILTER": "week"},
158 )
159
160 self.assertEqual("week", payload["search_recency_filter"])
161 self.assertNotIn("search_after_date_filter", payload)
162 self.assertNotIn("search_before_date_filter", payload)
163
164 def test_both_mode_keeps_synthesis_and_dedupes_raw_rows(self):
165 search_response = {
166 "id": "search-1",
167 "results": [
168 {
169 "title": "Duplicate ranked result",
170 "url": "https://example.com/a",
171 "snippet": "Raw row",
172 "date": "2026-05-15",
173 },
174 {
175 "title": "Unique ranked result",
176 "url": "https://example.com/unique",
177 "snippet": "Unique raw row",
178 "date": "2026-05-16",
179 },
180 ],
181 }
182 sonar_response = {
183 "choices": [{"message": {"content": "Sonar synthesis"}}],
184 "citations": ["https://example.com/a"],
185 "search_results": [
186 {
187 "title": "Citation result",
188 "url": "https://example.com/a",
189 "snippet": "Citation row",
190 "date": "2026-05-15",
191 }
192 ],
193 }
194 with patch(
195 "lib.perplexity.http.post",
196 side_effect=[search_response, sonar_response],
197 ) as post:
198 items, artifact = perplexity.search(
199 "test topic",
200 ("2026-05-01", "2026-06-01"),
201 {
202 "PERPLEXITY_API_KEY": "pplx-test",
203 "LAST30DAYS_PERPLEXITY_MODE": "both",
204 },
205 )
206
207 self.assertEqual(perplexity.PERPLEXITY_SEARCH_URL, post.call_args_list[0].args[0])
208 self.assertEqual(perplexity.PERPLEXITY_URL, post.call_args_list[1].args[0])
209 self.assertEqual("both", artifact["mode"])
210 self.assertEqual(3, artifact["itemCount"])
211 self.assertEqual("perplexity.ai", items[0]["source_domain"])
212 urls = [item["url"] for item in items if item["url"]]
213 self.assertEqual(["https://example.com/a", "https://example.com/unique"], urls)
214
215 def test_both_mode_keeps_search_rows_when_sonar_leg_fails(self):
216 search_response = {
217 "id": "search-1",
218 "results": [
219 {
220 "title": "Raw result",
221 "url": "https://example.com/raw",
222 "snippet": "Raw row",
223 "date": "2026-05-15",
224 },
225 ],
226 }
227 with patch(
228 "lib.perplexity.http.post",
229 side_effect=[
230 search_response,
231 perplexity.http.HTTPError("HTTP 500: Server Error", status_code=500),
232 ],
233 ):
234 items, artifact = perplexity.search(
235 "test topic",
236 ("2026-05-01", "2026-06-01"),
237 {
238 "PERPLEXITY_API_KEY": "pplx-test",
239 "LAST30DAYS_PERPLEXITY_MODE": "both",
240 },
241 )
242
243 self.assertEqual("Raw result", items[0]["title"])
244 self.assertEqual(1, artifact["itemCount"])
245 self.assertEqual("HTTPError", artifact["sonar"]["error"])
246 self.assertEqual(500, artifact["sonar"]["statusCode"])
247
248 def test_direct_deep_research_uses_async_api_and_wall_timeout_config(self):
249 create_response = {"id": "async-1", "status": "CREATED", "created_at": 123}
250 complete_response = {
251 "id": "async-1",
252 "status": "COMPLETED",
253 "created_at": 123,
254 "started_at": 124,
255 "completed_at": 130,
256 "response": {
257 "choices": [{"message": {"content": "Deep synthesis"}}],
258 "citations": ["https://example.com/deep"],
259 "search_results": [
260 {
261 "title": "Deep citation",
262 "url": "https://example.com/deep",
263 "snippet": "Deep snippet",
264 }
265 ],
266 "usage": {
267 "total_tokens": 123,
268 "cost": {"total_cost": 0.12},
269 },
270 },
271 }
272 with patch("lib.perplexity.http.post", return_value=create_response) as post, \
273 patch("lib.perplexity.http.get", return_value=complete_response) as get:
274 items, artifact = perplexity.search(
275 "test topic",
276 ("2026-05-01", "2026-06-01"),
277 {
278 "PERPLEXITY_API_KEY": "pplx-test",
279 "LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "300",
280 },
281 deep=True,
282 )
283
284 self.assertEqual(perplexity.PERPLEXITY_ASYNC_URL, post.call_args.args[0])
285 self.assertEqual(
286 perplexity.PERPLEXITY_ASYNC_URL,
287 perplexity._provider({"PERPLEXITY_API_KEY": "pplx-test"}, deep=True)[2],
288 )
289 create_payload = post.call_args.args[1]
290 self.assertEqual("sonar-deep-research", create_payload["request"]["model"])
291 self.assertTrue(create_payload["idempotency_key"].startswith("last30days:"))
292 self.assertEqual(f"{perplexity.PERPLEXITY_ASYNC_URL}/async-1", get.call_args.args[0])
293 self.assertEqual("async-sonar", artifact["endpoint"])
294 self.assertEqual(True, artifact["async"])
295 self.assertEqual(300, artifact["asyncTimeoutSeconds"])
296 self.assertEqual(create_payload["idempotency_key"], artifact["asyncIdempotencyKey"])
297 self.assertEqual(1, artifact["asyncPollCount"])
298 self.assertEqual("COMPLETED_REMOTE", artifact["asyncLocalStatus"])
299 self.assertEqual(123, artifact["asyncCreatedAt"])
300 self.assertEqual(124, artifact["asyncStartedAt"])
301 self.assertEqual(130, artifact["asyncCompletedAt"])
302 self.assertEqual(123, items[0]["metadata"]["usage"]["total_tokens"])
303
304 def test_direct_deep_research_timeout_returns_empty_result(self):
305 with patch("lib.perplexity.http.post", return_value={"id": "async-1", "status": "CREATED", "created_at": 123}), \
306 patch("lib.perplexity.http.get", return_value={
307 "id": "async-1",
308 "status": "IN_PROGRESS",
309 "created_at": 123,
310 "started_at": 124,
311 }), \
312 patch("lib.perplexity.time.monotonic", side_effect=[0, 0, 2, 2]), \
313 patch("lib.perplexity.time.sleep"):
314 items, artifact = perplexity.search(
315 "test topic",
316 ("2026-05-01", "2026-06-01"),
317 {
318 "PERPLEXITY_API_KEY": "pplx-test",
319 "LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "1",
320 },
321 deep=True,
322 )
323
324 self.assertEqual([], items)
325 self.assertEqual("timeout", artifact["error"])
326 self.assertEqual("async-1", artifact["asyncRequestId"])
327 self.assertEqual("IN_PROGRESS", artifact["asyncStatus"])
328 self.assertEqual(1, artifact["asyncTimeoutSeconds"])
329 self.assertEqual(1, artifact["asyncPollCount"])
330 self.assertEqual("PENDING_REMOTE", artifact["asyncLocalStatus"])
331 self.assertEqual(123, artifact["asyncCreatedAt"])
332 self.assertEqual(124, artifact["asyncStartedAt"])
333
334 def test_direct_deep_research_failed_status_returns_failure_artifact(self):
335 with patch("lib.perplexity.http.post", return_value={"id": "async-1", "status": "CREATED"}), \
336 patch("lib.perplexity.http.get", return_value={
337 "id": "async-1",
338 "status": "FAILED",
339 "failed_at": 130,
340 "error_message": "provider failure",
341 }):
342 items, artifact = perplexity.search(
343 "test topic",
344 ("2026-05-01", "2026-06-01"),
345 {"PERPLEXITY_API_KEY": "pplx-test"},
346 deep=True,
347 )
348
349 self.assertEqual([], items)
350 self.assertEqual("failed", artifact["error"])
351 self.assertEqual("async-1", artifact["asyncRequestId"])
352 self.assertEqual("FAILED", artifact["asyncStatus"])
353 self.assertEqual("FAILED_REMOTE", artifact["asyncLocalStatus"])
354 self.assertEqual(130, artifact["asyncFailedAt"])
355 self.assertEqual("provider failure", artifact["asyncErrorMessage"])
356
357 def test_direct_deep_research_poll_error_preserves_async_id(self):
358 with patch("lib.perplexity.http.post", return_value={
359 "id": "async-1",
360 "status": "CREATED",
361 "created_at": 123,
362 }), \
363 patch("lib.perplexity.http.get", side_effect=perplexity.http.HTTPError(
364 "HTTP 429: Too Many Requests",
365 status_code=429,
366 )):
367 items, artifact = perplexity.search(
368 "test topic",
369 ("2026-05-01", "2026-06-01"),
370 {"PERPLEXITY_API_KEY": "pplx-test"},
371 deep=True,
372 )
373
374 self.assertEqual([], items)
375 self.assertEqual("poll_error", artifact["error"])
376 self.assertEqual("async-1", artifact["asyncRequestId"])
377 self.assertEqual("CREATED", artifact["asyncStatus"])
378 self.assertEqual("POLL_ERROR", artifact["asyncLocalStatus"])
379 self.assertEqual(1, artifact["asyncPollCount"])
380 self.assertEqual(429, artifact["asyncPollStatusCode"])
381
382 def test_direct_deep_research_malformed_completed_preserves_async_id(self):
383 with patch("lib.perplexity.http.post", return_value={
384 "id": "async-1",
385 "status": "CREATED",
386 "created_at": 123,
387 }), \
388 patch("lib.perplexity.http.get", return_value={
389 "id": "async-1",
390 "status": "COMPLETED",
391 "created_at": 123,
392 "completed_at": 130,
393 "response": None,
394 }):
395 items, artifact = perplexity.search(
396 "test topic",
397 ("2026-05-01", "2026-06-01"),
398 {"PERPLEXITY_API_KEY": "pplx-test"},
399 deep=True,
400 )
401
402 self.assertEqual([], items)
403 self.assertEqual("failed", artifact["error"])
404 self.assertEqual("async-1", artifact["asyncRequestId"])
405 self.assertEqual("COMPLETED", artifact["asyncStatus"])
406 self.assertEqual("FAILED_REMOTE", artifact["asyncLocalStatus"])
407 self.assertEqual(1, artifact["asyncPollCount"])
408 self.assertEqual(130, artifact["asyncCompletedAt"])
409 self.assertEqual(
410 "Async Deep Research completed without response",
411 artifact["asyncErrorMessage"],
412 )
413
414 def test_direct_deep_research_empty_choices_preserves_async_id(self):
415 with patch("lib.perplexity.http.post", return_value={
416 "id": "async-1",
417 "status": "CREATED",
418 "created_at": 123,
419 }) as post, \
420 patch("lib.perplexity.http.get", return_value={
421 "id": "async-1",
422 "status": "COMPLETED",
423 "created_at": 123,
424 "completed_at": 130,
425 "response": {
426 "choices": [],
427 "usage": {"total_tokens": 321},
428 },
429 }):
430 items, artifact = perplexity.search(
431 "test topic",
432 ("2026-05-01", "2026-06-01"),
433 {"PERPLEXITY_API_KEY": "pplx-test"},
434 deep=True,
435 )
436
437 self.assertEqual([], items)
438 self.assertEqual("empty_choices", artifact["error"])
439 self.assertEqual("async-1", artifact["asyncRequestId"])
440 self.assertEqual("COMPLETED", artifact["asyncStatus"])
441 self.assertEqual("COMPLETED_REMOTE", artifact["asyncLocalStatus"])
442 self.assertEqual(1, artifact["asyncPollCount"])
443 self.assertEqual(130, artifact["asyncCompletedAt"])
444 self.assertEqual(
445 post.call_args.args[1]["idempotency_key"],
446 artifact["asyncIdempotencyKey"],
447 )
448 self.assertEqual(321, artifact["usage"]["total_tokens"])
449 self.assertEqual(
450 "Async Deep Research completed without choices",
451 artifact["asyncErrorMessage"],
452 )
453
454 def test_direct_deep_research_empty_synthesis_preserves_async_id(self):
455 with patch("lib.perplexity.http.post", return_value={
456 "id": "async-1",
457 "status": "CREATED",
458 "created_at": 123,
459 }) as post, \
460 patch("lib.perplexity.http.get", return_value={
461 "id": "async-1",
462 "status": "COMPLETED",
463 "created_at": 123,
464 "completed_at": 130,
465 "response": {
466 "choices": [{"message": {"content": ""}}],
467 },
468 }):
469 items, artifact = perplexity.search(
470 "test topic",
471 ("2026-05-01", "2026-06-01"),
472 {"PERPLEXITY_API_KEY": "pplx-test"},
473 deep=True,
474 )
475
476 self.assertEqual([], items)
477 self.assertEqual("empty_synthesis", artifact["error"])
478 self.assertEqual("async-1", artifact["asyncRequestId"])
479 self.assertEqual("COMPLETED", artifact["asyncStatus"])
480 self.assertEqual("COMPLETED_REMOTE", artifact["asyncLocalStatus"])
481 self.assertEqual(1, artifact["asyncPollCount"])
482 self.assertEqual(130, artifact["asyncCompletedAt"])
483 self.assertEqual(
484 post.call_args.args[1]["idempotency_key"],
485 artifact["asyncIdempotencyKey"],
486 )
487 self.assertEqual(
488 "Async Deep Research completed with empty synthesis",
489 artifact["asyncErrorMessage"],
490 )
491
492 def test_direct_deep_research_malformed_choice_preserves_async_id(self):
493 with patch("lib.perplexity.http.post", return_value={
494 "id": "async-1",
495 "status": "CREATED",
496 "created_at": 123,
497 }) as post, \
498 patch("lib.perplexity.http.get", return_value={
499 "id": "async-1",
500 "status": "COMPLETED",
501 "created_at": 123,
502 "completed_at": 130,
503 "response": {
504 "choices": [None],
505 },
506 }):
507 items, artifact = perplexity.search(
508 "test topic",
509 ("2026-05-01", "2026-06-01"),
510 {"PERPLEXITY_API_KEY": "pplx-test"},
511 deep=True,
512 )
513
514 self.assertEqual([], items)
515 self.assertEqual("empty_synthesis", artifact["error"])
516 self.assertEqual("async-1", artifact["asyncRequestId"])
517 self.assertEqual("COMPLETED", artifact["asyncStatus"])
518 self.assertEqual("COMPLETED_REMOTE", artifact["asyncLocalStatus"])
519 self.assertEqual(
520 post.call_args.args[1]["idempotency_key"],
521 artifact["asyncIdempotencyKey"],
522 )
523
524 def test_missing_keys_skip_without_http(self):
525 with patch("lib.perplexity.http.post") as post:
526 items, artifact = perplexity.search(
527 "test topic",
528 ("2026-05-01", "2026-06-01"),
529 {},
530 )
531
532 post.assert_not_called()
533 self.assertEqual([], items)
534 self.assertEqual({}, artifact)
535
535 lines PYTHON