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