Skip to main content

ai_usagebar/supergrok/
fetch.rs

1//! SuperGrok fetch/cache orchestration around Grok Build's billing ACP method.
2
3use std::future::Future;
4use std::path::Path;
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::cache::{Cache, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::{SuperGrokPeriod, SuperGrokSnapshot};
13
14use super::scope::ScopePaths;
15use super::{acp, direct, resets, scope, types};
16
17const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
18const CACHE_SCHEMA: u8 = 4;
19
20/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
21/// specialised to its snapshot.
22pub type FetchOutcome = crate::outcome::Outcome<SuperGrokSnapshot>;
23
24pub async fn fetch_snapshot(
25    grok_binary: &Path,
26    scope_paths: &ScopePaths,
27    cache: &Cache,
28    cache_ttl: Duration,
29) -> Result<FetchOutcome> {
30    fetch_snapshot_with(
31        cache,
32        cache_ttl,
33        Utc::now(),
34        || scope::fingerprint(scope_paths),
35        || fetch_billing_any(grok_binary, scope_paths),
36    )
37    .await
38}
39
40/// Direct HTTPS billing first, the ACP process as fallback.
41///
42/// Grok Build CLI 1.0.13 removed the `x.ai/billing` ACP extension, so the
43/// documented proxy endpoint is now the primary transport; the ACP path keeps
44/// serving builds where that endpoint is unavailable. When both fail, the
45/// direct error is reported — it reflects the actual login state.
46async fn fetch_billing_any(
47    grok_binary: &Path,
48    scope_paths: &ScopePaths,
49) -> Result<types::BillingResponse> {
50    let mut response = match direct::fetch_billing(&scope_paths.auth).await {
51        Ok(response) => Ok(response),
52        Err(direct_error) => match acp::fetch_billing(grok_binary).await {
53            Ok(response) => Ok(response),
54            Err(_) => Err(direct_error),
55        },
56    }?;
57    response.reset_credits = resets::fetch(&scope_paths.auth).await.unwrap_or_default();
58    if response.subscription_tier_display.is_none()
59        && let Ok(Some(display)) = direct::fetch_plan_display(&scope_paths.auth).await
60    {
61        response.subscription_tier_display = Some(display);
62    }
63    Ok(response)
64}
65
66async fn fetch_snapshot_with<S, F, Fut>(
67    cache: &Cache,
68    cache_ttl: Duration,
69    now: DateTime<Utc>,
70    read_scope: S,
71    fetch_billing: F,
72) -> Result<FetchOutcome>
73where
74    S: Fn() -> Option<String>,
75    F: FnOnce() -> Fut,
76    Fut: Future<Output = Result<types::BillingResponse>>,
77{
78    cache.ensure_dir()?;
79    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
80    let scope_before = read_scope();
81
82    if let Some(account_scope) = scope_before.as_deref()
83        && let Some(bytes) = cache.fresh_payload(cache_ttl)?
84        && let Ok(outcome) = reuse_cache(&bytes, cache, false, account_scope)
85        && !period_has_ended(&outcome.snapshot, now)
86    {
87        return Ok(outcome);
88    }
89
90    match fetch_billing().await {
91        Ok(response) => {
92            let account_scope = scope_before.as_deref().unwrap_or("uncached");
93            let mut snapshot = match types::to_snapshot(response, account_scope) {
94                Ok(snapshot) => snapshot,
95                Err(error) => return fallback(cache, scope_before.as_deref(), now, error),
96            };
97            let scope_after = read_scope();
98
99            // A concurrent login, config change, or token rotation means the
100            // account identity was not stable across the request. Return the
101            // live result, but do not bind it to either cache scope.
102            if scope_before.is_some() && scope_before == scope_after {
103                let account_scope = scope_before.as_deref().expect("checked Some");
104                snapshot.account = account_scope.to_string();
105                let bytes =
106                    serde_json::to_vec(&CachedEnvelope::from_snapshot(account_scope, &snapshot))?;
107                cache.write_payload(&bytes)?;
108            } else {
109                snapshot.account = "uncached".into();
110            }
111
112            Ok(crate::outcome::Outcome::fresh(snapshot))
113        }
114        Err(error) => fallback(cache, scope_before.as_deref(), now, error),
115    }
116}
117
118fn period_has_ended(snapshot: &SuperGrokSnapshot, now: DateTime<Utc>) -> bool {
119    snapshot.reset_at.is_some_and(|reset| reset <= now)
120}
121
122#[derive(Debug, Serialize, Deserialize)]
123struct CachedEnvelope {
124    schema: u8,
125    scope: String,
126    snapshot: CachedSnapshot,
127}
128
129#[derive(Debug, Serialize, Deserialize)]
130struct CachedSnapshot {
131    plan: String,
132    percent: i32,
133    period: String,
134    reset_at: Option<DateTime<Utc>>,
135    prepaid_balance: Option<f64>,
136    #[serde(default)]
137    reset_credits: crate::usage::ResetCredits,
138    #[serde(default)]
139    products: Vec<crate::usage::SuperGrokProduct>,
140}
141
142impl CachedEnvelope {
143    fn from_snapshot(scope: &str, snapshot: &SuperGrokSnapshot) -> Self {
144        let period = match snapshot.period {
145            SuperGrokPeriod::Weekly => "weekly",
146            SuperGrokPeriod::Monthly => "monthly",
147            SuperGrokPeriod::Unknown => "unknown",
148        };
149        Self {
150            schema: CACHE_SCHEMA,
151            scope: scope.to_string(),
152            snapshot: CachedSnapshot {
153                plan: snapshot.plan.clone(),
154                percent: snapshot.weekly_pct,
155                period: period.to_string(),
156                reset_at: snapshot.reset_at,
157                prepaid_balance: snapshot.prepaid_balance,
158                reset_credits: snapshot.reset_credits.clone(),
159                products: snapshot.products.clone(),
160            },
161        }
162    }
163}
164
165fn parse_cache(bytes: &[u8], account_scope: &str) -> Result<SuperGrokSnapshot> {
166    let cached: CachedEnvelope = serde_json::from_slice(bytes)?;
167    if cached.schema != CACHE_SCHEMA {
168        return Err(AppError::Schema(
169            "SuperGrok cache schema is obsolete; refetching".into(),
170        ));
171    }
172    if cached.scope != account_scope {
173        return Err(AppError::Schema(
174            "SuperGrok cache belongs to a different login; refetching".into(),
175        ));
176    }
177    if !(0..=100).contains(&cached.snapshot.percent) {
178        return Err(AppError::Schema(
179            "SuperGrok cached percentage is out of range".into(),
180        ));
181    }
182    if cached.snapshot.plan.chars().count() > 128
183        || cached.snapshot.plan.chars().any(char::is_control)
184    {
185        return Err(AppError::Schema(
186            "SuperGrok cached plan label is invalid".into(),
187        ));
188    }
189    let period = match cached.snapshot.period.as_str() {
190        "weekly" => SuperGrokPeriod::Weekly,
191        "monthly" => SuperGrokPeriod::Monthly,
192        "unknown" => SuperGrokPeriod::Unknown,
193        _ => {
194            return Err(AppError::Schema(
195                "SuperGrok cached period kind is invalid".into(),
196            ));
197        }
198    };
199    if cached
200        .snapshot
201        .prepaid_balance
202        .is_some_and(|balance| !balance.is_finite() || balance < 0.0)
203    {
204        return Err(AppError::Schema(
205            "SuperGrok cached prepaid balance is invalid".into(),
206        ));
207    }
208    // The count comes from the tokens themselves, so more expiries than
209    // credits means the two disagree about what was in the response.
210    if cached.snapshot.reset_credits.credits.len()
211        > cached.snapshot.reset_credits.available as usize
212    {
213        return Err(AppError::Schema(
214            "SuperGrok cached reset credits are inconsistent".into(),
215        ));
216    }
217    for product in &cached.snapshot.products {
218        if !(0..=100).contains(&product.percent)
219            || product.label.is_empty()
220            || product.label.chars().count() > 128
221            || product.label.chars().any(char::is_control)
222        {
223            return Err(AppError::Schema(
224                "SuperGrok cached product row is invalid".into(),
225            ));
226        }
227    }
228
229    Ok(SuperGrokSnapshot {
230        plan: cached.snapshot.plan,
231        account: account_scope.to_string(),
232        weekly_pct: cached.snapshot.percent,
233        period,
234        reset_at: cached.snapshot.reset_at,
235        prepaid_balance: cached.snapshot.prepaid_balance,
236        reset_credits: cached.snapshot.reset_credits,
237        products: cached.snapshot.products,
238    })
239}
240
241fn reuse_cache(
242    bytes: &[u8],
243    cache: &Cache,
244    stale: bool,
245    account_scope: &str,
246) -> Result<FetchOutcome> {
247    Ok(crate::outcome::Outcome::cached(
248        parse_cache(bytes, account_scope)?,
249        cache,
250        stale,
251    ))
252}
253
254/// SuperGrok adds one rule to the shared policy: a cached snapshot whose
255/// billing period has already ended is not a stale figure, it is a wrong one,
256/// so it is rejected the same way an unparseable payload is — by failing the
257/// parse, which makes `outcome::fallback` return the original error.
258fn fallback(
259    cache: &Cache,
260    account_scope: Option<&str>,
261    now: DateTime<Utc>,
262    original: AppError,
263) -> Result<FetchOutcome> {
264    let Some(account_scope) = account_scope else {
265        return Err(original);
266    };
267    let error = error_to_pair(&original);
268    let outcome = crate::outcome::fallback(cache, Some(error.clone()), original, |bytes| {
269        let snapshot = parse_cache(bytes, account_scope)?;
270        if period_has_ended(&snapshot, now) {
271            return Err(AppError::Schema(
272                "cached SuperGrok period has already ended".into(),
273            ));
274        }
275        Ok(snapshot)
276    })?;
277    // Only once a figure is actually going on screen is the failure worth
278    // recording beside it.
279    cache.mark_stale();
280    cache.write_last_error(error.0, &error.1);
281    Ok(outcome)
282}
283
284fn error_to_pair(error: &AppError) -> (u16, String) {
285    match error {
286        AppError::Http { status, body } => (*status, body.clone()),
287        other => (0, other.to_string()),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use chrono::TimeZone;
295    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
296    use tempfile::TempDir;
297
298    fn now() -> DateTime<Utc> {
299        Utc.with_ymd_and_hms(2026, 8, 7, 0, 0, 0).unwrap()
300    }
301
302    fn fixture() -> (TempDir, Cache) {
303        let td = TempDir::new().unwrap();
304        let cache = Cache::at(td.path().join("supergrok"));
305        (td, cache)
306    }
307
308    fn weekly_response(percent: f64) -> types::BillingResponse {
309        serde_json::from_value(serde_json::json!({
310            "config": {
311                "creditUsagePercent": percent,
312                "currentPeriod": {
313                    "type": "USAGE_PERIOD_TYPE_WEEKLY",
314                    "end": "2026-08-14T00:00:00Z"
315                }
316            },
317            "subscription_tier": "SuperGrok"
318        }))
319        .unwrap()
320    }
321
322    /// The reset inventory is fetched beside the billing response, so it has
323    /// to survive the cache with it: a hit that dropped it would show the
324    /// resets for one refresh and then quietly stop mentioning them.
325    #[tokio::test]
326    async fn banked_resets_survive_the_cache_round_trip() {
327        let (_td, cache) = fixture();
328        let expiry = Utc.with_ymd_and_hms(2026, 8, 12, 0, 0, 0).unwrap();
329        let mut response = weekly_response(10.0);
330        response.reset_credits = crate::usage::ResetCredits {
331            available: 2,
332            credits: vec![crate::usage::ResetCredit {
333                title: None,
334                expires_at: Some(expiry),
335            }],
336        };
337        let fresh = fetch_snapshot_with(
338            &cache,
339            Duration::from_secs(3600),
340            now(),
341            || Some("scope-a".into()),
342            || async { Ok(response) },
343        )
344        .await
345        .unwrap();
346        assert_eq!(fresh.snapshot.reset_credits.available, 2);
347
348        let cached = fetch_snapshot_with(
349            &cache,
350            Duration::from_secs(3600),
351            now(),
352            || Some("scope-a".into()),
353            || async { panic!("a fresh cache must not refetch") },
354        )
355        .await
356        .unwrap();
357        assert_eq!(cached.snapshot.reset_credits.available, 2);
358        assert_eq!(cached.snapshot.reset_credits.next_expiry(), Some(expiry));
359    }
360
361    #[test]
362    fn a_cache_claiming_more_expiries_than_credits_is_rejected() {
363        let cached = serde_json::json!({
364            "schema": CACHE_SCHEMA,
365            "scope": "scope-a",
366            "snapshot": {
367                "plan": "SuperGrok",
368                "percent": 5,
369                "period": "weekly",
370                "reset_at": null,
371                "prepaid_balance": null,
372                "reset_credits": {
373                    "available": 1,
374                    "credits": [
375                        {"expires_at": "2026-08-12T00:00:00Z"},
376                        {"expires_at": "2026-08-19T00:00:00Z"}
377                    ]
378                }
379            }
380        });
381        assert!(parse_cache(cached.to_string().as_bytes(), "scope-a").is_err());
382    }
383
384    /// A cache written before this vendor knew about banked resets is not
385    /// wrong, it is silent — it must still load, reporting none.
386    #[test]
387    fn a_cache_without_reset_credits_still_loads() {
388        let cached = serde_json::json!({
389            "schema": CACHE_SCHEMA,
390            "scope": "scope-a",
391            "snapshot": {
392                "plan": "SuperGrok",
393                "percent": 5,
394                "period": "weekly",
395                "reset_at": null,
396                "prepaid_balance": null
397            }
398        });
399        let snapshot = parse_cache(cached.to_string().as_bytes(), "scope-a").unwrap();
400        assert!(snapshot.reset_credits.is_empty());
401    }
402
403    #[tokio::test]
404    async fn live_fetch_writes_only_an_opaque_scope_to_cache() {
405        let (_td, cache) = fixture();
406        let outcome = fetch_snapshot_with(
407            &cache,
408            Duration::ZERO,
409            now(),
410            || Some("opaque-digest".into()),
411            || async { Ok(weekly_response(12.4)) },
412        )
413        .await
414        .unwrap();
415        assert_eq!(outcome.snapshot.weekly_pct, 12);
416        assert_eq!(outcome.snapshot.period, SuperGrokPeriod::Weekly);
417
418        let cache_text = std::fs::read_to_string(cache.payload_path()).unwrap();
419        assert!(cache_text.contains("opaque-digest"));
420        assert!(!cache_text.contains("access_token"));
421        assert!(!cache_text.contains("user_id"));
422    }
423
424    #[tokio::test]
425    async fn fresh_cache_skips_the_acp_process() {
426        let (_td, cache) = fixture();
427        cache.ensure_dir().unwrap();
428        let snapshot = types::to_snapshot(weekly_response(7.0), "scope-a").unwrap();
429        cache
430            .write_payload(
431                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
432            )
433            .unwrap();
434        let called = AtomicBool::new(false);
435
436        let outcome = fetch_snapshot_with(
437            &cache,
438            Duration::from_secs(3600),
439            now(),
440            || Some("scope-a".into()),
441            || async {
442                called.store(true, Ordering::SeqCst);
443                Ok(weekly_response(99.0))
444            },
445        )
446        .await
447        .unwrap();
448        assert_eq!(outcome.snapshot.weekly_pct, 7);
449        assert!(!called.load(Ordering::SeqCst));
450    }
451
452    #[tokio::test]
453    async fn a_scope_change_during_fetch_returns_live_but_does_not_cache() {
454        let (_td, cache) = fixture();
455        let calls = AtomicUsize::new(0);
456        let outcome = fetch_snapshot_with(
457            &cache,
458            Duration::ZERO,
459            now(),
460            || {
461                let call = calls.fetch_add(1, Ordering::SeqCst);
462                Some(if call == 0 { "before" } else { "after" }.into())
463            },
464            || async { Ok(weekly_response(20.0)) },
465        )
466        .await
467        .unwrap();
468        assert_eq!(outcome.snapshot.account, "uncached");
469        assert!(!cache.payload_path().exists());
470    }
471
472    #[tokio::test]
473    async fn failure_falls_back_only_for_the_same_scope_and_live_period() {
474        let (_td, cache) = fixture();
475        cache.ensure_dir().unwrap();
476        let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
477        cache
478            .write_payload(
479                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
480            )
481            .unwrap();
482
483        let fallback = fetch_snapshot_with(
484            &cache,
485            Duration::ZERO,
486            now(),
487            || Some("scope-a".into()),
488            || async { Err(AppError::Transport("offline".into())) },
489        )
490        .await
491        .unwrap();
492        assert!(fallback.stale);
493        assert_eq!(fallback.snapshot.weekly_pct, 33);
494
495        let other_scope = fetch_snapshot_with(
496            &cache,
497            Duration::ZERO,
498            now(),
499            || Some("scope-b".into()),
500            || async { Err(AppError::Transport("offline".into())) },
501        )
502        .await;
503        assert!(other_scope.is_err());
504    }
505
506    #[tokio::test]
507    async fn malformed_live_billing_preserves_the_last_good_same_scope_cache() {
508        let (_td, cache) = fixture();
509        cache.ensure_dir().unwrap();
510        let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
511        cache
512            .write_payload(
513                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
514            )
515            .unwrap();
516
517        let fallback = fetch_snapshot_with(
518            &cache,
519            Duration::ZERO,
520            now(),
521            || Some("scope-a".into()),
522            || async { Ok(weekly_response(999.0)) },
523        )
524        .await
525        .unwrap();
526        assert!(fallback.stale);
527        assert_eq!(fallback.snapshot.weekly_pct, 33);
528        assert!(
529            fallback
530                .last_error
531                .as_ref()
532                .is_some_and(|(_, message)| message.contains("outside the supported range"))
533        );
534    }
535
536    #[tokio::test]
537    async fn missing_scope_disables_cache_reuse() {
538        let (_td, cache) = fixture();
539        cache.ensure_dir().unwrap();
540        let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
541        cache
542            .write_payload(
543                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
544            )
545            .unwrap();
546        let outcome = fetch_snapshot_with(
547            &cache,
548            Duration::from_secs(3600),
549            now(),
550            || None,
551            || async { Err(AppError::Transport("offline".into())) },
552        )
553        .await;
554        assert!(outcome.is_err());
555    }
556
557    #[tokio::test]
558    async fn an_ended_period_is_never_resurrected_on_failure() {
559        let (_td, cache) = fixture();
560        cache.ensure_dir().unwrap();
561        let snapshot = types::to_snapshot(weekly_response(88.0), "scope-a").unwrap();
562        cache
563            .write_payload(
564                &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
565            )
566            .unwrap();
567        let after_reset = Utc.with_ymd_and_hms(2026, 8, 15, 0, 0, 0).unwrap();
568        let outcome = fetch_snapshot_with(
569            &cache,
570            Duration::from_secs(3600),
571            after_reset,
572            || Some("scope-a".into()),
573            || async { Err(AppError::Transport("offline".into())) },
574        )
575        .await;
576        assert!(outcome.is_err());
577    }
578
579    #[test]
580    fn cached_percentages_and_periods_are_strictly_validated() {
581        let base = serde_json::json!({
582            "schema": CACHE_SCHEMA,
583            "scope": "scope-a",
584            "snapshot": {
585                "plan": "SuperGrok",
586                "percent": 5,
587                "period": "weekly",
588                "reset_at": null,
589                "prepaid_balance": null
590            }
591        });
592        for (field, value) in [
593            ("percent", serde_json::json!(101)),
594            ("period", serde_json::json!("yearly")),
595            ("prepaid_balance", serde_json::json!(-1.0)),
596        ] {
597            let mut malformed = base.clone();
598            malformed["snapshot"][field] = value;
599            assert!(
600                parse_cache(malformed.to_string().as_bytes(), "scope-a").is_err(),
601                "field: {field}"
602            );
603        }
604
605        let mut obsolete = base;
606        obsolete["schema"] = serde_json::json!(1);
607        obsolete["account"] = serde_json::json!("person@example.test");
608        assert!(parse_cache(obsolete.to_string().as_bytes(), "scope-a").is_err());
609    }
610}