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 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
92fn 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
109fn 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 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 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 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 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 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}