1use std::time::Duration;
5
6use crate::cache::{Cache, MAX_STALE, 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
31#[derive(Debug, Clone)]
32pub struct FetchOutcome {
33 pub snapshot: OpenRouterSnapshot,
34 pub stale: bool,
35 pub last_error: Option<(u16, String)>,
36 pub cache_age: Option<Duration>,
37}
38
39pub async fn fetch_snapshot(
42 client: &reqwest::Client,
43 api_key: &str,
44 cache: &Cache,
45 endpoints: &Endpoints,
46 cache_ttl: Duration,
47) -> Result<FetchOutcome> {
48 cache.ensure_dir()?;
49 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
50
51 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
52 && let Ok(outcome) = reuse_cache(bytes, cache, false)
53 {
54 return Ok(outcome);
55 }
56 match fetch_live(client, endpoints, api_key).await {
60 Ok((credits, key)) => {
61 let snap = combine(credits, key);
62 let cache_repr = serde_json::json!({
64 "snapshot": serde_repr(&snap),
65 });
66 let bytes = serde_json::to_vec(&cache_repr)?;
67 cache.write_payload(&bytes)?;
68 Ok(FetchOutcome {
69 snapshot: snap,
70 stale: false,
71 last_error: None,
72 cache_age: Some(Duration::ZERO),
73 })
74 }
75 Err(e) if e.is_transient() => fallback_silent(cache, e),
76 Err(AppError::Http { status, body }) => {
77 cache.mark_stale();
78 let last_error = Some(cache.write_last_error(status, &body));
79 fallback_with_error(cache, last_error, AppError::Http { status, body })
80 }
81 Err(e) => {
82 cache.mark_stale();
83 let last_error = Some(cache.write_last_error(0, &e.to_string()));
84 fallback_with_error(cache, last_error, e)
85 }
86 }
87}
88
89fn fallback_silent(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
90 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
91 return Err(original);
92 };
93 reuse_cache(bytes, cache, true)
94}
95
96fn fallback_with_error(
101 cache: &Cache,
102 last_error: Option<(u16, String)>,
103 original: AppError,
104) -> Result<FetchOutcome> {
105 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
106 return Err(original);
107 };
108 let mut outcome = reuse_cache(bytes, cache, true)?;
109 outcome.last_error = last_error;
110 Ok(outcome)
111}
112
113fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
114 let snap = parse_cache(&bytes)?;
115 Ok(FetchOutcome {
116 snapshot: snap,
117 stale,
118 last_error: cache.read_last_error(),
119 cache_age: cache.payload_age(),
120 })
121}
122
123fn parse_cache(bytes: &[u8]) -> Result<OpenRouterSnapshot> {
127 let v: serde_json::Value = serde_json::from_slice(bytes)?;
128 let s = v
129 .get("snapshot")
130 .ok_or_else(|| AppError::Schema("openrouter cache missing 'snapshot' field".into()))?;
131 let money = |name: &str| -> Result<f64> {
132 let n = s
133 .get(name)
134 .and_then(serde_json::Value::as_f64)
135 .ok_or_else(|| AppError::Schema(format!("openrouter cache missing '{name}'")))?;
136 if n.is_finite() && n >= 0.0 {
137 Ok(n)
138 } else {
139 Err(AppError::Schema(format!(
140 "openrouter cache '{name}' is not finite and non-negative"
141 )))
142 }
143 };
144 let optional_money = |name: &str, nonnegative: bool| -> Result<Option<f64>> {
145 match s.get(name) {
146 None | Some(serde_json::Value::Null) => Ok(None),
147 Some(value) => {
148 let number = value.as_f64().ok_or_else(|| {
149 AppError::Schema(format!("openrouter cache '{name}' is not numeric or null"))
150 })?;
151 if number.is_finite() && (!nonnegative || number >= 0.0) {
152 Ok(Some(number))
153 } else {
154 Err(AppError::Schema(format!(
155 "openrouter cache '{name}' is outside its valid range"
156 )))
157 }
158 }
159 }
160 };
161 Ok(OpenRouterSnapshot {
162 label: s
163 .get("label")
164 .and_then(serde_json::Value::as_str)
165 .ok_or_else(|| AppError::Schema("openrouter cache missing 'label'".into()))?
166 .to_string(),
167 total_credits: money("total_credits")?,
168 total_usage: money("total_usage")?,
169 usage_daily: money("usage_daily")?,
170 usage_weekly: money("usage_weekly")?,
171 usage_monthly: money("usage_monthly")?,
172 is_free_tier: s["is_free_tier"]
173 .as_bool()
174 .ok_or_else(|| AppError::Schema("openrouter cache missing 'is_free_tier'".into()))?,
175 limit: optional_money("limit", true)?,
176 limit_remaining: optional_money("limit_remaining", false)?,
177 })
178}
179
180fn serde_repr(snap: &OpenRouterSnapshot) -> serde_json::Value {
181 serde_json::json!({
182 "label": snap.label,
183 "total_credits": snap.total_credits,
184 "total_usage": snap.total_usage,
185 "usage_daily": snap.usage_daily,
186 "usage_weekly": snap.usage_weekly,
187 "usage_monthly": snap.usage_monthly,
188 "is_free_tier": snap.is_free_tier,
189 "limit": snap.limit,
190 "limit_remaining": snap.limit_remaining,
191 })
192}
193
194async fn fetch_live(
195 client: &reqwest::Client,
196 endpoints: &Endpoints,
197 api_key: &str,
198) -> Result<(CreditsData, KeyData)> {
199 let credits_fut = fetch_one::<CreditsData>(client, &endpoints.credits, api_key);
201 let key_fut = fetch_one::<KeyData>(client, &endpoints.key, api_key);
202 let (credits, key) = tokio::join!(credits_fut, key_fut);
203 Ok((credits?, key?))
204}
205
206async fn fetch_one<T: for<'de> serde::Deserialize<'de>>(
207 client: &reqwest::Client,
208 url: &str,
209 api_key: &str,
210) -> Result<T> {
211 let resp = tokio::time::timeout(
212 HTTP_TIMEOUT,
213 client
214 .get(url)
215 .header("Authorization", format!("Bearer {api_key}"))
216 .send(),
217 )
218 .await
219 .map_err(|_| AppError::Transport(format!("openrouter timeout: {url}")))??;
220
221 let status = resp.status();
222 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
223
224 if !status.is_success() {
225 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
226 return Err(AppError::Http {
227 status: status.as_u16(),
228 body,
229 });
230 }
231 let env: OrEnvelope<T> = serde_json::from_slice(&bytes)
232 .map_err(|e| AppError::Schema(format!("openrouter {url}: {e}")))?;
233 Ok(env.data)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use tempfile::TempDir;
240
241 fn cache_fixture() -> (TempDir, Cache) {
242 let td = TempDir::new().unwrap();
243 let cache = Cache::at(td.path().join("openrouter"));
244 cache.ensure_dir().unwrap();
245 (td, cache)
246 }
247
248 #[tokio::test]
249 async fn live_fetch_combines_both_endpoints() {
250 let mut server = mockito::Server::new_async().await;
251 server
252 .mock("GET", "/api/v1/credits")
253 .with_status(200)
254 .with_body(r#"{"data":{"total_credits":100.0,"total_usage":25.5}}"#)
255 .create_async()
256 .await;
257 server
258 .mock("GET", "/api/v1/key")
259 .with_status(200)
260 .with_body(
261 r#"{"data":{"label":"prod","limit":50.0,"limit_remaining":24.5,
262 "usage":25.5,"usage_daily":1.0,"usage_weekly":7.0,"usage_monthly":25.5,
263 "is_free_tier":false}}"#,
264 )
265 .create_async()
266 .await;
267
268 let (_td, cache) = cache_fixture();
269 let client = reqwest::Client::new();
270 let endpoints = Endpoints {
271 credits: format!("{}/api/v1/credits", server.url()),
272 key: format!("{}/api/v1/key", server.url()),
273 };
274 let out = fetch_snapshot(
275 &client,
276 "sk-or-test",
277 &cache,
278 &endpoints,
279 Duration::from_secs(0),
280 )
281 .await
282 .unwrap();
283 assert_eq!(out.snapshot.total_credits, 100.0);
284 assert_eq!(out.snapshot.total_usage, 25.5);
285 assert!((out.snapshot.balance() - 74.5).abs() < 1e-9);
286 assert_eq!(out.snapshot.label, "OpenRouter — prod");
287 assert!(!out.stale);
288 }
289
290 #[tokio::test]
297 async fn an_http_error_with_no_cache_surfaces_the_status_not_a_cache_message() {
298 let mut server = mockito::Server::new_async().await;
299 server
300 .mock("GET", "/api/v1/credits")
301 .with_status(401)
302 .with_body(r#"{"error":"unauthorized"}"#)
303 .create_async()
304 .await;
305
306 let (_td, cache) = cache_fixture();
307 let endpoints = Endpoints {
308 credits: format!("{}/api/v1/credits", server.url()),
309 key: format!("{}/api/v1/key", server.url()),
310 };
311 let err = fetch_snapshot(
312 &reqwest::Client::new(),
313 "sk-or-test",
314 &cache,
315 &endpoints,
316 Duration::from_secs(0),
317 )
318 .await
319 .unwrap_err();
320
321 assert!(
322 matches!(err, AppError::Http { status: 401, .. }),
323 "expected the 401 to survive, got {err:?}"
324 );
325 }
326
327 #[tokio::test]
328 async fn http_error_falls_back_to_cache_when_present() {
329 let mut server = mockito::Server::new_async().await;
330 server
331 .mock("GET", "/api/v1/credits")
332 .with_status(401)
333 .with_body(r#"{"error":"unauthorized"}"#)
334 .create_async()
335 .await;
336 server
337 .mock("GET", "/api/v1/key")
338 .with_status(401)
339 .with_body(r#"{"error":"unauthorized"}"#)
340 .create_async()
341 .await;
342
343 let (_td, cache) = cache_fixture();
344 let seed = serde_json::json!({
346 "snapshot": {
347 "label":"OpenRouter — seed","total_credits": 50.0,
348 "total_usage": 10.0,"usage_daily":1.0,"usage_weekly":3.0,
349 "usage_monthly":10.0,"is_free_tier":false,
350 "limit":null,"limit_remaining":null
351 }
352 });
353 cache.write_payload(seed.to_string().as_bytes()).unwrap();
354
355 let client = reqwest::Client::new();
356 let endpoints = Endpoints {
357 credits: format!("{}/api/v1/credits", server.url()),
358 key: format!("{}/api/v1/key", server.url()),
359 };
360 let out = fetch_snapshot(&client, "k", &cache, &endpoints, Duration::from_secs(0))
361 .await
362 .unwrap();
363 assert!(out.stale);
364 assert_eq!(out.snapshot.label, "OpenRouter — seed");
365 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
366 }
367}