1use std::time::Duration;
7
8use crate::cache::{Cache, acquire_lock_async};
9use crate::error::{AppError, Result};
10use crate::usage::{GrokSnapshot, finite_amount};
11use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
12
13use super::types::{BalanceResp, Validation, to_snapshot};
14
15pub const BASE_URL: &str = "https://management-api.x.ai";
16const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
17const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
18
19#[derive(Debug, Clone)]
20pub struct Endpoints {
21 pub base: String,
22}
23
24impl Default for Endpoints {
25 fn default() -> Self {
26 Self {
27 base: BASE_URL.to_string(),
28 }
29 }
30}
31
32impl Endpoints {
33 fn validation_url(&self) -> String {
34 format!("{}/auth/management-keys/validation", self.base)
35 }
36 fn balance_url(&self, team: &str) -> String {
37 format!("{}/v1/billing/teams/{team}/prepaid/balance", self.base)
38 }
39}
40
41pub type FetchOutcome = crate::outcome::Outcome<GrokSnapshot>;
44
45pub async fn fetch_snapshot(
48 client: &reqwest::Client,
49 management_key: &str,
50 cache: &Cache,
51 endpoints: &Endpoints,
52 cache_ttl: Duration,
53 team_id: Option<&str>,
54) -> Result<FetchOutcome> {
55 cache.ensure_dir()?;
56 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
57
58 let target = target_key(management_key, team_id);
61
62 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
63 && let Ok(outcome) = reuse_cache(&bytes, cache, false, &target)
64 {
65 return Ok(outcome);
66 }
67
68 let team = match resolve_team(client, endpoints, management_key, team_id).await {
69 Ok(t) => t,
70 Err(e) if e.is_transient() => return fallback_silent(cache, &target, e),
71 Err(e) => {
72 cache.mark_stale();
73 let diag = cache.write_last_error(0, &e.to_string());
74 return fallback_with_error(cache, Some(diag), &target, e);
75 }
76 };
77
78 match fetch_live(client, endpoints, management_key, &team).await {
79 Ok(snap) => {
80 let bytes = serde_json::to_vec(&serde_json::json!({
81 "target": target,
82 "team": team,
83 "snapshot": { "balance": snap.balance },
84 }))?;
85 cache.write_payload(&bytes)?;
86 Ok(crate::outcome::Outcome::fresh(snap))
87 }
88 Err(e) if e.is_transient() => fallback_silent(cache, &target, e),
89 Err(AppError::Http { status, body }) => {
90 cache.mark_stale();
91 let diag = cache.write_last_error(status, &body);
92 fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
93 }
94 Err(e) => {
95 cache.mark_stale();
96 let diag = cache.write_last_error(0, &e.to_string());
97 fallback_with_error(cache, Some(diag), &target, e)
98 }
99 }
100}
101
102fn target_key(management_key: &str, team_id: Option<&str>) -> String {
108 match team_id.filter(|t| !t.is_empty()) {
109 Some(t) => format!("team:{t}"),
110 None => {
111 use std::hash::{Hash, Hasher};
112 let mut h = std::collections::hash_map::DefaultHasher::new();
113 management_key.hash(&mut h);
114 format!("key:{:016x}", h.finish())
115 }
116 }
117}
118
119async fn resolve_team(
122 client: &reqwest::Client,
123 endpoints: &Endpoints,
124 key: &str,
125 team_id: Option<&str>,
126) -> Result<String> {
127 match team_id {
128 Some(t) if !t.is_empty() => Ok(t.to_string()),
129 _ => {
130 let v: Validation = get_json(client, &endpoints.validation_url(), key).await?;
131 v.resolved_team()
132 }
133 }
134}
135
136fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
137 crate::outcome::fallback(cache, None, original, |bytes| parse_cache(bytes, target))
138}
139
140fn fallback_with_error(
145 cache: &Cache,
146 last_error: Option<(u16, String)>,
147 target: &str,
148 original: AppError,
149) -> Result<FetchOutcome> {
150 crate::outcome::fallback(cache, last_error, original, |bytes| {
151 parse_cache(bytes, target)
152 })
153}
154
155fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
156 let snap = parse_cache(bytes, target)?;
157 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
158}
159
160fn parse_cache(bytes: &[u8], target: &str) -> Result<GrokSnapshot> {
161 let v: serde_json::Value = serde_json::from_slice(bytes)?;
162 let cached_target = v.get("target").and_then(serde_json::Value::as_str);
166 if cached_target != Some(target) {
167 return Err(AppError::Schema(format!(
168 "grok cache belongs to a different team ({}); refetching",
169 v.get("team")
170 .and_then(serde_json::Value::as_str)
171 .or(cached_target)
172 .unwrap_or("unknown")
173 )));
174 }
175 let s = v
176 .get("snapshot")
177 .ok_or_else(|| AppError::Schema("grok cache missing 'snapshot' field".into()))?;
178 let balance = s["balance"]
179 .as_f64()
180 .ok_or_else(|| AppError::Schema("grok cache missing 'balance'".into()))?;
181 Ok(GrokSnapshot {
182 balance: finite_amount("grok cache", "balance", balance)?,
183 })
184}
185
186async fn fetch_live(
187 client: &reqwest::Client,
188 endpoints: &Endpoints,
189 key: &str,
190 team: &str,
191) -> Result<GrokSnapshot> {
192 let resp: BalanceResp = get_json(client, &endpoints.balance_url(team), key).await?;
193 to_snapshot(resp)
194}
195
196async fn get_json<T: for<'de> serde::Deserialize<'de>>(
197 client: &reqwest::Client,
198 url: &str,
199 key: &str,
200) -> Result<T> {
201 let resp = tokio::time::timeout(
202 HTTP_TIMEOUT,
203 client
204 .get(url)
205 .header("Authorization", format!("Bearer {key}"))
206 .send(),
207 )
208 .await
209 .map_err(|_| AppError::Transport(format!("grok timeout: {url}")))??;
210
211 let status = resp.status();
212 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
213 if !status.is_success() {
214 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
215 return Err(AppError::Http {
216 status: status.as_u16(),
217 body,
218 });
219 }
220 serde_json::from_slice(&bytes).map_err(|e| AppError::Schema(format!("grok {url}: {e}")))
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use tempfile::TempDir;
227
228 fn cache_fixture() -> (TempDir, Cache) {
229 let td = TempDir::new().unwrap();
230 let cache = Cache::at(td.path().join("grok"));
231 cache.ensure_dir().unwrap();
232 (td, cache)
233 }
234
235 #[tokio::test]
236 async fn resolves_team_then_reads_balance() {
237 let mut server = mockito::Server::new_async().await;
238 server
239 .mock("GET", "/auth/management-keys/validation")
240 .with_status(200)
241 .with_body(r#"{"scopeId":"team-xyz","teamId":"team-xyz"}"#)
242 .create_async()
243 .await;
244 server
245 .mock("GET", "/v1/billing/teams/team-xyz/prepaid/balance")
246 .match_header("authorization", "Bearer xai-mgmt")
247 .with_status(200)
248 .with_body(r#"{"changes":[],"total":{"val":"-2500"}}"#)
249 .create_async()
250 .await;
251
252 let (_td, cache) = cache_fixture();
253 let client = reqwest::Client::new();
254 let endpoints = Endpoints { base: server.url() };
255 let out = fetch_snapshot(
256 &client,
257 "xai-mgmt",
258 &cache,
259 &endpoints,
260 Duration::from_secs(0),
261 None,
262 )
263 .await
264 .unwrap();
265 assert!((out.snapshot.balance - 25.0).abs() < 1e-9);
266 assert!(!out.stale);
267 }
268
269 #[tokio::test]
270 async fn configured_team_skips_validation() {
271 let mut server = mockito::Server::new_async().await;
272 server
273 .mock("GET", "/v1/billing/teams/my-team/prepaid/balance")
274 .with_status(200)
275 .with_body(r#"{"total":{"val":"-500"}}"#)
276 .create_async()
277 .await;
278
279 let (_td, cache) = cache_fixture();
280 let client = reqwest::Client::new();
281 let endpoints = Endpoints { base: server.url() };
282 let out = fetch_snapshot(
283 &client,
284 "xai-mgmt",
285 &cache,
286 &endpoints,
287 Duration::from_secs(0),
288 Some("my-team"),
289 )
290 .await
291 .unwrap();
292 assert!((out.snapshot.balance - 5.0).abs() < 1e-9);
293 }
294
295 #[tokio::test]
296 async fn http_404_falls_back_to_cache_when_present() {
297 let mut server = mockito::Server::new_async().await;
298 server
299 .mock("GET", "/v1/billing/teams/t/prepaid/balance")
300 .with_status(404)
301 .with_body(r#"{"error":"no team"}"#)
302 .create_async()
303 .await;
304
305 let (_td, cache) = cache_fixture();
306 cache
307 .write_payload(
308 serde_json::json!({
309 "target": "team:t",
310 "team": "t",
311 "snapshot": { "balance": 12.0 },
312 })
313 .to_string()
314 .as_bytes(),
315 )
316 .unwrap();
317
318 let client = reqwest::Client::new();
319 let endpoints = Endpoints { base: server.url() };
320 let out = fetch_snapshot(
321 &client,
322 "k",
323 &cache,
324 &endpoints,
325 Duration::from_secs(0),
326 Some("t"),
327 )
328 .await
329 .unwrap();
330 assert!(out.stale);
331 assert_eq!(out.snapshot.balance, 12.0);
332 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(404));
333 }
334
335 #[tokio::test]
336 async fn switching_team_refetches_instead_of_reusing_the_cache() {
337 let mut server = mockito::Server::new_async().await;
338 server
339 .mock("GET", "/v1/billing/teams/team-b/prepaid/balance")
340 .with_status(200)
341 .with_body(r#"{"total":{"val":"-300"}}"#)
342 .create_async()
343 .await;
344
345 let (_td, cache) = cache_fixture();
346 cache
348 .write_payload(
349 serde_json::json!({
350 "target": "team:team-a",
351 "team": "team-a",
352 "snapshot": { "balance": 999.0 },
353 })
354 .to_string()
355 .as_bytes(),
356 )
357 .unwrap();
358
359 let client = reqwest::Client::new();
360 let endpoints = Endpoints { base: server.url() };
361 let out = fetch_snapshot(
362 &client,
363 "k",
364 &cache,
365 &endpoints,
366 Duration::from_secs(3600),
367 Some("team-b"),
368 )
369 .await
370 .unwrap();
371 assert!((out.snapshot.balance - 3.0).abs() < 1e-9);
372 assert!(!out.stale);
373 }
374
375 #[tokio::test]
376 async fn fresh_cache_makes_no_network_call() {
377 let server = mockito::Server::new_async().await;
379 let (_td, cache) = cache_fixture();
380 cache
381 .write_payload(
382 serde_json::json!({
383 "target": "team:t",
384 "team": "t",
385 "snapshot": { "balance": 4.5 },
386 })
387 .to_string()
388 .as_bytes(),
389 )
390 .unwrap();
391
392 let client = reqwest::Client::new();
393 let endpoints = Endpoints { base: server.url() };
394 let out = fetch_snapshot(
395 &client,
396 "k",
397 &cache,
398 &endpoints,
399 Duration::from_secs(3600),
400 Some("t"),
401 )
402 .await
403 .unwrap();
404 assert_eq!(out.snapshot.balance, 4.5);
405 }
406
407 #[tokio::test]
408 async fn organization_scoped_key_reports_an_actionable_error() {
409 let mut server = mockito::Server::new_async().await;
410 server
411 .mock("GET", "/auth/management-keys/validation")
412 .with_status(200)
413 .with_body(r#"{"scope":"SCOPE_ORGANIZATION","scopeId":"org-77"}"#)
414 .create_async()
415 .await;
416
417 let (_td, cache) = cache_fixture();
418 let client = reqwest::Client::new();
419 let endpoints = Endpoints { base: server.url() };
420 let out = fetch_snapshot(
422 &client,
423 "k",
424 &cache,
425 &endpoints,
426 Duration::from_secs(0),
427 None,
428 )
429 .await;
430 let err = out.unwrap_err().to_string();
431 assert!(err.contains("team_id"), "unhelpful error: {err}");
432 assert!(!err.contains("org-77"), "must not adopt the org id: {err}");
433 }
434
435 #[tokio::test]
436 async fn malformed_200_balance_does_not_become_zero() {
437 let mut server = mockito::Server::new_async().await;
438 server
439 .mock("GET", "/v1/billing/teams/t/prepaid/balance")
440 .with_status(200)
441 .with_body(r#"{"error":"forbidden"}"#)
442 .create_async()
443 .await;
444
445 let (_td, cache) = cache_fixture();
446 let client = reqwest::Client::new();
447 let endpoints = Endpoints { base: server.url() };
448 let out = fetch_snapshot(
449 &client,
450 "k",
451 &cache,
452 &endpoints,
453 Duration::from_secs(0),
454 Some("t"),
455 )
456 .await;
457 assert!(out.is_err(), "expected a schema error, got {out:?}");
458 }
459}