Skip to main content

ai_usagebar/kilo/
types.rs

1//! Wire types for Kilo Code's `/api/profile/balance`.
2//!
3//! The balance endpoint is **undocumented** (used internally by the Kilo Code
4//! extension, not in the public gateway API reference). Confirmed against
5//! `Kilo-Org/kilocode` `packages/kilo-gateway/src/api/profile.ts`: the response
6//! is `{ "balance": <number> }` where the number is a **plain USD decimal**
7//! (the extension renders it as `${balance.toFixed(2)}` with no conversion —
8//! it is NOT microdollars, despite older third-party notes).
9
10use serde::Deserialize;
11
12use crate::error::Result;
13use crate::usage::{KiloSnapshot, finite_amount};
14
15/// `GET /api/profile/balance` → `{ "balance": 12.34 }` (USD).
16///
17/// `balance` is **required**: a 200 response without it is an error envelope or
18/// a schema change, not an account with no money. Defaulting it to zero would
19/// cache a fabricated balance as authoritative.
20#[derive(Debug, Clone, Deserialize)]
21pub struct BalanceData {
22    pub balance: f64,
23}
24
25/// Project the wire balance into the canonical snapshot. Kilo doesn't expose a
26/// purchased-total via this endpoint, so there's no consumed-% — just the
27/// remaining USD balance.
28pub fn to_snapshot(balance: BalanceData) -> Result<KiloSnapshot> {
29    Ok(KiloSnapshot {
30        label: "Kilo".to_string(),
31        balance: finite_amount("kilo", "balance", balance.balance)?,
32    })
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38
39    #[test]
40    fn parses_balance() {
41        let raw = r#"{"balance":12.34}"#;
42        let d: BalanceData = serde_json::from_str(raw).unwrap();
43        assert_eq!(d.balance, 12.34);
44    }
45
46    #[test]
47    fn missing_balance_is_a_schema_error_not_zero() {
48        // A 200 error envelope must not be read as "you have $0.00".
49        assert!(serde_json::from_str::<BalanceData>("{}").is_err());
50        assert!(serde_json::from_str::<BalanceData>(r#"{"error":"forbidden"}"#).is_err());
51    }
52
53    #[test]
54    fn non_finite_balance_is_rejected() {
55        let d = BalanceData { balance: f64::NAN };
56        assert!(to_snapshot(d).is_err());
57    }
58
59    #[test]
60    fn to_snapshot_carries_balance_and_labels_kilo() {
61        let snap = to_snapshot(BalanceData { balance: 8.42 }).unwrap();
62        assert_eq!(snap.label, "Kilo");
63        assert_eq!(snap.balance, 8.42);
64    }
65}