1use 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
40pub 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 cache.write_last_error(status, &body);
82 let diag = (status, body.clone());
83 fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
84 }
85 Err(e) => {
86 cache.mark_stale();
87 cache.write_last_error(0, &e.to_string());
88 let diag = (0, e.to_string());
89 fallback_with_error(cache, Some(diag), &target, e)
90 }
91 }
92}
93
94fn target_key(organization_id: Option<&str>) -> String {
98 match organization_id.filter(|o| !o.is_empty()) {
99 Some(org) => format!("org:{org}"),
100 None => "personal".to_string(),
101 }
102}
103
104fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
105 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
106 return Err(original);
107 };
108 reuse_cache(&bytes, cache, true, target)
109}
110
111fn fallback_with_error(
115 cache: &Cache,
116 last_error: Option<(u16, String)>,
117 target: &str,
118 original: AppError,
119) -> Result<FetchOutcome> {
120 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
121 return Err(original);
122 };
123 let Ok(mut outcome) = reuse_cache(&bytes, cache, true, target) else {
125 return Err(original);
126 };
127 outcome.last_error = last_error;
128 Ok(outcome)
129}
130
131fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
132 let snap = parse_cache(bytes, target)?;
133 Ok(FetchOutcome {
134 snapshot: snap,
135 stale,
136 last_error: cache.read_last_error(),
137 cache_age: cache.payload_age(),
138 })
139}
140
141fn parse_cache(bytes: &[u8], target: &str) -> Result<KiloSnapshot> {
142 let v: serde_json::Value = serde_json::from_slice(bytes)?;
143 let cached_target = v.get("target").and_then(serde_json::Value::as_str);
146 if cached_target != Some(target) {
147 return Err(AppError::Schema(format!(
148 "kilo cache belongs to a different account ({}); refetching",
149 cached_target.unwrap_or("unknown")
150 )));
151 }
152 let s = v
153 .get("snapshot")
154 .ok_or_else(|| AppError::Schema("kilo cache missing 'snapshot' field".into()))?;
155 let balance = s["balance"]
156 .as_f64()
157 .ok_or_else(|| AppError::Schema("kilo cache missing 'balance'".into()))?;
158 Ok(KiloSnapshot {
159 label: s["label"].as_str().unwrap_or("Kilo").to_string(),
160 balance: finite_amount("kilo", "balance", balance)?,
161 })
162}
163
164async fn fetch_live(
165 client: &reqwest::Client,
166 endpoints: &Endpoints,
167 api_key: &str,
168 organization_id: Option<&str>,
169) -> Result<BalanceData> {
170 let mut req = client
171 .get(&endpoints.balance)
172 .header("Authorization", format!("Bearer {api_key}"))
173 .header("Content-Type", "application/json");
174 if let Some(org) = organization_id
175 && !org.is_empty()
176 {
177 req = req.header("x-kilocode-organizationid", org);
178 }
179
180 let resp = tokio::time::timeout(HTTP_TIMEOUT, req.send())
181 .await
182 .map_err(|_| AppError::Transport(format!("kilo timeout: {}", endpoints.balance)))??;
183
184 let status = resp.status();
185 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
186
187 if !status.is_success() {
188 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
189 return Err(AppError::Http {
190 status: status.as_u16(),
191 body,
192 });
193 }
194 let data: BalanceData = serde_json::from_slice(&bytes)
195 .map_err(|e| AppError::Schema(format!("kilo {}: {e}", endpoints.balance)))?;
196 Ok(data)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use tempfile::TempDir;
203
204 fn cache_fixture() -> (TempDir, Cache) {
205 let td = TempDir::new().unwrap();
206 let cache = Cache::at(td.path().join("kilo"));
207 cache.ensure_dir().unwrap();
208 (td, cache)
209 }
210
211 #[tokio::test]
212 async fn live_fetch_reads_balance() {
213 let mut server = mockito::Server::new_async().await;
214 server
215 .mock("GET", "/api/profile/balance")
216 .match_header("authorization", "Bearer sk-kilo-test")
217 .with_status(200)
218 .with_body(r#"{"balance":8.42}"#)
219 .create_async()
220 .await;
221
222 let (_td, cache) = cache_fixture();
223 let client = reqwest::Client::new();
224 let endpoints = Endpoints {
225 balance: format!("{}/api/profile/balance", server.url()),
226 };
227 let out = fetch_snapshot(
228 &client,
229 "sk-kilo-test",
230 &cache,
231 &endpoints,
232 Duration::from_secs(0),
233 None,
234 )
235 .await
236 .unwrap();
237 assert_eq!(out.snapshot.balance, 8.42);
238 assert_eq!(out.snapshot.label, "Kilo");
239 assert!(!out.stale);
240 }
241
242 #[tokio::test]
243 async fn org_header_is_sent_when_configured() {
244 let mut server = mockito::Server::new_async().await;
245 server
246 .mock("GET", "/api/profile/balance")
247 .match_header("x-kilocode-organizationid", "org_1")
248 .with_status(200)
249 .with_body(r#"{"balance":100.0}"#)
250 .create_async()
251 .await;
252
253 let (_td, cache) = cache_fixture();
254 let client = reqwest::Client::new();
255 let endpoints = Endpoints {
256 balance: format!("{}/api/profile/balance", server.url()),
257 };
258 let out = fetch_snapshot(
259 &client,
260 "k",
261 &cache,
262 &endpoints,
263 Duration::from_secs(0),
264 Some("org_1"),
265 )
266 .await
267 .unwrap();
268 assert_eq!(out.snapshot.balance, 100.0);
269 }
270
271 #[tokio::test]
272 async fn http_error_falls_back_to_cache_when_present() {
273 let mut server = mockito::Server::new_async().await;
274 server
275 .mock("GET", "/api/profile/balance")
276 .with_status(401)
277 .with_body(r#"{"error":"unauthorized"}"#)
278 .create_async()
279 .await;
280
281 let (_td, cache) = cache_fixture();
282 let seed = serde_json::json!({
283 "target": "personal",
284 "snapshot": { "label": "Kilo", "balance": 20.0 },
285 });
286 cache.write_payload(seed.to_string().as_bytes()).unwrap();
287
288 let client = reqwest::Client::new();
289 let endpoints = Endpoints {
290 balance: format!("{}/api/profile/balance", server.url()),
291 };
292 let out = fetch_snapshot(
293 &client,
294 "k",
295 &cache,
296 &endpoints,
297 Duration::from_secs(0),
298 None,
299 )
300 .await
301 .unwrap();
302 assert!(out.stale);
303 assert_eq!(out.snapshot.balance, 20.0);
304 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
305 }
306
307 #[tokio::test]
308 async fn switching_organization_refetches_instead_of_reusing_the_cache() {
309 let mut server = mockito::Server::new_async().await;
312 server
313 .mock("GET", "/api/profile/balance")
314 .match_header("x-kilocode-organizationid", "org_9")
315 .with_status(200)
316 .with_body(r#"{"balance":7.5}"#)
317 .create_async()
318 .await;
319
320 let (_td, cache) = cache_fixture();
321 let seed = serde_json::json!({
322 "target": "personal",
323 "snapshot": { "label": "Kilo", "balance": 999.0 },
324 });
325 cache.write_payload(seed.to_string().as_bytes()).unwrap();
326
327 let client = reqwest::Client::new();
328 let endpoints = Endpoints {
329 balance: format!("{}/api/profile/balance", server.url()),
330 };
331 let out = fetch_snapshot(
333 &client,
334 "k",
335 &cache,
336 &endpoints,
337 Duration::from_secs(3600),
338 Some("org_9"),
339 )
340 .await
341 .unwrap();
342 assert_eq!(out.snapshot.balance, 7.5);
343 assert!(!out.stale);
344 }
345
346 #[tokio::test]
347 async fn malformed_200_body_does_not_become_a_zero_balance() {
348 let mut server = mockito::Server::new_async().await;
349 server
350 .mock("GET", "/api/profile/balance")
351 .with_status(200)
352 .with_body(r#"{"error":"quota exceeded"}"#)
353 .create_async()
354 .await;
355
356 let (_td, cache) = cache_fixture();
357 let client = reqwest::Client::new();
358 let endpoints = Endpoints {
359 balance: format!("{}/api/profile/balance", server.url()),
360 };
361 let err = fetch_snapshot(
363 &client,
364 "k",
365 &cache,
366 &endpoints,
367 Duration::from_secs(0),
368 None,
369 )
370 .await;
371 assert!(err.is_err(), "expected a schema error, got {err:?}");
372 }
373}