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, 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
39/// Cache-aware fetch. Mirrors `anthropic::fetch::fetch_snapshot` semantics:
40/// fresh cache short-circuits; on failure, fall back to cache + mark stale.
41pub 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    // Corrupt fresh cache: fall through to live fetch rather than return a
57    // fabricated zero-credit snapshot.
58
59    match fetch_live(client, endpoints, api_key).await {
60        Ok((credits, key)) => {
61            let snap = combine(credits, key);
62            // Serialize back to JSON for the cache.
63            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
96/// On failure we show the last good figure with the error alongside it. With
97/// nothing usable cached there is nothing to show, so the **original** error is
98/// returned rather than a generic "no usable cache" that hides what went wrong
99/// — a cold cache and an expired key would otherwise look identical.
100fn 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
123/// Cached money is required, not optional: a truncated or half-written payload
124/// must be refetched rather than rendered as $0.00 with a free-tier badge.
125/// `limit`/`limit_remaining` stay optional — the API itself returns them null.
126fn 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    // Fetch in parallel.
200    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    /// With a cache to fall back on, the status rides along as `last_error`
291    /// and the user still sees a figure. With a *cold* cache there is no
292    /// figure, and the error is all the user gets — so it has to be the real
293    /// one. This returned `AppError::Other("openrouter: no usable cache")`
294    /// once, which reads as an internal problem on a first run where the
295    /// actual cause is a key that was never accepted.
296    #[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        // Seed cache with a "snapshot" repr.
345        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}