M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,610 @@
//! `x.ai/billing` extension handler.
//!
//! Fetches the authenticated user's Grok Build billing configuration
//! (credit limit, usage, on-demand cap, billing period, history) from
//! the backend. Used by the pager/desktop to display credits and usage.
use agent_client_protocol as acp;
use serde::{Deserialize, Serialize};
use super::{ExtResult, to_raw_response};
use crate::agent::MvpAgent;
/// Billing period cycle identifier.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingCycle {
pub year: i32,
pub month: i32,
}
/// Cent value from the billing API (USD cents).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cent {
/// proto3 JSON omits zero-valued scalars, so a `$0` Cent arrives as `{}`;
/// default to 0 rather than failing the whole parse.
#[serde(default)]
pub val: i64,
}
/// A usage period (weekly or monthly) from the newer credits config.
///
/// `start`/`end` are RFC 3339 timestamps. `period_type` is the proto enum name
/// (e.g. `USAGE_PERIOD_TYPE_WEEKLY`); kept so callers can distinguish weekly
/// vs monthly cycles.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsagePeriod {
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub period_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end: Option<String>,
}
/// Usage summary for one past billing period.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingPeriodUsage {
#[serde(skip_serializing_if = "Option::is_none")]
pub billing_cycle: Option<BillingCycle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_used: Option<Cent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_demand_used: Option<Cent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_used: Option<Cent>,
}
/// Current billing configuration for Grok Build coding credits.
///
/// Carries both the newer credits-config fields (`credit_usage_percent`,
/// `current_period`) and the deprecated `GrokBuildBillingConfig` fields
/// (`monthly_limit`, `used`, `billing_period_*`). Consumers should prefer the
/// new fields and fall back to the deprecated ones, so the same struct works
/// against both the new `GetGrokCreditsConfig` and the legacy
/// `GetGrokBuildBillingConfig` backend responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BillingConfig {
/// Included credit usage as a percentage of the allowance (0.0100.0).
/// Preferred over deriving from `monthly_limit`/`used`.
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_usage_percent: Option<f64>,
/// Current usage period (weekly or monthly). Preferred over
/// `billing_period_start`/`billing_period_end`.
#[serde(skip_serializing_if = "Option::is_none")]
pub current_period: Option<UsagePeriod>,
/// Deprecated: included monthly credit budget. Use `credit_usage_percent`.
#[serde(skip_serializing_if = "Option::is_none")]
pub monthly_limit: Option<Cent>,
/// Deprecated: credits used this period. Use `credit_usage_percent`.
#[serde(skip_serializing_if = "Option::is_none")]
pub used: Option<Cent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_demand_cap: Option<Cent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_demand_used: Option<Cent>,
/// Remaining prepaid (purchased) credit balance, positive — the "bought
/// credits" the user has topped up. Populated from the credits config
/// (`GetGrokCreditsConfig.prepaid_balance`); absent in the legacy billing
/// shape.
#[serde(skip_serializing_if = "Option::is_none")]
pub prepaid_balance: Option<Cent>,
/// Whether this user is on unified usage billing (shared weekly/monthly
/// pool). From `GrokCreditsConfig.is_unified_billing_user`, which billing
/// sets from remote settings `unified_consumer_billing_enabled`. `None` when
/// absent (legacy `GetGrokBuildBillingConfig` shape or older servers).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_unified_billing_user: Option<bool>,
/// Deprecated: use `current_period.start`.
#[serde(skip_serializing_if = "Option::is_none")]
pub billing_period_start: Option<String>,
/// Deprecated: use `current_period.end`.
#[serde(skip_serializing_if = "Option::is_none")]
pub billing_period_end: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<BillingPeriodUsage>,
}
/// Top-level response (primarily from `GET /rest/grok/credits` + auto-topup-rule).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillingConfigResponse {
pub config: Option<BillingConfig>,
/// Whether on-demand credit usage is enabled. When `false`, the pager
/// should hide on-demand controls. Populated from `RemoteSettings`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_demand_enabled: Option<bool>,
/// User-friendly subscription tier name (e.g. "SuperGrok Heavy").
/// Populated from `RemoteSettings` so the pager can update its cached
/// tier on every billing fetch without an extra request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscription_tier: Option<String>,
}
/// Auto top-up configuration (from GetAutoTopupRule).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoTopupRule {
/// proto3 JSON omits `false`, so a disabled rule arrives without this field;
/// default to `false` rather than failing the parse (which would otherwise
/// keep a stale cached rule in the pager).
#[serde(default)]
pub enabled: bool,
pub min_before_hitting_sl: Option<Cent>,
pub topup_amount: Option<Cent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_amount_per_month: Option<Cent>,
}
/// Wrapper for the auto top-up rule response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetAutoTopupRuleResponse {
#[serde(default)]
pub rule: Option<AutoTopupRule>,
}
#[tracing::instrument(skip_all, fields(method = %args.method))]
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() {
"x.ai/billing" => {
tracing::info!("handling billing config request");
handle_get_billing(agent).await
}
"x.ai/auto-topup-rule" => {
tracing::info!("handling auto top-up rule request");
handle_get_auto_topup_rule(agent).await
}
_ => Err(acp::Error::method_not_found()),
}
}
/// Structured context for unified-log entries from a successful billing fetch.
///
/// Keeps history to a count + the most recent period so `~/.kigi/logs/unified.jsonl`
/// stays useful without dumping unbounded period arrays.
fn billing_unified_log_ctx(billing: &BillingConfigResponse) -> serde_json::Value {
let history_len = billing
.config
.as_ref()
.map(|c| c.history.len())
.unwrap_or(0);
let latest_history = billing
.config
.as_ref()
.and_then(|c| c.history.last())
.and_then(|p| serde_json::to_value(p).ok());
let mut config_value = billing
.config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok())
.unwrap_or(serde_json::Value::Null);
if let Some(obj) = config_value.as_object_mut() {
// Drop full history array; surface length + latest entry instead.
obj.remove("history");
obj.insert("historyLen".into(), serde_json::json!(history_len));
if let Some(latest) = latest_history {
obj.insert("latestHistory".into(), latest);
}
}
serde_json::json!({
"config": config_value,
"onDemandEnabled": billing.on_demand_enabled,
"subscriptionTier": billing.subscription_tier,
})
}
async fn handle_get_billing(agent: &MvpAgent) -> ExtResult {
let auth = super::auth_gate::require_xai_auth(
&agent.auth_manager,
"Authentication required to fetch billing data",
"Billing data requires auth with grok.com. Run `grok login` to authenticate.",
)?;
let proxy_base = agent.cli_chat_proxy_base_url();
let base = proxy_base.trim_end_matches('/');
// Credits balance / usage (new billing system) via the CLI proxy, which
// forwards to the backend `GetGrokCreditsConfig`.
let credits_url = format!("{}/billing?format=credits", base);
let credits_resp = crate::http::shared_client()
.get(&credits_url)
.header("Authorization", format!("Bearer {}", auth.key))
.header(
"X-XAI-Token-Auth",
crate::auth::GrokComConfig::default().token_header,
)
.header("x-userid", &auth.user_id)
.header("x-grok-client-version", kigi_version::VERSION)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, "billing: upstream request failed");
kigi_log::unified_log::warn(
"billing: upstream request failed",
None,
Some(serde_json::json!({ "error": e.to_string() })),
);
acp::Error::internal_error().data(format!("Failed to fetch billing data: {e}"))
})?;
if !credits_resp.status().is_success() {
let status = credits_resp.status().as_u16();
let body = credits_resp.text().await.unwrap_or_default();
tracing::warn!(status, url = %credits_url, "billing: upstream error");
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| format!("HTTP {status}"));
kigi_log::unified_log::warn(
"billing: upstream error",
None,
Some(serde_json::json!({
"status": status,
"detail": detail,
})),
);
return Err(acp::Error::internal_error().data(format!("Billing service error: {detail}")));
}
let mut billing: BillingConfigResponse = credits_resp.json().await.map_err(|e| {
tracing::error!(error = %e, "billing: failed to parse response");
kigi_log::unified_log::warn(
"billing: failed to parse response",
None,
Some(serde_json::json!({ "error": e.to_string() })),
);
acp::Error::internal_error().data(format!("Failed to parse billing data: {e}"))
})?;
// Enrich with fields from remote settings.
let rs = agent.cfg.borrow().remote_settings.clone();
billing.on_demand_enabled = rs.as_ref().and_then(|rs| rs.on_demand_enabled);
billing.subscription_tier = rs.as_ref().and_then(|rs| {
rs.subscription_tier_display
.clone()
.or_else(|| rs.subscription_tier.clone())
});
// Every prompt / /usage / poll path hits `x.ai/billing`; log the fetched
// credits snapshot so support can correlate limit UX with real balances.
kigi_log::unified_log::info(
"billing: fetched credits config",
None,
Some(billing_unified_log_ctx(&billing)),
);
to_raw_response(&billing)
}
async fn handle_get_auto_topup_rule(agent: &MvpAgent) -> ExtResult {
let auth = super::auth_gate::require_xai_auth(
&agent.auth_manager,
"Authentication required to fetch auto top-up rule",
"Auto top-up data requires auth with grok.com. Run `grok login` to authenticate.",
)?;
let proxy_base = agent.cli_chat_proxy_base_url();
let base = proxy_base.trim_end_matches('/');
// Auto top-up rule via the CLI proxy, which forwards to the backend
// `GetAutoTopupRule`.
let url = format!("{}/auto-topup-rule", base);
let response = crate::http::shared_client()
.get(&url)
.header("Authorization", format!("Bearer {}", auth.key))
.header(
"X-XAI-Token-Auth",
crate::auth::GrokComConfig::default().token_header,
)
.header("x-userid", &auth.user_id)
.header("x-grok-client-version", kigi_version::VERSION)
.header(
crate::http::CLIENT_MODE_HEADER,
crate::http::process_client_mode(),
)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, "auto-topup: upstream request failed");
acp::Error::internal_error().data(format!("Failed to fetch auto top-up rule: {e}"))
})?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status, url = %url, "auto-topup: upstream error");
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| format!("HTTP {status}"));
return Err(
acp::Error::internal_error().data(format!("Auto top-up service error: {detail}"))
);
}
// Return the upstream response body verbatim (as a JSON value) so /usage
// can print the exact data from this request unformatted.
let body_text = response.text().await.unwrap_or_default();
let value: serde_json::Value =
serde_json::from_str(&body_text).unwrap_or(serde_json::json!({"raw": body_text}));
to_raw_response(&value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_topup_disabled_rule_omits_enabled_field() {
// proto3 JSON omits `false` / `0`, so a disabled rule arrives without
// `enabled` (and zero Cents as `{}`). It must still deserialize (as
// disabled) rather than erroring — otherwise the pager keeps a stale
// cached rule.
let json = serde_json::json!({
"rule": { "topupAmount": {"val": 500}, "minBeforeHittingSl": {} }
});
let resp: GetAutoTopupRuleResponse = serde_json::from_value(json).unwrap();
let rule = resp.rule.expect("rule present");
assert!(!rule.enabled);
assert_eq!(rule.topup_amount.unwrap().val, 500);
assert_eq!(rule.min_before_hitting_sl.unwrap().val, 0);
}
#[test]
fn billing_config_response_deserializes_from_backend_json() {
let json = serde_json::json!({
"config": {
"monthlyLimit": {"val": 2000},
"used": {"val": 1234},
"onDemandCap": {"val": 500},
"billingPeriodStart": "2025-04-01T00:00:00Z",
"billingPeriodEnd": "2025-05-01T00:00:00Z",
"history": [
{
"billingCycle": {"year": 2025, "month": 3},
"includedUsed": {"val": 1800},
"onDemandUsed": {"val": 0},
"totalUsed": {"val": 1800}
}
]
}
});
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
let config = resp.config.unwrap();
assert_eq!(config.monthly_limit.unwrap().val, 2000);
assert_eq!(config.used.unwrap().val, 1234);
assert_eq!(config.on_demand_cap.unwrap().val, 500);
assert_eq!(
config.billing_period_start.as_deref(),
Some("2025-04-01T00:00:00Z")
);
assert_eq!(config.history.len(), 1);
let period = &config.history[0];
let cycle = period.billing_cycle.as_ref().unwrap();
assert_eq!(cycle.year, 2025);
assert_eq!(cycle.month, 3);
assert_eq!(period.included_used.as_ref().unwrap().val, 1800);
assert_eq!(period.total_used.as_ref().unwrap().val, 1800);
}
#[test]
fn billing_unified_log_ctx_includes_credits_and_collapses_history() {
let resp = BillingConfigResponse {
config: Some(BillingConfig {
credit_usage_percent: Some(42.5),
current_period: Some(UsagePeriod {
period_type: Some("USAGE_PERIOD_TYPE_WEEKLY".into()),
start: Some("2025-04-01T00:00:00Z".into()),
end: Some("2025-04-08T00:00:00Z".into()),
}),
monthly_limit: Some(Cent { val: 2000 }),
used: Some(Cent { val: 850 }),
on_demand_cap: Some(Cent { val: 500 }),
on_demand_used: Some(Cent { val: 0 }),
prepaid_balance: Some(Cent { val: 100 }),
is_unified_billing_user: Some(true),
billing_period_start: None,
billing_period_end: None,
history: vec![
BillingPeriodUsage {
billing_cycle: Some(BillingCycle {
year: 2025,
month: 2,
}),
included_used: Some(Cent { val: 1000 }),
on_demand_used: Some(Cent { val: 0 }),
total_used: Some(Cent { val: 1000 }),
},
BillingPeriodUsage {
billing_cycle: Some(BillingCycle {
year: 2025,
month: 3,
}),
included_used: Some(Cent { val: 1800 }),
on_demand_used: Some(Cent { val: 0 }),
total_used: Some(Cent { val: 1800 }),
},
],
}),
on_demand_enabled: Some(true),
subscription_tier: Some("SuperGrok".into()),
};
let ctx = billing_unified_log_ctx(&resp);
assert_eq!(ctx["onDemandEnabled"], true);
assert_eq!(ctx["subscriptionTier"], "SuperGrok");
let config = ctx["config"].as_object().expect("config object");
assert!(
config.get("history").is_none(),
"full history must be collapsed"
);
assert_eq!(config["historyLen"], 2);
assert_eq!(
config["latestHistory"]["billingCycle"]["month"], 3,
"latest history period retained"
);
assert_eq!(config["creditUsagePercent"], 42.5);
assert_eq!(config["prepaidBalance"]["val"], 100);
}
#[test]
fn billing_config_response_roundtrips_through_json() {
let config = BillingConfig {
credit_usage_percent: None,
current_period: None,
monthly_limit: Some(Cent { val: 5000 }),
used: Some(Cent { val: 123 }),
on_demand_cap: Some(Cent { val: 0 }),
on_demand_used: Some(Cent { val: 50 }),
prepaid_balance: Some(Cent { val: 750 }),
is_unified_billing_user: None,
billing_period_start: Some("2025-04-01T00:00:00Z".to_string()),
billing_period_end: Some("2025-05-01T00:00:00Z".to_string()),
history: vec![BillingPeriodUsage {
billing_cycle: Some(BillingCycle {
year: 2025,
month: 3,
}),
included_used: Some(Cent { val: 4500 }),
on_demand_used: Some(Cent { val: 100 }),
total_used: Some(Cent { val: 4600 }),
}],
};
let resp = BillingConfigResponse {
config: Some(config),
on_demand_enabled: None,
subscription_tier: None,
};
let json = serde_json::to_value(&resp).unwrap();
let roundtripped: BillingConfigResponse = serde_json::from_value(json).unwrap();
let rt_config = roundtripped.config.unwrap();
assert_eq!(rt_config.monthly_limit.unwrap().val, 5000);
assert_eq!(rt_config.used.unwrap().val, 123);
assert_eq!(rt_config.prepaid_balance.unwrap().val, 750);
assert_eq!(rt_config.history.len(), 1);
}
#[test]
fn billing_config_response_handles_null_config() {
let json = serde_json::json!({"config": null});
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
assert!(resp.config.is_none());
}
#[test]
fn billing_config_response_handles_empty_history() {
let json = serde_json::json!({
"config": {
"monthlyLimit": {"val": 1000},
"used": {"val": 0}
}
});
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
let config = resp.config.unwrap();
assert_eq!(config.monthly_limit.unwrap().val, 1000);
assert!(config.history.is_empty());
}
#[test]
fn billing_config_serializes_camel_case() {
let config = BillingConfig {
credit_usage_percent: None,
current_period: None,
monthly_limit: Some(Cent { val: 100 }),
used: None,
on_demand_cap: None,
on_demand_used: None,
prepaid_balance: None,
is_unified_billing_user: None,
billing_period_start: None,
billing_period_end: None,
history: vec![],
};
let json = serde_json::to_value(&config).unwrap();
assert!(json.get("monthlyLimit").is_some());
// Fields with None are skipped
assert!(json.get("creditUsagePercent").is_none());
assert!(json.get("currentPeriod").is_none());
assert!(json.get("used").is_none());
assert!(json.get("onDemandCap").is_none());
assert!(json.get("onDemandUsed").is_none());
assert!(json.get("prepaidBalance").is_none());
assert!(json.get("billingPeriodStart").is_none());
// Empty history is skipped
assert!(json.get("history").is_none());
}
#[test]
fn billing_config_deserializes_credits_config_shape() {
// Newer `GetGrokCreditsConfig` response: percentage-based usage,
// a typed current period, and history keyed by `period`.
let json = serde_json::json!({
"config": {
"creditUsagePercent": 42.5,
"currentPeriod": {
"type": "USAGE_PERIOD_TYPE_WEEKLY",
"start": "2026-06-01T00:00:00Z",
"end": "2026-06-08T00:00:00Z"
},
"onDemandCap": {"val": 5000},
"onDemandUsed": {"val": 300},
"prepaidBalance": {"val": 1250},
"isUnifiedBillingUser": true,
"productUsage": [
{"product": "PRODUCT_GROK_BUILD", "usagePercent": 61.2}
],
"history": [
{
"period": {
"type": "USAGE_PERIOD_TYPE_WEEKLY",
"start": "2026-05-25T00:00:00Z",
"end": "2026-06-01T00:00:00Z"
},
"onDemandUsed": {"val": 120}
}
]
}
});
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
let config = resp.config.unwrap();
assert_eq!(config.credit_usage_percent, Some(42.5));
let period = config.current_period.as_ref().unwrap();
assert_eq!(
period.period_type.as_deref(),
Some("USAGE_PERIOD_TYPE_WEEKLY")
);
assert_eq!(period.end.as_deref(), Some("2026-06-08T00:00:00Z"));
// Deprecated fields are absent in the credits shape.
assert!(config.monthly_limit.is_none());
assert!(config.billing_period_end.is_none());
assert_eq!(config.on_demand_cap.unwrap().val, 5000);
assert_eq!(config.on_demand_used.unwrap().val, 300);
// Bought (prepaid) credit balance is parsed from the credits config.
assert_eq!(config.prepaid_balance.unwrap().val, 1250);
assert_eq!(config.is_unified_billing_user, Some(true));
// productUsage is still unused by the CLI billing surface.
assert_eq!(config.history.len(), 1);
assert_eq!(config.history[0].on_demand_used.as_ref().unwrap().val, 120);
}
#[test]
fn cent_serializes_as_val_field() {
let c = Cent { val: 4299 };
let json = serde_json::to_value(&c).unwrap();
assert_eq!(json, serde_json::json!({"val": 4299}));
}
}