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            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
114/// Identity of the inputs that determine *whose* balance gets fetched. With an
115/// explicit `team_id` that is the team; otherwise the team is derived from the
116/// management key, so the key itself is the identity — fingerprinted, never
117/// stored in clear. (The fingerprint is only a change detector: if its value
118/// ever shifts, the effect is one extra refetch.)
119fn 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
131/// An explicit `team_id` wins; otherwise introspect the management key. The
132/// scope decides whether `scopeId` is a team at all — see [`Validation`].
133async 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
155/// On failure we show the last good figure with the error alongside it. With
156/// nothing usable cached there is nothing to show, so the **original** error is
157/// returned — a first run against an organization-scoped key must reach the
158/// user with its guidance intact, not as a generic "no usable cache".
159fn 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    // A cache we cannot attribute to this target is no better than no cache.
169    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    // A balance cached for another team (or fetched with a different key) is
189    // not this team's money. Payloads written before the target was recorded
190    // are equally unattributable.
191    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        // Fresh, but it is team-a's money.
373        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        // No mocks registered: any request would fail the test.
404        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        // No cache to fall back on, so the guidance must surface to the user.
447        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}