返回 last30days-skill
perplexity.py
根目录 / skills / last30days / scripts / lib / perplexity.py
1 """Perplexity Sonar, Search API, and Deep Research.
2
3 Direct Perplexity keys are preferred so the source can use first-party Search
4 API results and async Deep Research. OpenRouter remains a Sonar compatibility
5 fallback when no direct Perplexity key is configured.
6 """
7
8 from __future__ import annotations
9
10 import hashlib
11 import json
12 import random
13 import sys
14 import time
15 from datetime import datetime
16 from urllib.parse import urlparse
17
18 from . import http, log
19
20
21 OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
22 PERPLEXITY_URL = "https://api.perplexity.ai/v1/sonar"
23 PERPLEXITY_SEARCH_URL = "https://api.perplexity.ai/search"
24 PERPLEXITY_ASYNC_URL = "https://api.perplexity.ai/v1/async/sonar"
25
26 OPENROUTER_MODEL_SONAR_PRO = "perplexity/sonar-pro"
27 OPENROUTER_MODEL_DEEP_RESEARCH = "perplexity/sonar-deep-research"
28 PERPLEXITY_MODEL_SONAR = "sonar"
29 PERPLEXITY_MODEL_SONAR_PRO = "sonar-pro"
30 PERPLEXITY_MODEL_REASONING_PRO = "sonar-reasoning-pro"
31 PERPLEXITY_MODEL_DEEP_RESEARCH = "sonar-deep-research"
32 PERPLEXITY_MODE_SONAR = "sonar"
33 PERPLEXITY_MODE_SEARCH = "search"
34 PERPLEXITY_MODE_BOTH = "both"
35 PERPLEXITY_DEFAULT_DEEP_TIMEOUT_SECONDS = 600
36 PERPLEXITY_DEEP_INITIAL_POLL_DELAY_SECONDS = 5.0
37 PERPLEXITY_DEEP_MAX_POLL_DELAY_SECONDS = 60.0
38
39 DIRECT_MODELS = {
40 PERPLEXITY_MODEL_SONAR,
41 PERPLEXITY_MODEL_SONAR_PRO,
42 PERPLEXITY_MODEL_REASONING_PRO,
43 PERPLEXITY_MODEL_DEEP_RESEARCH,
44 }
45 DIRECT_MODES = {
46 PERPLEXITY_MODE_SONAR,
47 PERPLEXITY_MODE_SEARCH,
48 PERPLEXITY_MODE_BOTH,
49 }
50 SEARCH_CONTEXT_SIZES = {"low", "medium", "high"}
51 SEARCH_RECENCY_FILTERS = {"hour", "day", "week", "month", "year"}
52 SONAR_SEARCH_MODES = {"web", "academic", "sec"}
53 REASONING_EFFORTS = {"minimal", "low", "medium", "high"}
54
55
56 class AsyncDeepResearchTimeout(TimeoutError):
57 def __init__(self, metadata: dict):
58 timeout_seconds = metadata.get("asyncTimeoutSeconds") or "unknown"
59 super().__init__(f"Async Deep Research exceeded {timeout_seconds}s wall timeout")
60 self.metadata = metadata
61
62
63 class AsyncDeepResearchFailed(RuntimeError):
64 def __init__(self, metadata: dict):
65 message = metadata.get("asyncErrorMessage") or "Async Deep Research failed"
66 super().__init__(str(message))
67 self.metadata = metadata
68
69
70 class AsyncDeepResearchPollError(RuntimeError):
71 def __init__(self, metadata: dict):
72 message = metadata.get("asyncPollError") or "Async Deep Research poll failed"
73 super().__init__(str(message))
74 self.metadata = metadata
75
76
77 def _log(msg: str):
78 log.source_log("Perplexity", msg, tty_only=False)
79
80
81 def _domain(url: str) -> str:
82 return urlparse(url).netloc.strip().lower()
83
84
85 def _provider(config: dict, deep: bool) -> tuple[str, str, str, str] | None:
86 """Return (provider, api_key, url, model), preferring direct Perplexity."""
87 if config.get("PERPLEXITY_API_KEY"):
88 model = _direct_model(config, deep)
89 url = PERPLEXITY_ASYNC_URL if deep else PERPLEXITY_URL
90 return "perplexity", config["PERPLEXITY_API_KEY"], url, model
91 if config.get("OPENROUTER_API_KEY"):
92 model = OPENROUTER_MODEL_DEEP_RESEARCH if deep else OPENROUTER_MODEL_SONAR_PRO
93 return "openrouter", config["OPENROUTER_API_KEY"], OPENROUTER_URL, model
94 return None
95
96
97 def _config_text(config: dict, key: str) -> str:
98 return str(config.get(key) or "").strip()
99
100
101 def _csv_values(raw: str, limit: int | None = None) -> list[str]:
102 values = [part.strip() for part in raw.split(",") if part.strip()]
103 # values[:None] already returns the whole list, so no None guard is needed.
104 return values[:limit]
105
106
107 def _direct_model(config: dict, deep: bool) -> str:
108 if deep:
109 return PERPLEXITY_MODEL_DEEP_RESEARCH
110 model = _config_text(config, "LAST30DAYS_PERPLEXITY_MODEL") or PERPLEXITY_MODEL_SONAR_PRO
111 if model not in DIRECT_MODELS:
112 _log(f"Unsupported LAST30DAYS_PERPLEXITY_MODEL={model!r}; using sonar-pro")
113 return PERPLEXITY_MODEL_SONAR_PRO
114 if model == PERPLEXITY_MODEL_DEEP_RESEARCH:
115 return PERPLEXITY_MODEL_SONAR_PRO
116 return model
117
118
119 def _mode(config: dict, provider: str, deep: bool) -> str:
120 if deep:
121 return PERPLEXITY_MODE_SONAR
122 mode = (_config_text(config, "LAST30DAYS_PERPLEXITY_MODE") or PERPLEXITY_MODE_SONAR).lower()
123 if mode not in DIRECT_MODES:
124 _log(f"Unsupported LAST30DAYS_PERPLEXITY_MODE={mode!r}; using sonar")
125 return PERPLEXITY_MODE_SONAR
126 if provider != "perplexity" and mode != PERPLEXITY_MODE_SONAR:
127 _log("Search API modes require PERPLEXITY_API_KEY; using OpenRouter Sonar fallback")
128 return PERPLEXITY_MODE_SONAR
129 return mode
130
131
132 def _positive_int(raw: object, default: int, min_value: int, max_value: int | None = None) -> int:
133 try:
134 value = int(str(raw).strip())
135 except (TypeError, ValueError):
136 return default
137 value = max(value, min_value)
138 if max_value is not None:
139 value = min(value, max_value)
140 return value
141
142
143 def _mmddyyyy(date: str | None) -> str | None:
144 if not date:
145 return None
146 try:
147 return datetime.strptime(date, "%Y-%m-%d").strftime("%m/%d/%Y")
148 except ValueError:
149 return None
150
151
152 def _usage(data: dict) -> dict:
153 usage = data.get("usage")
154 return usage if isinstance(usage, dict) else {}
155
156
157 def _idempotency_key(json_data: dict) -> str:
158 payload = json.dumps(json_data, sort_keys=True, separators=(",", ":"))
159 digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
160 return f"last30days:{digest}"
161
162
163 def _async_metadata(
164 data: dict,
165 request_id: str,
166 timeout_seconds: int,
167 idempotency_key: str,
168 poll_count: int,
169 local_status: str,
170 ) -> dict:
171 metadata = {
172 "async": True,
173 "asyncRequestId": request_id,
174 "asyncStatus": data.get("status"),
175 "asyncTimeoutSeconds": timeout_seconds,
176 "asyncIdempotencyKey": idempotency_key,
177 "asyncPollCount": poll_count,
178 "asyncLocalStatus": local_status,
179 "asyncCreatedAt": data.get("created_at"),
180 "asyncStartedAt": data.get("started_at"),
181 "asyncCompletedAt": data.get("completed_at"),
182 "asyncFailedAt": data.get("failed_at"),
183 "asyncErrorMessage": data.get("error_message"),
184 }
185 return {k: v for k, v in metadata.items() if v is not None}
186
187
188 def _error_artifact(exc: Exception) -> dict:
189 artifact = {
190 "error": type(exc).__name__,
191 "message": str(exc)[:200],
192 }
193 if isinstance(exc, http.HTTPError):
194 artifact["statusCode"] = exc.status_code
195 return artifact
196
197
198 def _empty_async_sonar_artifact(
199 provider: str,
200 model: str,
201 deep: bool,
202 query: str,
203 data: dict,
204 async_artifact: dict,
205 error: str,
206 message: str,
207 ) -> dict:
208 if not async_artifact:
209 return {}
210 artifact = {
211 "label": "perplexity",
212 "provider": provider,
213 "mode": PERPLEXITY_MODE_SONAR,
214 "endpoint": "async-sonar",
215 "model": model,
216 "deep": deep,
217 "query": query,
218 "error": error,
219 "synthesisLength": 0,
220 "citationCount": 0,
221 "usage": _usage(data),
222 **async_artifact,
223 }
224 if not artifact.get("asyncErrorMessage"):
225 artifact["asyncErrorMessage"] = message
226 return artifact
227
228
229 def _build_sonar_payload(prompt: str, model: str, date_range: tuple[str, str], config: dict) -> dict:
230 payload = {
231 "model": model,
232 "messages": [{"role": "user", "content": prompt}],
233 }
234
235 from_date, to_date = date_range
236 web_options: dict[str, object] = {}
237 search_mode = _config_text(config, "LAST30DAYS_PERPLEXITY_SEARCH_MODE").lower()
238 if search_mode in SONAR_SEARCH_MODES:
239 web_options["search_mode"] = search_mode
240
241 domains = _csv_values(_config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"), limit=20)
242 if domains:
243 web_options["search_domain_filter"] = domains
244
245 languages = _csv_values(_config_text(config, "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER"), limit=20)
246 if languages:
247 web_options["search_language_filter"] = languages
248
249 recency = _config_text(config, "LAST30DAYS_PERPLEXITY_RECENCY_FILTER").lower()
250 if recency in SEARCH_RECENCY_FILTERS:
251 web_options["search_recency_filter"] = recency
252
253 after = _mmddyyyy(from_date)
254 before = _mmddyyyy(to_date)
255 if after:
256 web_options["search_after_date_filter"] = after
257 if before:
258 web_options["search_before_date_filter"] = before
259
260 if web_options:
261 payload["web_search_options"] = web_options
262
263 effort = _config_text(config, "LAST30DAYS_PERPLEXITY_REASONING_EFFORT").lower()
264 if effort in REASONING_EFFORTS:
265 payload["reasoning_effort"] = effort
266
267 return payload
268
269
270 def _build_search_payload(query: str, date_range: tuple[str, str], config: dict) -> dict:
271 from_date, to_date = date_range
272 payload: dict[str, object] = {
273 "query": query,
274 "max_results": _positive_int(config.get("LAST30DAYS_PERPLEXITY_MAX_RESULTS"), 10, 1, 20),
275 }
276
277 context_size = _config_text(config, "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE").lower()
278 if context_size in SEARCH_CONTEXT_SIZES:
279 payload["search_context_size"] = context_size
280
281 country = _config_text(config, "LAST30DAYS_PERPLEXITY_COUNTRY").upper()
282 if len(country) == 2:
283 payload["country"] = country
284
285 domains = _csv_values(_config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"), limit=20)
286 if domains:
287 payload["search_domain_filter"] = domains
288
289 languages = _csv_values(_config_text(config, "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER"), limit=20)
290 if languages:
291 payload["search_language_filter"] = languages
292
293 after = _mmddyyyy(from_date)
294 before = _mmddyyyy(to_date)
295 if after:
296 payload["search_after_date_filter"] = after
297 if before:
298 payload["search_before_date_filter"] = before
299
300 # Perplexity Search API rejects search_recency_filter when explicit
301 # published-date filters are present. last30days already passes an exact
302 # date range, so prefer that and keep recency only for undated callers.
303 recency = _config_text(config, "LAST30DAYS_PERPLEXITY_RECENCY_FILTER").lower()
304 if recency in SEARCH_RECENCY_FILTERS and not (after or before):
305 payload["search_recency_filter"] = recency
306
307 return payload
308
309
310 def _append_citation(citations: list[dict], seen_urls: set[str], citation: dict) -> None:
311 url = (citation.get("url") or "").strip()
312 if not url or url in seen_urls:
313 return
314 seen_urls.add(url)
315 citations.append({
316 "url": url,
317 "title": citation.get("title") or "",
318 "snippet": citation.get("snippet") or "",
319 "date": citation.get("date"),
320 })
321
322
323 def _extract_citations(data: dict, choice: dict) -> list[dict]:
324 """Extract citations from direct Perplexity and OpenRouter response shapes."""
325 citations: list[dict] = []
326 seen_urls: set[str] = set()
327
328 search_results_by_url: dict[str, dict] = {}
329 for result in data.get("search_results") or []:
330 if not isinstance(result, dict):
331 continue
332 url = (result.get("url") or "").strip()
333 if not url:
334 continue
335 search_results_by_url[url] = result
336 _append_citation(citations, seen_urls, result)
337
338 for url in data.get("citations") or []:
339 if not isinstance(url, str):
340 continue
341 result = search_results_by_url.get(url, {})
342 _append_citation(citations, seen_urls, {
343 "url": url,
344 "title": result.get("title") or _domain(url),
345 "snippet": result.get("snippet") or "",
346 "date": result.get("date"),
347 })
348
349 annotations = choice.get("message", {}).get("annotations", [])
350 for ann in annotations or []:
351 if not isinstance(ann, dict):
352 continue
353 url_citation = ann.get("url_citation", {})
354 if not isinstance(url_citation, dict):
355 continue
356 _append_citation(citations, seen_urls, {
357 "url": url_citation.get("url") or "",
358 "title": url_citation.get("title") or "",
359 })
360
361 return citations
362
363
364 def _poll_async_sonar(json_data: dict, headers: dict, config: dict) -> tuple[dict, dict]:
365 timeout_seconds = _positive_int(
366 config.get("LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS"),
367 PERPLEXITY_DEFAULT_DEEP_TIMEOUT_SECONDS,
368 1,
369 None,
370 )
371 idempotency_key = _idempotency_key(json_data)
372 created = http.post(
373 PERPLEXITY_ASYNC_URL,
374 {"request": json_data, "idempotency_key": idempotency_key},
375 headers=headers,
376 timeout=30,
377 retries=2,
378 )
379 request_id = created.get("id")
380 if not request_id:
381 raise http.HTTPError("Async Deep Research response missing id")
382
383 deadline = time.monotonic() + timeout_seconds
384 poll_url = f"{PERPLEXITY_ASYNC_URL}/{request_id}"
385 delay = PERPLEXITY_DEEP_INITIAL_POLL_DELAY_SECONDS
386 last_status = created.get("status")
387 poll_count = 0
388 last_data = created
389 if last_status:
390 _log(f"Deep Research async status: {last_status}")
391
392 while time.monotonic() < deadline:
393 try:
394 data = http.get(poll_url, headers=headers, timeout=30, retries=2)
395 except http.HTTPError as e:
396 metadata = _async_metadata(
397 last_data, request_id, timeout_seconds, idempotency_key, poll_count + 1,
398 "POLL_ERROR",
399 )
400 metadata["asyncPollError"] = str(e)
401 if e.status_code is not None:
402 metadata["asyncPollStatusCode"] = e.status_code
403 raise AsyncDeepResearchPollError(metadata)
404 poll_count += 1
405 last_data = data
406 status = data.get("status")
407 if status and status != last_status:
408 _log(f"Deep Research async status: {status}")
409 last_status = status
410 if status == "COMPLETED":
411 response = data.get("response")
412 if not isinstance(response, dict):
413 metadata = _async_metadata(
414 data, request_id, timeout_seconds, idempotency_key, poll_count,
415 "FAILED_REMOTE",
416 )
417 metadata["asyncErrorMessage"] = "Async Deep Research completed without response"
418 raise AsyncDeepResearchFailed(metadata)
419 return response, _async_metadata(
420 data, request_id, timeout_seconds, idempotency_key, poll_count,
421 "COMPLETED_REMOTE",
422 )
423 if status == "FAILED":
424 raise AsyncDeepResearchFailed(_async_metadata(
425 data, request_id, timeout_seconds, idempotency_key, poll_count,
426 "FAILED_REMOTE",
427 ))
428 remaining = deadline - time.monotonic()
429 if remaining <= 0:
430 break
431 jitter = random.uniform(0, 2)
432 time.sleep(min(delay + jitter, max(0.1, remaining)))
433 delay = min(delay * 1.5, PERPLEXITY_DEEP_MAX_POLL_DELAY_SECONDS)
434
435 raise AsyncDeepResearchTimeout(_async_metadata(
436 last_data, request_id, timeout_seconds, idempotency_key, poll_count,
437 "PENDING_REMOTE",
438 ))
439
440
441 def _search_api(
442 query: str,
443 date_range: tuple[str, str],
444 config: dict,
445 api_key: str,
446 ) -> tuple[list[dict], dict]:
447 from_date, to_date = date_range
448 headers = {
449 "Authorization": f"Bearer {api_key}",
450 "Content-Type": "application/json",
451 }
452 payload = _build_search_payload(query, date_range, config)
453 _log(f"Querying Perplexity Search API for '{query}' ({from_date} to {to_date})")
454
455 data = http.post(PERPLEXITY_SEARCH_URL, payload, headers=headers, timeout=30)
456 results = data.get("results") or []
457 if not isinstance(results, list):
458 results = []
459
460 items = []
461 for i, result in enumerate(results):
462 if not isinstance(result, dict):
463 continue
464 url = (result.get("url") or "").strip()
465 if not url:
466 continue
467 items.append({
468 "id": f"PXS{i + 1}",
469 "title": result.get("title") or _domain(url),
470 "url": url,
471 "source_domain": _domain(url),
472 "snippet": result.get("snippet") or "",
473 "date": result.get("date"),
474 "relevance": max(0.55, 0.85 - (i * 0.03)),
475 "why_relevant": f"Ranked by Perplexity Search API for '{query}'",
476 "engagement": {},
477 "metadata": {
478 "last_updated": result.get("last_updated"),
479 "perplexity_search_id": data.get("id"),
480 },
481 })
482
483 artifact = {
484 "label": "perplexity",
485 "provider": "perplexity",
486 "mode": PERPLEXITY_MODE_SEARCH,
487 "endpoint": "search",
488 "query": query,
489 "resultCount": len(items),
490 "request": {
491 k: v
492 for k, v in payload.items()
493 if k not in {"query"}
494 },
495 "responseId": data.get("id"),
496 "serverTime": data.get("server_time"),
497 }
498 _log(f"Got {len(items)} Search API results")
499 return items, artifact
500
501
502 def _sonar_search(
503 query: str,
504 date_range: tuple[str, str],
505 config: dict,
506 provider: str,
507 api_key: str,
508 url: str,
509 model: str,
510 deep: bool,
511 ) -> tuple[list[dict], dict]:
512 from_date, to_date = date_range
513 timeout = 120 if deep else 30
514
515 if deep:
516 print("[Perplexity] Using Deep Research (~$0.90/query)", file=sys.stderr)
517
518 prompt = (
519 f"What has been happening with {query} between {from_date} and {to_date}? "
520 "Include specific dates, names, numbers, and sources."
521 )
522
523 headers = {
524 "Authorization": f"Bearer {api_key}",
525 "Content-Type": "application/json",
526 }
527
528 json_data = _build_sonar_payload(prompt, model, date_range, config)
529 if provider != "perplexity":
530 json_data.pop("web_search_options", None)
531 json_data.pop("reasoning_effort", None)
532
533 _log(f"Querying {provider} {model} for '{query}' ({from_date} to {to_date})")
534
535 async_artifact = {}
536 if provider == "perplexity" and deep:
537 data, async_artifact = _poll_async_sonar(json_data, headers, config)
538 else:
539 data = http.post(url, json_data, headers=headers, timeout=timeout)
540
541 # Parse response
542 choices = data.get("choices", [])
543 if not choices:
544 _log("No choices in response")
545 return [], _empty_async_sonar_artifact(
546 provider, model, deep, query, data, async_artifact,
547 "empty_choices",
548 "Async Deep Research completed without choices",
549 )
550
551 choice = choices[0] if isinstance(choices[0], dict) else {}
552 message = choice.get("message")
553 message = message if isinstance(message, dict) else {}
554 synthesis = message.get("content") or ""
555 if not isinstance(synthesis, str):
556 synthesis = ""
557 if not synthesis:
558 _log("Empty synthesis content")
559 return [], _empty_async_sonar_artifact(
560 provider, model, deep, query, data, async_artifact,
561 "empty_synthesis",
562 "Async Deep Research completed with empty synthesis",
563 )
564
565 citations = _extract_citations(data, choice)
566
567 _log(f"Got synthesis ({len(synthesis)} chars) with {len(citations)} citations")
568
569 # Build items list
570 items = []
571
572 # Primary item: the synthesis itself
573 snippet = synthesis[:2000]
574 items.append({
575 "id": "PX1",
576 "title": f"Perplexity {'Deep Research' if deep else 'Sonar'}: {query}",
577 "url": "",
578 "source_domain": "perplexity.ai",
579 "snippet": snippet,
580 "date": to_date,
581 "relevance": 0.9,
582 "why_relevant": f"AI synthesis of recent activity for '{query}'",
583 "engagement": {"citations": len(citations)},
584 "metadata": {
585 "citations": citations,
586 "usage": _usage(data),
587 **async_artifact,
588 },
589 })
590
591 # Individual items for each citation
592 for i, cit in enumerate(citations):
593 items.append({
594 "id": f"PX{i + 2}",
595 "title": cit["title"] or _domain(cit["url"]),
596 "url": cit["url"],
597 "source_domain": _domain(cit["url"]),
598 "snippet": cit.get("snippet") or "",
599 "date": cit.get("date"),
600 "relevance": 0.7,
601 "why_relevant": f"Cited in Perplexity synthesis for '{query}'",
602 "engagement": {"citations": 1},
603 "metadata": {"citations": [cit]},
604 })
605
606 artifact = {
607 "label": "perplexity",
608 "provider": provider,
609 "mode": PERPLEXITY_MODE_SONAR,
610 "endpoint": "async-sonar" if async_artifact else "sonar",
611 "model": model,
612 "deep": deep,
613 "query": query,
614 "synthesisLength": len(synthesis),
615 "citationCount": len(citations),
616 "usage": _usage(data),
617 **async_artifact,
618 }
619
620 return items, artifact
621
622
623 def _merge_sonar_and_search(sonar_items: list[dict], search_items: list[dict]) -> list[dict]:
624 if not sonar_items:
625 return search_items
626 merged = sonar_items[:1]
627 seen_urls = {item.get("url") for item in merged if item.get("url")}
628 for item in [*search_items, *sonar_items[1:]]:
629 url = item.get("url")
630 if url and url in seen_urls:
631 continue
632 if url:
633 seen_urls.add(url)
634 merged.append(item)
635 return merged
636
637
638 def search(
639 query: str,
640 date_range: tuple[str, str],
641 config: dict,
642 deep: bool = False,
643 ) -> tuple[list[dict], dict]:
644 """Search via Perplexity Sonar Pro or Deep Research.
645
646 Args:
647 query: Search topic
648 date_range: (from_date, to_date) as YYYY-MM-DD strings
649 config: Must contain PERPLEXITY_API_KEY or OPENROUTER_API_KEY
650 deep: Use Deep Research model (~$0.90/query) instead of Sonar Pro
651
652 Returns:
653 Tuple of (items list, artifact dict).
654 """
655 resolved = _provider(config, deep)
656 if not resolved:
657 _log("No PERPLEXITY_API_KEY or OPENROUTER_API_KEY configured, skipping")
658 return [], {}
659 provider, api_key, url, model = resolved
660 mode = _mode(config, provider, deep)
661
662 try:
663 if mode == PERPLEXITY_MODE_SEARCH:
664 return _search_api(query, date_range, config, api_key)
665 if mode == PERPLEXITY_MODE_BOTH:
666 search_items: list[dict] = []
667 sonar_items: list[dict] = []
668 search_artifact: dict = {}
669 sonar_artifact: dict = {}
670 try:
671 search_items, search_artifact = _search_api(query, date_range, config, api_key)
672 except Exception as e:
673 _log(f"Search API leg failed in both mode: {e}")
674 search_artifact = _error_artifact(e)
675 try:
676 sonar_items, sonar_artifact = _sonar_search(
677 query, date_range, config, provider, api_key, url, model, deep
678 )
679 except Exception as e:
680 _log(f"Sonar leg failed in both mode: {e}")
681 sonar_artifact = _error_artifact(e)
682 items = _merge_sonar_and_search(sonar_items, search_items)
683 return items, {
684 "label": "perplexity",
685 "provider": "perplexity",
686 "mode": PERPLEXITY_MODE_BOTH,
687 "query": query,
688 "search": search_artifact,
689 "sonar": sonar_artifact,
690 "itemCount": len(items),
691 }
692 return _sonar_search(query, date_range, config, provider, api_key, url, model, deep)
693 except http.HTTPError as e:
694 if e.status_code == 401:
695 _log(f"Invalid {provider} API key (401)")
696 elif e.status_code == 429:
697 _log(f"Rate limited by {provider} (429)")
698 else:
699 _log(f"HTTP error: {e}")
700 return [], {}
701 except AsyncDeepResearchTimeout as e:
702 _log(f"Request timed out: {e}")
703 return [], {
704 "label": "perplexity",
705 "provider": provider,
706 "mode": PERPLEXITY_MODE_SONAR,
707 "endpoint": "async-sonar",
708 "model": model,
709 "deep": deep,
710 "query": query,
711 "error": "timeout",
712 **e.metadata,
713 }
714 except AsyncDeepResearchFailed as e:
715 _log(f"Deep Research failed: {e}")
716 return [], {
717 "label": "perplexity",
718 "provider": provider,
719 "mode": PERPLEXITY_MODE_SONAR,
720 "endpoint": "async-sonar",
721 "model": model,
722 "deep": deep,
723 "query": query,
724 "error": "failed",
725 **e.metadata,
726 }
727 except AsyncDeepResearchPollError as e:
728 _log(f"Deep Research poll failed: {e}")
729 return [], {
730 "label": "perplexity",
731 "provider": provider,
732 "mode": PERPLEXITY_MODE_SONAR,
733 "endpoint": "async-sonar",
734 "model": model,
735 "deep": deep,
736 "query": query,
737 "error": "poll_error",
738 **e.metadata,
739 }
740 except TimeoutError as e:
741 _log(f"Request timed out: {e}")
742 return [], {"label": "perplexity", "provider": provider, "error": "timeout"}
743 except Exception as e:
744 _log(f"Request failed: {e}")
745 return [], {}
746
746 lines PYTHON