Skip to main content

usage_monitor_cli/provider/
perplexity.rs

1use async_trait::async_trait;
2use chrono::{TimeZone, Utc};
3
4use crate::error::SpendPanelError;
5use crate::model::{CreditsSnapshot, PlanInfo, RateWindow, UsageSnapshot};
6use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
7
8/// Default session cookie name used by perplexity.ai (next-auth).
9const DEFAULT_COOKIE_NAME: &str = "__Secure-next-auth.session-token";
10
11#[derive(Debug, serde::Deserialize)]
12struct PerplexityCreditsResponse {
13    #[serde(default)]
14    balance_cents: f64,
15    #[serde(default)]
16    renewal_date_ts: f64,
17    #[serde(default)]
18    current_period_purchased_cents: f64,
19    #[serde(default)]
20    credit_grants: Vec<PerplexityCreditGrant>,
21    #[serde(default)]
22    total_usage_cents: f64,
23}
24
25#[derive(Debug, serde::Deserialize)]
26struct PerplexityCreditGrant {
27    #[serde(rename = "type")]
28    grant_type: String,
29    #[serde(default)]
30    amount_cents: f64,
31    #[serde(default)]
32    expires_at_ts: Option<f64>,
33}
34
35/// Credit pools resolved from the raw response (all values in cents).
36#[derive(Debug, Clone, PartialEq)]
37struct PerplexityCredits {
38    recurring_total: f64,
39    recurring_used: f64,
40    promo_total: f64,
41    promo_used: f64,
42    purchased_total: f64,
43    purchased_used: f64,
44    balance_cents: f64,
45    total_usage_cents: f64,
46    renewal_ts: f64,
47    promo_expiry_ts: Option<f64>,
48}
49
50impl PerplexityCredits {
51    /// Mirrors CodexBar's waterfall attribution: recurring → purchased → promo.
52    fn from_response(resp: &PerplexityCreditsResponse, now_ts: f64) -> Self {
53        let sum = |kind: &str| -> f64 {
54            resp.credit_grants
55                .iter()
56                .filter(|g| g.grant_type == kind)
57                .map(|g| g.amount_cents)
58                .sum::<f64>()
59                .max(0.0)
60        };
61
62        let recurring_sum = sum("recurring");
63        let promo_sum = resp
64            .credit_grants
65            .iter()
66            .filter(|g| g.grant_type == "promotional")
67            .filter(|g| g.expires_at_ts.unwrap_or(f64::INFINITY) > now_ts)
68            .map(|g| g.amount_cents)
69            .sum::<f64>()
70            .max(0.0);
71
72        // Purchased credits can appear in the grants array, the top-level field,
73        // or both. Take whichever is larger to avoid double counting.
74        let purchased_from_grants = sum("purchased");
75        let purchased_from_field = resp.current_period_purchased_cents.max(0.0);
76        let purchased_sum = purchased_from_grants.max(purchased_from_field);
77
78        let mut remaining = resp.total_usage_cents;
79        let used_from_recurring = remaining.min(recurring_sum).max(0.0);
80        remaining -= used_from_recurring;
81        let used_from_purchased = remaining.min(purchased_sum).max(0.0);
82        remaining -= used_from_purchased;
83        let used_from_promo = remaining.min(promo_sum).max(0.0);
84
85        let promo_expiry_ts = resp
86            .credit_grants
87            .iter()
88            .filter(|g| g.grant_type == "promotional")
89            .filter(|g| g.expires_at_ts.unwrap_or(f64::INFINITY) > now_ts)
90            .filter_map(|g| g.expires_at_ts)
91            .fold(None, |acc: Option<f64>, ts| {
92                Some(acc.map_or(ts, |cur| cur.min(ts)))
93            });
94
95        Self {
96            recurring_total: recurring_sum,
97            recurring_used: used_from_recurring,
98            promo_total: promo_sum,
99            promo_used: used_from_promo,
100            purchased_total: purchased_sum,
101            purchased_used: used_from_purchased,
102            balance_cents: resp.balance_cents,
103            total_usage_cents: resp.total_usage_cents,
104            renewal_ts: resp.renewal_date_ts,
105            promo_expiry_ts,
106        }
107    }
108
109    /// Infer plan name from the recurring allotment (Free=0, Pro<$50, Max≥$50).
110    fn plan_name(&self) -> Option<&'static str> {
111        if self.recurring_total <= 0.0 {
112            None
113        } else if self.recurring_total < 5000.0 {
114            Some("Pro")
115        } else {
116            Some("Max")
117        }
118    }
119}
120
121/// Perplexity credits/usage provider (browser-cookie auth).
122pub struct PerplexityProvider {
123    metadata: ProviderMetadata,
124    /// Base URL override for tests.
125    base_url: Option<String>,
126}
127
128impl PerplexityProvider {
129    pub fn new() -> Self {
130        Self {
131            metadata: ProviderMetadata {
132                id: "perplexity",
133                name: "Perplexity",
134                description: "Perplexity AI credits monitor (browser cookie)",
135                auth_methods: &["cookie", "token", "env"],
136                website: Some("https://www.perplexity.ai"),
137            },
138            base_url: None,
139        }
140    }
141
142    /// Creates a provider with a custom base URL (for tests).
143    pub fn with_base_url(url: &str) -> Self {
144        let mut p = Self::new();
145        p.base_url = Some(url.to_string());
146        p
147    }
148
149    fn api_base(&self) -> &str {
150        self.base_url
151            .as_deref()
152            .unwrap_or("https://www.perplexity.ai")
153    }
154
155    fn clean(raw: &str) -> String {
156        let mut value = raw.trim();
157        if value.len() >= 2
158            && ((value.starts_with('"') && value.ends_with('"'))
159                || (value.starts_with('\'') && value.ends_with('\'')))
160        {
161            value = &value[1..value.len() - 1];
162        }
163        value.trim().to_string()
164    }
165
166    /// Builds the `Cookie` header from config or environment.
167    ///
168    /// A full `cookie` value is sent verbatim; a bare session `token` is wrapped
169    /// in the default next-auth cookie name.
170    fn resolve_cookie(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
171        if let Some(cookie) = ctx.config.get("cookie") {
172            let cleaned = Self::clean(cookie);
173            if !cleaned.is_empty() {
174                return Ok(cleaned);
175            }
176        }
177        for key in ["token", "session_token", "api_key"] {
178            if let Some(value) = ctx.config.get(key) {
179                let cleaned = Self::clean(value);
180                if !cleaned.is_empty() {
181                    return Ok(format!("{}={}", DEFAULT_COOKIE_NAME, cleaned));
182                }
183            }
184        }
185        if let Ok(value) = std::env::var("PERPLEXITY_SESSION_TOKEN") {
186            let cleaned = Self::clean(&value);
187            if !cleaned.is_empty() {
188                return Ok(format!("{}={}", DEFAULT_COOKIE_NAME, cleaned));
189            }
190        }
191        Err(SpendPanelError::AuthFailed(
192            "perplexity".into(),
193            "no session cookie found in cookie/token config or PERPLEXITY_SESSION_TOKEN".into(),
194        ))
195    }
196
197    fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
198        reqwest::Client::builder()
199            .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
200            .build()
201            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
202    }
203
204    async fn fetch_credits(
205        base_url: &str,
206        client: &reqwest::Client,
207        cookie: &str,
208    ) -> Result<PerplexityCreditsResponse, SpendPanelError> {
209        let url = format!(
210            "{}/rest/billing/credits?version=2.18&source=default",
211            base_url.trim_end_matches('/')
212        );
213        let resp = client
214            .get(url)
215            .header("Accept", "application/json")
216            .header("Cookie", cookie)
217            .header("Origin", "https://www.perplexity.ai")
218            .header("Referer", "https://www.perplexity.ai/account/usage")
219            .send()
220            .await
221            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
222
223        let status = resp.status();
224        let body = resp
225            .text()
226            .await
227            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
228
229        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
230            return Err(SpendPanelError::AuthFailed(
231                "perplexity".into(),
232                format!(
233                    "invalid or expired session cookie (HTTP {})",
234                    status.as_u16()
235                ),
236            ));
237        }
238        if !status.is_success() {
239            return Err(SpendPanelError::ProviderError(
240                "perplexity".into(),
241                format!("HTTP {}: {}", status, body),
242            ));
243        }
244
245        serde_json::from_str(&body)
246            .map_err(|e| SpendPanelError::ParseError("perplexity".into(), e.to_string()))
247    }
248
249    /// Converts a positive Unix-seconds timestamp to a UTC datetime (skips 0/negative).
250    fn ts_to_date(ts: f64) -> Option<chrono::DateTime<Utc>> {
251        if ts > 0.0 {
252            Utc.timestamp_opt(ts as i64, 0).single()
253        } else {
254            None
255        }
256    }
257
258    fn snapshot_from_credits(credits: PerplexityCredits) -> UsageSnapshot {
259        let mut snapshot = UsageSnapshot::new("perplexity");
260
261        // Primary: recurring (monthly) plan credits.
262        if credits.recurring_total > 0.0 {
263            let mut window = RateWindow::new(
264                credits.recurring_used.round() as u64,
265                credits.recurring_total.round() as u64,
266                "Plan credits",
267                0,
268            );
269            window.resets_at = Self::ts_to_date(credits.renewal_ts);
270            snapshot.primary_rate_window = Some(window);
271        }
272
273        // Secondary: promotional bonus credits.
274        if credits.promo_total > 0.0 {
275            let mut window = RateWindow::new(
276                credits.promo_used.round() as u64,
277                credits.promo_total.round() as u64,
278                "Bonus credits",
279                0,
280            );
281            window.resets_at = credits
282                .promo_expiry_ts
283                .and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single());
284            snapshot.secondary_rate_window = Some(window);
285        }
286
287        // Tertiary: on-demand purchased credits.
288        if credits.purchased_total > 0.0 {
289            snapshot.tertiary_rate_window = Some(RateWindow::new(
290                credits.purchased_used.round() as u64,
291                credits.purchased_total.round() as u64,
292                "Purchased credits",
293                0,
294            ));
295        }
296
297        let mut credits_snapshot = CreditsSnapshot::new(credits.balance_cents / 100.0, "USD");
298        credits_snapshot.used = Some(credits.total_usage_cents / 100.0);
299        credits_snapshot.bonus = Some(credits.promo_total / 100.0);
300        credits_snapshot.purchased = Some(credits.purchased_total / 100.0);
301        credits_snapshot.renews_at = Self::ts_to_date(credits.renewal_ts);
302        snapshot.credits = Some(credits_snapshot);
303
304        if let Some(plan) = credits.plan_name() {
305            snapshot.plan = Some(PlanInfo {
306                name: plan.to_string(),
307                tier: None,
308                features: Vec::new(),
309                price: None,
310                currency: None,
311                billing_period: Some("monthly".into()),
312            });
313        }
314
315        snapshot
316    }
317}
318
319impl Default for PerplexityProvider {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325#[async_trait]
326impl UsageProvider for PerplexityProvider {
327    fn metadata(&self) -> &ProviderMetadata {
328        &self.metadata
329    }
330
331    fn detect_credentials(&self) -> bool {
332        std::env::var("PERPLEXITY_SESSION_TOKEN")
333            .map(|v| !v.trim().is_empty())
334            .unwrap_or(false)
335    }
336
337    async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
338        let cookie = Self::resolve_cookie(ctx)?;
339        let client = Self::build_client(ctx)?;
340        let response = Self::fetch_credits(self.api_base(), &client, &cookie).await?;
341        let now_ts = Utc::now().timestamp() as f64;
342        let credits = PerplexityCredits::from_response(&response, now_ts);
343        Ok(Self::snapshot_from_credits(credits))
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use pretty_assertions::assert_eq;
351    use wiremock::matchers::{header, method, path};
352    use wiremock::{Mock, MockServer, ResponseTemplate};
353
354    const SAMPLE: &str = r#"{
355      "balance_cents": 1500.0,
356      "renewal_date_ts": 1788000000,
357      "current_period_purchased_cents": 1000.0,
358      "total_usage_cents": 700.0,
359      "credit_grants": [
360        {"type": "recurring", "amount_cents": 500.0},
361        {"type": "promotional", "amount_cents": 300.0, "expires_at_ts": 9999999999},
362        {"type": "purchased", "amount_cents": 1000.0}
363      ]
364    }"#;
365
366    fn parse(body: &str, now_ts: f64) -> PerplexityCredits {
367        let resp: PerplexityCreditsResponse = serde_json::from_str(body).unwrap();
368        PerplexityCredits::from_response(&resp, now_ts)
369    }
370
371    #[test]
372    fn test_metadata() {
373        let p = PerplexityProvider::new();
374        assert_eq!(p.metadata().id, "perplexity");
375        assert!(p.metadata().auth_methods.contains(&"cookie"));
376    }
377
378    #[test]
379    fn test_resolve_cookie_full_cookie_verbatim() {
380        let mut ctx = ProviderContext::new();
381        ctx.config.insert(
382            "cookie".into(),
383            "__Secure-next-auth.session-token=abc".into(),
384        );
385        assert_eq!(
386            PerplexityProvider::resolve_cookie(&ctx).unwrap(),
387            "__Secure-next-auth.session-token=abc"
388        );
389    }
390
391    #[test]
392    fn test_resolve_cookie_token_wrapped() {
393        let mut ctx = ProviderContext::new();
394        ctx.config.insert("token".into(), "sess-xyz".into());
395        assert_eq!(
396            PerplexityProvider::resolve_cookie(&ctx).unwrap(),
397            "__Secure-next-auth.session-token=sess-xyz"
398        );
399    }
400
401    #[test]
402    fn test_resolve_cookie_missing_is_error() {
403        let err = PerplexityProvider::resolve_cookie(&ProviderContext::new()).unwrap_err();
404        assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
405    }
406
407    #[test]
408    fn test_waterfall_attribution() {
409        // total_usage 700: recurring(500) fully used, then purchased(200), promo 0.
410        let credits = parse(SAMPLE, 1.0);
411        assert_eq!(credits.recurring_total, 500.0);
412        assert_eq!(credits.recurring_used, 500.0);
413        assert_eq!(credits.purchased_total, 1000.0);
414        assert_eq!(credits.purchased_used, 200.0);
415        assert_eq!(credits.promo_total, 300.0);
416        assert_eq!(credits.promo_used, 0.0);
417    }
418
419    #[test]
420    fn test_expired_promo_excluded() {
421        let body = r#"{
422          "balance_cents": 0,
423          "renewal_date_ts": 0,
424          "current_period_purchased_cents": 0,
425          "total_usage_cents": 0,
426          "credit_grants": [
427            {"type": "promotional", "amount_cents": 300.0, "expires_at_ts": 100}
428          ]
429        }"#;
430        let credits = parse(body, 200.0);
431        assert_eq!(credits.promo_total, 0.0);
432    }
433
434    #[test]
435    fn test_plan_name_thresholds() {
436        let pro = PerplexityCredits {
437            recurring_total: 2000.0,
438            ..parse(SAMPLE, 1.0)
439        };
440        assert_eq!(pro.plan_name(), Some("Pro"));
441        let max = PerplexityCredits {
442            recurring_total: 10000.0,
443            ..parse(SAMPLE, 1.0)
444        };
445        assert_eq!(max.plan_name(), Some("Max"));
446    }
447
448    #[test]
449    fn test_snapshot_maps_pools() {
450        let snapshot = PerplexityProvider::snapshot_from_credits(parse(SAMPLE, 1.0));
451        let primary = snapshot.primary_rate_window.unwrap();
452        assert_eq!(primary.used, Some(500));
453        assert_eq!(primary.limit, Some(500));
454        let tertiary = snapshot.tertiary_rate_window.unwrap();
455        assert_eq!(tertiary.used, Some(200));
456        assert_eq!(tertiary.limit, Some(1000));
457        let credits = snapshot.credits.unwrap();
458        assert_eq!(credits.balance, 15.0);
459        assert_eq!(credits.purchased, Some(10.0));
460    }
461
462    #[test]
463    fn test_no_recurring_drops_primary_keeps_pools() {
464        // Free plan: no recurring credits, but purchased/bonus remain.
465        let body = r#"{
466          "balance_cents": 500,
467          "renewal_date_ts": 0,
468          "current_period_purchased_cents": 800,
469          "total_usage_cents": 100,
470          "credit_grants": [
471            {"type": "purchased", "amount_cents": 800},
472            {"type": "promotional", "amount_cents": 200, "expires_at_ts": 9999999999}
473          ]
474        }"#;
475        let snapshot = PerplexityProvider::snapshot_from_credits(parse(body, 1.0));
476        assert!(
477            snapshot.primary_rate_window.is_none(),
478            "no recurring → no primary"
479        );
480        assert!(snapshot.secondary_rate_window.is_some());
481        assert!(snapshot.tertiary_rate_window.is_some());
482        // renewal_date_ts 0 must not produce a 1970 reset.
483        assert!(snapshot.credits.unwrap().renews_at.is_none());
484    }
485
486    #[tokio::test]
487    async fn test_fetch_usage_success() {
488        let server = MockServer::start().await;
489        Mock::given(method("GET"))
490            .and(path("/rest/billing/credits"))
491            .and(header("cookie", "__Secure-next-auth.session-token=abc"))
492            .respond_with(ResponseTemplate::new(200).set_body_raw(SAMPLE, "application/json"))
493            .mount(&server)
494            .await;
495
496        let provider = PerplexityProvider::with_base_url(&server.uri());
497        let mut ctx = ProviderContext::new();
498        ctx.config.insert("token".into(), "abc".into());
499        let snapshot = provider.fetch_usage(&ctx).await.unwrap();
500        assert_eq!(snapshot.credits.unwrap().balance, 15.0);
501    }
502
503    #[tokio::test]
504    async fn test_fetch_usage_401_is_auth_failed() {
505        let server = MockServer::start().await;
506        Mock::given(method("GET"))
507            .and(path("/rest/billing/credits"))
508            .respond_with(ResponseTemplate::new(401))
509            .mount(&server)
510            .await;
511
512        let provider = PerplexityProvider::with_base_url(&server.uri());
513        let mut ctx = ProviderContext::new();
514        ctx.config.insert("token".into(), "bad".into());
515        let err = provider.fetch_usage(&ctx).await.unwrap_err();
516        assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
517    }
518}