返回 last30days-skill
perplexity.py
根目录 / skills / last30days / scripts / lib / perplexity.py
1 """Perplexity Agent API, Search API, and OpenRouter compatibility integration.
2
3 The Perplexity source is paid and opt-in. A direct key uses a controlled Agent
4 API request with only web search enabled. Direct Deep Research uses an Agent
5 API background run with the dynamic high preset. When no direct Perplexity key
6 exists, OpenRouter preserves the legacy synchronous Sonar fallback.
7 """
8
9 from __future__ import annotations
10
11 import time
12 from datetime import datetime
13 from typing import Any
14 from urllib.parse import urlparse
15
16 from . import health, http, log
17
18
19 OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
20 PERPLEXITY_AGENT_URL = "https://api.perplexity.ai/v1/agent"
21 PERPLEXITY_SEARCH_URL = "https://api.perplexity.ai/search"
22
23 PERPLEXITY_MODE_AGENT = "agent"
24 PERPLEXITY_MODE_SONAR = "sonar" # Direct-key alias and OpenRouter artifact mode.
25 PERPLEXITY_MODE_SEARCH = "search"
26 PERPLEXITY_MODE_BOTH = "both"
27 PERPLEXITY_DEFAULT_AGENT_TIMEOUT_SECONDS = 120
28 PERPLEXITY_DEFAULT_DEEP_TIMEOUT_SECONDS = 600
29 PERPLEXITY_DEEP_INITIAL_POLL_DELAY_SECONDS = 5.0
30 PERPLEXITY_DEEP_MAX_POLL_DELAY_SECONDS = 60.0
31
32 PERPLEXITY_DEFAULT_AGENT_MODEL = "perplexity/sonar"
33 OPENROUTER_MODEL_SONAR_PRO = "perplexity/sonar-pro"
34 OPENROUTER_MODEL_DEEP_RESEARCH = "perplexity/sonar-deep-research"
35 PERPLEXITY_DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS = 4096
36 PERPLEXITY_CONTROLLED_PROFILE = "last30days-controlled-web-search/v1"
37 PERPLEXITY_PRESET_PROFILE = "perplexity-agent-preset"
38 AGENT_PRESETS = {"fast", "low", "medium", "high"}
39 DIRECT_MODES = {
40 PERPLEXITY_MODE_AGENT,
41 PERPLEXITY_MODE_SEARCH,
42 PERPLEXITY_MODE_BOTH,
43 }
44 SEARCH_CONTEXT_SIZES = {"low", "medium", "high"}
45 SEARCH_RECENCY_FILTERS = {"hour", "day", "week", "month", "year"}
46 REASONING_EFFORTS = {"minimal", "low", "medium", "high"}
47
48 CONTROLLED_AGENT_INSTRUCTIONS = (
49 "Use the supplied web search tool for current, source-grounded research. "
50 "Keep the answer concise. Cite source-backed claims in the answer. "
51 "Do not use tools other than those supplied in this request."
52 )
53
54
55 class AgentBackgroundTimeout(TimeoutError):
56 def __init__(self, metadata: dict[str, Any]):
57 timeout_seconds = metadata.get("backgroundTimeoutSeconds") or "unknown"
58 super().__init__(f"Agent background run exceeded {timeout_seconds}s wall timeout")
59 self.metadata = metadata
60
61
62 class AgentBackgroundFailed(RuntimeError):
63 def __init__(self, metadata: dict[str, Any]):
64 detail = metadata.get("backgroundErrorMessage") or "Agent background run failed"
65 super().__init__(str(detail))
66 self.metadata = metadata
67
68
69 class AgentBackgroundPollError(RuntimeError):
70 def __init__(self, metadata: dict[str, Any]):
71 detail = metadata.get("backgroundPollError") or "Agent background poll failed"
72 super().__init__(str(detail))
73 self.metadata = metadata
74
75
76 def _log(message: str) -> None:
77 log.source_log("Perplexity", message, tty_only=False)
78
79
80 def _domain(url: str) -> str:
81 return urlparse(url).netloc.strip().lower()
82
83
84 def _config_text(config: dict[str, Any], key: str) -> str:
85 return str(config.get(key) or "").strip()
86
87
88 def _csv_values(raw: str, limit: int | None = None) -> list[str]:
89 values = [part.strip() for part in raw.split(",") if part.strip()]
90 return values[:limit]
91
92
93 def _positive_int(
94 raw: object,
95 default: int,
96 min_value: int,
97 max_value: int | None = None,
98 ) -> int:
99 try:
100 value = int(str(raw).strip())
101 except (TypeError, ValueError):
102 return default
103 value = max(value, min_value)
104 if max_value is not None:
105 value = min(value, max_value)
106 return value
107
108
109 def _mmddyyyy(date: str | None) -> str | None:
110 if not date:
111 return None
112 try:
113 return datetime.strptime(date, "%Y-%m-%d").strftime("%m/%d/%Y")
114 except ValueError:
115 return None
116
117
118 def _usage(data: dict[str, Any]) -> dict[str, Any]:
119 usage = data.get("usage")
120 return usage if isinstance(usage, dict) else {}
121
122
123 def _error_artifact(exc: Exception) -> dict[str, Any]:
124 artifact: dict[str, Any] = {
125 "error": type(exc).__name__,
126 "message": str(exc)[:200],
127 }
128 if isinstance(exc, http.HTTPError):
129 artifact["statusCode"] = exc.status_code
130 return artifact
131
132
133 def _provider(config: dict[str, Any]) -> tuple[str, str] | None:
134 """Prefer direct Perplexity, then preserve the OpenRouter Sonar fallback."""
135 api_key = _config_text(config, "PERPLEXITY_API_KEY")
136 if api_key:
137 return "perplexity", api_key
138 openrouter_key = _config_text(config, "OPENROUTER_API_KEY")
139 if openrouter_key:
140 return "openrouter", openrouter_key
141 return None
142
143
144 def _mode(config: dict[str, Any], deep: bool, provider: str) -> str:
145 if deep:
146 return (
147 PERPLEXITY_MODE_AGENT
148 if provider == "perplexity"
149 else PERPLEXITY_MODE_SONAR
150 )
151
152 mode = (
153 _config_text(config, "LAST30DAYS_PERPLEXITY_MODE")
154 or PERPLEXITY_MODE_AGENT
155 ).lower()
156 if provider == "openrouter":
157 if mode in {PERPLEXITY_MODE_SEARCH, PERPLEXITY_MODE_BOTH}:
158 _log(
159 f"LAST30DAYS_PERPLEXITY_MODE={mode} requires PERPLEXITY_API_KEY; "
160 "using the OpenRouter Sonar fallback"
161 )
162 return PERPLEXITY_MODE_SONAR
163 if mode == PERPLEXITY_MODE_SONAR:
164 _log(
165 "LAST30DAYS_PERPLEXITY_MODE=sonar is deprecated; "
166 "using the Agent API controlled profile"
167 )
168 return PERPLEXITY_MODE_AGENT
169 if mode not in DIRECT_MODES:
170 _log(f"Unsupported LAST30DAYS_PERPLEXITY_MODE={mode!r}; using agent")
171 return PERPLEXITY_MODE_AGENT
172 return mode
173
174
175 def _agent_preset(config: dict[str, Any], deep: bool) -> str | None:
176 if deep:
177 return "high"
178
179 preset = _config_text(config, "LAST30DAYS_PERPLEXITY_AGENT_PRESET").lower()
180 if not preset:
181 return None
182 if preset in AGENT_PRESETS:
183 return preset
184 _log(
185 "Unsupported LAST30DAYS_PERPLEXITY_AGENT_PRESET="
186 f"{preset!r}; using the controlled profile"
187 )
188 return None
189
190
191 def _agent_model(config: dict[str, Any]) -> str:
192 legacy_model = _config_text(config, "LAST30DAYS_PERPLEXITY_MODEL")
193 if legacy_model:
194 _log(
195 "LAST30DAYS_PERPLEXITY_MODEL is a legacy Sonar setting and is "
196 "ignored by the Agent API; set LAST30DAYS_PERPLEXITY_AGENT_MODEL "
197 "for an explicit Agent model"
198 )
199 return (
200 _config_text(config, "LAST30DAYS_PERPLEXITY_AGENT_MODEL")
201 or PERPLEXITY_DEFAULT_AGENT_MODEL
202 )
203
204
205 def _agent_timeout(config: dict[str, Any]) -> int:
206 return _positive_int(
207 config.get("LAST30DAYS_PERPLEXITY_AGENT_TIMEOUT_SECONDS"),
208 PERPLEXITY_DEFAULT_AGENT_TIMEOUT_SECONDS,
209 1,
210 600,
211 )
212
213
214 def _safe_error_message(data: dict[str, Any]) -> str | None:
215 error = data.get("error")
216 if isinstance(error, str):
217 return error[:200]
218 if isinstance(error, dict):
219 for key in ("message", "detail", "code"):
220 value = error.get(key)
221 if isinstance(value, str) and value:
222 return value[:200]
223 return None
224
225
226 def _safe_incomplete_reason(data: dict[str, Any]) -> str | None:
227 details = data.get("incomplete_details")
228 if not isinstance(details, dict):
229 return None
230 reason = details.get("reason")
231 return reason[:100] if isinstance(reason, str) and reason else None
232
233
234 def _build_search_payload(
235 query: str,
236 date_range: tuple[str, str],
237 config: dict[str, Any],
238 ) -> dict[str, Any]:
239 from_date, to_date = date_range
240 payload: dict[str, Any] = {
241 "query": query,
242 "max_results": _positive_int(
243 config.get("LAST30DAYS_PERPLEXITY_MAX_RESULTS"),
244 10,
245 1,
246 20,
247 ),
248 }
249
250 context_size = _config_text(
251 config,
252 "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE",
253 ).lower()
254 if context_size in SEARCH_CONTEXT_SIZES:
255 payload["search_context_size"] = context_size
256
257 country = _config_text(config, "LAST30DAYS_PERPLEXITY_COUNTRY").upper()
258 if len(country) == 2:
259 payload["country"] = country
260
261 domains = _csv_values(
262 _config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"),
263 limit=20,
264 )
265 if domains:
266 payload["search_domain_filter"] = domains
267
268 languages = _csv_values(
269 _config_text(config, "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER"),
270 limit=20,
271 )
272 if languages:
273 payload["search_language_filter"] = languages
274
275 after = _mmddyyyy(from_date)
276 before = _mmddyyyy(to_date)
277 if after:
278 payload["search_after_date_filter"] = after
279 if before:
280 payload["search_before_date_filter"] = before
281
282 # Perplexity Search API rejects search_recency_filter when explicit
283 # published-date filters are present. Preserve the upstream fix.
284 recency = _config_text(
285 config,
286 "LAST30DAYS_PERPLEXITY_RECENCY_FILTER",
287 ).lower()
288 if recency in SEARCH_RECENCY_FILTERS and not (after or before):
289 payload["search_recency_filter"] = recency
290
291 return payload
292
293
294 def _build_web_search_tool(
295 date_range: tuple[str, str],
296 config: dict[str, Any],
297 ) -> dict[str, Any]:
298 from_date, to_date = date_range
299 tool: dict[str, Any] = {
300 "type": "web_search",
301 "max_results": _positive_int(
302 config.get("LAST30DAYS_PERPLEXITY_MAX_RESULTS"),
303 10,
304 1,
305 20,
306 ),
307 }
308
309 context_size = _config_text(
310 config,
311 "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE",
312 ).lower()
313 if context_size in SEARCH_CONTEXT_SIZES:
314 tool["search_context_size"] = context_size
315
316 country = _config_text(config, "LAST30DAYS_PERPLEXITY_COUNTRY").upper()
317 if len(country) == 2:
318 tool["user_location"] = {"country": country}
319
320 filters: dict[str, Any] = {}
321 domains = _csv_values(
322 _config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"),
323 limit=20,
324 )
325 if domains:
326 filters["search_domain_filter"] = domains
327
328 after = _mmddyyyy(from_date)
329 before = _mmddyyyy(to_date)
330 if after:
331 filters["search_after_date_filter"] = after
332 if before:
333 filters["search_before_date_filter"] = before
334
335 recency = _config_text(
336 config,
337 "LAST30DAYS_PERPLEXITY_RECENCY_FILTER",
338 ).lower()
339 if recency in SEARCH_RECENCY_FILTERS and not (after or before):
340 filters["search_recency_filter"] = recency
341 if filters:
342 tool["filters"] = filters
343
344 language_filter = _config_text(
345 config,
346 "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER",
347 )
348 if language_filter:
349 _log(
350 "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER has no Agent API "
351 "equivalent and applies only to Search API mode"
352 )
353 search_mode = _config_text(config, "LAST30DAYS_PERPLEXITY_SEARCH_MODE").lower()
354 if search_mode and search_mode != "web":
355 _log(
356 "LAST30DAYS_PERPLEXITY_SEARCH_MODE has no Agent API equivalent; "
357 "using web search"
358 )
359
360 return tool
361
362
363 def _safe_request(payload: dict[str, Any]) -> dict[str, Any]:
364 request: dict[str, Any] = {}
365 for key in (
366 "model",
367 "preset",
368 "max_steps",
369 "max_output_tokens",
370 "background",
371 ):
372 if key in payload:
373 request[key] = payload[key]
374 reasoning = payload.get("reasoning")
375 if isinstance(reasoning, dict) and isinstance(reasoning.get("effort"), str):
376 request["reasoning"] = {"effort": reasoning["effort"]}
377 tool_choice = payload.get("tool_choice")
378 if tool_choice == {"type": "web_search"}:
379 request["tool_choice"] = tool_choice
380 tools = payload.get("tools")
381 if isinstance(tools, list):
382 request["tools"] = [
383 {
384 key: tool[key]
385 for key in (
386 "type",
387 "max_results",
388 "search_context_size",
389 "user_location",
390 "filters",
391 )
392 if key in tool
393 }
394 for tool in tools
395 if isinstance(tool, dict) and tool.get("type") == "web_search"
396 ]
397 return request
398
399
400 def _build_agent_payload(
401 prompt: str,
402 date_range: tuple[str, str],
403 config: dict[str, Any],
404 deep: bool,
405 ) -> tuple[dict[str, Any], dict[str, Any]]:
406 preset = _agent_preset(config, deep)
407 if preset:
408 payload = {
409 "preset": preset,
410 "input": prompt,
411 # A supplied web_search tool merges with a preset's tools. This
412 # preserves the user's date, domain, location, and result bounds
413 # without claiming to disable any other dynamic-preset tools.
414 "tools": [_build_web_search_tool(date_range, config)],
415 }
416 if deep:
417 payload["background"] = True
418 return payload, {
419 "profile": PERPLEXITY_PRESET_PROFILE,
420 "preset": preset,
421 "dynamicPreset": True,
422 "request": _safe_request(payload),
423 }
424
425 payload: dict[str, Any] = {
426 "model": _agent_model(config),
427 "instructions": CONTROLLED_AGENT_INSTRUCTIONS,
428 "input": prompt,
429 "tools": [_build_web_search_tool(date_range, config)],
430 "tool_choice": {"type": "web_search"},
431 "max_steps": _positive_int(
432 config.get("LAST30DAYS_PERPLEXITY_AGENT_MAX_STEPS"),
433 5,
434 1,
435 15,
436 ),
437 }
438 if str(payload["model"]).lower().startswith("anthropic/"):
439 payload["max_output_tokens"] = _positive_int(
440 config.get("LAST30DAYS_PERPLEXITY_AGENT_MAX_OUTPUT_TOKENS"),
441 PERPLEXITY_DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS,
442 1,
443 32768,
444 )
445 effort = _config_text(
446 config,
447 "LAST30DAYS_PERPLEXITY_REASONING_EFFORT",
448 ).lower()
449 if effort in REASONING_EFFORTS:
450 payload["reasoning"] = {"effort": effort}
451 return payload, {
452 "profile": PERPLEXITY_CONTROLLED_PROFILE,
453 "model": payload["model"],
454 "dynamicPreset": False,
455 "request": _safe_request(payload),
456 }
457
458
459 def _append_citation(
460 citations: list[dict[str, Any]],
461 seen_urls: set[str],
462 citation: dict[str, Any],
463 ) -> None:
464 url = str(citation.get("url") or "").strip()
465 if not url:
466 return
467 if url in seen_urls:
468 # Message annotations commonly carry only a URL and title. Merge the
469 # later search_results item instead of discarding its snippet/date.
470 for existing in citations:
471 if existing.get("url") != url:
472 continue
473 for key in ("title", "snippet", "date"):
474 if not existing.get(key) and citation.get(key):
475 existing[key] = citation[key]
476 return
477 seen_urls.add(url)
478 citations.append(
479 {
480 "url": url,
481 "title": citation.get("title") or "",
482 "snippet": citation.get("snippet") or "",
483 "date": citation.get("date"),
484 }
485 )
486
487
488 def _append_annotations(
489 citations: list[dict[str, Any]],
490 seen_urls: set[str],
491 annotations: Any,
492 ) -> None:
493 if not isinstance(annotations, list):
494 return
495 for annotation in annotations:
496 if not isinstance(annotation, dict):
497 continue
498 citation = annotation.get("url_citation")
499 if not isinstance(citation, dict):
500 citation = annotation
501 _append_citation(citations, seen_urls, citation)
502
503
504 def _extract_agent_citations(data: dict[str, Any]) -> list[dict[str, Any]]:
505 citations: list[dict[str, Any]] = []
506 seen_urls: set[str] = set()
507
508 for result in data.get("results") or []:
509 if isinstance(result, dict):
510 _append_citation(citations, seen_urls, result)
511
512 output = data.get("output")
513 if not isinstance(output, list):
514 return citations
515
516 for item in output:
517 if not isinstance(item, dict):
518 continue
519 item_type = item.get("type")
520 if item_type == "search_results":
521 for result in item.get("results") or []:
522 if isinstance(result, dict):
523 _append_citation(citations, seen_urls, result)
524 continue
525 if item_type != "message":
526 continue
527 _append_annotations(citations, seen_urls, item.get("annotations"))
528 content = item.get("content")
529 if not isinstance(content, list):
530 continue
531 for part in content:
532 if isinstance(part, dict):
533 _append_annotations(citations, seen_urls, part.get("annotations"))
534
535 return citations
536
537
538 def _extract_openrouter_citations(
539 data: dict[str, Any],
540 choice: dict[str, Any],
541 ) -> list[dict[str, Any]]:
542 """Read the legacy OpenAI-compatible Sonar citation shapes."""
543 citations: list[dict[str, Any]] = []
544 seen_urls: set[str] = set()
545 search_results: dict[str, dict[str, Any]] = {}
546 for result in data.get("search_results") or []:
547 if not isinstance(result, dict):
548 continue
549 url = str(result.get("url") or "").strip()
550 if not url:
551 continue
552 search_results[url] = result
553 _append_citation(citations, seen_urls, result)
554
555 for url in data.get("citations") or []:
556 if not isinstance(url, str):
557 continue
558 result = search_results.get(url, {})
559 _append_citation(
560 citations,
561 seen_urls,
562 {
563 "url": url,
564 "title": result.get("title") or _domain(url),
565 "snippet": result.get("snippet") or "",
566 "date": result.get("date"),
567 },
568 )
569
570 message = choice.get("message")
571 if isinstance(message, dict):
572 _append_annotations(citations, seen_urls, message.get("annotations"))
573 return citations
574
575
576 def _output_types(data: dict[str, Any]) -> list[str]:
577 output = data.get("output")
578 if not isinstance(output, list):
579 return []
580 return [
581 item["type"]
582 for item in output
583 if isinstance(item, dict) and isinstance(item.get("type"), str)
584 ]
585
586
587 def _output_text(data: dict[str, Any]) -> str:
588 direct = data.get("output_text")
589 if isinstance(direct, str) and direct.strip():
590 return direct
591
592 parts: list[str] = []
593 output = data.get("output")
594 if not isinstance(output, list):
595 return ""
596 for item in output:
597 if not isinstance(item, dict) or item.get("type") != "message":
598 continue
599 content = item.get("content")
600 if isinstance(content, str):
601 parts.append(content)
602 continue
603 if not isinstance(content, list):
604 continue
605 for part in content:
606 if not isinstance(part, dict):
607 continue
608 if part.get("type") not in {"output_text", "text"}:
609 continue
610 text = part.get("text")
611 if isinstance(text, str):
612 parts.append(text)
613 return "".join(parts).strip()
614
615
616 def _background_metadata(
617 data: dict[str, Any],
618 response_id: str,
619 timeout_seconds: int,
620 poll_count: int,
621 local_status: str,
622 ) -> dict[str, Any]:
623 metadata: dict[str, Any] = {
624 "background": True,
625 "responseId": response_id,
626 "backgroundStatus": data.get("status"),
627 "servedModel": data.get("model"),
628 "usage": _usage(data),
629 "outputTypes": _output_types(data),
630 "backgroundTimeoutSeconds": timeout_seconds,
631 "backgroundPollCount": poll_count,
632 "backgroundLocalStatus": local_status,
633 }
634 message = _safe_error_message(data)
635 if message:
636 metadata["backgroundErrorMessage"] = message
637 incomplete_reason = _safe_incomplete_reason(data)
638 if incomplete_reason:
639 metadata["incompleteReason"] = incomplete_reason
640 return {key: value for key, value in metadata.items() if value is not None}
641
642
643 def _log_deep_receipt(artifact: dict[str, Any]) -> None:
644 """Expose the safe Deep receipt to slash-command and CLI consumers."""
645 fields = (
646 ("model", artifact.get("servedModel")),
647 ("response_id", artifact.get("responseId")),
648 ("provider_status", artifact.get("backgroundStatus") or artifact.get("status")),
649 ("local_status", artifact.get("backgroundLocalStatus")),
650 ("incomplete_reason", artifact.get("incompleteReason")),
651 ("polls", artifact.get("backgroundPollCount")),
652 ("timeout_seconds", artifact.get("backgroundTimeoutSeconds")),
653 )
654 rendered = " ".join(
655 f"{key}={value}"
656 for key, value in fields
657 if value is not None
658 )
659 _log(f"Deep Research receipt: {rendered or 'unavailable'}")
660
661
662 def _poll_agent_background(
663 payload: dict[str, Any],
664 headers: dict[str, str],
665 config: dict[str, Any],
666 ) -> tuple[dict[str, Any], dict[str, Any]]:
667 timeout_seconds = _positive_int(
668 config.get("LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS"),
669 PERPLEXITY_DEFAULT_DEEP_TIMEOUT_SECONDS,
670 1,
671 None,
672 )
673 created = http.post(
674 PERPLEXITY_AGENT_URL,
675 payload,
676 headers=headers,
677 timeout=30,
678 retries=1,
679 )
680 response_id = created.get("id")
681 if not isinstance(response_id, str) or not response_id:
682 raise http.HTTPError("Agent background response missing id")
683
684 terminal = {"completed", "failed", "cancelled", "incomplete"}
685 status = str(created.get("status") or "").lower()
686 data = created
687 poll_count = 0
688 if status:
689 _log(f"Agent background status: {status}")
690
691 deadline = time.monotonic() + timeout_seconds
692 delay = PERPLEXITY_DEEP_INITIAL_POLL_DELAY_SECONDS
693 while status not in terminal:
694 if time.monotonic() >= deadline:
695 raise AgentBackgroundTimeout(
696 _background_metadata(
697 data,
698 response_id,
699 timeout_seconds,
700 poll_count,
701 "PENDING_REMOTE",
702 )
703 )
704 try:
705 data = http.get(
706 f"{PERPLEXITY_AGENT_URL}/{response_id}",
707 headers=headers,
708 timeout=30,
709 retries=2,
710 deadline_monotonic=deadline,
711 )
712 except http.HTTPError as exc:
713 if isinstance(exc, http.DeadlineExceeded) or time.monotonic() >= deadline:
714 raise AgentBackgroundTimeout(
715 _background_metadata(
716 data,
717 response_id,
718 timeout_seconds,
719 poll_count + 1,
720 "PENDING_REMOTE",
721 )
722 ) from exc
723 metadata = _background_metadata(
724 data,
725 response_id,
726 timeout_seconds,
727 poll_count + 1,
728 "POLL_ERROR",
729 )
730 metadata["backgroundPollError"] = str(exc)[:200]
731 if exc.status_code is not None:
732 metadata["backgroundPollStatusCode"] = exc.status_code
733 raise AgentBackgroundPollError(metadata) from exc
734
735 poll_count += 1
736 next_status = str(data.get("status") or "").lower()
737 if next_status and next_status != status:
738 _log(f"Agent background status: {next_status}")
739 status = next_status
740 remaining = deadline - time.monotonic()
741 if remaining <= 0:
742 raise AgentBackgroundTimeout(
743 _background_metadata(
744 data,
745 response_id,
746 timeout_seconds,
747 poll_count,
748 "PENDING_REMOTE",
749 )
750 )
751 if status in terminal:
752 break
753 time.sleep(min(delay, remaining))
754 delay = min(delay * 1.5, PERPLEXITY_DEEP_MAX_POLL_DELAY_SECONDS)
755
756 metadata = _background_metadata(
757 data,
758 response_id,
759 timeout_seconds,
760 poll_count,
761 "COMPLETED_REMOTE" if status == "completed" else "TERMINAL_REMOTE",
762 )
763 if status == "completed":
764 return data, metadata
765 if not status:
766 metadata["backgroundErrorMessage"] = "Agent background response has no status"
767 raise AgentBackgroundFailed(metadata)
768
769
770 def _search_api(
771 query: str,
772 date_range: tuple[str, str],
773 config: dict[str, Any],
774 api_key: str,
775 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
776 from_date, to_date = date_range
777 headers = {
778 "Authorization": f"Bearer {api_key}",
779 "Content-Type": "application/json",
780 }
781 payload = _build_search_payload(query, date_range, config)
782 _log(f"Querying Perplexity Search API for '{query}' ({from_date} to {to_date})")
783
784 data = http.post(
785 PERPLEXITY_SEARCH_URL,
786 payload,
787 headers=headers,
788 timeout=30,
789 retries=1,
790 )
791 results = data.get("results") or []
792 if not isinstance(results, list):
793 results = []
794
795 items: list[dict[str, Any]] = []
796 for index, result in enumerate(results):
797 if not isinstance(result, dict):
798 continue
799 url = str(result.get("url") or "").strip()
800 if not url:
801 continue
802 items.append(
803 {
804 "id": f"PXS{index + 1}",
805 "title": result.get("title") or _domain(url),
806 "url": url,
807 "source_domain": _domain(url),
808 "snippet": result.get("snippet") or "",
809 "date": result.get("date"),
810 "relevance": max(0.55, 0.85 - (index * 0.03)),
811 "why_relevant": f"Ranked by Perplexity Search API for '{query}'",
812 "engagement": {},
813 "metadata": {
814 "last_updated": result.get("last_updated"),
815 "perplexity_search_id": data.get("id"),
816 },
817 }
818 )
819
820 artifact = {
821 "label": "perplexity",
822 "provider": "perplexity",
823 "mode": PERPLEXITY_MODE_SEARCH,
824 "endpoint": "search",
825 "query": query,
826 "resultCount": len(items),
827 "request": {key: value for key, value in payload.items() if key != "query"},
828 "responseId": data.get("id"),
829 "serverTime": data.get("server_time"),
830 }
831 _log(f"Got {len(items)} Search API results")
832 return items, artifact
833
834
835 def _agent_failure_artifact(
836 query: str,
837 deep: bool,
838 selection: dict[str, Any],
839 *,
840 error: str,
841 metadata: dict[str, Any] | None = None,
842 ) -> dict[str, Any]:
843 return {
844 "label": "perplexity",
845 "provider": "perplexity",
846 "mode": PERPLEXITY_MODE_AGENT,
847 "endpoint": "agent-background" if deep else "agent",
848 "deep": deep,
849 "query": query,
850 "error": error,
851 "synthesisLength": 0,
852 "citationCount": 0,
853 **selection,
854 **(metadata or {}),
855 }
856
857
858 def _agent_result(
859 query: str,
860 date_range: tuple[str, str],
861 deep: bool,
862 data: dict[str, Any],
863 selection: dict[str, Any],
864 background_metadata: dict[str, Any] | None = None,
865 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
866 _, to_date = date_range
867 status = str(data.get("status") or "").lower()
868 if status and status != "completed":
869 metadata: dict[str, Any] = {
870 "responseId": data.get("id"),
871 "status": data.get("status"),
872 "servedModel": data.get("model"),
873 "usage": _usage(data),
874 "outputTypes": _output_types(data),
875 }
876 message = _safe_error_message(data)
877 if message:
878 metadata["agentErrorMessage"] = message
879 incomplete_reason = _safe_incomplete_reason(data)
880 if incomplete_reason:
881 metadata["incompleteReason"] = incomplete_reason
882 if background_metadata:
883 metadata.update(background_metadata)
884 return [], _agent_failure_artifact(
885 query,
886 deep,
887 selection,
888 error=status,
889 metadata=metadata,
890 )
891
892 synthesis = _output_text(data)
893 citations = _extract_agent_citations(data)
894 if not synthesis:
895 _log("Empty Agent API synthesis")
896 return [], _agent_failure_artifact(
897 query,
898 deep,
899 selection,
900 error="empty_synthesis",
901 metadata={
902 "responseId": data.get("id"),
903 "status": data.get("status"),
904 "servedModel": data.get("model"),
905 "usage": _usage(data),
906 "outputTypes": _output_types(data),
907 **(background_metadata or {}),
908 },
909 )
910
911 _log(f"Got Agent API synthesis ({len(synthesis)} chars) with {len(citations)} citations")
912 title_mode = "Deep Research" if deep else "Agent"
913 items: list[dict[str, Any]] = [
914 {
915 "id": "PX1",
916 "title": f"Perplexity {title_mode}: {query}",
917 "url": "",
918 "source_domain": "perplexity.ai",
919 "snippet": synthesis[:2000],
920 "date": to_date,
921 "relevance": 0.9,
922 "why_relevant": f"AI synthesis of recent activity for '{query}'",
923 "engagement": {"citations": len(citations)},
924 "metadata": {
925 "citations": citations,
926 "usage": _usage(data),
927 "perplexity_response_id": data.get("id"),
928 },
929 }
930 ]
931 for index, citation in enumerate(citations):
932 items.append(
933 {
934 "id": f"PX{index + 2}",
935 "title": citation["title"] or _domain(citation["url"]),
936 "url": citation["url"],
937 "source_domain": _domain(citation["url"]),
938 "snippet": citation.get("snippet") or "",
939 "date": citation.get("date"),
940 "relevance": 0.7,
941 "why_relevant": f"Cited in Perplexity synthesis for '{query}'",
942 "engagement": {"citations": 1},
943 "metadata": {"citations": [citation]},
944 }
945 )
946
947 artifact: dict[str, Any] = {
948 "label": "perplexity",
949 "provider": "perplexity",
950 "mode": PERPLEXITY_MODE_AGENT,
951 "endpoint": "agent-background" if deep else "agent",
952 "deep": deep,
953 "query": query,
954 "synthesisLength": len(synthesis),
955 "citationCount": len(citations),
956 "responseId": data.get("id"),
957 "status": data.get("status"),
958 "servedModel": data.get("model"),
959 "usage": _usage(data),
960 "outputTypes": _output_types(data),
961 **selection,
962 }
963 if background_metadata:
964 artifact.update(background_metadata)
965 return items, artifact
966
967
968 def _agent_search(
969 query: str,
970 date_range: tuple[str, str],
971 config: dict[str, Any],
972 api_key: str,
973 deep: bool,
974 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
975 from_date, to_date = date_range
976 prompt = (
977 f"What has been happening with {query} between {from_date} and {to_date}? "
978 "Include specific dates, names, numbers, and sources."
979 )
980 payload, selection = _build_agent_payload(prompt, date_range, config, deep)
981 headers = {
982 "Authorization": f"Bearer {api_key}",
983 "Content-Type": "application/json",
984 }
985 _log(f"Querying Perplexity Agent API for '{query}' ({from_date} to {to_date})")
986
987 try:
988 if deep:
989 data, background_metadata = _poll_agent_background(payload, headers, config)
990 else:
991 data = http.post(
992 PERPLEXITY_AGENT_URL,
993 payload,
994 headers=headers,
995 timeout=_agent_timeout(config),
996 retries=1,
997 )
998 background_metadata = None
999 except AgentBackgroundTimeout as exc:
1000 _log(f"Agent background request timed out: {exc}")
1001 return [], _agent_failure_artifact(
1002 query,
1003 deep,
1004 selection,
1005 error="timeout",
1006 metadata=exc.metadata,
1007 )
1008 except AgentBackgroundFailed as exc:
1009 _log(f"Agent background request failed: {exc}")
1010 return [], _agent_failure_artifact(
1011 query,
1012 deep,
1013 selection,
1014 error="failed",
1015 metadata=exc.metadata,
1016 )
1017 except AgentBackgroundPollError as exc:
1018 _log(f"Agent background poll failed: {exc}")
1019 return [], _agent_failure_artifact(
1020 query,
1021 deep,
1022 selection,
1023 error="poll_error",
1024 metadata=exc.metadata,
1025 )
1026
1027 return _agent_result(
1028 query,
1029 date_range,
1030 deep,
1031 data,
1032 selection,
1033 background_metadata,
1034 )
1035
1036
1037 def _openrouter_sonar_search(
1038 query: str,
1039 date_range: tuple[str, str],
1040 api_key: str,
1041 deep: bool,
1042 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
1043 """Preserve the pre-Agent OpenRouter Sonar compatibility path."""
1044 from_date, to_date = date_range
1045 model = (
1046 OPENROUTER_MODEL_DEEP_RESEARCH
1047 if deep
1048 else OPENROUTER_MODEL_SONAR_PRO
1049 )
1050 prompt = (
1051 f"What has been happening with {query} between {from_date} and {to_date}? "
1052 "Include specific dates, names, numbers, and sources."
1053 )
1054 payload = {
1055 "model": model,
1056 "messages": [{"role": "user", "content": prompt}],
1057 }
1058 headers = {
1059 "Authorization": f"Bearer {api_key}",
1060 "Content-Type": "application/json",
1061 }
1062 _log(f"Querying OpenRouter {model} for '{query}' ({from_date} to {to_date})")
1063 data = http.post(
1064 OPENROUTER_URL,
1065 payload,
1066 headers=headers,
1067 timeout=120 if deep else 30,
1068 retries=1,
1069 )
1070
1071 choices = data.get("choices")
1072 if not isinstance(choices, list) or not choices:
1073 return [], {
1074 "label": "perplexity",
1075 "provider": "openrouter",
1076 "mode": PERPLEXITY_MODE_SONAR,
1077 "endpoint": "openrouter-chat-completions",
1078 "model": model,
1079 "deep": deep,
1080 "query": query,
1081 "error": "empty_choices",
1082 "responseId": data.get("id"),
1083 "servedModel": data.get("model") or model,
1084 "usage": _usage(data),
1085 }
1086
1087 choice = choices[0] if isinstance(choices[0], dict) else {}
1088 message = choice.get("message")
1089 message = message if isinstance(message, dict) else {}
1090 synthesis = message.get("content")
1091 synthesis = synthesis if isinstance(synthesis, str) else ""
1092 if not synthesis:
1093 return [], {
1094 "label": "perplexity",
1095 "provider": "openrouter",
1096 "mode": PERPLEXITY_MODE_SONAR,
1097 "endpoint": "openrouter-chat-completions",
1098 "model": model,
1099 "deep": deep,
1100 "query": query,
1101 "error": "empty_synthesis",
1102 "responseId": data.get("id"),
1103 "servedModel": data.get("model") or model,
1104 "usage": _usage(data),
1105 }
1106
1107 citations = _extract_openrouter_citations(data, choice)
1108 title_mode = "Deep Research" if deep else "Sonar"
1109 items: list[dict[str, Any]] = [
1110 {
1111 "id": "PX1",
1112 "title": f"Perplexity {title_mode}: {query}",
1113 "url": "",
1114 "source_domain": "perplexity.ai",
1115 "snippet": synthesis[:2000],
1116 "date": to_date,
1117 "relevance": 0.9,
1118 "why_relevant": f"AI synthesis of recent activity for '{query}'",
1119 "engagement": {"citations": len(citations)},
1120 "metadata": {
1121 "citations": citations,
1122 "usage": _usage(data),
1123 "openrouter_response_id": data.get("id"),
1124 },
1125 }
1126 ]
1127 for index, citation in enumerate(citations):
1128 items.append(
1129 {
1130 "id": f"PX{index + 2}",
1131 "title": citation["title"] or _domain(citation["url"]),
1132 "url": citation["url"],
1133 "source_domain": _domain(citation["url"]),
1134 "snippet": citation.get("snippet") or "",
1135 "date": citation.get("date"),
1136 "relevance": 0.7,
1137 "why_relevant": f"Cited in Perplexity synthesis for '{query}'",
1138 "engagement": {"citations": 1},
1139 "metadata": {"citations": [citation]},
1140 }
1141 )
1142
1143 return items, {
1144 "label": "perplexity",
1145 "provider": "openrouter",
1146 "mode": PERPLEXITY_MODE_SONAR,
1147 "endpoint": "openrouter-chat-completions",
1148 "model": model,
1149 "deep": deep,
1150 "query": query,
1151 "synthesisLength": len(synthesis),
1152 "citationCount": len(citations),
1153 "responseId": data.get("id"),
1154 "servedModel": data.get("model") or model,
1155 "usage": _usage(data),
1156 }
1157
1158
1159 def _merge_agent_and_search(
1160 agent_items: list[dict[str, Any]],
1161 search_items: list[dict[str, Any]],
1162 ) -> list[dict[str, Any]]:
1163 if not agent_items:
1164 return search_items
1165 merged = agent_items[:1]
1166 seen_urls = {item.get("url") for item in merged if item.get("url")}
1167 for item in [*search_items, *agent_items[1:]]:
1168 url = item.get("url")
1169 if url and url in seen_urls:
1170 continue
1171 if url:
1172 seen_urls.add(url)
1173 merged.append(item)
1174 return merged
1175
1176
1177 def _top_level_failure(
1178 query: str,
1179 mode: str,
1180 deep: bool,
1181 exc: Exception,
1182 provider: str = "perplexity",
1183 ) -> dict[str, Any]:
1184 artifact = _error_artifact(exc)
1185 if provider == "openrouter":
1186 endpoint = "openrouter-chat-completions"
1187 elif deep:
1188 endpoint = "agent-background"
1189 elif mode == PERPLEXITY_MODE_SEARCH:
1190 endpoint = "search"
1191 else:
1192 endpoint = "agent"
1193 artifact.update(
1194 {
1195 "label": "perplexity",
1196 "provider": provider,
1197 "mode": mode,
1198 "endpoint": endpoint,
1199 "deep": deep,
1200 "query": query,
1201 }
1202 )
1203 return artifact
1204
1205
1206 def search(
1207 query: str,
1208 date_range: tuple[str, str],
1209 config: dict[str, Any],
1210 deep: bool = False,
1211 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
1212 """Search through Perplexity's Agent API or raw Search API.
1213
1214 Normal synthesis uses the controlled Agent profile. Search API mode remains
1215 available for raw ranked rows. Deep Research is a dynamic high-preset
1216 background run and requires an explicit --deep-research invocation.
1217 """
1218 resolved = _provider(config)
1219 if not resolved:
1220 _log(
1221 "No PERPLEXITY_API_KEY or OPENROUTER_API_KEY configured, skipping"
1222 )
1223 return [], {}
1224 provider, api_key = resolved
1225
1226 mode = _mode(config, deep, provider)
1227 try:
1228 if provider == "openrouter":
1229 result = _openrouter_sonar_search(
1230 query,
1231 date_range,
1232 api_key,
1233 deep,
1234 )
1235 if deep:
1236 _log_deep_receipt(result[1])
1237 return result
1238 if mode == PERPLEXITY_MODE_SEARCH:
1239 return _search_api(query, date_range, config, api_key)
1240 if mode == PERPLEXITY_MODE_BOTH:
1241 search_items: list[dict[str, Any]] = []
1242 agent_items: list[dict[str, Any]] = []
1243 search_artifact: dict[str, Any] = {}
1244 agent_artifact: dict[str, Any] = {}
1245 try:
1246 search_items, search_artifact = _search_api(
1247 query,
1248 date_range,
1249 config,
1250 api_key,
1251 )
1252 except Exception as exc:
1253 _log(f"Search API leg failed in both mode: {exc}")
1254 search_artifact = _error_artifact(exc)
1255 try:
1256 agent_items, agent_artifact = _agent_search(
1257 query,
1258 date_range,
1259 config,
1260 api_key,
1261 deep=False,
1262 )
1263 except Exception as exc:
1264 _log(f"Agent API leg failed in both mode: {exc}")
1265 agent_artifact = _error_artifact(exc)
1266 items = _merge_agent_and_search(agent_items, search_items)
1267 return items, {
1268 "label": "perplexity",
1269 "provider": "perplexity",
1270 "mode": PERPLEXITY_MODE_BOTH,
1271 "query": query,
1272 "search": search_artifact,
1273 "agent": agent_artifact,
1274 "itemCount": len(items),
1275 }
1276 result = _agent_search(query, date_range, config, api_key, deep)
1277 if deep:
1278 _log_deep_receipt(result[1])
1279 return result
1280 except http.HTTPError as exc:
1281 if exc.status_code == 401:
1282 _log(f"Invalid {provider} API key (401)")
1283 elif exc.status_code == 429:
1284 _log(f"Rate limited by {provider} (429)")
1285 else:
1286 _log(f"HTTP error: {exc}")
1287 artifact = _top_level_failure(
1288 query,
1289 mode,
1290 deep,
1291 exc,
1292 provider=provider,
1293 )
1294 if deep:
1295 _log_deep_receipt(artifact)
1296 return [], artifact
1297 except TimeoutError as exc:
1298 _log(f"Request timed out: {exc}")
1299 artifact = _top_level_failure(
1300 query,
1301 mode,
1302 deep,
1303 exc,
1304 provider=provider,
1305 )
1306 if deep:
1307 _log_deep_receipt(artifact)
1308 return [], artifact
1309 except Exception as exc:
1310 _log(f"Request failed: {exc}")
1311 artifact = _top_level_failure(
1312 query,
1313 mode,
1314 deep,
1315 exc,
1316 provider=provider,
1317 )
1318 if deep:
1319 _log_deep_receipt(artifact)
1320 return [], artifact
1321
1321 lines PYTHON