1use 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
32pub type FetchOutcome = crate::outcome::Outcome<KiloSnapshot>;
35
36pub 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
83fn 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 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 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 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 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}