chasm_cli/providers/cloud/
perplexity.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Perplexity AI cloud provider
4//!
5//! Fetches conversation history from Perplexity web interface.
6//!
7//! ## Authentication
8//!
9//! Requires either:
10//! - API key via `PERPLEXITY_API_KEY` environment variable
11//! - Session token for web interface access
12
13use super::common::{
14    build_http_client, CloudConversation, CloudMessage, CloudProvider, FetchOptions,
15    HttpClientConfig,
16};
17use anyhow::{anyhow, Result};
18use chrono::{DateTime, Utc};
19use serde::Deserialize;
20
21const PERPLEXITY_API: &str = "https://api.perplexity.ai";
22const PERPLEXITY_WEB_API: &str = "https://www.perplexity.ai/api";
23
24/// Perplexity AI provider for fetching conversation history
25pub struct PerplexityProvider {
26    api_key: Option<String>,
27    session_token: Option<String>,
28    client: Option<reqwest::blocking::Client>,
29}
30
31impl PerplexityProvider {
32    pub fn new(api_key: Option<String>) -> Self {
33        Self {
34            api_key,
35            session_token: None,
36            client: None,
37        }
38    }
39
40    fn ensure_client(&mut self) -> Result<&reqwest::blocking::Client> {
41        if self.client.is_none() {
42            let config = HttpClientConfig::default();
43            self.client = Some(build_http_client(&config)?);
44        }
45        Ok(self.client.as_ref().unwrap())
46    }
47}
48
49#[derive(Debug, Deserialize)]
50struct PerplexityThread {
51    uuid: String,
52    #[serde(default)]
53    title: Option<String>,
54    created_at: String,
55    updated_at: String,
56    #[serde(default)]
57    messages: Vec<PerplexityMessage>,
58}
59
60#[derive(Debug, Deserialize)]
61struct PerplexityMessage {
62    uuid: String,
63    text: String,
64    role: String, // "user" or "assistant"
65    created_at: String,
66    #[serde(default)]
67    sources: Vec<PerplexitySource>,
68}
69
70#[derive(Debug, Deserialize)]
71struct PerplexitySource {
72    url: String,
73    title: Option<String>,
74}
75
76impl CloudProvider for PerplexityProvider {
77    fn name(&self) -> &'static str {
78        "Perplexity"
79    }
80
81    fn api_base_url(&self) -> &str {
82        PERPLEXITY_WEB_API
83    }
84
85    fn is_authenticated(&self) -> bool {
86        self.api_key.is_some() || self.session_token.is_some()
87    }
88
89    fn set_credentials(&mut self, api_key: Option<String>, session_token: Option<String>) {
90        self.api_key = api_key;
91        self.session_token = session_token;
92    }
93
94    fn list_conversations(&self, _options: &FetchOptions) -> Result<Vec<CloudConversation>> {
95        if !self.is_authenticated() {
96            return Err(anyhow!(
97                "Perplexity requires authentication. Set PERPLEXITY_API_KEY or provide a session token."
98            ));
99        }
100
101        eprintln!("Note: Perplexity conversation history requires web session authentication.");
102        eprintln!("The Perplexity API is stateless and doesn't store conversation history.");
103
104        Ok(vec![])
105    }
106
107    fn fetch_conversation(&self, _id: &str) -> Result<CloudConversation> {
108        if !self.is_authenticated() {
109            return Err(anyhow!("Perplexity requires authentication"));
110        }
111
112        Err(anyhow!(
113            "Fetching Perplexity conversations requires web session authentication."
114        ))
115    }
116
117    fn api_key_env_var(&self) -> &'static str {
118        "PERPLEXITY_API_KEY"
119    }
120}
121
122/// Parse Perplexity export data
123pub fn parse_perplexity_export(json_data: &str) -> Result<Vec<CloudConversation>> {
124    let threads: Vec<PerplexityThread> = serde_json::from_str(json_data)?;
125
126    Ok(threads
127        .into_iter()
128        .map(|thread| {
129            CloudConversation {
130                id: thread.uuid,
131                title: thread.title,
132                created_at: parse_iso_timestamp(&thread.created_at).unwrap_or_else(|_| Utc::now()),
133                updated_at: Some(
134                    parse_iso_timestamp(&thread.updated_at).unwrap_or_else(|_| Utc::now()),
135                ),
136                model: Some("perplexity".to_string()),
137                messages: thread
138                    .messages
139                    .into_iter()
140                    .map(|msg| {
141                        // Append sources to assistant messages
142                        let content = if !msg.sources.is_empty() && msg.role == "assistant" {
143                            let sources_text = msg
144                                .sources
145                                .iter()
146                                .enumerate()
147                                .map(|(i, s)| format!("[{}] {}", i + 1, s.url))
148                                .collect::<Vec<_>>()
149                                .join("\n");
150                            format!("{}\n\nSources:\n{}", msg.text, sources_text)
151                        } else {
152                            msg.text
153                        };
154
155                        CloudMessage {
156                            id: Some(msg.uuid),
157                            role: msg.role,
158                            content,
159                            timestamp: parse_iso_timestamp(&msg.created_at).ok(),
160                            model: None,
161                        }
162                    })
163                    .collect(),
164                metadata: None,
165            }
166        })
167        .collect())
168}
169
170fn parse_iso_timestamp(s: &str) -> Result<DateTime<Utc>> {
171    DateTime::parse_from_rfc3339(s)
172        .map(|dt| dt.with_timezone(&Utc))
173        .map_err(|e| anyhow!("Failed to parse timestamp: {}", e))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_perplexity_provider_new() {
182        let provider = PerplexityProvider::new(Some("test-key".to_string()));
183        assert_eq!(provider.name(), "Perplexity");
184        assert!(provider.is_authenticated());
185    }
186
187    #[test]
188    fn test_perplexity_provider_unauthenticated() {
189        let provider = PerplexityProvider::new(None);
190        assert!(!provider.is_authenticated());
191    }
192
193    #[test]
194    fn test_api_key_env_var() {
195        let provider = PerplexityProvider::new(None);
196        assert_eq!(provider.api_key_env_var(), "PERPLEXITY_API_KEY");
197    }
198}