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]
302 async fn http_error_falls_back_to_cache_when_present() {
303 let mut server = mockito::Server::new_async().await;
304 server
305 .mock("GET", "/api/v1/credits")
306 .with_status(401)
307 .with_body(r#"{"error":"unauthorized"}"#)
308 .create_async()
309 .await;
310 server
311 .mock("GET", "/api/v1/key")
312 .with_status(401)
313 .with_body(r#"{"error":"unauthorized"}"#)
314 .create_async()
315 .await;
316
317 let (_td, cache) = cache_fixture();
318 let seed = serde_json::json!({
320 "snapshot": {
321 "label":"OpenRouter — seed","total_credits": 50.0,
322 "total_usage": 10.0,"usage_daily":1.0,"usage_weekly":3.0,
323 "usage_monthly":10.0,"is_free_tier":false,
324 "limit":null,"limit_remaining":null
325 }
326 });
327 cache.write_payload(seed.to_string().as_bytes()).unwrap();
328
329 let client = reqwest::Client::new();
330 let endpoints = Endpoints {
331 credits: format!("{}/api/v1/credits", server.url()),
332 key: format!("{}/api/v1/key", server.url()),
333 };
334 let out = fetch_snapshot(&client, "k", &cache, &endpoints, Duration::from_secs(0))
335 .await
336 .unwrap();
337 assert!(out.stale);
338 assert_eq!(out.snapshot.label, "OpenRouter — seed");
339 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
340 }
341}