返回 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 def _agent_response(
8 text: str = "Agent synthesis",
9 *,
10 response_id: str = "agent-1",
11 status: str = "completed",
12 model: str = "perplexity/sonar",
13 citations: list[dict] | None = None,
14 include_output_text: bool = True,
15 ) -> dict:
16 citation_parts = [
17 {
18 "type": "output_text",
19 "text": text,
20 "annotations": [
21 {
22 "url_citation": {
23 "url": citation["url"],
24 "title": citation.get("title", ""),
25 }
26 }
27 for citation in citations or []
28 ],
29 }
30 ]
31 response = {
32 "id": response_id,
33 "status": status,
34 "model": model,
35 "output": [
36 {"type": "message", "content": citation_parts},
37 {"type": "search_results", "results": citations or []},
38 ],
39 "usage": {"total_tokens": 123},
40 }
41 if include_output_text:
42 response["output_text"] = text
43 return response
44
45
46 class PerplexityAgentTests(unittest.TestCase):
47 def test_controlled_agent_uses_direct_key_and_explicit_web_search(self):
48 citations = [
49 {
50 "title": "Example A",
51 "url": "https://example.com/a",
52 "date": "2026-06-01",
53 "snippet": "Direct source snippet",
54 }
55 ]
56 response = _agent_response("Direct synthesis", citations=citations)
57 config = {
58 "PERPLEXITY_API_KEY": "pplx-test",
59 "OPENROUTER_API_KEY": "or-test",
60 "LAST30DAYS_PERPLEXITY_MAX_RESULTS": "3",
61 "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE": "low",
62 "LAST30DAYS_PERPLEXITY_COUNTRY": "us",
63 "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER": "example.com,example.org",
64 "LAST30DAYS_PERPLEXITY_RECENCY_FILTER": "year",
65 "LAST30DAYS_PERPLEXITY_REASONING_EFFORT": "high",
66 "LAST30DAYS_PERPLEXITY_AGENT_MAX_STEPS": "4",
67 }
68 with patch("lib.perplexity.http.post", return_value=response) as post:
69 items, artifact = perplexity.search(
70 "test topic",
71 ("2026-05-01", "2026-06-01"),
72 config,
73 )
74
75 url, payload = post.call_args.args[:2]
76 headers = post.call_args.kwargs["headers"]
77 tool = payload["tools"][0]
78 self.assertEqual(perplexity.PERPLEXITY_AGENT_URL, url)
79 self.assertEqual("Bearer pplx-test", headers["Authorization"])
80 self.assertEqual(1, post.call_args.kwargs["retries"])
81 self.assertEqual("perplexity/sonar", payload["model"])
82 self.assertIn("input", payload)
83 self.assertIn("instructions", payload)
84 self.assertEqual(4, payload["max_steps"])
85 self.assertEqual({"effort": "high"}, payload["reasoning"])
86 self.assertEqual({"type": "web_search"}, payload["tool_choice"])
87 self.assertEqual("web_search", tool["type"])
88 self.assertEqual(3, tool["max_results"])
89 self.assertEqual("low", tool["search_context_size"])
90 self.assertEqual({"country": "US"}, tool["user_location"])
91 self.assertEqual(
92 ["example.com", "example.org"],
93 tool["filters"]["search_domain_filter"],
94 )
95 self.assertEqual("05/01/2026", tool["filters"]["search_after_date_filter"])
96 self.assertEqual("06/01/2026", tool["filters"]["search_before_date_filter"])
97 self.assertNotIn("search_recency_filter", tool["filters"])
98 self.assertNotIn("preset", payload)
99
100 self.assertEqual("perplexity", artifact["provider"])
101 self.assertEqual("agent", artifact["mode"])
102 self.assertEqual("agent", artifact["endpoint"])
103 self.assertEqual("perplexity/sonar", artifact["model"])
104 self.assertEqual(perplexity.PERPLEXITY_CONTROLLED_PROFILE, artifact["profile"])
105 self.assertFalse(artifact["dynamicPreset"])
106 self.assertEqual("agent-1", artifact["responseId"])
107 self.assertEqual("perplexity/sonar", artifact["servedModel"])
108 self.assertEqual(["message", "search_results"], artifact["outputTypes"])
109 self.assertNotIn("input", artifact["request"])
110 self.assertNotIn("instructions", artifact["request"])
111 self.assertEqual({"effort": "high"}, artifact["request"]["reasoning"])
112 self.assertEqual(
113 {"type": "web_search"},
114 artifact["request"]["tool_choice"],
115 )
116 self.assertEqual("Example A", items[1]["title"])
117 self.assertEqual("Direct source snippet", items[1]["snippet"])
118
119 def test_legacy_sonar_mode_is_an_agent_alias(self):
120 response = _agent_response()
121 with patch("lib.perplexity.http.post", return_value=response) as post:
122 _, artifact = perplexity.search(
123 "test topic",
124 ("2026-05-01", "2026-06-01"),
125 {
126 "PERPLEXITY_API_KEY": "pplx-test",
127 "LAST30DAYS_PERPLEXITY_MODE": "sonar",
128 "LAST30DAYS_PERPLEXITY_MODEL": "sonar-pro",
129 },
130 )
131
132 self.assertEqual(perplexity.PERPLEXITY_AGENT_URL, post.call_args.args[0])
133 self.assertEqual(1, post.call_args.kwargs["retries"])
134 self.assertEqual("perplexity/sonar", post.call_args.args[1]["model"])
135 self.assertEqual("agent", artifact["mode"])
136
137 def test_controlled_agent_max_steps_clamps_to_local_safety_limit(self):
138 payload, _ = perplexity._build_agent_payload(
139 "test topic",
140 ("2026-05-01", "2026-06-01"),
141 {"LAST30DAYS_PERPLEXITY_AGENT_MAX_STEPS": "100"},
142 deep=False,
143 )
144
145 self.assertEqual(15, payload["max_steps"])
146
147 def test_explicit_anthropic_model_gets_required_output_token_budget(self):
148 payload, selection = perplexity._build_agent_payload(
149 "test topic",
150 ("2026-05-01", "2026-06-01"),
151 {
152 "LAST30DAYS_PERPLEXITY_AGENT_MODEL": "anthropic/claude-sonnet-4-6",
153 "LAST30DAYS_PERPLEXITY_AGENT_MAX_OUTPUT_TOKENS": "8192",
154 },
155 deep=False,
156 )
157
158 self.assertEqual(8192, payload["max_output_tokens"])
159 self.assertEqual(8192, selection["request"]["max_output_tokens"])
160
161 def test_non_anthropic_model_omits_output_token_override(self):
162 payload, _ = perplexity._build_agent_payload(
163 "test topic",
164 ("2026-05-01", "2026-06-01"),
165 {},
166 deep=False,
167 )
168
169 self.assertNotIn("max_output_tokens", payload)
170
171 def test_explicit_agent_preset_is_marked_dynamic(self):
172 response = _agent_response(model="openai/gpt-5.6-luna")
173 with patch("lib.perplexity.http.post", return_value=response) as post:
174 _, artifact = perplexity.search(
175 "test topic",
176 ("2026-05-01", "2026-06-01"),
177 {
178 "PERPLEXITY_API_KEY": "pplx-test",
179 "LAST30DAYS_PERPLEXITY_AGENT_PRESET": "low",
180 },
181 )
182
183 payload = post.call_args.args[1]
184 self.assertEqual("low", payload["preset"])
185 self.assertIn("input", payload)
186 self.assertNotIn("model", payload)
187 self.assertEqual("web_search", payload["tools"][0]["type"])
188 self.assertEqual(10, payload["tools"][0]["max_results"])
189 self.assertTrue(artifact["dynamicPreset"])
190 self.assertEqual("low", artifact["preset"])
191 self.assertEqual("openai/gpt-5.6-luna", artifact["servedModel"])
192 self.assertEqual(perplexity.PERPLEXITY_PRESET_PROFILE, artifact["profile"])
193
194 def test_agent_falls_back_to_message_output_when_output_text_is_missing(self):
195 citations = [
196 {
197 "title": "Example A",
198 "url": "https://example.com/a",
199 "snippet": "Direct source snippet",
200 }
201 ]
202 response = _agent_response(
203 "Message-only synthesis",
204 citations=citations,
205 include_output_text=False,
206 )
207 with patch("lib.perplexity.http.post", return_value=response):
208 items, artifact = perplexity.search(
209 "test topic",
210 ("2026-05-01", "2026-06-01"),
211 {"PERPLEXITY_API_KEY": "pplx-test"},
212 )
213
214 self.assertEqual("Message-only synthesis", items[0]["snippet"])
215 self.assertEqual(1, artifact["citationCount"])
216
217 def test_malformed_agent_output_returns_safe_empty_artifact(self):
218 response = {
219 "id": "agent-empty",
220 "status": "completed",
221 "model": "perplexity/sonar",
222 "output": [{"type": "function_call", "arguments": "untrusted trace"}],
223 "usage": {"total_tokens": 2},
224 }
225 with patch("lib.perplexity.http.post", return_value=response):
226 items, artifact = perplexity.search(
227 "test topic",
228 ("2026-05-01", "2026-06-01"),
229 {"PERPLEXITY_API_KEY": "pplx-test"},
230 )
231
232 self.assertEqual([], items)
233 self.assertEqual("empty_synthesis", artifact["error"])
234 self.assertEqual("perplexity/sonar", artifact["servedModel"])
235 self.assertEqual(["function_call"], artifact["outputTypes"])
236 self.assertNotIn("arguments", artifact)
237
238 def test_sync_terminal_failure_keeps_safe_response_metadata(self):
239 response = {
240 "id": "agent-failed",
241 "status": "failed",
242 "error": {"message": "Provider safely rejected this request"},
243 "output": [{"type": "function_call", "arguments": "raw tool trace"}],
244 "usage": {"total_tokens": 7},
245 }
246 with patch("lib.perplexity.http.post", return_value=response):
247 items, artifact = perplexity.search(
248 "test topic",
249 ("2026-05-01", "2026-06-01"),
250 {"PERPLEXITY_API_KEY": "pplx-test"},
251 )
252
253 self.assertEqual([], items)
254 self.assertEqual("failed", artifact["error"])
255 self.assertEqual("agent-failed", artifact["responseId"])
256 self.assertEqual("failed", artifact["status"])
257 self.assertEqual("Provider safely rejected this request", artifact["agentErrorMessage"])
258 self.assertEqual({"total_tokens": 7}, artifact["usage"])
259 self.assertEqual(["function_call"], artifact["outputTypes"])
260 self.assertNotIn("arguments", artifact)
261
262 def test_sync_incomplete_response_keeps_safe_reason(self):
263 response = {
264 "id": "agent-incomplete",
265 "status": "incomplete",
266 "incomplete_details": {"reason": "max_output_tokens"},
267 "output": [],
268 "usage": {"total_tokens": 11},
269 }
270 with patch("lib.perplexity.http.post", return_value=response):
271 items, artifact = perplexity.search(
272 "test topic",
273 ("2026-05-01", "2026-06-01"),
274 {"PERPLEXITY_API_KEY": "pplx-test"},
275 )
276
277 self.assertEqual([], items)
278 self.assertEqual("incomplete", artifact["error"])
279 self.assertEqual("max_output_tokens", artifact["incompleteReason"])
280 self.assertNotIn("incomplete_details", artifact)
281
282 def test_openrouter_only_config_uses_sonar_fallback(self):
283 response = {
284 "id": "openrouter-1",
285 "model": "perplexity/sonar-pro",
286 "choices": [
287 {
288 "message": {
289 "content": "OpenRouter fallback synthesis",
290 "annotations": [
291 {
292 "url_citation": {
293 "url": "https://example.com/openrouter",
294 "title": "OpenRouter citation",
295 }
296 }
297 ],
298 }
299 }
300 ],
301 "usage": {"total_tokens": 42},
302 }
303 with patch("lib.perplexity.http.post", return_value=response) as post, patch(
304 "lib.perplexity.http.get"
305 ) as get:
306 items, artifact = perplexity.search(
307 "test topic",
308 ("2026-05-01", "2026-06-01"),
309 {"OPENROUTER_API_KEY": "or-test"},
310 )
311
312 url, payload = post.call_args.args[:2]
313 self.assertEqual(perplexity.OPENROUTER_URL, url)
314 self.assertEqual("perplexity/sonar-pro", payload["model"])
315 self.assertEqual("Bearer or-test", post.call_args.kwargs["headers"]["Authorization"])
316 self.assertEqual(1, post.call_args.kwargs["retries"])
317 get.assert_not_called()
318 self.assertEqual("openrouter", artifact["provider"])
319 self.assertEqual("sonar", artifact["mode"])
320 self.assertEqual("openrouter-chat-completions", artifact["endpoint"])
321 self.assertEqual("OpenRouter fallback synthesis", items[0]["snippet"])
322 self.assertEqual("OpenRouter citation", items[1]["title"])
323
324 def test_openrouter_search_mode_degrades_to_sonar_fallback(self):
325 response = {
326 "id": "openrouter-2",
327 "model": "perplexity/sonar-pro",
328 "choices": [{"message": {"content": "Fallback synthesis"}}],
329 }
330 with patch("lib.perplexity.http.post", return_value=response) as post:
331 _, artifact = perplexity.search(
332 "test topic",
333 ("2026-05-01", "2026-06-01"),
334 {
335 "OPENROUTER_API_KEY": "or-test",
336 "LAST30DAYS_PERPLEXITY_MODE": "search",
337 },
338 )
339
340 self.assertEqual(perplexity.OPENROUTER_URL, post.call_args.args[0])
341 self.assertEqual("openrouter", artifact["provider"])
342 self.assertEqual("sonar", artifact["mode"])
343
344 def test_openrouter_deep_research_keeps_synchronous_legacy_fallback(self):
345 response = {
346 "id": "openrouter-deep-1",
347 "model": "perplexity/sonar-deep-research",
348 "choices": [{"message": {"content": "Deep fallback synthesis"}}],
349 }
350 with patch("lib.perplexity.http.post", return_value=response) as post, patch(
351 "lib.perplexity.http.get"
352 ) as get:
353 items, artifact = perplexity.search(
354 "test topic",
355 ("2026-05-01", "2026-06-01"),
356 {"OPENROUTER_API_KEY": "or-test"},
357 deep=True,
358 )
359
360 self.assertEqual(perplexity.OPENROUTER_URL, post.call_args.args[0])
361 self.assertEqual(
362 "perplexity/sonar-deep-research",
363 post.call_args.args[1]["model"],
364 )
365 get.assert_not_called()
366 self.assertEqual("Deep fallback synthesis", items[0]["snippet"])
367 self.assertTrue(artifact["deep"])
368 self.assertEqual("openrouter", artifact["provider"])
369
370 def test_openrouter_failure_keeps_typed_source_receipt(self):
371 with patch(
372 "lib.perplexity.http.post",
373 side_effect=perplexity.http.HTTPError(
374 "HTTP 429: Too Many Requests",
375 status_code=429,
376 ),
377 ):
378 items, artifact = perplexity.search(
379 "test topic",
380 ("2026-05-01", "2026-06-01"),
381 {"OPENROUTER_API_KEY": "or-test"},
382 )
383
384 self.assertEqual([], items)
385 self.assertEqual("openrouter", artifact["provider"])
386 self.assertEqual("openrouter-chat-completions", artifact["endpoint"])
387 self.assertEqual(429, artifact["statusCode"])
388
389 def test_search_api_mode_returns_ranked_rows_with_filters(self):
390 response = {
391 "id": "search-1",
392 "server_time": "2026-06-01T00:00:00Z",
393 "results": [
394 {
395 "title": "Ranked result",
396 "url": "https://example.com/ranked",
397 "snippet": "Search API snippet",
398 "date": "2026-05-15",
399 "last_updated": "2026-05-20",
400 }
401 ],
402 }
403 config = {
404 "PERPLEXITY_API_KEY": "pplx-test",
405 "LAST30DAYS_PERPLEXITY_MODE": "search",
406 "LAST30DAYS_PERPLEXITY_MAX_RESULTS": "3",
407 "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE": "low",
408 "LAST30DAYS_PERPLEXITY_COUNTRY": "us",
409 "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER": "example.com,example.org",
410 "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER": "en",
411 "LAST30DAYS_PERPLEXITY_RECENCY_FILTER": "year",
412 }
413 with patch("lib.perplexity.http.post", return_value=response) as post:
414 items, artifact = perplexity.search(
415 "test topic",
416 ("2026-05-01", "2026-06-01"),
417 config,
418 )
419
420 url, payload = post.call_args.args[:2]
421 self.assertEqual(perplexity.PERPLEXITY_SEARCH_URL, url)
422 self.assertEqual(1, post.call_args.kwargs["retries"])
423 self.assertEqual("test topic", payload["query"])
424 self.assertEqual(3, payload["max_results"])
425 self.assertEqual("low", payload["search_context_size"])
426 self.assertEqual("US", payload["country"])
427 self.assertEqual(["example.com", "example.org"], payload["search_domain_filter"])
428 self.assertEqual(["en"], payload["search_language_filter"])
429 self.assertEqual("05/01/2026", payload["search_after_date_filter"])
430 self.assertEqual("06/01/2026", payload["search_before_date_filter"])
431 self.assertNotIn("search_recency_filter", payload)
432 self.assertEqual("search", artifact["mode"])
433 self.assertEqual("Ranked result", items[0]["title"])
434 self.assertEqual("2026-05-20", items[0]["metadata"]["last_updated"])
435
436 def test_search_api_keeps_recency_filter_when_no_exact_dates_are_available(self):
437 payload = perplexity._build_search_payload(
438 "test topic",
439 ("not-a-date", "also-not-a-date"),
440 {"LAST30DAYS_PERPLEXITY_RECENCY_FILTER": "week"},
441 )
442
443 self.assertEqual("week", payload["search_recency_filter"])
444 self.assertNotIn("search_after_date_filter", payload)
445 self.assertNotIn("search_before_date_filter", payload)
446
447 def test_both_mode_keeps_synthesis_and_dedupes_raw_rows(self):
448 search_response = {
449 "id": "search-1",
450 "results": [
451 {
452 "title": "Duplicate ranked result",
453 "url": "https://example.com/a",
454 "snippet": "Raw row",
455 "date": "2026-05-15",
456 },
457 {
458 "title": "Unique ranked result",
459 "url": "https://example.com/unique",
460 "snippet": "Unique raw row",
461 "date": "2026-05-16",
462 },
463 ],
464 }
465 agent_response = _agent_response(
466 citations=[
467 {
468 "title": "Citation result",
469 "url": "https://example.com/a",
470 "snippet": "Citation row",
471 "date": "2026-05-15",
472 }
473 ]
474 )
475 with patch(
476 "lib.perplexity.http.post",
477 side_effect=[search_response, agent_response],
478 ) as post:
479 items, artifact = perplexity.search(
480 "test topic",
481 ("2026-05-01", "2026-06-01"),
482 {
483 "PERPLEXITY_API_KEY": "pplx-test",
484 "LAST30DAYS_PERPLEXITY_MODE": "both",
485 },
486 )
487
488 self.assertEqual(perplexity.PERPLEXITY_SEARCH_URL, post.call_args_list[0].args[0])
489 self.assertEqual(perplexity.PERPLEXITY_AGENT_URL, post.call_args_list[1].args[0])
490 self.assertEqual("both", artifact["mode"])
491 self.assertEqual(3, artifact["itemCount"])
492 self.assertEqual("perplexity.ai", items[0]["source_domain"])
493 urls = [item["url"] for item in items if item["url"]]
494 self.assertEqual(["https://example.com/a", "https://example.com/unique"], urls)
495 self.assertIn("agent", artifact)
496 self.assertNotIn("sonar", artifact)
497
498 def test_both_mode_keeps_search_rows_when_agent_leg_fails(self):
499 search_response = {
500 "id": "search-1",
501 "results": [
502 {
503 "title": "Raw result",
504 "url": "https://example.com/raw",
505 "snippet": "Raw row",
506 "date": "2026-05-15",
507 },
508 ],
509 }
510 with patch(
511 "lib.perplexity.http.post",
512 side_effect=[
513 search_response,
514 perplexity.http.HTTPError("HTTP 500: Server Error", status_code=500),
515 ],
516 ):
517 items, artifact = perplexity.search(
518 "test topic",
519 ("2026-05-01", "2026-06-01"),
520 {
521 "PERPLEXITY_API_KEY": "pplx-test",
522 "LAST30DAYS_PERPLEXITY_MODE": "both",
523 },
524 )
525
526 self.assertEqual("Raw result", items[0]["title"])
527 self.assertEqual(1, artifact["itemCount"])
528 self.assertEqual("HTTPError", artifact["agent"]["error"])
529 self.assertEqual(500, artifact["agent"]["statusCode"])
530
531 def test_deep_research_uses_agent_background_high_preset(self):
532 created = {"id": "agent-deep-1", "status": "queued"}
533 completed = _agent_response(
534 "Deep synthesis",
535 response_id="agent-deep-1",
536 citations=[
537 {
538 "title": "Deep citation",
539 "url": "https://example.com/deep",
540 "snippet": "Deep snippet",
541 }
542 ],
543 )
544 with patch("lib.perplexity.http.post", return_value=created) as post, patch(
545 "lib.perplexity.http.get",
546 return_value=completed,
547 ) as get:
548 items, artifact = perplexity.search(
549 "test topic",
550 ("2026-05-01", "2026-06-01"),
551 {
552 "PERPLEXITY_API_KEY": "pplx-test",
553 "LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "300",
554 },
555 deep=True,
556 )
557
558 self.assertEqual(perplexity.PERPLEXITY_AGENT_URL, post.call_args.args[0])
559 payload = post.call_args.args[1]
560 self.assertEqual(1, post.call_args.kwargs["retries"])
561 self.assertEqual("high", payload["preset"])
562 self.assertTrue(payload["background"])
563 self.assertNotIn("model", payload)
564 tool = payload["tools"][0]
565 self.assertEqual("web_search", tool["type"])
566 self.assertEqual(10, tool["max_results"])
567 self.assertEqual("05/01/2026", tool["filters"]["search_after_date_filter"])
568 self.assertEqual("06/01/2026", tool["filters"]["search_before_date_filter"])
569 self.assertEqual(
570 f"{perplexity.PERPLEXITY_AGENT_URL}/agent-deep-1",
571 get.call_args.args[0],
572 )
573 self.assertEqual("agent-background", artifact["endpoint"])
574 self.assertTrue(artifact["background"])
575 self.assertEqual(300, artifact["backgroundTimeoutSeconds"])
576 self.assertEqual(1, artifact["backgroundPollCount"])
577 self.assertEqual("COMPLETED_REMOTE", artifact["backgroundLocalStatus"])
578 self.assertEqual("high", artifact["preset"])
579 self.assertTrue(artifact["dynamicPreset"])
580 self.assertEqual(123, items[0]["metadata"]["usage"]["total_tokens"])
581
582 def test_deep_research_timeout_preserves_background_response_id(self):
583 with patch(
584 "lib.perplexity.http.post",
585 return_value={"id": "agent-deep-1", "status": "queued"},
586 ) as post, patch(
587 "lib.perplexity.time.monotonic",
588 side_effect=[0, 1],
589 ), patch("lib.perplexity.http.get") as get:
590 items, artifact = perplexity.search(
591 "test topic",
592 ("2026-05-01", "2026-06-01"),
593 {
594 "PERPLEXITY_API_KEY": "pplx-test",
595 "LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "1",
596 },
597 deep=True,
598 )
599
600 post.assert_called_once()
601 get.assert_not_called()
602 self.assertEqual([], items)
603 self.assertEqual("timeout", artifact["error"])
604 self.assertEqual("agent-deep-1", artifact["responseId"])
605 self.assertEqual("queued", artifact["backgroundStatus"])
606 self.assertEqual(1, artifact["backgroundTimeoutSeconds"])
607 self.assertEqual(0, artifact["backgroundPollCount"])
608 self.assertEqual("PENDING_REMOTE", artifact["backgroundLocalStatus"])
609
610 def test_deep_research_nonterminal_poll_after_deadline_is_timeout(self):
611 with patch(
612 "lib.perplexity.http.post",
613 return_value={"id": "agent-deep-1", "status": "queued"},
614 ), patch(
615 "lib.perplexity.http.get",
616 return_value={"id": "agent-deep-1", "status": "queued"},
617 ) as get, patch(
618 "lib.perplexity.time.monotonic",
619 side_effect=[0.0, 0.5, 1.5],
620 ):
621 items, artifact = perplexity.search(
622 "test topic",
623 ("2026-05-01", "2026-06-01"),
624 {
625 "PERPLEXITY_API_KEY": "pplx-test",
626 "LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "1",
627 },
628 deep=True,
629 )
630
631 self.assertEqual([], items)
632 self.assertEqual("timeout", artifact["error"])
633 self.assertEqual("PENDING_REMOTE", artifact["backgroundLocalStatus"])
634 self.assertEqual(1, artifact["backgroundPollCount"])
635 self.assertEqual(1.0, get.call_args.kwargs["deadline_monotonic"])
636
637 def test_deep_research_poll_retry_deadline_is_timeout_not_poll_error(self):
638 with patch(
639 "lib.perplexity.http.post",
640 return_value={"id": "agent-deep-1", "status": "queued"},
641 ), patch(
642 "lib.perplexity.http.get",
643 side_effect=perplexity.http.DeadlineExceeded(),
644 ), patch(
645 "lib.perplexity.time.monotonic",
646 side_effect=[0.0, 0.5],
647 ):
648 items, artifact = perplexity.search(
649 "test topic",
650 ("2026-05-01", "2026-06-01"),
651 {
652 "PERPLEXITY_API_KEY": "pplx-test",
653 "LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "1",
654 },
655 deep=True,
656 )
657
658 self.assertEqual([], items)
659 self.assertEqual("timeout", artifact["error"])
660 self.assertEqual("PENDING_REMOTE", artifact["backgroundLocalStatus"])
661 self.assertEqual(1, artifact["backgroundPollCount"])
662
663 def test_deep_research_early_transport_timeout_is_poll_error(self):
664 with patch(
665 "lib.perplexity.http.post",
666 return_value={"id": "agent-deep-1", "status": "queued"},
667 ), patch(
668 "lib.perplexity.http.get",
669 side_effect=perplexity.http.HTTPError(
670 "URL Error: timed out",
671 outcome_state=perplexity.health.TIMEOUT,
672 ),
673 ), patch(
674 "lib.perplexity.time.monotonic",
675 side_effect=[0.0, 10.0, 10.0],
676 ):
677 items, artifact = perplexity.search(
678 "test topic",
679 ("2026-05-01", "2026-06-01"),
680 {
681 "PERPLEXITY_API_KEY": "pplx-test",
682 "LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "600",
683 },
684 deep=True,
685 )
686
687 self.assertEqual([], items)
688 self.assertEqual("poll_error", artifact["error"])
689 self.assertEqual("POLL_ERROR", artifact["backgroundLocalStatus"])
690
691 def test_deep_research_terminal_poll_after_deadline_is_timeout(self):
692 with patch(
693 "lib.perplexity.http.post",
694 return_value={"id": "agent-deep-1", "status": "queued"},
695 ), patch(
696 "lib.perplexity.http.get",
697 return_value={"id": "agent-deep-1", "status": "completed"},
698 ), patch(
699 "lib.perplexity.time.monotonic",
700 side_effect=[0.0, 0.5, 1.5],
701 ):
702 items, artifact = perplexity.search(
703 "test topic",
704 ("2026-05-01", "2026-06-01"),
705 {
706 "PERPLEXITY_API_KEY": "pplx-test",
707 "LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "1",
708 },
709 deep=True,
710 )
711
712 self.assertEqual([], items)
713 self.assertEqual("timeout", artifact["error"])
714 self.assertEqual("completed", artifact["backgroundStatus"])
715 self.assertEqual("PENDING_REMOTE", artifact["backgroundLocalStatus"])
716
717 def test_deep_research_terminal_failures_are_recorded(self):
718 for status in ("failed", "cancelled", "incomplete"):
719 response = {
720 "id": "agent-deep-1",
721 "status": status,
722 "model": "openai/gpt-5.6-sol",
723 "usage": {"total_tokens": 17},
724 "output": [{"type": "message", "content": []}],
725 }
726 if status == "incomplete":
727 response["incomplete_details"] = {"reason": "max_output_tokens"}
728 with self.subTest(status=status), patch(
729 "lib.perplexity.http.post",
730 return_value=response,
731 ) as post, patch("lib.perplexity.http.get") as get:
732 items, artifact = perplexity.search(
733 "test topic",
734 ("2026-05-01", "2026-06-01"),
735 {"PERPLEXITY_API_KEY": "pplx-test"},
736 deep=True,
737 )
738
739 post.assert_called_once()
740 get.assert_not_called()
741 self.assertEqual([], items)
742 self.assertEqual("failed", artifact["error"])
743 self.assertEqual(status, artifact["backgroundStatus"])
744 self.assertEqual("TERMINAL_REMOTE", artifact["backgroundLocalStatus"])
745 self.assertEqual("openai/gpt-5.6-sol", artifact["servedModel"])
746 self.assertEqual({"total_tokens": 17}, artifact["usage"])
747 self.assertEqual(["message"], artifact["outputTypes"])
748 if status == "incomplete":
749 self.assertEqual("max_output_tokens", artifact["incompleteReason"])
750
751 def test_deep_research_logs_safe_receipt_for_cli_consumers(self):
752 response = {
753 "id": "agent-deep-1",
754 "status": "incomplete",
755 "model": "openai/gpt-5.6-sol",
756 "incomplete_details": {"reason": "max_output_tokens"},
757 }
758 with patch("lib.perplexity.http.post", return_value=response), patch(
759 "lib.perplexity._log",
760 ) as logger:
761 perplexity.search(
762 "test topic",
763 ("2026-05-01", "2026-06-01"),
764 {"PERPLEXITY_API_KEY": "pplx-test"},
765 deep=True,
766 )
767
768 receipt = next(
769 call.args[0]
770 for call in logger.call_args_list
771 if call.args and call.args[0].startswith("Deep Research receipt:")
772 )
773 self.assertIn("model=openai/gpt-5.6-sol", receipt)
774 self.assertIn("response_id=agent-deep-1", receipt)
775 self.assertIn("provider_status=incomplete", receipt)
776 self.assertIn("incomplete_reason=max_output_tokens", receipt)
777
778 def test_deep_research_poll_error_preserves_response_id(self):
779 with patch(
780 "lib.perplexity.http.post",
781 return_value={"id": "agent-deep-1", "status": "queued"},
782 ), patch(
783 "lib.perplexity.http.get",
784 side_effect=perplexity.http.HTTPError(
785 "HTTP 429: Too Many Requests",
786 status_code=429,
787 ),
788 ):
789 items, artifact = perplexity.search(
790 "test topic",
791 ("2026-05-01", "2026-06-01"),
792 {"PERPLEXITY_API_KEY": "pplx-test"},
793 deep=True,
794 )
795
796 self.assertEqual([], items)
797 self.assertEqual("poll_error", artifact["error"])
798 self.assertEqual("agent-deep-1", artifact["responseId"])
799 self.assertEqual("queued", artifact["backgroundStatus"])
800 self.assertEqual("POLL_ERROR", artifact["backgroundLocalStatus"])
801 self.assertEqual(1, artifact["backgroundPollCount"])
802 self.assertEqual(429, artifact["backgroundPollStatusCode"])
803
804 def test_missing_keys_skip_without_http(self):
805 with patch("lib.perplexity.http.post") as post:
806 items, artifact = perplexity.search(
807 "test topic",
808 ("2026-05-01", "2026-06-01"),
809 {},
810 )
811
812 post.assert_not_called()
813 self.assertEqual([], items)
814 self.assertEqual({}, artifact)
815
816
817 if __name__ == "__main__":
818 unittest.main()
819
819 lines PYTHON