Skip to main content

ai_usagebar/grok/
fetch.rs

1//! xAI (Grok) fetch — reads the prepaid balance from the Management API. Two
2//! steps when no team id is configured: resolve the team from the management
3//! key (`/auth/management-keys/validation`), then read
4//! `/v1/billing/teams/{team}/prepaid/balance`.
5
6use 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
41/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
42/// specialised to its snapshot.
43pub type FetchOutcome = crate::outcome::Outcome<GrokSnapshot>;
44
45/// `management_key` is the xAI Management API key (distinct from the inference
46/// key). `team_id`, when set, skips the validation round-trip.
47pub 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    // Identity is derived from the *inputs*, not the resolved team, so a fresh
59    // cache still costs zero network calls.
60    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        // Recorded under its own status, like the balance call below: the
72        // cache redacts a 401/403 body and arms the backoff on a 429 by code.
73        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
110/// Identity of the inputs that determine *whose* balance gets fetched. With an
111/// explicit `team_id` that is the team; otherwise the team is derived from the
112/// management key, so the key itself is the identity — fingerprinted, never
113/// stored in clear. (The fingerprint is only a change detector: if its value
114/// ever shifts, the effect is one extra refetch.)
115fn 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
127/// An explicit `team_id` wins; otherwise introspect the management key. The
128/// scope decides whether `scopeId` is a team at all — see [`Validation`].
129async 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
148/// A cache we cannot attribute to this target is no better than no cache, so
149/// `outcome::fallback` returns `original` for both. That matters most on a
150/// first run against an organization-scoped key, whose guidance must reach the
151/// user intact.
152fn 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    // A balance cached for another team (or fetched with a different key) is
171    // not this team's money. Payloads written before the target was recorded
172    // are equally unattributable.
173    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    /// Without a `team_id` the key is validated before the balance call, and
344    /// a refusal there is recorded under its HTTP status like one from the
345    /// balance call: the cache keeps the neutral auth message, not the body.
346    #[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    /// A 429 from the validation endpoint arms the shared backoff, so the
390    /// next polls wait instead of asking again every minute.
391    #[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        // Fresh, but it is team-a's money.
430        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        // No mocks registered: any request would fail the test.
461        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        // No cache to fall back on, so the guidance must surface to the user.
504        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}