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