1use std::time::Duration;
5
6use crate::cache::{Cache, acquire_lock_async};
7use crate::error::{AppError, Result};
8use crate::usage::OpenRouterSnapshot;
9
10use super::types::{CreditsData, KeyData, OrEnvelope, combine};
11
12pub const BASE_URL: &str = "https://openrouter.ai/api/v1";
13const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
14const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
15
16#[derive(Debug, Clone)]
17pub struct Endpoints {
18 pub credits: String,
19 pub key: String,
20}
21
22impl Default for Endpoints {
23 fn default() -> Self {
24 Self {
25 credits: format!("{BASE_URL}/credits"),
26 key: format!("{BASE_URL}/key"),
27 }
28 }
29}
30
31pub type FetchOutcome = crate::outcome::Outcome<OpenRouterSnapshot>;
34
35pub async fn fetch_snapshot(
38 client: &reqwest::Client,
39 api_key: &str,
40 cache: &Cache,
41 endpoints: &Endpoints,
42 cache_ttl: Duration,
43) -> Result<FetchOutcome> {
44 cache.ensure_dir()?;
45 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
46
47 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
48 && let Ok(outcome) = reuse_cache(bytes, cache, false)
49 {
50 return Ok(outcome);
51 }
52 match fetch_live(client, endpoints, api_key).await {
56 Ok((credits, key)) => {
57 let snap = combine(credits, key);
58 let cache_repr = serde_json::json!({
60 "snapshot": serde_repr(&snap),
61 });
62 let bytes = serde_json::to_vec(&cache_repr)?;
63 cache.write_payload(&bytes)?;
64 Ok(crate::outcome::Outcome::fresh(snap))
65 }
66 Err(e) if e.is_transient() => fallback_silent(cache, e),
67 Err(AppError::Http { status, body }) => {
68 cache.mark_stale();
69 let last_error = Some(cache.write_last_error(status, &body));
70 fallback_with_error(cache, last_error, AppError::Http { status, body })
71 }
72 Err(e) => {
73 cache.mark_stale();
74 let last_error = Some(cache.write_last_error(0, &e.to_string()));
75 fallback_with_error(cache, last_error, e)
76 }
77 }
78}
79
80fn fallback_silent(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
81 crate::outcome::fallback(cache, None, original, parse_cache)
82}
83
84fn fallback_with_error(
85 cache: &Cache,
86 last_error: Option<(u16, String)>,
87 original: AppError,
88) -> Result<FetchOutcome> {
89 crate::outcome::fallback(cache, last_error, original, parse_cache)
90}
91
92fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
93 let snap = parse_cache(&bytes)?;
94 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
95}
96
97fn parse_cache(bytes: &[u8]) -> Result<OpenRouterSnapshot> {
101 let v: serde_json::Value = serde_json::from_slice(bytes)?;
102 let s = v
103 .get("snapshot")
104 .ok_or_else(|| AppError::Schema("openrouter cache missing 'snapshot' field".into()))?;
105 let money = |name: &str| -> Result<f64> {
106 let n = s
107 .get(name)
108 .and_then(serde_json::Value::as_f64)
109 .ok_or_else(|| AppError::Schema(format!("openrouter cache missing '{name}'")))?;
110 if n.is_finite() && n >= 0.0 {
111 Ok(n)
112 } else {
113 Err(AppError::Schema(format!(
114 "openrouter cache '{name}' is not finite and non-negative"
115 )))
116 }
117 };
118 let optional_money = |name: &str, nonnegative: bool| -> Result<Option<f64>> {
119 match s.get(name) {
120 None | Some(serde_json::Value::Null) => Ok(None),
121 Some(value) => {
122 let number = value.as_f64().ok_or_else(|| {
123 AppError::Schema(format!("openrouter cache '{name}' is not numeric or null"))
124 })?;
125 if number.is_finite() && (!nonnegative || number >= 0.0) {
126 Ok(Some(number))
127 } else {
128 Err(AppError::Schema(format!(
129 "openrouter cache '{name}' is outside its valid range"
130 )))
131 }
132 }
133 }
134 };
135 Ok(OpenRouterSnapshot {
136 label: s
137 .get("label")
138 .and_then(serde_json::Value::as_str)
139 .ok_or_else(|| AppError::Schema("openrouter cache missing 'label'".into()))?
140 .to_string(),
141 total_credits: money("total_credits")?,
142 total_usage: money("total_usage")?,
143 usage_daily: money("usage_daily")?,
144 usage_weekly: money("usage_weekly")?,
145 usage_monthly: money("usage_monthly")?,
146 is_free_tier: s["is_free_tier"]
147 .as_bool()
148 .ok_or_else(|| AppError::Schema("openrouter cache missing 'is_free_tier'".into()))?,
149 limit: optional_money("limit", true)?,
150 limit_remaining: optional_money("limit_remaining", false)?,
151 })
152}
153
154fn serde_repr(snap: &OpenRouterSnapshot) -> serde_json::Value {
155 serde_json::json!({
156 "label": snap.label,
157 "total_credits": snap.total_credits,
158 "total_usage": snap.total_usage,
159 "usage_daily": snap.usage_daily,
160 "usage_weekly": snap.usage_weekly,
161 "usage_monthly": snap.usage_monthly,
162 "is_free_tier": snap.is_free_tier,
163 "limit": snap.limit,
164 "limit_remaining": snap.limit_remaining,
165 })
166}
167
168async fn fetch_live(
169 client: &reqwest::Client,
170 endpoints: &Endpoints,
171 api_key: &str,
172) -> Result<(CreditsData, KeyData)> {
173 let credits_fut = fetch_one::<CreditsData>(client, &endpoints.credits, api_key);
175 let key_fut = fetch_one::<KeyData>(client, &endpoints.key, api_key);
176 let (credits, key) = tokio::join!(credits_fut, key_fut);
177 Ok((credits?, key?))
178}
179
180async fn fetch_one<T: for<'de> serde::Deserialize<'de>>(
181 client: &reqwest::Client,
182 url: &str,
183 api_key: &str,
184) -> Result<T> {
185 let resp = tokio::time::timeout(
186 HTTP_TIMEOUT,
187 client
188 .get(url)
189 .header("Authorization", format!("Bearer {api_key}"))
190 .send(),
191 )
192 .await
193 .map_err(|_| AppError::Transport(format!("openrouter timeout: {url}")))??;
194
195 let status = resp.status();
196 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
197
198 if !status.is_success() {
199 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
200 return Err(AppError::Http {
201 status: status.as_u16(),
202 body,
203 });
204 }
205 let env: OrEnvelope<T> = serde_json::from_slice(&bytes)
206 .map_err(|e| AppError::Schema(format!("openrouter {url}: {e}")))?;
207 Ok(env.data)
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use tempfile::TempDir;
214
215 fn cache_fixture() -> (TempDir, Cache) {
216 let td = TempDir::new().unwrap();
217 let cache = Cache::at(td.path().join("openrouter"));
218 cache.ensure_dir().unwrap();
219 (td, cache)
220 }
221
222 #[tokio::test]
223 async fn live_fetch_combines_both_endpoints() {
224 let mut server = mockito::Server::new_async().await;
225 server
226 .mock("GET", "/api/v1/credits")
227 .with_status(200)
228 .with_body(r#"{"data":{"total_credits":100.0,"total_usage":25.5}}"#)
229 .create_async()
230 .await;
231 server
232 .mock("GET", "/api/v1/key")
233 .with_status(200)
234 .with_body(
235 r#"{"data":{"label":"prod","limit":50.0,"limit_remaining":24.5,
236 "usage":25.5,"usage_daily":1.0,"usage_weekly":7.0,"usage_monthly":25.5,
237 "is_free_tier":false}}"#,
238 )
239 .create_async()
240 .await;
241
242 let (_td, cache) = cache_fixture();
243 let client = reqwest::Client::new();
244 let endpoints = Endpoints {
245 credits: format!("{}/api/v1/credits", server.url()),
246 key: format!("{}/api/v1/key", server.url()),
247 };
248 let out = fetch_snapshot(
249 &client,
250 "sk-or-test",
251 &cache,
252 &endpoints,
253 Duration::from_secs(0),
254 )
255 .await
256 .unwrap();
257 assert_eq!(out.snapshot.total_credits, 100.0);
258 assert_eq!(out.snapshot.total_usage, 25.5);
259 assert!((out.snapshot.balance() - 74.5).abs() < 1e-9);
260 assert_eq!(out.snapshot.label, "OpenRouter — prod");
261 assert!(!out.stale);
262 }
263
264 #[tokio::test]
271 async fn an_http_error_with_no_cache_surfaces_the_status_not_a_cache_message() {
272 let mut server = mockito::Server::new_async().await;
273 server
274 .mock("GET", "/api/v1/credits")
275 .with_status(401)
276 .with_body(r#"{"error":"unauthorized"}"#)
277 .create_async()
278 .await;
279
280 let (_td, cache) = cache_fixture();
281 let endpoints = Endpoints {
282 credits: format!("{}/api/v1/credits", server.url()),
283 key: format!("{}/api/v1/key", server.url()),
284 };
285 let err = fetch_snapshot(
286 &reqwest::Client::new(),
287 "sk-or-test",
288 &cache,
289 &endpoints,
290 Duration::from_secs(0),
291 )
292 .await
293 .unwrap_err();
294
295 assert!(
296 matches!(err, AppError::Http { status: 401, .. }),
297 "expected the 401 to survive, got {err:?}"
298 );
299 }
300
301 #[tokio::test]
307 async fn two_accounts_fan_out_to_distinct_entries_and_caches() {
308 let mut server = mockito::Server::new_async().await;
309 server
310 .mock("GET", "/api/v1/credits")
311 .match_header("authorization", "Bearer work-key")
312 .with_status(200)
313 .with_body(r#"{"data":{"total_credits":100.0,"total_usage":25.5}}"#)
314 .create_async()
315 .await;
316 server
317 .mock("GET", "/api/v1/key")
318 .match_header("authorization", "Bearer work-key")
319 .with_status(200)
320 .with_body(
321 r#"{"data":{"label":"work","limit":null,"limit_remaining":null,
322 "usage":25.5,"usage_daily":1.0,"usage_weekly":7.0,"usage_monthly":25.5,
323 "is_free_tier":false}}"#,
324 )
325 .create_async()
326 .await;
327 server
328 .mock("GET", "/api/v1/credits")
329 .match_header("authorization", "Bearer personal-key")
330 .with_status(200)
331 .with_body(r#"{"data":{"total_credits":40.0,"total_usage":4.0}}"#)
332 .create_async()
333 .await;
334 server
335 .mock("GET", "/api/v1/key")
336 .match_header("authorization", "Bearer personal-key")
337 .with_status(200)
338 .with_body(
339 r#"{"data":{"label":"home","limit":null,"limit_remaining":null,
340 "usage":4.0,"usage_daily":0.5,"usage_weekly":2.0,"usage_monthly":4.0,
341 "is_free_tier":true}}"#,
342 )
343 .create_async()
344 .await;
345
346 let td = tempfile::TempDir::new().unwrap();
347 let endpoints = Endpoints {
348 credits: format!("{}/api/v1/credits", server.url()),
349 key: format!("{}/api/v1/key", server.url()),
350 };
351 let mut outcomes = Vec::new();
352 let mut caches = Vec::new();
353 for (label, key) in [("work", "work-key"), ("personal", "personal-key")] {
354 let cache = Cache::at(td.path().join("openrouter").join(label));
357 cache.ensure_dir().unwrap();
358 let outcome = fetch_snapshot(
359 &reqwest::Client::new(),
360 key,
361 &cache,
362 &endpoints,
363 Duration::from_secs(0),
364 )
365 .await
366 .unwrap();
367 outcomes.push(outcome);
368 caches.push(cache);
369 }
370
371 let [work, personal] = &outcomes[..] else {
372 panic!("expected exactly two account outcomes");
373 };
374 assert_eq!(work.snapshot.label, "OpenRouter — work");
375 assert!((work.snapshot.balance() - 74.5).abs() < 1e-9);
376 assert_eq!(personal.snapshot.label, "OpenRouter — home");
377 assert!((personal.snapshot.balance() - 36.0).abs() < 1e-9);
378 assert_ne!(work.snapshot, personal.snapshot);
379
380 let work_bytes = caches[0].maybe_payload().unwrap().unwrap();
382 let personal_bytes = caches[1].maybe_payload().unwrap().unwrap();
383 assert_ne!(work_bytes, personal_bytes);
384 assert_eq!(parse_cache(&work_bytes).unwrap().label, "OpenRouter — work");
385 assert_eq!(
386 parse_cache(&personal_bytes).unwrap().label,
387 "OpenRouter — home"
388 );
389 }
390
391 #[tokio::test]
395 async fn a_401_on_one_account_does_not_fail_the_other() {
396 let mut server = mockito::Server::new_async().await;
397 for path in ["/api/v1/credits", "/api/v1/key"] {
398 server
399 .mock("GET", path)
400 .match_header("authorization", "Bearer revoked-key")
401 .with_status(401)
402 .with_body(r#"{"error":"unauthorized"}"#)
403 .create_async()
404 .await;
405 }
406 server
407 .mock("GET", "/api/v1/credits")
408 .match_header("authorization", "Bearer live-key")
409 .with_status(200)
410 .with_body(r#"{"data":{"total_credits":30.0,"total_usage":6.0}}"#)
411 .create_async()
412 .await;
413 server
414 .mock("GET", "/api/v1/key")
415 .match_header("authorization", "Bearer live-key")
416 .with_status(200)
417 .with_body(
418 r#"{"data":{"label":"live","limit":null,"limit_remaining":null,
419 "usage":6.0,"usage_daily":2.0,"usage_weekly":4.0,"usage_monthly":6.0,
420 "is_free_tier":false}}"#,
421 )
422 .create_async()
423 .await;
424
425 let td = tempfile::TempDir::new().unwrap();
426 let endpoints = Endpoints {
427 credits: format!("{}/api/v1/credits", server.url()),
428 key: format!("{}/api/v1/key", server.url()),
429 };
430 let client = reqwest::Client::new();
431
432 let revoked_cache = Cache::at(td.path().join("openrouter").join("revoked"));
433 revoked_cache.ensure_dir().unwrap();
434 let revoked = fetch_snapshot(
435 &client,
436 "revoked-key",
437 &revoked_cache,
438 &endpoints,
439 Duration::from_secs(0),
440 )
441 .await;
442 assert!(
443 matches!(revoked, Err(AppError::Http { status: 401, .. })),
444 "expected the revoked account to fail with its own 401, got {revoked:?}"
445 );
446
447 let live_cache = Cache::at(td.path().join("openrouter").join("live"));
448 live_cache.ensure_dir().unwrap();
449 let live = fetch_snapshot(
450 &client,
451 "live-key",
452 &live_cache,
453 &endpoints,
454 Duration::from_secs(0),
455 )
456 .await
457 .unwrap();
458 assert_eq!(live.snapshot.label, "OpenRouter — live");
459 assert!((live.snapshot.balance() - 24.0).abs() < 1e-9);
460 assert!(
461 live_cache.maybe_payload().unwrap().is_some(),
462 "the healthy account's cache must still be written"
463 );
464 }
465
466 #[tokio::test]
467 async fn http_error_falls_back_to_cache_when_present() {
468 let mut server = mockito::Server::new_async().await;
469 server
470 .mock("GET", "/api/v1/credits")
471 .with_status(401)
472 .with_body(r#"{"error":"unauthorized"}"#)
473 .create_async()
474 .await;
475 server
476 .mock("GET", "/api/v1/key")
477 .with_status(401)
478 .with_body(r#"{"error":"unauthorized"}"#)
479 .create_async()
480 .await;
481
482 let (_td, cache) = cache_fixture();
483 let seed = serde_json::json!({
485 "snapshot": {
486 "label":"OpenRouter — seed","total_credits": 50.0,
487 "total_usage": 10.0,"usage_daily":1.0,"usage_weekly":3.0,
488 "usage_monthly":10.0,"is_free_tier":false,
489 "limit":null,"limit_remaining":null
490 }
491 });
492 cache.write_payload(seed.to_string().as_bytes()).unwrap();
493
494 let client = reqwest::Client::new();
495 let endpoints = Endpoints {
496 credits: format!("{}/api/v1/credits", server.url()),
497 key: format!("{}/api/v1/key", server.url()),
498 };
499 let out = fetch_snapshot(&client, "k", &cache, &endpoints, Duration::from_secs(0))
500 .await
501 .unwrap();
502 assert!(out.stale);
503 assert_eq!(out.snapshot.label, "OpenRouter — seed");
504 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
505 }
506}