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