Skip to main content

ai_usagebar/openrouter/
fetch.rs

1//! OpenRouter fetch — combines `/api/v1/credits` and `/api/v1/key` under
2//! the shared cache + flock primitives.
3
4use 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
31/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
32/// specialised to its snapshot.
33pub type FetchOutcome = crate::outcome::Outcome<OpenRouterSnapshot>;
34
35/// Cache-aware fetch. Mirrors `anthropic::fetch::fetch_snapshot` semantics:
36/// fresh cache short-circuits; on failure, fall back to cache + mark stale.
37pub 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    // Corrupt fresh cache: fall through to live fetch rather than return a
53    // fabricated zero-credit snapshot.
54
55    match fetch_live(client, endpoints, api_key).await {
56        Ok((credits, key)) => {
57            let snap = combine(credits, key);
58            // Serialize back to JSON for the cache.
59            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
97/// Cached money is required, not optional: a truncated or half-written payload
98/// must be refetched rather than rendered as $0.00 with a free-tier badge.
99/// `limit`/`limit_remaining` stay optional — the API itself returns them null.
100fn 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    // Fetch in parallel.
174    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    /// With a cache to fall back on, the status rides along as `last_error`
265    /// and the user still sees a figure. With a *cold* cache there is no
266    /// figure, and the error is all the user gets — so it has to be the real
267    /// one. This returned `AppError::Other("openrouter: no usable cache")`
268    /// once, which reads as an internal problem on a first run where the
269    /// actual cause is a key that was never accepted.
270    #[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    /// The fan-out behind `[[openrouter.accounts]]` (#221): every account is
302    /// its own `fetch_snapshot` call with its own key and its own cache
303    /// subdirectory (what `Cache::for_vendor_account` lays out in production),
304    /// so two keys must land as two distinct snapshots that never share a
305    /// payload — not even a stale one.
306    #[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            // The same per-account subdirectory `Cache::for_vendor_account`
355            // builds in production, created via the hermetic `Cache::at`.
356            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        // Each cache holds its own account's payload, and only its own.
381        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    /// One account's dead key must not take the others down: the fan-out
392    /// runs one fetch per account against one cache per account, so a 401
393    /// stays inside the entry it belongs to (#221).
394    #[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        // Seed cache with a "snapshot" repr.
484        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}