ai_usagebar/lyceum/
fetch.rs1use 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}