luckiest-co 1.0.46 → 1.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +8 -0
  257. package/skills/video-studio/SKILL.md +6 -5
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio/scripts/qa.mjs +64 -2
  261. package/skills/video-studio/scripts/qa.test.mjs +29 -1
  262. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  263. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  264. package/skills/video-studio-motion/SKILL.md +8 -5
  265. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  266. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -1,135 +1,101 @@
1
- """Perplexity Sonar, Search API, and Deep Research.
1
+ """Perplexity Agent API, Search API, and OpenRouter compatibility integration.
2
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.
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.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
9
10
 
10
- import hashlib
11
- import json
12
- import random
13
- import sys
14
11
  import time
15
12
  from datetime import datetime
13
+ from typing import Any
16
14
  from urllib.parse import urlparse
17
15
 
18
- from . import http, log
16
+ from . import health, http, log, providers
19
17
 
20
18
 
21
19
  OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
22
- PERPLEXITY_URL = "https://api.perplexity.ai/v1/sonar"
20
+ PERPLEXITY_AGENT_URL = "https://api.perplexity.ai/v1/agent"
23
21
  PERPLEXITY_SEARCH_URL = "https://api.perplexity.ai/search"
24
- PERPLEXITY_ASYNC_URL = "https://api.perplexity.ai/v1/async/sonar"
25
22
 
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"
23
+ PERPLEXITY_MODE_AGENT = "agent"
24
+ PERPLEXITY_MODE_SONAR = "sonar" # Direct-key alias and OpenRouter artifact mode.
33
25
  PERPLEXITY_MODE_SEARCH = "search"
34
26
  PERPLEXITY_MODE_BOTH = "both"
27
+ PERPLEXITY_DEFAULT_AGENT_TIMEOUT_SECONDS = 120
35
28
  PERPLEXITY_DEFAULT_DEEP_TIMEOUT_SECONDS = 600
36
29
  PERPLEXITY_DEEP_INITIAL_POLL_DELAY_SECONDS = 5.0
37
30
  PERPLEXITY_DEEP_MAX_POLL_DELAY_SECONDS = 60.0
38
31
 
39
- DIRECT_MODELS = {
40
- PERPLEXITY_MODEL_SONAR,
41
- PERPLEXITY_MODEL_SONAR_PRO,
42
- PERPLEXITY_MODEL_REASONING_PRO,
43
- PERPLEXITY_MODEL_DEEP_RESEARCH,
44
- }
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"}
45
39
  DIRECT_MODES = {
46
- PERPLEXITY_MODE_SONAR,
40
+ PERPLEXITY_MODE_AGENT,
47
41
  PERPLEXITY_MODE_SEARCH,
48
42
  PERPLEXITY_MODE_BOTH,
49
43
  }
50
44
  SEARCH_CONTEXT_SIZES = {"low", "medium", "high"}
51
45
  SEARCH_RECENCY_FILTERS = {"hour", "day", "week", "month", "year"}
52
- SONAR_SEARCH_MODES = {"web", "academic", "sec"}
53
46
  REASONING_EFFORTS = {"minimal", "low", "medium", "high"}
54
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
+
55
54
 
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")
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")
60
59
  self.metadata = metadata
61
60
 
62
61
 
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))
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))
67
66
  self.metadata = metadata
68
67
 
69
68
 
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))
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))
74
73
  self.metadata = metadata
75
74
 
76
75
 
77
- def _log(msg: str):
78
- log.source_log("Perplexity", msg, tty_only=False)
76
+ def _log(message: str) -> None:
77
+ log.source_log("Perplexity", message, tty_only=False)
79
78
 
80
79
 
81
80
  def _domain(url: str) -> str:
82
81
  return urlparse(url).netloc.strip().lower()
83
82
 
84
83
 
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:
84
+ def _config_text(config: dict[str, Any], key: str) -> str:
98
85
  return str(config.get(key) or "").strip()
99
86
 
100
87
 
101
88
  def _csv_values(raw: str, limit: int | None = None) -> list[str]:
102
89
  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
90
  return values[:limit]
105
91
 
106
92
 
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:
93
+ def _positive_int(
94
+ raw: object,
95
+ default: int,
96
+ min_value: int,
97
+ max_value: int | None = None,
98
+ ) -> int:
133
99
  try:
134
100
  value = int(str(raw).strip())
135
101
  except (TypeError, ValueError):
@@ -149,44 +115,13 @@ def _mmddyyyy(date: str | None) -> str | None:
149
115
  return None
150
116
 
151
117
 
152
- def _usage(data: dict) -> dict:
118
+ def _usage(data: dict[str, Any]) -> dict[str, Any]:
153
119
  usage = data.get("usage")
154
120
  return usage if isinstance(usage, dict) else {}
155
121
 
156
122
 
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 = {
123
+ def _error_artifact(exc: Exception) -> dict[str, Any]:
124
+ artifact: dict[str, Any] = {
190
125
  "error": type(exc).__name__,
191
126
  "message": str(exc)[:200],
192
127
  }
@@ -195,86 +130,140 @@ def _error_artifact(exc: Exception) -> dict:
195
130
  return artifact
196
131
 
197
132
 
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
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
227
142
 
228
143
 
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
- }
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
+ )
234
151
 
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
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
240
173
 
241
- domains = _csv_values(_config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"), limit=20)
242
- if domains:
243
- web_options["search_domain_filter"] = domains
244
174
 
245
- languages = _csv_values(_config_text(config, "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER"), limit=20)
246
- if languages:
247
- web_options["search_language_filter"] = languages
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
248
189
 
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
190
 
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
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
+ )
259
203
 
260
- if web_options:
261
- payload["web_search_options"] = web_options
262
204
 
263
- effort = _config_text(config, "LAST30DAYS_PERPLEXITY_REASONING_EFFORT").lower()
264
- if effort in REASONING_EFFORTS:
265
- payload["reasoning_effort"] = effort
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
+ )
266
212
 
267
- return payload
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
268
242
 
269
243
 
270
- def _build_search_payload(query: str, date_range: tuple[str, str], config: dict) -> dict:
244
+ def _build_search_payload(
245
+ query: str,
246
+ date_range: tuple[str, str],
247
+ config: dict[str, Any],
248
+ ) -> dict[str, Any]:
271
249
  from_date, to_date = date_range
272
- payload: dict[str, object] = {
250
+ payload: dict[str, Any] = {
273
251
  "query": query,
274
- "max_results": _positive_int(config.get("LAST30DAYS_PERPLEXITY_MAX_RESULTS"), 10, 1, 20),
252
+ "max_results": _positive_int(
253
+ config.get("LAST30DAYS_PERPLEXITY_MAX_RESULTS"),
254
+ 10,
255
+ 1,
256
+ 20,
257
+ ),
275
258
  }
276
259
 
277
- context_size = _config_text(config, "LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE").lower()
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
278
267
  if context_size in SEARCH_CONTEXT_SIZES:
279
268
  payload["search_context_size"] = context_size
280
269
 
@@ -282,11 +271,17 @@ def _build_search_payload(query: str, date_range: tuple[str, str], config: dict)
282
271
  if len(country) == 2:
283
272
  payload["country"] = country
284
273
 
285
- domains = _csv_values(_config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"), limit=20)
274
+ domains = _csv_values(
275
+ _config_text(config, "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER"),
276
+ limit=20,
277
+ )
286
278
  if domains:
287
279
  payload["search_domain_filter"] = domains
288
280
 
289
- languages = _csv_values(_config_text(config, "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER"), limit=20)
281
+ languages = _csv_values(
282
+ _config_text(config, "LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER"),
283
+ limit=20,
284
+ )
290
285
  if languages:
291
286
  payload["search_language_filter"] = languages
292
287
 
@@ -298,152 +293,503 @@ def _build_search_payload(query: str, date_range: tuple[str, str], config: dict)
298
293
  payload["search_before_date_filter"] = before
299
294
 
300
295
  # 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()
296
+ # published-date filters are present. Preserve the upstream fix.
297
+ recency = _config_text(
298
+ config,
299
+ "LAST30DAYS_PERPLEXITY_RECENCY_FILTER",
300
+ ).lower()
304
301
  if recency in SEARCH_RECENCY_FILTERS and not (after or before):
305
302
  payload["search_recency_filter"] = recency
306
303
 
307
304
  return payload
308
305
 
309
306
 
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:
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:
313
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
314
494
  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
- })
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
+ )
321
503
 
322
504
 
323
- def _extract_citations(data: dict, choice: dict) -> list[dict]:
324
- """Extract citations from direct Perplexity and OpenRouter response shapes."""
325
- citations: list[dict] = []
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]] = []
326
523
  seen_urls: set[str] = set()
327
524
 
328
- search_results_by_url: dict[str, dict] = {}
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]] = {}
329
563
  for result in data.get("search_results") or []:
330
564
  if not isinstance(result, dict):
331
565
  continue
332
- url = (result.get("url") or "").strip()
566
+ url = str(result.get("url") or "").strip()
333
567
  if not url:
334
568
  continue
335
- search_results_by_url[url] = result
569
+ search_results[url] = result
336
570
  _append_citation(citations, seen_urls, result)
337
571
 
338
572
  for url in data.get("citations") or []:
339
573
  if not isinstance(url, str):
340
574
  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
- })
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
+ )
360
586
 
587
+ message = choice.get("message")
588
+ if isinstance(message, dict):
589
+ _append_annotations(citations, seen_urls, message.get("annotations"))
361
590
  return citations
362
591
 
363
592
 
364
- def _poll_async_sonar(json_data: dict, headers: dict, config: dict) -> tuple[dict, dict]:
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]]:
365
684
  timeout_seconds = _positive_int(
366
685
  config.get("LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS"),
367
686
  PERPLEXITY_DEFAULT_DEEP_TIMEOUT_SECONDS,
368
687
  1,
369
688
  None,
370
689
  )
371
- idempotency_key = _idempotency_key(json_data)
372
690
  created = http.post(
373
- PERPLEXITY_ASYNC_URL,
374
- {"request": json_data, "idempotency_key": idempotency_key},
691
+ PERPLEXITY_AGENT_URL,
692
+ payload,
375
693
  headers=headers,
376
694
  timeout=30,
377
- retries=2,
695
+ retries=1,
378
696
  )
379
- request_id = created.get("id")
380
- if not request_id:
381
- raise http.HTTPError("Async Deep Research response missing id")
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")
382
700
 
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")
701
+ terminal = {"completed", "failed", "cancelled", "incomplete"}
702
+ status = str(created.get("status") or "").lower()
703
+ data = created
387
704
  poll_count = 0
388
- last_data = created
389
- if last_status:
390
- _log(f"Deep Research async status: {last_status}")
705
+ if status:
706
+ _log(f"Agent background status: {status}")
391
707
 
392
- while time.monotonic() < deadline:
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
+ )
393
721
  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,
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,
398
745
  "POLL_ERROR",
399
746
  )
400
- metadata["asyncPollError"] = str(e)
401
- if e.status_code is not None:
402
- metadata["asyncPollStatusCode"] = e.status_code
403
- raise AsyncDeepResearchPollError(metadata)
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
+
404
752
  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
- ))
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
428
757
  remaining = deadline - time.monotonic()
429
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:
430
769
  break
431
- jitter = random.uniform(0, 2)
432
- time.sleep(min(delay + jitter, max(0.1, remaining)))
770
+ time.sleep(min(delay, remaining))
433
771
  delay = min(delay * 1.5, PERPLEXITY_DEEP_MAX_POLL_DELAY_SECONDS)
434
772
 
435
- raise AsyncDeepResearchTimeout(_async_metadata(
436
- last_data, request_id, timeout_seconds, idempotency_key, poll_count,
437
- "PENDING_REMOTE",
438
- ))
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)
439
785
 
440
786
 
441
787
  def _search_api(
442
788
  query: str,
443
789
  date_range: tuple[str, str],
444
- config: dict,
790
+ config: dict[str, Any],
445
791
  api_key: str,
446
- ) -> tuple[list[dict], dict]:
792
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
447
793
  from_date, to_date = date_range
448
794
  headers = {
449
795
  "Authorization": f"Bearer {api_key}",
@@ -452,33 +798,41 @@ def _search_api(
452
798
  payload = _build_search_payload(query, date_range, config)
453
799
  _log(f"Querying Perplexity Search API for '{query}' ({from_date} to {to_date})")
454
800
 
455
- data = http.post(PERPLEXITY_SEARCH_URL, payload, headers=headers, timeout=30)
801
+ data = http.post(
802
+ PERPLEXITY_SEARCH_URL,
803
+ payload,
804
+ headers=headers,
805
+ timeout=30,
806
+ retries=1,
807
+ )
456
808
  results = data.get("results") or []
457
809
  if not isinstance(results, list):
458
810
  results = []
459
811
 
460
- items = []
461
- for i, result in enumerate(results):
812
+ items: list[dict[str, Any]] = []
813
+ for index, result in enumerate(results):
462
814
  if not isinstance(result, dict):
463
815
  continue
464
- url = (result.get("url") or "").strip()
816
+ url = str(result.get("url") or "").strip()
465
817
  if not url:
466
818
  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
- })
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
+ )
482
836
 
483
837
  artifact = {
484
838
  "label": "perplexity",
@@ -487,11 +841,7 @@ def _search_api(
487
841
  "endpoint": "search",
488
842
  "query": query,
489
843
  "resultCount": len(items),
490
- "request": {
491
- k: v
492
- for k, v in payload.items()
493
- if k not in {"query"}
494
- },
844
+ "request": {key: value for key, value in payload.items() if key != "query"},
495
845
  "responseId": data.get("id"),
496
846
  "serverTime": data.get("server_time"),
497
847
  }
@@ -499,133 +849,342 @@ def _search_api(
499
849
  return items, artifact
500
850
 
501
851
 
502
- def _sonar_search(
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(
503
876
  query: str,
504
877
  date_range: tuple[str, str],
505
- config: dict,
506
- provider: str,
507
- api_key: str,
508
- url: str,
509
- model: str,
510
878
  deep: bool,
511
- ) -> tuple[list[dict], dict]:
512
- from_date, to_date = date_range
513
- timeout = 120 if deep else 30
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
+ )
514
908
 
515
- if deep:
516
- print("[Perplexity] Using Deep Research (~$0.90/query)", file=sys.stderr)
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
+ )
517
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
518
993
  prompt = (
519
994
  f"What has been happening with {query} between {from_date} and {to_date}? "
520
995
  "Include specific dates, names, numbers, and sources."
521
996
  )
522
-
997
+ payload, selection = _build_agent_payload(prompt, date_range, config, deep)
523
998
  headers = {
524
999
  "Authorization": f"Bearer {api_key}",
525
1000
  "Content-Type": "application/json",
526
1001
  }
1002
+ _log(f"Querying Perplexity Agent API for '{query}' ({from_date} to {to_date})")
527
1003
 
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)
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
+ )
532
1043
 
533
- _log(f"Querying {provider} {model} for '{query}' ({from_date} to {to_date})")
1044
+ return _agent_result(
1045
+ query,
1046
+ date_range,
1047
+ deep,
1048
+ data,
1049
+ selection,
1050
+ background_metadata,
1051
+ )
534
1052
 
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
- )
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
+ # Honor OPENROUTER_BASE_URL like the planner/rerank client does (providers.py).
1081
+ url = providers.resolve_endpoint("OPENROUTER_BASE_URL", OPENROUTER_URL)
1082
+ data = http.post(
1083
+ url,
1084
+ payload,
1085
+ headers=headers,
1086
+ timeout=120 if deep else 30,
1087
+ retries=1,
1088
+ bypass_proxy=providers.is_loopback_http_endpoint(url),
1089
+ )
1090
+
1091
+ choices = data.get("choices")
1092
+ if not isinstance(choices, list) or not choices:
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_choices",
1102
+ "responseId": data.get("id"),
1103
+ "servedModel": data.get("model") or model,
1104
+ "usage": _usage(data),
1105
+ }
550
1106
 
551
1107
  choice = choices[0] if isinstance(choices[0], dict) else {}
552
1108
  message = choice.get("message")
553
1109
  message = message if isinstance(message, dict) else {}
554
- synthesis = message.get("content") or ""
555
- if not isinstance(synthesis, str):
556
- synthesis = ""
1110
+ synthesis = message.get("content")
1111
+ synthesis = synthesis if isinstance(synthesis, str) else ""
557
1112
  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,
1113
+ return [], {
1114
+ "label": "perplexity",
1115
+ "provider": "openrouter",
1116
+ "mode": PERPLEXITY_MODE_SONAR,
1117
+ "endpoint": "openrouter-chat-completions",
1118
+ "model": model,
1119
+ "deep": deep,
1120
+ "query": query,
1121
+ "error": "empty_synthesis",
1122
+ "responseId": data.get("id"),
1123
+ "servedModel": data.get("model") or model,
586
1124
  "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
- })
1125
+ }
605
1126
 
606
- artifact = {
1127
+ citations = _extract_openrouter_citations(data, choice)
1128
+ title_mode = "Deep Research" if deep else "Sonar"
1129
+ items: list[dict[str, Any]] = [
1130
+ {
1131
+ "id": "PX1",
1132
+ "title": f"Perplexity {title_mode}: {query}",
1133
+ "url": "",
1134
+ "source_domain": "perplexity.ai",
1135
+ "snippet": synthesis[:2000],
1136
+ "date": to_date,
1137
+ "relevance": 0.9,
1138
+ "why_relevant": f"AI synthesis of recent activity for '{query}'",
1139
+ "engagement": {"citations": len(citations)},
1140
+ "metadata": {
1141
+ "citations": citations,
1142
+ "usage": _usage(data),
1143
+ "openrouter_response_id": data.get("id"),
1144
+ },
1145
+ }
1146
+ ]
1147
+ for index, citation in enumerate(citations):
1148
+ items.append(
1149
+ {
1150
+ "id": f"PX{index + 2}",
1151
+ "title": citation["title"] or _domain(citation["url"]),
1152
+ "url": citation["url"],
1153
+ "source_domain": _domain(citation["url"]),
1154
+ "snippet": citation.get("snippet") or "",
1155
+ "date": citation.get("date"),
1156
+ "relevance": 0.7,
1157
+ "why_relevant": f"Cited in Perplexity synthesis for '{query}'",
1158
+ "engagement": {"citations": 1},
1159
+ "metadata": {"citations": [citation]},
1160
+ }
1161
+ )
1162
+
1163
+ return items, {
607
1164
  "label": "perplexity",
608
- "provider": provider,
1165
+ "provider": "openrouter",
609
1166
  "mode": PERPLEXITY_MODE_SONAR,
610
- "endpoint": "async-sonar" if async_artifact else "sonar",
1167
+ "endpoint": "openrouter-chat-completions",
611
1168
  "model": model,
612
1169
  "deep": deep,
613
1170
  "query": query,
614
1171
  "synthesisLength": len(synthesis),
615
1172
  "citationCount": len(citations),
1173
+ "responseId": data.get("id"),
1174
+ "servedModel": data.get("model") or model,
616
1175
  "usage": _usage(data),
617
- **async_artifact,
618
1176
  }
619
1177
 
620
- return items, artifact
621
-
622
1178
 
623
- def _merge_sonar_and_search(sonar_items: list[dict], search_items: list[dict]) -> list[dict]:
624
- if not sonar_items:
1179
+ def _merge_agent_and_search(
1180
+ agent_items: list[dict[str, Any]],
1181
+ search_items: list[dict[str, Any]],
1182
+ ) -> list[dict[str, Any]]:
1183
+ if not agent_items:
625
1184
  return search_items
626
- merged = sonar_items[:1]
1185
+ merged = agent_items[:1]
627
1186
  seen_urls = {item.get("url") for item in merged if item.get("url")}
628
- for item in [*search_items, *sonar_items[1:]]:
1187
+ for item in [*search_items, *agent_items[1:]]:
629
1188
  url = item.get("url")
630
1189
  if url and url in seen_urls:
631
1190
  continue
@@ -635,111 +1194,164 @@ def _merge_sonar_and_search(sonar_items: list[dict], search_items: list[dict]) -
635
1194
  return merged
636
1195
 
637
1196
 
1197
+ def _top_level_failure(
1198
+ query: str,
1199
+ mode: str,
1200
+ deep: bool,
1201
+ exc: Exception,
1202
+ provider: str = "perplexity",
1203
+ requested_search_type: str | None = None,
1204
+ ) -> dict[str, Any]:
1205
+ artifact = _error_artifact(exc)
1206
+ if provider == "perplexity" and requested_search_type is not None:
1207
+ artifact["requested_search_type"] = requested_search_type
1208
+ if provider == "openrouter":
1209
+ endpoint = "openrouter-chat-completions"
1210
+ elif deep:
1211
+ endpoint = "agent-background"
1212
+ elif mode == PERPLEXITY_MODE_SEARCH:
1213
+ endpoint = "search"
1214
+ else:
1215
+ endpoint = "agent"
1216
+ artifact.update(
1217
+ {
1218
+ "label": "perplexity",
1219
+ "provider": provider,
1220
+ "mode": mode,
1221
+ "endpoint": endpoint,
1222
+ "deep": deep,
1223
+ "query": query,
1224
+ }
1225
+ )
1226
+ return artifact
1227
+
1228
+
638
1229
  def search(
639
1230
  query: str,
640
1231
  date_range: tuple[str, str],
641
- config: dict,
1232
+ config: dict[str, Any],
642
1233
  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
1234
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
1235
+ """Search through Perplexity's Agent API or raw Search API.
651
1236
 
652
- Returns:
653
- Tuple of (items list, artifact dict).
1237
+ Normal synthesis uses the controlled Agent profile. Search API mode remains
1238
+ available for raw ranked rows. Deep Research is a dynamic high-preset
1239
+ background run and requires an explicit --deep-research invocation.
654
1240
  """
655
- resolved = _provider(config, deep)
1241
+ resolved = _provider(config)
656
1242
  if not resolved:
657
- _log("No PERPLEXITY_API_KEY or OPENROUTER_API_KEY configured, skipping")
1243
+ _log(
1244
+ "No PERPLEXITY_API_KEY or OPENROUTER_API_KEY configured, skipping"
1245
+ )
658
1246
  return [], {}
659
- provider, api_key, url, model = resolved
660
- mode = _mode(config, provider, deep)
1247
+ provider, api_key = resolved
661
1248
 
1249
+ mode = _mode(config, deep, provider)
1250
+ search_type = None
662
1251
  try:
1252
+ if provider == "openrouter":
1253
+ # Sonar ignores the direct-only setting, so never validate it here:
1254
+ # an unusable value must not block an otherwise working search.
1255
+ if _config_text(config, "LAST30DAYS_PERPLEXITY_SEARCH_TYPE"):
1256
+ _log("LAST30DAYS_PERPLEXITY_SEARCH_TYPE requires PERPLEXITY_API_KEY; "
1257
+ "it does not change OpenRouter Sonar search")
1258
+ result = _openrouter_sonar_search(
1259
+ query,
1260
+ date_range,
1261
+ api_key,
1262
+ deep,
1263
+ )
1264
+ if deep:
1265
+ _log_deep_receipt(result[1])
1266
+ return result
1267
+ search_type = _search_type(config)
663
1268
  if mode == PERPLEXITY_MODE_SEARCH:
664
1269
  return _search_api(query, date_range, config, api_key)
665
1270
  if mode == PERPLEXITY_MODE_BOTH:
666
- search_items: list[dict] = []
667
- sonar_items: list[dict] = []
668
- search_artifact: dict = {}
669
- sonar_artifact: dict = {}
1271
+ search_items: list[dict[str, Any]] = []
1272
+ agent_items: list[dict[str, Any]] = []
1273
+ search_artifact: dict[str, Any] = {}
1274
+ agent_artifact: dict[str, Any] = {}
670
1275
  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)
1276
+ search_items, search_artifact = _search_api(
1277
+ query,
1278
+ date_range,
1279
+ config,
1280
+ api_key,
1281
+ )
1282
+ except Exception as exc:
1283
+ _log(f"Search API leg failed in both mode: {exc}")
1284
+ search_artifact = _error_artifact(exc)
1285
+ if search_type is not None:
1286
+ search_artifact["requested_search_type"] = search_type
675
1287
  try:
676
- sonar_items, sonar_artifact = _sonar_search(
677
- query, date_range, config, provider, api_key, url, model, deep
1288
+ agent_items, agent_artifact = _agent_search(
1289
+ query,
1290
+ date_range,
1291
+ config,
1292
+ api_key,
1293
+ deep=False,
678
1294
  )
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)
1295
+ except Exception as exc:
1296
+ _log(f"Agent API leg failed in both mode: {exc}")
1297
+ agent_artifact = _error_artifact(exc)
1298
+ if search_type is not None:
1299
+ agent_artifact["requested_search_type"] = search_type
1300
+ items = _merge_agent_and_search(agent_items, search_items)
683
1301
  return items, {
684
1302
  "label": "perplexity",
685
1303
  "provider": "perplexity",
686
1304
  "mode": PERPLEXITY_MODE_BOTH,
687
1305
  "query": query,
688
1306
  "search": search_artifact,
689
- "sonar": sonar_artifact,
1307
+ "agent": agent_artifact,
690
1308
  "itemCount": len(items),
691
1309
  }
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:
1310
+ result = _agent_search(query, date_range, config, api_key, deep)
1311
+ if deep:
1312
+ _log_deep_receipt(result[1])
1313
+ return result
1314
+ except http.HTTPError as exc:
1315
+ if exc.status_code == 401:
695
1316
  _log(f"Invalid {provider} API key (401)")
696
- elif e.status_code == 429:
1317
+ elif exc.status_code == 429:
697
1318
  _log(f"Rate limited by {provider} (429)")
698
1319
  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 [], {}
1320
+ _log(f"HTTP error: {exc}")
1321
+ artifact = _top_level_failure(
1322
+ query,
1323
+ mode,
1324
+ deep,
1325
+ exc,
1326
+ provider=provider,
1327
+ requested_search_type=search_type,
1328
+ )
1329
+ if deep:
1330
+ _log_deep_receipt(artifact)
1331
+ return [], artifact
1332
+ except TimeoutError as exc:
1333
+ _log(f"Request timed out: {exc}")
1334
+ artifact = _top_level_failure(
1335
+ query,
1336
+ mode,
1337
+ deep,
1338
+ exc,
1339
+ provider=provider,
1340
+ requested_search_type=search_type,
1341
+ )
1342
+ if deep:
1343
+ _log_deep_receipt(artifact)
1344
+ return [], artifact
1345
+ except Exception as exc:
1346
+ _log(f"Request failed: {exc}")
1347
+ artifact = _top_level_failure(
1348
+ query,
1349
+ mode,
1350
+ deep,
1351
+ exc,
1352
+ provider=provider,
1353
+ requested_search_type=search_type,
1354
+ )
1355
+ if deep:
1356
+ _log_deep_receipt(artifact)
1357
+ return [], artifact