返回 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 _search_type(config: dict[str, Any]) -> str | None:
235 """Keep omission intact; search backends are separate from Agent presets."""
236 value = _config_text(config, "LAST30DAYS_PERPLEXITY_SEARCH_TYPE").lower()
237 if not value:
238 return None
239 if value not in {"web", "fast"}:
240 raise ValueError("LAST30DAYS_PERPLEXITY_SEARCH_TYPE must be web or fast")
241 return value
242
243
244 def _build_search_payload(
245 query: str,
246 date_range: tuple[str, str],
247 config: dict[str, Any],
248 ) -> dict[str, Any]:
249 from_date, to_date = date_range
250 payload: dict[str, Any] = {
251 "query": query,
252 "max_results": _positive_int(
253 config.get("LAST30DAYS_PERPLEXITY_MAX_RESULTS"),
254 10,
255 1,
256 20,
257 ),
258 }
259
260 context_size = _config_text(
261 config,
262 "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE",
263 ).lower()
264 search_type = _search_type(config)
265 if search_type is not None:
266 payload["search_type"] = search_type
267 if context_size in SEARCH_CONTEXT_SIZES:
268 payload["search_context_size"] = context_size
269
270 country = _config_text(config, "LAST30DAYS_PERPLEXITY_COUNTRY").upper()
271 if len(country) == 2:
272 payload["country"] = country
273
274 domains = _csv_values(
275 _config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"),
276 limit=20,
277 )
278 if domains:
279 payload["search_domain_filter"] = domains
280
281 languages = _csv_values(
282 _config_text(config, "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER"),
283 limit=20,
284 )
285 if languages:
286 payload["search_language_filter"] = languages
287
288 after = _mmddyyyy(from_date)
289 before = _mmddyyyy(to_date)
290 if after:
291 payload["search_after_date_filter"] = after
292 if before:
293 payload["search_before_date_filter"] = before
294
295 # Perplexity Search API rejects search_recency_filter when explicit
296 # published-date filters are present. Preserve the upstream fix.
297 recency = _config_text(
298 config,
299 "LAST30DAYS_PERPLEXITY_RECENCY_FILTER",
300 ).lower()
301 if recency in SEARCH_RECENCY_FILTERS and not (after or before):
302 payload["search_recency_filter"] = recency
303
304 return payload
305
306
307 def _build_web_search_tool(
308 date_range: tuple[str, str],
309 config: dict[str, Any],
310 ) -> dict[str, Any]:
311 from_date, to_date = date_range
312 tool: dict[str, Any] = {
313 "type": "web_search",
314 "max_results": _positive_int(
315 config.get("LAST30DAYS_PERPLEXITY_MAX_RESULTS"),
316 10,
317 1,
318 20,
319 ),
320 }
321
322 context_size = _config_text(
323 config,
324 "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE",
325 ).lower()
326 search_type = _search_type(config)
327 if search_type is not None:
328 tool["search_type"] = search_type
329 if context_size in SEARCH_CONTEXT_SIZES:
330 tool["search_context_size"] = context_size
331
332 country = _config_text(config, "LAST30DAYS_PERPLEXITY_COUNTRY").upper()
333 if len(country) == 2:
334 tool["user_location"] = {"country": country}
335
336 filters: dict[str, Any] = {}
337 domains = _csv_values(
338 _config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"),
339 limit=20,
340 )
341 if domains:
342 filters["search_domain_filter"] = domains
343
344 after = _mmddyyyy(from_date)
345 before = _mmddyyyy(to_date)
346 if after:
347 filters["search_after_date_filter"] = after
348 if before:
349 filters["search_before_date_filter"] = before
350
351 recency = _config_text(
352 config,
353 "LAST30DAYS_PERPLEXITY_RECENCY_FILTER",
354 ).lower()
355 if recency in SEARCH_RECENCY_FILTERS and not (after or before):
356 filters["search_recency_filter"] = recency
357 if filters:
358 tool["filters"] = filters
359
360 language_filter = _config_text(
361 config,
362 "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER",
363 )
364 if language_filter:
365 _log(
366 "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER has no Agent API "
367 "equivalent and applies only to Search API mode"
368 )
369 search_mode = _config_text(config, "LAST30DAYS_PERPLEXITY_SEARCH_MODE").lower()
370 if search_mode and search_mode != "web":
371 _log(
372 "LAST30DAYS_PERPLEXITY_SEARCH_MODE has no Agent API equivalent; "
373 "using web search"
374 )
375
376 return tool
377
378
379 def _safe_request(payload: dict[str, Any]) -> dict[str, Any]:
380 request: dict[str, Any] = {}
381 for key in (
382 "model",
383 "preset",
384 "max_steps",
385 "max_output_tokens",
386 "background",
387 ):
388 if key in payload:
389 request[key] = payload[key]
390 reasoning = payload.get("reasoning")
391 if isinstance(reasoning, dict) and isinstance(reasoning.get("effort"), str):
392 request["reasoning"] = {"effort": reasoning["effort"]}
393 tool_choice = payload.get("tool_choice")
394 if tool_choice == {"type": "web_search"}:
395 request["tool_choice"] = tool_choice
396 tools = payload.get("tools")
397 if isinstance(tools, list):
398 request["tools"] = [
399 {
400 key: tool[key]
401 for key in (
402 "type",
403 "max_results",
404 "search_context_size",
405 "search_type",
406 "user_location",
407 "filters",
408 )
409 if key in tool
410 }
411 for tool in tools
412 if isinstance(tool, dict) and tool.get("type") == "web_search"
413 ]
414 return request
415
416
417 def _build_agent_payload(
418 prompt: str,
419 date_range: tuple[str, str],
420 config: dict[str, Any],
421 deep: bool,
422 ) -> tuple[dict[str, Any], dict[str, Any]]:
423 preset = _agent_preset(config, deep)
424 if preset:
425 payload = {
426 "preset": preset,
427 "input": prompt,
428 # A supplied web_search tool merges with a preset's tools. This
429 # preserves the user's date, domain, location, and result bounds
430 # without claiming to disable any other dynamic-preset tools.
431 "tools": [_build_web_search_tool(date_range, config)],
432 }
433 if deep:
434 payload["background"] = True
435 return payload, {
436 "profile": PERPLEXITY_PRESET_PROFILE,
437 "preset": preset,
438 "dynamicPreset": True,
439 "request": _safe_request(payload),
440 }
441
442 payload: dict[str, Any] = {
443 "model": _agent_model(config),
444 "instructions": CONTROLLED_AGENT_INSTRUCTIONS,
445 "input": prompt,
446 "tools": [_build_web_search_tool(date_range, config)],
447 "tool_choice": {"type": "web_search"},
448 "max_steps": _positive_int(
449 config.get("LAST30DAYS_PERPLEXITY_AGENT_MAX_STEPS"),
450 5,
451 1,
452 15,
453 ),
454 }
455 if str(payload["model"]).lower().startswith("anthropic/"):
456 payload["max_output_tokens"] = _positive_int(
457 config.get("LAST30DAYS_PERPLEXITY_AGENT_MAX_OUTPUT_TOKENS"),
458 PERPLEXITY_DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS,
459 1,
460 32768,
461 )
462 effort = _config_text(
463 config,
464 "LAST30DAYS_PERPLEXITY_REASONING_EFFORT",
465 ).lower()
466 if effort in REASONING_EFFORTS:
467 payload["reasoning"] = {"effort": effort}
468 return payload, {
469 "profile": PERPLEXITY_CONTROLLED_PROFILE,
470 "model": payload["model"],
471 "dynamicPreset": False,
472 "request": _safe_request(payload),
473 }
474
475
476 def _append_citation(
477 citations: list[dict[str, Any]],
478 seen_urls: set[str],
479 citation: dict[str, Any],
480 ) -> None:
481 url = str(citation.get("url") or "").strip()
482 if not url:
483 return
484 if url in seen_urls:
485 # Message annotations commonly carry only a URL and title. Merge the
486 # later search_results item instead of discarding its snippet/date.
487 for existing in citations:
488 if existing.get("url") != url:
489 continue
490 for key in ("title", "snippet", "date"):
491 if not existing.get(key) and citation.get(key):
492 existing[key] = citation[key]
493 return
494 seen_urls.add(url)
495 citations.append(
496 {
497 "url": url,
498 "title": citation.get("title") or "",
499 "snippet": citation.get("snippet") or "",
500 "date": citation.get("date"),
501 }
502 )
503
504
505 def _append_annotations(
506 citations: list[dict[str, Any]],
507 seen_urls: set[str],
508 annotations: Any,
509 ) -> None:
510 if not isinstance(annotations, list):
511 return
512 for annotation in annotations:
513 if not isinstance(annotation, dict):
514 continue
515 citation = annotation.get("url_citation")
516 if not isinstance(citation, dict):
517 citation = annotation
518 _append_citation(citations, seen_urls, citation)
519
520
521 def _extract_agent_citations(data: dict[str, Any]) -> list[dict[str, Any]]:
522 citations: list[dict[str, Any]] = []
523 seen_urls: set[str] = set()
524
525 for result in data.get("results") or []:
526 if isinstance(result, dict):
527 _append_citation(citations, seen_urls, result)
528
529 output = data.get("output")
530 if not isinstance(output, list):
531 return citations
532
533 for item in output:
534 if not isinstance(item, dict):
535 continue
536 item_type = item.get("type")
537 if item_type == "search_results":
538 for result in item.get("results") or []:
539 if isinstance(result, dict):
540 _append_citation(citations, seen_urls, result)
541 continue
542 if item_type != "message":
543 continue
544 _append_annotations(citations, seen_urls, item.get("annotations"))
545 content = item.get("content")
546 if not isinstance(content, list):
547 continue
548 for part in content:
549 if isinstance(part, dict):
550 _append_annotations(citations, seen_urls, part.get("annotations"))
551
552 return citations
553
554
555 def _extract_openrouter_citations(
556 data: dict[str, Any],
557 choice: dict[str, Any],
558 ) -> list[dict[str, Any]]:
559 """Read the legacy OpenAI-compatible Sonar citation shapes."""
560 citations: list[dict[str, Any]] = []
561 seen_urls: set[str] = set()
562 search_results: dict[str, dict[str, Any]] = {}
563 for result in data.get("search_results") or []:
564 if not isinstance(result, dict):
565 continue
566 url = str(result.get("url") or "").strip()
567 if not url:
568 continue
569 search_results[url] = result
570 _append_citation(citations, seen_urls, result)
571
572 for url in data.get("citations") or []:
573 if not isinstance(url, str):
574 continue
575 result = search_results.get(url, {})
576 _append_citation(
577 citations,
578 seen_urls,
579 {
580 "url": url,
581 "title": result.get("title") or _domain(url),
582 "snippet": result.get("snippet") or "",
583 "date": result.get("date"),
584 },
585 )
586
587 message = choice.get("message")
588 if isinstance(message, dict):
589 _append_annotations(citations, seen_urls, message.get("annotations"))
590 return citations
591
592
593 def _output_types(data: dict[str, Any]) -> list[str]:
594 output = data.get("output")
595 if not isinstance(output, list):
596 return []
597 return [
598 item["type"]
599 for item in output
600 if isinstance(item, dict) and isinstance(item.get("type"), str)
601 ]
602
603
604 def _output_text(data: dict[str, Any]) -> str:
605 direct = data.get("output_text")
606 if isinstance(direct, str) and direct.strip():
607 return direct
608
609 parts: list[str] = []
610 output = data.get("output")
611 if not isinstance(output, list):
612 return ""
613 for item in output:
614 if not isinstance(item, dict) or item.get("type") != "message":
615 continue
616 content = item.get("content")
617 if isinstance(content, str):
618 parts.append(content)
619 continue
620 if not isinstance(content, list):
621 continue
622 for part in content:
623 if not isinstance(part, dict):
624 continue
625 if part.get("type") not in {"output_text", "text"}:
626 continue
627 text = part.get("text")
628 if isinstance(text, str):
629 parts.append(text)
630 return "".join(parts).strip()
631
632
633 def _background_metadata(
634 data: dict[str, Any],
635 response_id: str,
636 timeout_seconds: int,
637 poll_count: int,
638 local_status: str,
639 ) -> dict[str, Any]:
640 metadata: dict[str, Any] = {
641 "background": True,
642 "responseId": response_id,
643 "backgroundStatus": data.get("status"),
644 "servedModel": data.get("model"),
645 "usage": _usage(data),
646 "outputTypes": _output_types(data),
647 "backgroundTimeoutSeconds": timeout_seconds,
648 "backgroundPollCount": poll_count,
649 "backgroundLocalStatus": local_status,
650 }
651 message = _safe_error_message(data)
652 if message:
653 metadata["backgroundErrorMessage"] = message
654 incomplete_reason = _safe_incomplete_reason(data)
655 if incomplete_reason:
656 metadata["incompleteReason"] = incomplete_reason
657 return {key: value for key, value in metadata.items() if value is not None}
658
659
660 def _log_deep_receipt(artifact: dict[str, Any]) -> None:
661 """Expose the safe Deep receipt to slash-command and CLI consumers."""
662 fields = (
663 ("model", artifact.get("servedModel")),
664 ("response_id", artifact.get("responseId")),
665 ("provider_status", artifact.get("backgroundStatus") or artifact.get("status")),
666 ("local_status", artifact.get("backgroundLocalStatus")),
667 ("incomplete_reason", artifact.get("incompleteReason")),
668 ("polls", artifact.get("backgroundPollCount")),
669 ("timeout_seconds", artifact.get("backgroundTimeoutSeconds")),
670 )
671 rendered = " ".join(
672 f"{key}={value}"
673 for key, value in fields
674 if value is not None
675 )
676 _log(f"Deep Research receipt: {rendered or 'unavailable'}")
677
678
679 def _poll_agent_background(
680 payload: dict[str, Any],
681 headers: dict[str, str],
682 config: dict[str, Any],
683 ) -> tuple[dict[str, Any], dict[str, Any]]:
684 timeout_seconds = _positive_int(
685 config.get("LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS"),
686 PERPLEXITY_DEFAULT_DEEP_TIMEOUT_SECONDS,
687 1,
688 None,
689 )
690 created = http.post(
691 PERPLEXITY_AGENT_URL,
692 payload,
693 headers=headers,
694 timeout=30,
695 retries=1,
696 )
697 response_id = created.get("id")
698 if not isinstance(response_id, str) or not response_id:
699 raise http.HTTPError("Agent background response missing id")
700
701 terminal = {"completed", "failed", "cancelled", "incomplete"}
702 status = str(created.get("status") or "").lower()
703 data = created
704 poll_count = 0
705 if status:
706 _log(f"Agent background status: {status}")
707
708 deadline = time.monotonic() + timeout_seconds
709 delay = PERPLEXITY_DEEP_INITIAL_POLL_DELAY_SECONDS
710 while status not in terminal:
711 if time.monotonic() >= deadline:
712 raise AgentBackgroundTimeout(
713 _background_metadata(
714 data,
715 response_id,
716 timeout_seconds,
717 poll_count,
718 "PENDING_REMOTE",
719 )
720 )
721 try:
722 data = http.get(
723 f"{PERPLEXITY_AGENT_URL}/{response_id}",
724 headers=headers,
725 timeout=30,
726 retries=2,
727 deadline_monotonic=deadline,
728 )
729 except http.HTTPError as exc:
730 if isinstance(exc, http.DeadlineExceeded) or time.monotonic() >= deadline:
731 raise AgentBackgroundTimeout(
732 _background_metadata(
733 data,
734 response_id,
735 timeout_seconds,
736 poll_count + 1,
737 "PENDING_REMOTE",
738 )
739 ) from exc
740 metadata = _background_metadata(
741 data,
742 response_id,
743 timeout_seconds,
744 poll_count + 1,
745 "POLL_ERROR",
746 )
747 metadata["backgroundPollError"] = str(exc)[:200]
748 if exc.status_code is not None:
749 metadata["backgroundPollStatusCode"] = exc.status_code
750 raise AgentBackgroundPollError(metadata) from exc
751
752 poll_count += 1
753 next_status = str(data.get("status") or "").lower()
754 if next_status and next_status != status:
755 _log(f"Agent background status: {next_status}")
756 status = next_status
757 remaining = deadline - time.monotonic()
758 if remaining <= 0:
759 raise AgentBackgroundTimeout(
760 _background_metadata(
761 data,
762 response_id,
763 timeout_seconds,
764 poll_count,
765 "PENDING_REMOTE",
766 )
767 )
768 if status in terminal:
769 break
770 time.sleep(min(delay, remaining))
771 delay = min(delay * 1.5, PERPLEXITY_DEEP_MAX_POLL_DELAY_SECONDS)
772
773 metadata = _background_metadata(
774 data,
775 response_id,
776 timeout_seconds,
777 poll_count,
778 "COMPLETED_REMOTE" if status == "completed" else "TERMINAL_REMOTE",
779 )
780 if status == "completed":
781 return data, metadata
782 if not status:
783 metadata["backgroundErrorMessage"] = "Agent background response has no status"
784 raise AgentBackgroundFailed(metadata)
785
786
787 def _search_api(
788 query: str,
789 date_range: tuple[str, str],
790 config: dict[str, Any],
791 api_key: str,
792 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
793 from_date, to_date = date_range
794 headers = {
795 "Authorization": f"Bearer {api_key}",
796 "Content-Type": "application/json",
797 }
798 payload = _build_search_payload(query, date_range, config)
799 _log(f"Querying Perplexity Search API for '{query}' ({from_date} to {to_date})")
800
801 data = http.post(
802 PERPLEXITY_SEARCH_URL,
803 payload,
804 headers=headers,
805 timeout=30,
806 retries=1,
807 )
808 results = data.get("results") or []
809 if not isinstance(results, list):
810 results = []
811
812 items: list[dict[str, Any]] = []
813 for index, result in enumerate(results):
814 if not isinstance(result, dict):
815 continue
816 url = str(result.get("url") or "").strip()
817 if not url:
818 continue
819 items.append(
820 {
821 "id": f"PXS{index + 1}",
822 "title": result.get("title") or _domain(url),
823 "url": url,
824 "source_domain": _domain(url),
825 "snippet": result.get("snippet") or "",
826 "date": result.get("date"),
827 "relevance": max(0.55, 0.85 - (index * 0.03)),
828 "why_relevant": f"Ranked by Perplexity Search API for '{query}'",
829 "engagement": {},
830 "metadata": {
831 "last_updated": result.get("last_updated"),
832 "perplexity_search_id": data.get("id"),
833 },
834 }
835 )
836
837 artifact = {
838 "label": "perplexity",
839 "provider": "perplexity",
840 "mode": PERPLEXITY_MODE_SEARCH,
841 "endpoint": "search",
842 "query": query,
843 "resultCount": len(items),
844 "request": {key: value for key, value in payload.items() if key != "query"},
845 "responseId": data.get("id"),
846 "serverTime": data.get("server_time"),
847 }
848 _log(f"Got {len(items)} Search API results")
849 return items, artifact
850
851
852 def _agent_failure_artifact(
853 query: str,
854 deep: bool,
855 selection: dict[str, Any],
856 *,
857 error: str,
858 metadata: dict[str, Any] | None = None,
859 ) -> dict[str, Any]:
860 return {
861 "label": "perplexity",
862 "provider": "perplexity",
863 "mode": PERPLEXITY_MODE_AGENT,
864 "endpoint": "agent-background" if deep else "agent",
865 "deep": deep,
866 "query": query,
867 "error": error,
868 "synthesisLength": 0,
869 "citationCount": 0,
870 **selection,
871 **(metadata or {}),
872 }
873
874
875 def _agent_result(
876 query: str,
877 date_range: tuple[str, str],
878 deep: bool,
879 data: dict[str, Any],
880 selection: dict[str, Any],
881 background_metadata: dict[str, Any] | None = None,
882 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
883 _, to_date = date_range
884 status = str(data.get("status") or "").lower()
885 if status and status != "completed":
886 metadata: dict[str, Any] = {
887 "responseId": data.get("id"),
888 "status": data.get("status"),
889 "servedModel": data.get("model"),
890 "usage": _usage(data),
891 "outputTypes": _output_types(data),
892 }
893 message = _safe_error_message(data)
894 if message:
895 metadata["agentErrorMessage"] = message
896 incomplete_reason = _safe_incomplete_reason(data)
897 if incomplete_reason:
898 metadata["incompleteReason"] = incomplete_reason
899 if background_metadata:
900 metadata.update(background_metadata)
901 return [], _agent_failure_artifact(
902 query,
903 deep,
904 selection,
905 error=status,
906 metadata=metadata,
907 )
908
909 synthesis = _output_text(data)
910 citations = _extract_agent_citations(data)
911 if not synthesis:
912 _log("Empty Agent API synthesis")
913 return [], _agent_failure_artifact(
914 query,
915 deep,
916 selection,
917 error="empty_synthesis",
918 metadata={
919 "responseId": data.get("id"),
920 "status": data.get("status"),
921 "servedModel": data.get("model"),
922 "usage": _usage(data),
923 "outputTypes": _output_types(data),
924 **(background_metadata or {}),
925 },
926 )
927
928 _log(f"Got Agent API synthesis ({len(synthesis)} chars) with {len(citations)} citations")
929 title_mode = "Deep Research" if deep else "Agent"
930 items: list[dict[str, Any]] = [
931 {
932 "id": "PX1",
933 "title": f"Perplexity {title_mode}: {query}",
934 "url": "",
935 "source_domain": "perplexity.ai",
936 "snippet": synthesis[:2000],
937 "date": to_date,
938 "relevance": 0.9,
939 "why_relevant": f"AI synthesis of recent activity for '{query}'",
940 "engagement": {"citations": len(citations)},
941 "metadata": {
942 "citations": citations,
943 "usage": _usage(data),
944 "perplexity_response_id": data.get("id"),
945 },
946 }
947 ]
948 for index, citation in enumerate(citations):
949 items.append(
950 {
951 "id": f"PX{index + 2}",
952 "title": citation["title"] or _domain(citation["url"]),
953 "url": citation["url"],
954 "source_domain": _domain(citation["url"]),
955 "snippet": citation.get("snippet") or "",
956 "date": citation.get("date"),
957 "relevance": 0.7,
958 "why_relevant": f"Cited in Perplexity synthesis for '{query}'",
959 "engagement": {"citations": 1},
960 "metadata": {"citations": [citation]},
961 }
962 )
963
964 artifact: dict[str, Any] = {
965 "label": "perplexity",
966 "provider": "perplexity",
967 "mode": PERPLEXITY_MODE_AGENT,
968 "endpoint": "agent-background" if deep else "agent",
969 "deep": deep,
970 "query": query,
971 "synthesisLength": len(synthesis),
972 "citationCount": len(citations),
973 "responseId": data.get("id"),
974 "status": data.get("status"),
975 "servedModel": data.get("model"),
976 "usage": _usage(data),
977 "outputTypes": _output_types(data),
978 **selection,
979 }
980 if background_metadata:
981 artifact.update(background_metadata)
982 return items, artifact
983
984
985 def _agent_search(
986 query: str,
987 date_range: tuple[str, str],
988 config: dict[str, Any],
989 api_key: str,
990 deep: bool,
991 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
992 from_date, to_date = date_range
993 prompt = (
994 f"What has been happening with {query} between {from_date} and {to_date}? "
995 "Include specific dates, names, numbers, and sources."
996 )
997 payload, selection = _build_agent_payload(prompt, date_range, config, deep)
998 headers = {
999 "Authorization": f"Bearer {api_key}",
1000 "Content-Type": "application/json",
1001 }
1002 _log(f"Querying Perplexity Agent API for '{query}' ({from_date} to {to_date})")
1003
1004 try:
1005 if deep:
1006 data, background_metadata = _poll_agent_background(payload, headers, config)
1007 else:
1008 data = http.post(
1009 PERPLEXITY_AGENT_URL,
1010 payload,
1011 headers=headers,
1012 timeout=_agent_timeout(config),
1013 retries=1,
1014 )
1015 background_metadata = None
1016 except AgentBackgroundTimeout as exc:
1017 _log(f"Agent background request timed out: {exc}")
1018 return [], _agent_failure_artifact(
1019 query,
1020 deep,
1021 selection,
1022 error="timeout",
1023 metadata=exc.metadata,
1024 )
1025 except AgentBackgroundFailed as exc:
1026 _log(f"Agent background request failed: {exc}")
1027 return [], _agent_failure_artifact(
1028 query,
1029 deep,
1030 selection,
1031 error="failed",
1032 metadata=exc.metadata,
1033 )
1034 except AgentBackgroundPollError as exc:
1035 _log(f"Agent background poll failed: {exc}")
1036 return [], _agent_failure_artifact(
1037 query,
1038 deep,
1039 selection,
1040 error="poll_error",
1041 metadata=exc.metadata,
1042 )
1043
1044 return _agent_result(
1045 query,
1046 date_range,
1047 deep,
1048 data,
1049 selection,
1050 background_metadata,
1051 )
1052
1053
1054 def _openrouter_sonar_search(
1055 query: str,
1056 date_range: tuple[str, str],
1057 api_key: str,
1058 deep: bool,
1059 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
1060 """Preserve the pre-Agent OpenRouter Sonar compatibility path."""
1061 from_date, to_date = date_range
1062 model = (
1063 OPENROUTER_MODEL_DEEP_RESEARCH
1064 if deep
1065 else OPENROUTER_MODEL_SONAR_PRO
1066 )
1067 prompt = (
1068 f"What has been happening with {query} between {from_date} and {to_date}? "
1069 "Include specific dates, names, numbers, and sources."
1070 )
1071 payload = {
1072 "model": model,
1073 "messages": [{"role": "user", "content": prompt}],
1074 }
1075 headers = {
1076 "Authorization": f"Bearer {api_key}",
1077 "Content-Type": "application/json",
1078 }
1079 _log(f"Querying OpenRouter {model} for '{query}' ({from_date} to {to_date})")
1080 data = http.post(
1081 OPENROUTER_URL,
1082 payload,
1083 headers=headers,
1084 timeout=120 if deep else 30,
1085 retries=1,
1086 )
1087
1088 choices = data.get("choices")
1089 if not isinstance(choices, list) or not choices:
1090 return [], {
1091 "label": "perplexity",
1092 "provider": "openrouter",
1093 "mode": PERPLEXITY_MODE_SONAR,
1094 "endpoint": "openrouter-chat-completions",
1095 "model": model,
1096 "deep": deep,
1097 "query": query,
1098 "error": "empty_choices",
1099 "responseId": data.get("id"),
1100 "servedModel": data.get("model") or model,
1101 "usage": _usage(data),
1102 }
1103
1104 choice = choices[0] if isinstance(choices[0], dict) else {}
1105 message = choice.get("message")
1106 message = message if isinstance(message, dict) else {}
1107 synthesis = message.get("content")
1108 synthesis = synthesis if isinstance(synthesis, str) else ""
1109 if not synthesis:
1110 return [], {
1111 "label": "perplexity",
1112 "provider": "openrouter",
1113 "mode": PERPLEXITY_MODE_SONAR,
1114 "endpoint": "openrouter-chat-completions",
1115 "model": model,
1116 "deep": deep,
1117 "query": query,
1118 "error": "empty_synthesis",
1119 "responseId": data.get("id"),
1120 "servedModel": data.get("model") or model,
1121 "usage": _usage(data),
1122 }
1123
1124 citations = _extract_openrouter_citations(data, choice)
1125 title_mode = "Deep Research" if deep else "Sonar"
1126 items: list[dict[str, Any]] = [
1127 {
1128 "id": "PX1",
1129 "title": f"Perplexity {title_mode}: {query}",
1130 "url": "",
1131 "source_domain": "perplexity.ai",
1132 "snippet": synthesis[:2000],
1133 "date": to_date,
1134 "relevance": 0.9,
1135 "why_relevant": f"AI synthesis of recent activity for '{query}'",
1136 "engagement": {"citations": len(citations)},
1137 "metadata": {
1138 "citations": citations,
1139 "usage": _usage(data),
1140 "openrouter_response_id": data.get("id"),
1141 },
1142 }
1143 ]
1144 for index, citation in enumerate(citations):
1145 items.append(
1146 {
1147 "id": f"PX{index + 2}",
1148 "title": citation["title"] or _domain(citation["url"]),
1149 "url": citation["url"],
1150 "source_domain": _domain(citation["url"]),
1151 "snippet": citation.get("snippet") or "",
1152 "date": citation.get("date"),
1153 "relevance": 0.7,
1154 "why_relevant": f"Cited in Perplexity synthesis for '{query}'",
1155 "engagement": {"citations": 1},
1156 "metadata": {"citations": [citation]},
1157 }
1158 )
1159
1160 return items, {
1161 "label": "perplexity",
1162 "provider": "openrouter",
1163 "mode": PERPLEXITY_MODE_SONAR,
1164 "endpoint": "openrouter-chat-completions",
1165 "model": model,
1166 "deep": deep,
1167 "query": query,
1168 "synthesisLength": len(synthesis),
1169 "citationCount": len(citations),
1170 "responseId": data.get("id"),
1171 "servedModel": data.get("model") or model,
1172 "usage": _usage(data),
1173 }
1174
1175
1176 def _merge_agent_and_search(
1177 agent_items: list[dict[str, Any]],
1178 search_items: list[dict[str, Any]],
1179 ) -> list[dict[str, Any]]:
1180 if not agent_items:
1181 return search_items
1182 merged = agent_items[:1]
1183 seen_urls = {item.get("url") for item in merged if item.get("url")}
1184 for item in [*search_items, *agent_items[1:]]:
1185 url = item.get("url")
1186 if url and url in seen_urls:
1187 continue
1188 if url:
1189 seen_urls.add(url)
1190 merged.append(item)
1191 return merged
1192
1193
1194 def _top_level_failure(
1195 query: str,
1196 mode: str,
1197 deep: bool,
1198 exc: Exception,
1199 provider: str = "perplexity",
1200 requested_search_type: str | None = None,
1201 ) -> dict[str, Any]:
1202 artifact = _error_artifact(exc)
1203 if provider == "perplexity" and requested_search_type is not None:
1204 artifact["requested_search_type"] = requested_search_type
1205 if provider == "openrouter":
1206 endpoint = "openrouter-chat-completions"
1207 elif deep:
1208 endpoint = "agent-background"
1209 elif mode == PERPLEXITY_MODE_SEARCH:
1210 endpoint = "search"
1211 else:
1212 endpoint = "agent"
1213 artifact.update(
1214 {
1215 "label": "perplexity",
1216 "provider": provider,
1217 "mode": mode,
1218 "endpoint": endpoint,
1219 "deep": deep,
1220 "query": query,
1221 }
1222 )
1223 return artifact
1224
1225
1226 def search(
1227 query: str,
1228 date_range: tuple[str, str],
1229 config: dict[str, Any],
1230 deep: bool = False,
1231 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
1232 """Search through Perplexity's Agent API or raw Search API.
1233
1234 Normal synthesis uses the controlled Agent profile. Search API mode remains
1235 available for raw ranked rows. Deep Research is a dynamic high-preset
1236 background run and requires an explicit --deep-research invocation.
1237 """
1238 resolved = _provider(config)
1239 if not resolved:
1240 _log(
1241 "No PERPLEXITY_API_KEY or OPENROUTER_API_KEY configured, skipping"
1242 )
1243 return [], {}
1244 provider, api_key = resolved
1245
1246 mode = _mode(config, deep, provider)
1247 search_type = None
1248 try:
1249 if provider == "openrouter":
1250 # Sonar ignores the direct-only setting, so never validate it here:
1251 # an unusable value must not block an otherwise working search.
1252 if _config_text(config, "LAST30DAYS_PERPLEXITY_SEARCH_TYPE"):
1253 _log("LAST30DAYS_PERPLEXITY_SEARCH_TYPE requires PERPLEXITY_API_KEY; "
1254 "it does not change OpenRouter Sonar search")
1255 result = _openrouter_sonar_search(
1256 query,
1257 date_range,
1258 api_key,
1259 deep,
1260 )
1261 if deep:
1262 _log_deep_receipt(result[1])
1263 return result
1264 search_type = _search_type(config)
1265 if mode == PERPLEXITY_MODE_SEARCH:
1266 return _search_api(query, date_range, config, api_key)
1267 if mode == PERPLEXITY_MODE_BOTH:
1268 search_items: list[dict[str, Any]] = []
1269 agent_items: list[dict[str, Any]] = []
1270 search_artifact: dict[str, Any] = {}
1271 agent_artifact: dict[str, Any] = {}
1272 try:
1273 search_items, search_artifact = _search_api(
1274 query,
1275 date_range,
1276 config,
1277 api_key,
1278 )
1279 except Exception as exc:
1280 _log(f"Search API leg failed in both mode: {exc}")
1281 search_artifact = _error_artifact(exc)
1282 if search_type is not None:
1283 search_artifact["requested_search_type"] = search_type
1284 try:
1285 agent_items, agent_artifact = _agent_search(
1286 query,
1287 date_range,
1288 config,
1289 api_key,
1290 deep=False,
1291 )
1292 except Exception as exc:
1293 _log(f"Agent API leg failed in both mode: {exc}")
1294 agent_artifact = _error_artifact(exc)
1295 if search_type is not None:
1296 agent_artifact["requested_search_type"] = search_type
1297 items = _merge_agent_and_search(agent_items, search_items)
1298 return items, {
1299 "label": "perplexity",
1300 "provider": "perplexity",
1301 "mode": PERPLEXITY_MODE_BOTH,
1302 "query": query,
1303 "search": search_artifact,
1304 "agent": agent_artifact,
1305 "itemCount": len(items),
1306 }
1307 result = _agent_search(query, date_range, config, api_key, deep)
1308 if deep:
1309 _log_deep_receipt(result[1])
1310 return result
1311 except http.HTTPError as exc:
1312 if exc.status_code == 401:
1313 _log(f"Invalid {provider} API key (401)")
1314 elif exc.status_code == 429:
1315 _log(f"Rate limited by {provider} (429)")
1316 else:
1317 _log(f"HTTP error: {exc}")
1318 artifact = _top_level_failure(
1319 query,
1320 mode,
1321 deep,
1322 exc,
1323 provider=provider,
1324 requested_search_type=search_type,
1325 )
1326 if deep:
1327 _log_deep_receipt(artifact)
1328 return [], artifact
1329 except TimeoutError as exc:
1330 _log(f"Request timed out: {exc}")
1331 artifact = _top_level_failure(
1332 query,
1333 mode,
1334 deep,
1335 exc,
1336 provider=provider,
1337 requested_search_type=search_type,
1338 )
1339 if deep:
1340 _log_deep_receipt(artifact)
1341 return [], artifact
1342 except Exception as exc:
1343 _log(f"Request failed: {exc}")
1344 artifact = _top_level_failure(
1345 query,
1346 mode,
1347 deep,
1348 exc,
1349 provider=provider,
1350 requested_search_type=search_type,
1351 )
1352 if deep:
1353 _log_deep_receipt(artifact)
1354 return [], artifact
1355
1355 lines PYTHON