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    #[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        // Seed cache with a "snapshot" repr.
319        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}