Skip to main content

ai_usagebar/lyceum/
fetch.rs

1//! Lyceum billing-credit balance fetcher.
2use super::types::Credits;
3use crate::cache::{Cache, acquire_lock_async};
4use crate::error::{AppError, Result};
5use crate::usage::{LyceumSnapshot, finite_amount};
6use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
7use std::time::Duration;
8
9pub const BASE_URL: &str = "https://api.lyceum.technology";
10const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
11const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
12#[derive(Debug, Clone)]
13pub struct Endpoints {
14    pub credits: String,
15}
16impl Default for Endpoints {
17    fn default() -> Self {
18        Self {
19            credits: format!("{BASE_URL}/api/v2/external/billing/credits"),
20        }
21    }
22}
23pub type FetchOutcome = crate::outcome::Outcome<LyceumSnapshot>;
24
25pub async fn fetch_snapshot(
26    client: &reqwest::Client,
27    api_key: &str,
28    cache: &Cache,
29    ttl: Duration,
30) -> Result<FetchOutcome> {
31    cache.ensure_dir()?;
32    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
33    if let Some(bytes) = cache.fresh_payload(ttl)?
34        && let Ok(snapshot) = parse_cache(&bytes)
35    {
36        return Ok(crate::outcome::Outcome::cached(snapshot, cache, false));
37    }
38    match fetch_live(client, api_key, &Endpoints::default()).await {
39        Ok(snapshot) => {
40            cache.write_payload(&serde_json::to_vec(
41                &serde_json::json!({"snapshot": snapshot}),
42            )?)?;
43            Ok(crate::outcome::Outcome::fresh(snapshot))
44        }
45        Err(e) if e.is_transient() => crate::outcome::fallback(cache, None, e, parse_cache),
46        Err(e) => {
47            cache.mark_stale();
48            let status = match &e {
49                AppError::Http { status, .. } => *status,
50                _ => 0,
51            };
52            let safe = match &e {
53                AppError::Http { status, .. } => AppError::Http {
54                    status: *status,
55                    body: "Lyceum request failed".into(),
56                },
57                _ => AppError::Other("Lyceum request failed".into()),
58            };
59            let diag = cache.write_last_error(status, &safe.to_string());
60            crate::outcome::fallback(cache, Some(diag), safe, parse_cache)
61        }
62    }
63}
64
65fn parse_cache(bytes: &[u8]) -> Result<LyceumSnapshot> {
66    let value: serde_json::Value = serde_json::from_slice(bytes)?;
67    let snapshot = value
68        .get("snapshot")
69        .ok_or_else(|| AppError::Schema("lyceum cache missing snapshot".into()))?;
70    let field = |name: &str| -> Result<f64> {
71        let n = snapshot
72            .get(name)
73            .and_then(serde_json::Value::as_f64)
74            .ok_or_else(|| AppError::Schema(format!("lyceum cache missing {name}")))?;
75        finite_amount("lyceum cache", name, n)
76    };
77    Ok(LyceumSnapshot {
78        available_credits: field("available_credits")?,
79        used_credits: field("used_credits")?,
80        total_credits_used: field("total_credits_used")?,
81        remaining_credits: field("remaining_credits")?,
82        monthly_free_credits: field("monthly_free_credits")?,
83        purchased_credits: field("purchased_credits")?,
84    })
85}
86
87async fn fetch_live(
88    client: &reqwest::Client,
89    key: &str,
90    endpoints: &Endpoints,
91) -> Result<LyceumSnapshot> {
92    let response = tokio::time::timeout(
93        HTTP_TIMEOUT,
94        client
95            .get(&endpoints.credits)
96            .bearer_auth(key)
97            .header("Accept", "application/json")
98            .send(),
99    )
100    .await
101    .map_err(|_| AppError::Transport("lyceum request timed out".into()))??;
102    let status = response.status();
103    let body = read_body_capped(response, MAX_BODY_BYTES).await?;
104    if !status.is_success() {
105        return Err(AppError::Http {
106            status: status.as_u16(),
107            body: "Lyceum billing request failed".into(),
108        });
109    }
110    let response: Credits = serde_json::from_slice(&body)
111        .map_err(|_| AppError::Schema("lyceum: invalid credits response".into()))?;
112    response.into_snapshot()
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use tempfile::TempDir;
119    #[tokio::test]
120    async fn request_uses_bearer_and_parses_credit_balance() {
121        let mut server = mockito::Server::new_async().await;
122        let mock = server.mock("GET", "/api/v2/external/billing/credits").match_header("authorization", "Bearer synthetic-key").with_status(200).with_body(r#"{"available_credits":13.5,"used_credits":2.5,"total_credits_used":2.5,"remaining_credits":13.5,"monthly_free_credits":0,"purchased_credits":10,"extra":true}"#).create_async().await;
123        let endpoint = Endpoints {
124            credits: format!("{}/api/v2/external/billing/credits", server.url()),
125        };
126        let got = fetch_live(&reqwest::Client::new(), "synthetic-key", &endpoint)
127            .await
128            .unwrap();
129        mock.assert();
130        assert_eq!(got.available_credits, 13.5);
131    }
132    #[tokio::test]
133    async fn unauthorized_response_does_not_echo_body() {
134        let mut server = mockito::Server::new_async().await;
135        let mock = server
136            .mock("GET", "/api/v2/external/billing/credits")
137            .with_status(401)
138            .with_body("private synthetic credential")
139            .create_async()
140            .await;
141        let endpoint = Endpoints {
142            credits: format!("{}/api/v2/external/billing/credits", server.url()),
143        };
144        let err = fetch_live(&reqwest::Client::new(), "synthetic-key", &endpoint)
145            .await
146            .unwrap_err();
147        assert!(!err.to_string().contains("private synthetic credential"));
148        mock.assert();
149    }
150    #[test]
151    fn cache_round_trips_balance_only_snapshot() {
152        let td = TempDir::new().unwrap();
153        let cache = Cache::at(td.path().join("lyceum"));
154        cache.ensure_dir().unwrap();
155        let raw = serde_json::to_vec(&serde_json::json!({"snapshot":{"available_credits":10.0,"used_credits":1.0,"total_credits_used":1.0,"remaining_credits":10.0,"monthly_free_credits":0.0,"purchased_credits":10.0}})).unwrap();
156        let parsed = parse_cache(&raw).unwrap();
157        assert_eq!(parsed.remaining_credits, 10.0);
158        let broken = br#"{"snapshot":{"available_credits":10}}"#;
159        assert!(parse_cache(broken).is_err());
160    }
161}