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