use crate::auth::AuthResolver;
use crate::providers::{ProviderKind, ProviderQuota, ProviderResult, ProviderStatus, QuotaWindow};
use crate::Result;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::OnceLock;
const CLI_CHAT_BASE: &str = "https://cli-chat-proxy.grok.com";
const MGMT_BASE: &str = "https://management-api.x.ai";
const CONSOLE_URL: &str = "https://console.x.ai/team/default/billing";
const GROK_USAGE_URL: &str = "https://grok.com?_s=usage";
const GROK_CLIENT_VERSION_FALLBACK: &str = "0.0.0";
pub struct GrokProvider {
http: Client,
auth: Box<dyn AuthResolver>,
}
impl GrokProvider {
pub fn new(auth: Box<dyn AuthResolver>) -> Self {
Self {
http: Client::new(),
auth,
}
}
async fn fetch_quota(&self, key: &str) -> Result<ProviderResult> {
let cli_auth_err = match self.fetch_cli_billing(key).await {
Ok(r) => return Ok(r),
Err(e @ crate::Error::Auth(_)) => Some(e),
Err(crate::Error::Provider(_)) => None,
Err(e) => {
if let Ok(r) = self.fetch_mgmt_billing(key).await {
return Ok(r);
}
return Err(e);
}
};
match self.fetch_mgmt_billing(key).await {
Ok(r) => Ok(r),
Err(mgmt_err) => Err(cli_auth_err.unwrap_or(mgmt_err)),
}
}
async fn fetch_cli_billing(&self, key: &str) -> Result<ProviderResult> {
let auth_hdr = format!("Bearer {}", key);
let client_version = resolve_grok_client_version();
let (default_resp, credits_resp) = tokio::join!(
self.http
.get(format!("{}/v1/billing", CLI_CHAT_BASE))
.header("Authorization", &auth_hdr)
.header("Accept", "application/json")
.header("x-grok-client-version", client_version.as_str())
.header("x-grok-client-surface", "grok-build")
.send(),
self.http
.get(format!("{}/v1/billing?format=credits", CLI_CHAT_BASE))
.header("Authorization", &auth_hdr)
.header("Accept", "application/json")
.header("x-grok-client-version", client_version.as_str())
.header("x-grok-client-surface", "grok-build")
.send(),
);
let default_resp = default_resp?;
let default_status = default_resp.status();
let default_body: serde_json::Value = default_resp.json().await?;
if default_status.as_u16() == 401 || default_status.as_u16() == 403 {
let msg = extract_error_message(&default_body)
.unwrap_or_else(|| format!("HTTP {}", default_status.as_u16()));
return Err(crate::Error::Auth(msg));
}
if !default_status.is_success() {
let msg = extract_error_message(&default_body)
.unwrap_or_else(|| format!("HTTP {}", default_status.as_u16()));
return Err(crate::Error::Provider(msg));
}
if default_body.get("error").is_some() && default_body.get("config").is_none() {
let msg =
extract_error_message(&default_body).unwrap_or_else(|| "billing error".into());
return Err(crate::Error::Provider(msg));
}
let credits_body = match credits_resp {
Ok(resp) if resp.status().is_success() => resp.json().await.ok(),
_ => None,
};
let quota = parse_cli_billing(&default_body, credits_body.as_ref())?;
Ok(ProviderResult {
kind: ProviderKind::Grok,
status: ProviderStatus::Available { quota },
fetched_at: Utc::now(),
raw_response: Some(serde_json::json!({
"default": default_body,
"format_credits": credits_body,
})),
auth_source: None,
cached_at: None,
instance_key: None,
profile_id: None,
card_title: None,
})
}
async fn fetch_mgmt_billing(&self, key: &str) -> Result<ProviderResult> {
let validation = self.validate_mgmt_key(key).await?;
let team_id = validation
.team_id
.as_ref()
.filter(|s| !s.is_empty())
.cloned()
.or_else(|| {
validation
.scope_id
.as_ref()
.filter(|s| !s.is_empty())
.cloned()
})
.ok_or_else(|| {
crate::Error::Provider("management key validation returned no team id".into())
})?;
let (balance_resp, preview_resp) = tokio::join!(
self.http
.get(format!(
"{}/v1/billing/teams/{}/prepaid/balance",
MGMT_BASE, team_id
))
.header("Authorization", format!("Bearer {}", key))
.send(),
self.http
.get(format!(
"{}/v1/billing/teams/{}/postpaid/invoice/preview",
MGMT_BASE, team_id
))
.header("Authorization", format!("Bearer {}", key))
.send(),
);
let balance_resp = balance_resp?;
let balance_status = balance_resp.status();
let balance_body: serde_json::Value = balance_resp.json().await?;
if !balance_status.is_success() {
let msg = extract_error_message(&balance_body)
.unwrap_or_else(|| format!("HTTP {}", balance_status.as_u16()));
return Ok(ProviderResult {
kind: ProviderKind::Grok,
status: ProviderStatus::Unavailable {
info: crate::providers::UnavailableInfo {
reason: msg,
console_url: Some(CONSOLE_URL.into()),
},
},
fetched_at: Utc::now(),
raw_response: Some(serde_json::json!({
"validation": validation_raw(&validation),
"balance": balance_body,
})),
auth_source: None,
cached_at: None,
instance_key: None,
profile_id: None,
card_title: None,
});
}
let preview_body = match preview_resp {
Ok(resp) if resp.status().is_success() => resp.json().await.ok(),
_ => None,
};
let quota = parse_mgmt_billing(&balance_body, preview_body.as_ref())?;
Ok(ProviderResult {
kind: ProviderKind::Grok,
status: ProviderStatus::Available { quota },
fetched_at: Utc::now(),
raw_response: Some(serde_json::json!({
"validation": validation_raw(&validation),
"balance": balance_body,
"invoice_preview": preview_body,
})),
auth_source: None,
cached_at: None,
instance_key: None,
profile_id: None,
card_title: None,
})
}
async fn validate_mgmt_key(&self, key: &str) -> Result<ValidationInfo> {
let resp = self
.http
.get(format!("{}/auth/management-keys/validation", MGMT_BASE))
.header("Authorization", format!("Bearer {}", key))
.send()
.await?;
let status = resp.status();
let body: serde_json::Value = resp.json().await?;
if !status.is_success() {
let msg = extract_error_message(&body)
.unwrap_or_else(|| format!("management key validation failed (HTTP {})", status));
return Err(crate::Error::Auth(msg));
}
let team_id = body
.get("teamId")
.or_else(|| body.get("team_id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let scope_id = body
.get("scopeId")
.or_else(|| body.get("scope_id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let name = body
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(ValidationInfo {
team_id,
scope_id,
name,
})
}
}
struct ValidationInfo {
team_id: Option<String>,
scope_id: Option<String>,
name: Option<String>,
}
fn validation_raw(v: &ValidationInfo) -> serde_json::Value {
serde_json::json!({
"teamId": v.team_id,
"scopeId": v.scope_id,
"name": v.name,
})
}
fn extract_error_message(body: &serde_json::Value) -> Option<String> {
body.pointer("/error/message")
.or_else(|| body.get("message"))
.or_else(|| body.get("error").filter(|e| e.is_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn resolve_grok_client_version() -> String {
static CACHED: OnceLock<String> = OnceLock::new();
CACHED
.get_or_init(|| {
read_grok_version_from_file()
.or_else(read_grok_version_from_cli)
.unwrap_or_else(|| GROK_CLIENT_VERSION_FALLBACK.to_string())
})
.clone()
}
fn grok_version_json_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(home) = std::env::var("GROK_HOME") {
if !home.is_empty() {
paths.push(PathBuf::from(home).join("version.json"));
}
}
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".grok/version.json"));
}
paths
}
pub(crate) fn parse_grok_version_json(content: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(content).ok()?;
let ver = v.get("version")?.as_str()?.trim();
if ver.is_empty() || ver == "null" {
return None;
}
if !ver
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
{
return None;
}
Some(ver.to_string())
}
fn read_grok_version_from_file() -> Option<String> {
for path in grok_version_json_paths() {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
if let Some(v) = parse_grok_version_json(&content) {
return Some(v);
}
}
None
}
pub(crate) fn parse_grok_version_cli_output(stdout: &str) -> Option<String> {
for token in stdout.split_whitespace() {
let t = token.trim().trim_matches(|c: char| c == ',' || c == ';');
if t.chars().next().is_some_and(|c| c.is_ascii_digit())
&& t.contains('.')
&& t.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '+')
{
return Some(t.to_string());
}
}
None
}
fn read_grok_version_from_cli() -> Option<String> {
let output = std::process::Command::new("grok")
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() && output.stdout.is_empty() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_grok_version_cli_output(&stdout)
}
fn cents_str_to_units(val: &str) -> i64 {
let cents: f64 = val.parse().unwrap_or(0.0);
(cents.abs() * 100.0).round() as i64
}
fn cents_value_to_units(v: &serde_json::Value) -> Option<i64> {
if let Some(obj) = v.as_object() {
if let Some(val) = obj.get("val").and_then(|x| x.as_str()) {
return Some(cents_str_to_units(val));
}
if let Some(n) = obj.get("val").and_then(|x| x.as_f64()) {
return Some((n.abs() * 100.0).round() as i64);
}
}
if let Some(s) = v.as_str() {
return Some(cents_str_to_units(s));
}
if let Some(n) = v.as_f64() {
return Some((n.abs() * 100.0).round() as i64);
}
None
}
fn parse_rfc3339(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
fn pct_to_units(pct: f64) -> (i64, i64, i64) {
let used = pct.clamp(0.0, 100.0).round() as i64;
let limit = 100;
let remaining = (limit - used).max(0);
(used, limit, remaining)
}
fn period_type_label(period_type: &str) -> &'static str {
let upper = period_type.to_ascii_uppercase();
if upper.contains("WEEK") {
"7d"
} else if upper.contains("MONTH") {
"monthly"
} else if upper.contains("DAY") {
"daily"
} else {
"period"
}
}
fn shorten_grok_product(name: &str) -> &str {
match name {
"GrokBuild" => "build",
"Api" | "API" => "api",
"GrokChat" => "chat",
other => other,
}
}
fn append_on_demand(
windows: &mut Vec<QuotaWindow>,
config: &serde_json::Value,
reset_at: Option<DateTime<Utc>>,
period_seconds: Option<i64>,
) {
let cap = config
.get("onDemandCap")
.or_else(|| config.get("on_demand_cap"))
.and_then(cents_value_to_units)
.unwrap_or(0);
if cap <= 0 {
return;
}
let used = config
.get("onDemandUsed")
.or_else(|| config.get("on_demand_used"))
.and_then(cents_value_to_units)
.unwrap_or(0);
windows.push(QuotaWindow {
window_type: "on_demand_usd".into(),
used,
limit: cap.max(used),
remaining: (cap - used).max(0),
reset_at,
period_seconds,
});
}
pub(crate) fn parse_cli_billing(
default_body: &serde_json::Value,
credits_body: Option<&serde_json::Value>,
) -> Result<ProviderQuota> {
let default_cfg = default_body
.get("config")
.cloned()
.unwrap_or_else(|| default_body.clone());
let credits_cfg = credits_body
.and_then(|b| b.get("config"))
.cloned()
.unwrap_or_default();
let mut windows: Vec<QuotaWindow> = Vec::new();
let period = credits_cfg.get("currentPeriod").or_else(|| credits_cfg.get("current_period"));
let period_label = period
.and_then(|p| p.get("type"))
.and_then(|t| t.as_str())
.map(period_type_label)
.unwrap_or("7d");
let period_start = period
.and_then(|p| p.get("start"))
.and_then(|v| v.as_str())
.and_then(parse_rfc3339)
.or_else(|| {
credits_cfg
.get("billingPeriodStart")
.or_else(|| credits_cfg.get("billing_period_start"))
.and_then(|v| v.as_str())
.and_then(parse_rfc3339)
});
let period_end = period
.and_then(|p| p.get("end"))
.and_then(|v| v.as_str())
.and_then(parse_rfc3339)
.or_else(|| {
credits_cfg
.get("billingPeriodEnd")
.or_else(|| credits_cfg.get("billing_period_end"))
.and_then(|v| v.as_str())
.and_then(parse_rfc3339)
});
let period_seconds = match (period_start, period_end) {
(Some(s), Some(e)) => Some((e - s).num_seconds().max(0)),
_ => None,
};
if let Some(pct) = credits_cfg
.get("creditUsagePercent")
.or_else(|| credits_cfg.get("credit_usage_percent"))
.and_then(|v| v.as_f64())
{
let (used, limit, remaining) = pct_to_units(pct);
windows.push(QuotaWindow {
window_type: period_label.into(),
used,
limit,
remaining,
reset_at: period_end,
period_seconds,
});
}
if let Some(products) = credits_cfg
.get("productUsage")
.or_else(|| credits_cfg.get("product_usage"))
.and_then(|v| v.as_array())
{
for prod in products {
let Some(pct) = prod
.get("usagePercent")
.or_else(|| prod.get("usage_percent"))
.and_then(|v| v.as_f64())
else {
continue;
};
let name = prod
.get("product")
.and_then(|v| v.as_str())
.unwrap_or("product");
let (used, limit, remaining) = pct_to_units(pct);
let short = shorten_grok_product(name);
windows.push(QuotaWindow {
window_type: format!("{}/{}", period_label, short),
used,
limit,
remaining,
reset_at: period_end,
period_seconds,
});
}
}
let month_start = default_cfg
.get("billingPeriodStart")
.or_else(|| default_cfg.get("billing_period_start"))
.and_then(|v| v.as_str())
.and_then(parse_rfc3339);
let month_end = default_cfg
.get("billingPeriodEnd")
.or_else(|| default_cfg.get("billing_period_end"))
.and_then(|v| v.as_str())
.and_then(parse_rfc3339);
let month_seconds = match (month_start, month_end) {
(Some(s), Some(e)) => Some((e - s).num_seconds().max(0)),
_ => None,
};
let limit_units = default_cfg
.get("monthlyLimit")
.or_else(|| default_cfg.get("monthly_limit"))
.and_then(cents_value_to_units)
.unwrap_or(0);
let used_units = default_cfg
.get("used")
.and_then(cents_value_to_units)
.unwrap_or(0);
if limit_units > 0 || used_units > 0 {
windows.push(QuotaWindow {
window_type: "monthly_allowance".into(),
used: used_units,
limit: limit_units.max(used_units),
remaining: (limit_units - used_units).max(0),
reset_at: month_end,
period_seconds: month_seconds,
});
}
let on_demand_src = if credits_cfg.get("onDemandCap").is_some()
|| credits_cfg.get("on_demand_cap").is_some()
{
&credits_cfg
} else {
&default_cfg
};
append_on_demand(
&mut windows,
on_demand_src,
period_end.or(month_end),
period_seconds.or(month_seconds),
);
if let Some(bal) = credits_cfg
.get("prepaidBalance")
.or_else(|| credits_cfg.get("prepaid_balance"))
.and_then(cents_value_to_units)
{
if bal > 0 {
windows.push(QuotaWindow {
window_type: "balance_usd".into(),
used: 0,
limit: bal,
remaining: bal,
reset_at: None,
period_seconds: None,
});
}
}
let plan_name = if credits_cfg
.get("isUnifiedBillingUser")
.or_else(|| credits_cfg.get("is_unified_billing_user"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
"Grok Build".to_string()
} else {
"Grok".to_string()
};
let unlimited = windows.is_empty();
Ok(ProviderQuota {
plan_name,
windows,
unlimited,
banked_resets: None,
})
}
#[derive(Deserialize, Default)]
struct BalanceBody {
total: Option<serde_json::Value>,
}
pub(crate) fn parse_mgmt_billing(
balance_body: &serde_json::Value,
preview_body: Option<&serde_json::Value>,
) -> Result<ProviderQuota> {
let balance: BalanceBody =
serde_json::from_value(balance_body.clone()).unwrap_or_default();
let remaining_units = balance
.total
.as_ref()
.and_then(cents_value_to_units)
.unwrap_or(0);
let mut windows: Vec<QuotaWindow> = Vec::new();
windows.push(QuotaWindow {
window_type: "balance_usd".into(),
used: 0,
limit: remaining_units,
remaining: remaining_units,
reset_at: None,
period_seconds: None,
});
if let Some(preview) = preview_body {
let core = preview
.get("coreInvoice")
.or_else(|| preview.get("core_invoice"));
if let Some(used) = core
.and_then(|c| {
c.get("prepaidCreditsUsed")
.or_else(|| c.get("prepaid_credits_used"))
})
.and_then(cents_value_to_units)
{
if used > 0 {
let issued = remaining_units.saturating_add(used);
windows.push(QuotaWindow {
window_type: "credits_usd".into(),
used,
limit: issued.max(used),
remaining: remaining_units,
reset_at: None,
period_seconds: None,
});
}
}
if let Some(limit) = preview
.get("effectiveSpendingLimit")
.or_else(|| preview.get("effective_spending_limit"))
.and_then(cents_value_to_units)
{
if limit > 0 {
windows.push(QuotaWindow {
window_type: "spend_limit_usd".into(),
used: 0,
limit,
remaining: limit,
reset_at: None,
period_seconds: None,
});
}
}
if let Some(default_credits) = preview
.get("defaultCredits")
.or_else(|| preview.get("default_credits"))
.and_then(cents_value_to_units)
{
if default_credits > 0 {
windows.push(QuotaWindow {
window_type: "granted_usd".into(),
used: 0,
limit: default_credits,
remaining: default_credits,
reset_at: None,
period_seconds: None,
});
}
}
}
Ok(ProviderQuota {
plan_name: "xAI API".into(),
windows,
unlimited: false,
banked_resets: None,
})
}
#[async_trait]
impl crate::providers::Provider for GrokProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Grok
}
async fn fetch(&self) -> Result<ProviderResult> {
let auth = self.auth.resolve().await?;
let key = auth.credential.unwrap_token()?.to_string();
match self.fetch_quota(&key).await {
Ok(r) => Ok(r),
Err(crate::Error::Auth(msg)) => Ok(ProviderResult {
kind: ProviderKind::Grok,
status: ProviderStatus::Unavailable {
info: crate::providers::UnavailableInfo {
reason: format!(
"{} — run `grok login`, or set XAI_MANAGEMENT_KEY for API prepaid balance",
msg
),
console_url: Some(GROK_USAGE_URL.into()),
},
},
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
cached_at: None,
instance_key: None,
profile_id: None,
card_title: None,
}),
Err(e) => Ok(ProviderResult {
kind: ProviderKind::Grok,
status: ProviderStatus::NetworkError {
message: e.to_string(),
},
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
cached_at: None,
instance_key: None,
profile_id: None,
card_title: None,
}),
}
}
fn auth_resolver(&self) -> &dyn AuthResolver {
&*self.auth
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn fixture(name: &str) -> serde_json::Value {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/grok")
.join(name);
let raw = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read fixture {}: {}", path.display(), e));
serde_json::from_str(&raw).expect("parse fixture json")
}
#[test]
fn parse_grok_version_json_reads_version_field() {
let raw = r#"{
"version": "0.2.99",
"stable_version": null,
"checked_at": "2026-07-13T20:21:35.514284689Z"
}"#;
assert_eq!(
parse_grok_version_json(raw).as_deref(),
Some("0.2.99")
);
assert!(parse_grok_version_json("{}").is_none());
assert!(parse_grok_version_json(r#"{"version":""}"#).is_none());
assert!(parse_grok_version_json(r#"{"version":"not-a-version"}"#).is_none());
}
#[test]
fn parse_grok_version_cli_output_extracts_semver() {
assert_eq!(
parse_grok_version_cli_output("grok 0.2.99 (b1b49ccb71)").as_deref(),
Some("0.2.99")
);
assert_eq!(
parse_grok_version_cli_output("0.2.99\n").as_deref(),
Some("0.2.99")
);
assert!(parse_grok_version_cli_output("error: not found").is_none());
}
#[test]
fn parses_live_fixtures_weekly_and_monthly() {
let default = fixture("cli_billing_default.json");
let credits = fixture("cli_billing_format_credits.json");
let quota = parse_cli_billing(&default, Some(&credits)).unwrap();
assert_eq!(quota.plan_name, "Grok Build");
let weekly = quota
.windows
.iter()
.find(|w| w.window_type == "7d/build")
.expect("7d/build window");
assert!(weekly.used > 0);
assert_eq!(weekly.limit, 100);
assert!(weekly.reset_at.is_some());
assert_eq!(weekly.period_seconds, Some(7 * 24 * 3600));
let monthly = quota
.windows
.iter()
.find(|w| w.window_type == "monthly_allowance")
.expect("monthly allowance window");
assert_eq!(monthly.limit, 1_500_000);
assert!(monthly.used > 0);
assert!(monthly.reset_at.is_some());
}
#[test]
fn parses_cli_billing_monthly_only() {
let body = serde_json::json!({
"config": {
"monthlyLimit": { "val": 15000 },
"used": { "val": 2712 },
"onDemandCap": { "val": 0 },
"billingPeriodStart": "2026-07-01T00:00:00+00:00",
"billingPeriodEnd": "2026-08-01T00:00:00+00:00"
}
});
let quota = parse_cli_billing(&body, None).unwrap();
assert_eq!(quota.windows.len(), 1);
let w = "a.windows[0];
assert_eq!(w.window_type, "monthly_allowance");
assert_eq!(w.limit, 1_500_000);
assert_eq!(w.used, 271_200);
assert_eq!(w.remaining, 1_228_800);
assert_eq!(w.period_seconds, Some(31 * 24 * 3600));
}
#[test]
fn parses_cli_billing_weekly_product_usage() {
let default = serde_json::json!({
"config": {
"monthlyLimit": { "val": 15000 },
"used": { "val": 1000 },
"billingPeriodStart": "2026-07-01T00:00:00Z",
"billingPeriodEnd": "2026-08-01T00:00:00Z"
}
});
let credits = serde_json::json!({
"config": {
"currentPeriod": {
"type": "USAGE_PERIOD_TYPE_WEEKLY",
"start": "2026-07-07T10:46:52.885620+00:00",
"end": "2026-07-14T10:46:52.885620+00:00"
},
"creditUsagePercent": 74.0,
"productUsage": [
{ "product": "GrokBuild", "usagePercent": 74.0 }
],
"isUnifiedBillingUser": true,
"onDemandCap": { "val": 0 },
"onDemandUsed": { "val": 0 },
"prepaidBalance": { "val": 0 }
}
});
let quota = parse_cli_billing(&default, Some(&credits)).unwrap();
assert_eq!(quota.plan_name, "Grok Build");
assert_eq!(quota.windows[0].window_type, "7d");
assert_eq!(quota.windows[0].used, 74);
assert_eq!(quota.windows[0].limit, 100);
assert_eq!(quota.windows[0].remaining, 26);
assert_eq!(quota.windows[1].window_type, "7d/build");
assert_eq!(quota.windows[1].used, 74);
assert_eq!(quota.windows[2].window_type, "monthly_allowance");
}
#[test]
fn parses_combined_weekly_with_api_and_build_products() {
let default = serde_json::json!({
"config": {
"monthlyLimit": { "val": 150000 },
"used": { "val": 133563 },
"billingPeriodStart": "2026-07-01T00:00:00Z",
"billingPeriodEnd": "2026-08-01T00:00:00Z"
}
});
let credits = serde_json::json!({
"config": {
"currentPeriod": {
"type": "USAGE_PERIOD_TYPE_WEEKLY",
"start": "2026-07-21T10:46:52.885620+00:00",
"end": "2026-07-28T10:46:52.885620+00:00"
},
"creditUsagePercent": 100.0,
"productUsage": [
{ "product": "GrokBuild", "usagePercent": 54.0 },
{ "product": "Api", "usagePercent": 46.0 },
{ "product": "GrokChat" }
],
"isUnifiedBillingUser": true
}
});
let quota = parse_cli_billing(&default, Some(&credits)).unwrap();
let types: Vec<&str> = quota.windows.iter().map(|w| w.window_type.as_str()).collect();
assert_eq!(types, ["7d", "7d/build", "7d/api", "monthly_allowance"]);
assert_eq!(quota.windows[0].used, 100);
assert_eq!(quota.windows[0].remaining, 0);
assert_eq!(quota.windows[1].used, 54);
assert_eq!(quota.windows[2].used, 46);
assert!(!types.iter().any(|t| t.contains("chat")));
}
#[test]
fn parses_cli_billing_with_on_demand() {
let body = serde_json::json!({
"config": {
"monthlyLimit": { "val": "10000" },
"used": { "val": "1000" },
"onDemandCap": { "val": "5000" },
"billingPeriodStart": "2026-07-01T00:00:00Z",
"billingPeriodEnd": "2026-08-01T00:00:00Z"
}
});
let quota = parse_cli_billing(&body, None).unwrap();
assert_eq!(quota.windows.len(), 2);
assert_eq!(quota.windows[1].window_type, "on_demand_usd");
assert_eq!(quota.windows[1].remaining, 500_000);
}
#[test]
fn parses_prepaid_balance_only() {
let balance = serde_json::json!({
"changes": [],
"total": { "val": "-1250" }
});
let quota = parse_mgmt_billing(&balance, None).unwrap();
assert_eq!(quota.plan_name, "xAI API");
assert_eq!(quota.windows.len(), 1);
assert_eq!(quota.windows[0].window_type, "balance_usd");
assert_eq!(quota.windows[0].remaining, 125_000);
}
#[test]
fn parses_balance_with_invoice_preview() {
let balance = serde_json::json!({
"total": { "val": "-4500" }
});
let preview = serde_json::json!({
"coreInvoice": {
"prepaidCredits": { "val": "-4500" },
"prepaidCreditsUsed": { "val": "500" }
},
"effectiveSpendingLimit": "20000",
"defaultCredits": "0"
});
let quota = parse_mgmt_billing(&balance, Some(&preview)).unwrap();
assert_eq!(quota.windows.len(), 3);
assert_eq!(quota.windows[1].window_type, "credits_usd");
assert_eq!(quota.windows[1].used, 50_000);
assert_eq!(quota.windows[1].limit, 500_000);
}
#[test]
fn cents_conversion_handles_positive_and_negative() {
assert_eq!(cents_str_to_units("-1000"), 100_000);
assert_eq!(cents_str_to_units("250"), 25_000);
assert_eq!(cents_str_to_units("0"), 0);
}
}