Skip to main content

ai_usagebar/kilo/
fetch.rs

1//! Kilo Code fetch — reads the credit balance from `/api/profile/balance`
2//! under the shared cache + flock primitives. Mirrors `openrouter::fetch`
3//! semantics (fresh cache short-circuits; on failure, fall back to cache +
4//! mark stale), but Kilo is a single-endpoint balance.
5
6use std::time::Duration;
7
8use crate::cache::{Cache, acquire_lock_async};
9use crate::error::{AppError, Result};
10use crate::usage::{KiloSnapshot, finite_amount};
11use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
12
13use super::types::{BalanceData, to_snapshot};
14
15pub const BASE_URL: &str = "https://api.kilo.ai";
16const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
17const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
18
19#[derive(Debug, Clone)]
20pub struct Endpoints {
21    pub balance: String,
22}
23
24impl Default for Endpoints {
25    fn default() -> Self {
26        Self {
27            balance: format!("{BASE_URL}/api/profile/balance"),
28        }
29    }
30}
31
32/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
33/// specialised to its snapshot.
34pub type FetchOutcome = crate::outcome::Outcome<KiloSnapshot>;
35
36/// Cache-aware fetch. `organization_id`, when set, scopes the balance to a team
37/// (the `x-kilocode-organizationid` header); omitting it returns the personal
38/// balance.
39pub async fn fetch_snapshot(
40    client: &reqwest::Client,
41    api_key: &str,
42    cache: &Cache,
43    endpoints: &Endpoints,
44    cache_ttl: Duration,
45    organization_id: Option<&str>,
46) -> Result<FetchOutcome> {
47    cache.ensure_dir()?;
48    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
49
50    let target = target_key(organization_id);
51
52    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
53        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &target)
54    {
55        return Ok(outcome);
56    }
57
58    match fetch_live(client, endpoints, api_key, organization_id).await {
59        Ok(balance) => {
60            let snap = to_snapshot(balance)?;
61            let cache_repr = serde_json::json!({
62                "target": target,
63                "snapshot": { "label": snap.label, "balance": snap.balance },
64            });
65            let bytes = serde_json::to_vec(&cache_repr)?;
66            cache.write_payload(&bytes)?;
67            Ok(crate::outcome::Outcome::fresh(snap))
68        }
69        Err(e) if e.is_transient() => fallback_silent(cache, &target, e),
70        Err(AppError::Http { status, body }) => {
71            cache.mark_stale();
72            let diag = cache.write_last_error(status, &body);
73            fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
74        }
75        Err(e) => {
76            cache.mark_stale();
77            let diag = cache.write_last_error(0, &e.to_string());
78            fallback_with_error(cache, Some(diag), &target, e)
79        }
80    }
81}
82
83/// Identity of the account the cached figure belongs to. A balance fetched for
84/// one organization must never be displayed after the user switches to another
85/// (or back to the personal account).
86fn target_key(organization_id: Option<&str>) -> String {
87    match organization_id.filter(|o| !o.is_empty()) {
88        Some(org) => format!("org:{org}"),
89        None => "personal".to_string(),
90    }
91}
92
93fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
94    crate::outcome::fallback(cache, None, original, |bytes| parse_cache(bytes, target))
95}
96
97fn fallback_with_error(
98    cache: &Cache,
99    last_error: Option<(u16, String)>,
100    target: &str,
101    original: AppError,
102) -> Result<FetchOutcome> {
103    crate::outcome::fallback(cache, last_error, original, |bytes| {
104        parse_cache(bytes, target)
105    })
106}
107
108fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
109    let snap = parse_cache(bytes, target)?;
110    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
111}
112
113fn parse_cache(bytes: &[u8], target: &str) -> Result<KiloSnapshot> {
114    let v: serde_json::Value = serde_json::from_slice(bytes)?;
115    // Payloads written before the target was recorded are not attributable to
116    // an account, so they are discarded rather than shown against this one.
117    let cached_target = v.get("target").and_then(serde_json::Value::as_str);
118    if cached_target != Some(target) {
119        return Err(AppError::Schema(format!(
120            "kilo cache belongs to a different account ({}); refetching",
121            cached_target.unwrap_or("unknown")
122        )));
123    }
124    let s = v
125        .get("snapshot")
126        .ok_or_else(|| AppError::Schema("kilo cache missing 'snapshot' field".into()))?;
127    let balance = s["balance"]
128        .as_f64()
129        .ok_or_else(|| AppError::Schema("kilo cache missing 'balance'".into()))?;
130    Ok(KiloSnapshot {
131        label: s["label"].as_str().unwrap_or("Kilo").to_string(),
132        balance: finite_amount("kilo", "balance", balance)?,
133    })
134}
135
136async fn fetch_live(
137    client: &reqwest::Client,
138    endpoints: &Endpoints,
139    api_key: &str,
140    organization_id: Option<&str>,
141) -> Result<BalanceData> {
142    let mut req = client
143        .get(&endpoints.balance)
144        .header("Authorization", format!("Bearer {api_key}"))
145        .header("Content-Type", "application/json");
146    if let Some(org) = organization_id
147        && !org.is_empty()
148    {
149        req = req.header("x-kilocode-organizationid", org);
150    }
151
152    let resp = tokio::time::timeout(HTTP_TIMEOUT, req.send())
153        .await
154        .map_err(|_| AppError::Transport(format!("kilo timeout: {}", endpoints.balance)))??;
155
156    let status = resp.status();
157    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
158
159    if !status.is_success() {
160        let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
161        return Err(AppError::Http {
162            status: status.as_u16(),
163            body,
164        });
165    }
166    let data: BalanceData = serde_json::from_slice(&bytes)
167        .map_err(|e| AppError::Schema(format!("kilo {}: {e}", endpoints.balance)))?;
168    Ok(data)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use tempfile::TempDir;
175
176    fn cache_fixture() -> (TempDir, Cache) {
177        let td = TempDir::new().unwrap();
178        let cache = Cache::at(td.path().join("kilo"));
179        cache.ensure_dir().unwrap();
180        (td, cache)
181    }
182
183    #[tokio::test]
184    async fn live_fetch_reads_balance() {
185        let mut server = mockito::Server::new_async().await;
186        server
187            .mock("GET", "/api/profile/balance")
188            .match_header("authorization", "Bearer sk-kilo-test")
189            .with_status(200)
190            .with_body(r#"{"balance":8.42}"#)
191            .create_async()
192            .await;
193
194        let (_td, cache) = cache_fixture();
195        let client = reqwest::Client::new();
196        let endpoints = Endpoints {
197            balance: format!("{}/api/profile/balance", server.url()),
198        };
199        let out = fetch_snapshot(
200            &client,
201            "sk-kilo-test",
202            &cache,
203            &endpoints,
204            Duration::from_secs(0),
205            None,
206        )
207        .await
208        .unwrap();
209        assert_eq!(out.snapshot.balance, 8.42);
210        assert_eq!(out.snapshot.label, "Kilo");
211        assert!(!out.stale);
212    }
213
214    #[tokio::test]
215    async fn org_header_is_sent_when_configured() {
216        let mut server = mockito::Server::new_async().await;
217        server
218            .mock("GET", "/api/profile/balance")
219            .match_header("x-kilocode-organizationid", "org_1")
220            .with_status(200)
221            .with_body(r#"{"balance":100.0}"#)
222            .create_async()
223            .await;
224
225        let (_td, cache) = cache_fixture();
226        let client = reqwest::Client::new();
227        let endpoints = Endpoints {
228            balance: format!("{}/api/profile/balance", server.url()),
229        };
230        let out = fetch_snapshot(
231            &client,
232            "k",
233            &cache,
234            &endpoints,
235            Duration::from_secs(0),
236            Some("org_1"),
237        )
238        .await
239        .unwrap();
240        assert_eq!(out.snapshot.balance, 100.0);
241    }
242
243    #[tokio::test]
244    async fn http_error_falls_back_to_cache_when_present() {
245        let mut server = mockito::Server::new_async().await;
246        server
247            .mock("GET", "/api/profile/balance")
248            .with_status(401)
249            .with_body(r#"{"error":"unauthorized"}"#)
250            .create_async()
251            .await;
252
253        let (_td, cache) = cache_fixture();
254        let seed = serde_json::json!({
255            "target": "personal",
256            "snapshot": { "label": "Kilo", "balance": 20.0 },
257        });
258        cache.write_payload(seed.to_string().as_bytes()).unwrap();
259
260        let client = reqwest::Client::new();
261        let endpoints = Endpoints {
262            balance: format!("{}/api/profile/balance", server.url()),
263        };
264        let out = fetch_snapshot(
265            &client,
266            "k",
267            &cache,
268            &endpoints,
269            Duration::from_secs(0),
270            None,
271        )
272        .await
273        .unwrap();
274        assert!(out.stale);
275        assert_eq!(out.snapshot.balance, 20.0);
276        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
277    }
278
279    #[tokio::test]
280    async fn switching_organization_refetches_instead_of_reusing_the_cache() {
281        // A fresh cache for the personal account must not be shown as the
282        // organization's balance just because the TTL has not expired.
283        let mut server = mockito::Server::new_async().await;
284        server
285            .mock("GET", "/api/profile/balance")
286            .match_header("x-kilocode-organizationid", "org_9")
287            .with_status(200)
288            .with_body(r#"{"balance":7.5}"#)
289            .create_async()
290            .await;
291
292        let (_td, cache) = cache_fixture();
293        let seed = serde_json::json!({
294            "target": "personal",
295            "snapshot": { "label": "Kilo", "balance": 999.0 },
296        });
297        cache.write_payload(seed.to_string().as_bytes()).unwrap();
298
299        let client = reqwest::Client::new();
300        let endpoints = Endpoints {
301            balance: format!("{}/api/profile/balance", server.url()),
302        };
303        // A long TTL: the payload IS fresh, it just belongs to another target.
304        let out = fetch_snapshot(
305            &client,
306            "k",
307            &cache,
308            &endpoints,
309            Duration::from_secs(3600),
310            Some("org_9"),
311        )
312        .await
313        .unwrap();
314        assert_eq!(out.snapshot.balance, 7.5);
315        assert!(!out.stale);
316    }
317
318    #[tokio::test]
319    async fn malformed_200_body_does_not_become_a_zero_balance() {
320        let mut server = mockito::Server::new_async().await;
321        server
322            .mock("GET", "/api/profile/balance")
323            .with_status(200)
324            .with_body(r#"{"error":"quota exceeded"}"#)
325            .create_async()
326            .await;
327
328        let (_td, cache) = cache_fixture();
329        let client = reqwest::Client::new();
330        let endpoints = Endpoints {
331            balance: format!("{}/api/profile/balance", server.url()),
332        };
333        // No cache to fall back on: the schema error must surface.
334        let err = fetch_snapshot(
335            &client,
336            "k",
337            &cache,
338            &endpoints,
339            Duration::from_secs(0),
340            None,
341        )
342        .await;
343        assert!(err.is_err(), "expected a schema error, got {err:?}");
344    }
345}