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}