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