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, 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
49/// `management_key` is the xAI Management API key (distinct from the inference
50/// key). `team_id`, when set, skips the validation round-trip.
51pub 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    // Identity is derived from the *inputs*, not the resolved team, so a fresh
63    // cache still costs zero network calls.
64    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
111/// Identity of the inputs that determine *whose* balance gets fetched. With an
112/// explicit `team_id` that is the team; otherwise the team is derived from the
113/// management key, so the key itself is the identity — fingerprinted, never
114/// stored in clear. (The fingerprint is only a change detector: if its value
115/// ever shifts, the effect is one extra refetch.)
116fn 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
128/// An explicit `team_id` wins; otherwise introspect the management key. The
129/// scope decides whether `scopeId` is a team at all — see [`Validation`].
130async 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
152/// On failure we show the last good figure with the error alongside it. With
153/// nothing usable cached there is nothing to show, so the **original** error is
154/// returned — a first run against an organization-scoped key must reach the
155/// user with its guidance intact, not as a generic "no usable cache".
156fn 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    // A cache we cannot attribute to this target is no better than no cache.
166    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    // A balance cached for another team (or fetched with a different key) is
186    // not this team's money. Payloads written before the target was recorded
187    // are equally unattributable.
188    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        // Fresh, but it is team-a's money.
370        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        // No mocks registered: any request would fail the test.
401        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        // No cache to fall back on, so the guidance must surface to the user.
444        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}