| 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 |