luckiest-co 1.0.47 → 1.0.49
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -1,135 +1,101 @@
|
|
|
1
|
-
"""Perplexity
|
|
1
|
+
"""Perplexity Agent API, Search API, and OpenRouter compatibility integration.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
API
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
def __init__(self, metadata: dict):
|
|
58
|
-
timeout_seconds = metadata.get("
|
|
59
|
-
super().__init__(f"
|
|
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
|
|
64
|
-
def __init__(self, metadata: dict):
|
|
65
|
-
|
|
66
|
-
super().__init__(str(
|
|
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
|
|
71
|
-
def __init__(self, metadata: dict):
|
|
72
|
-
|
|
73
|
-
super().__init__(str(
|
|
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(
|
|
78
|
-
log.source_log("Perplexity",
|
|
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
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
158
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
246
|
-
if
|
|
247
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
if
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
250
|
+
payload: dict[str, Any] = {
|
|
273
251
|
"query": query,
|
|
274
|
-
"max_results": _positive_int(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
342
|
-
_append_citation(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
-
|
|
374
|
-
|
|
691
|
+
PERPLEXITY_AGENT_URL,
|
|
692
|
+
payload,
|
|
375
693
|
headers=headers,
|
|
376
694
|
timeout=30,
|
|
377
|
-
retries=
|
|
695
|
+
retries=1,
|
|
378
696
|
)
|
|
379
|
-
|
|
380
|
-
if not
|
|
381
|
-
raise http.HTTPError("
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
_log(f"Deep Research async status: {last_status}")
|
|
705
|
+
if status:
|
|
706
|
+
_log(f"Agent background status: {status}")
|
|
391
707
|
|
|
392
|
-
|
|
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(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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["
|
|
401
|
-
if
|
|
402
|
-
metadata["
|
|
403
|
-
raise
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
"
|
|
479
|
-
|
|
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
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
1044
|
+
return _agent_result(
|
|
1045
|
+
query,
|
|
1046
|
+
date_range,
|
|
1047
|
+
deep,
|
|
1048
|
+
data,
|
|
1049
|
+
selection,
|
|
1050
|
+
background_metadata,
|
|
1051
|
+
)
|
|
534
1052
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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")
|
|
555
|
-
if
|
|
556
|
-
synthesis = ""
|
|
1110
|
+
synthesis = message.get("content")
|
|
1111
|
+
synthesis = synthesis if isinstance(synthesis, str) else ""
|
|
557
1112
|
if not synthesis:
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
provider
|
|
561
|
-
"
|
|
562
|
-
"
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
1165
|
+
"provider": "openrouter",
|
|
609
1166
|
"mode": PERPLEXITY_MODE_SONAR,
|
|
610
|
-
"endpoint": "
|
|
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
|
|
624
|
-
|
|
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 =
|
|
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, *
|
|
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
|
|
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
|
-
|
|
653
|
-
|
|
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
|
|
1241
|
+
resolved = _provider(config)
|
|
656
1242
|
if not resolved:
|
|
657
|
-
_log(
|
|
1243
|
+
_log(
|
|
1244
|
+
"No PERPLEXITY_API_KEY or OPENROUTER_API_KEY configured, skipping"
|
|
1245
|
+
)
|
|
658
1246
|
return [], {}
|
|
659
|
-
provider, api_key
|
|
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
|
-
|
|
668
|
-
search_artifact: dict = {}
|
|
669
|
-
|
|
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(
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
677
|
-
query,
|
|
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
|
|
680
|
-
_log(f"
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
"
|
|
1307
|
+
"agent": agent_artifact,
|
|
690
1308
|
"itemCount": len(items),
|
|
691
1309
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
1317
|
+
elif exc.status_code == 429:
|
|
697
1318
|
_log(f"Rate limited by {provider} (429)")
|
|
698
1319
|
else:
|
|
699
|
-
_log(f"HTTP error: {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|