Skip to main content

ai_usagebar/orcarouter/
fetch.rs

1//! OrcaRouter fetch — combines `/v1/dashboard/billing/usage` and
2//! `/v1/dashboard/billing/subscription` under the shared cache + flock
3//! primitives. Mirrors `openrouter::fetch` semantics (fresh cache
4//! short-circuits; on failure, fall back to cache + mark stale), with the
5//! minimax-style key-fingerprint cache scope: a single key selects the
6//! account, so a cache written for one key must never be shown for another.
7
8use std::time::Duration;
9
10use crate::cache::{Cache, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::OrcaRouterSnapshot;
13use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
14
15use super::types::{SubscriptionResponse, UsageResponse, combine};
16
17pub const BASE_URL: &str = "https://api.orcarouter.ai/v1";
18const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
19const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
20
21#[derive(Debug, Clone)]
22pub struct Endpoints {
23    pub usage: String,
24    pub subscription: String,
25}
26
27impl Default for Endpoints {
28    fn default() -> Self {
29        Self {
30            usage: format!("{BASE_URL}/dashboard/billing/usage"),
31            subscription: format!("{BASE_URL}/dashboard/billing/subscription"),
32        }
33    }
34}
35
36/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
37/// specialised to its snapshot.
38pub type FetchOutcome = crate::outcome::Outcome<OrcaRouterSnapshot>;
39
40/// Cache-aware fetch.
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    let target = target_key(endpoints, api_key);
52
53    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
54        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &target)
55    {
56        return Ok(outcome);
57    }
58
59    match fetch_live(client, endpoints, api_key).await {
60        Ok((usage, subscription)) => {
61            let snap = combine(&usage, &subscription);
62            let cache_repr = serde_json::json!({
63                "target": target,
64                "snapshot": serde_repr(&snap),
65            });
66            let bytes = serde_json::to_vec(&cache_repr)?;
67            cache.write_payload(&bytes)?;
68            Ok(crate::outcome::Outcome::fresh(snap))
69        }
70        Err(e) if e.is_transient() => fallback_silent(cache, &target, e),
71        Err(AppError::Http { status, body }) => {
72            cache.mark_stale();
73            let diag = cache.write_last_error(status, &body);
74            fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
75        }
76        Err(e) => {
77            cache.mark_stale();
78            let diag = cache.write_last_error(0, &e.to_string());
79            fallback_with_error(cache, Some(diag), &target, e)
80        }
81    }
82}
83
84/// Identity of the key the cached card belongs to. Replacing the configured
85/// key can select another OrcaRouter account, so store only a fingerprint of
86/// it: a cache change detector, not an authentication secret (minimax
87/// pattern).
88fn target_key(endpoints: &Endpoints, api_key: &str) -> String {
89    use std::hash::{Hash, Hasher};
90    let mut hasher = std::collections::hash_map::DefaultHasher::new();
91    api_key.hash(&mut hasher);
92    format!("{}|key:{:016x}", endpoints.usage, hasher.finish())
93}
94
95fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
96    crate::outcome::fallback(cache, None, original, |bytes| parse_cache(bytes, target))
97}
98
99fn fallback_with_error(
100    cache: &Cache,
101    last_error: Option<(u16, String)>,
102    target: &str,
103    original: AppError,
104) -> Result<FetchOutcome> {
105    crate::outcome::fallback(cache, last_error, original, |bytes| {
106        parse_cache(bytes, target)
107    })
108}
109
110fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
111    let snap = parse_cache(bytes, target)?;
112    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
113}
114
115fn serde_repr(snap: &OrcaRouterSnapshot) -> serde_json::Value {
116    serde_json::json!({
117        "spent_cents": snap.spent_cents,
118        "limit_cents": snap.limit_cents,
119        "access_until": snap.access_until.map(|t| t.timestamp()),
120    })
121}
122
123/// Cached spend is required, not optional: a truncated or half-written payload
124/// must be refetched rather than rendered as $0.00 spent. `limit_cents` stays
125/// optional — the wire itself can omit it (or send the unlimited sentinel).
126fn parse_cache(bytes: &[u8], target: &str) -> Result<OrcaRouterSnapshot> {
127    let v: serde_json::Value = serde_json::from_slice(bytes)?;
128    let cached_target = v.get("target").and_then(serde_json::Value::as_str);
129    if cached_target != Some(target) {
130        return Err(AppError::Schema(format!(
131            "orcarouter cache belongs to a different key ({}); refetching",
132            cached_target.unwrap_or("unknown")
133        )));
134    }
135    let s = v
136        .get("snapshot")
137        .ok_or_else(|| AppError::Schema("orcarouter cache missing 'snapshot' field".into()))?;
138    let spent_cents = s["spent_cents"]
139        .as_i64()
140        .ok_or_else(|| AppError::Schema("orcarouter cache missing 'spent_cents'".into()))?;
141    if spent_cents < 0 {
142        return Err(AppError::Schema(
143            "orcarouter cache 'spent_cents' cannot be negative".into(),
144        ));
145    }
146    let limit_cents = match s.get("limit_cents") {
147        None | Some(serde_json::Value::Null) => None,
148        Some(value) => {
149            let cents = value.as_i64().ok_or_else(|| {
150                AppError::Schema("orcarouter cache 'limit_cents' is not an integer".into())
151            })?;
152            if cents <= 0 {
153                return Err(AppError::Schema(
154                    "orcarouter cache 'limit_cents' must be positive".into(),
155                ));
156            }
157            Some(cents)
158        }
159    };
160    let access_until = match s.get("access_until") {
161        None | Some(serde_json::Value::Null) => None,
162        Some(value) => {
163            let secs = value.as_i64().ok_or_else(|| {
164                AppError::Schema("orcarouter cache 'access_until' is not an integer".into())
165            })?;
166            if secs <= 0 {
167                return Err(AppError::Schema(
168                    "orcarouter cache 'access_until' must be a positive Unix timestamp".into(),
169                ));
170            }
171            Some(chrono::DateTime::from_timestamp(secs, 0).ok_or_else(|| {
172                AppError::Schema("orcarouter cache 'access_until' is out of range".into())
173            })?)
174        }
175    };
176    Ok(OrcaRouterSnapshot {
177        spent_cents,
178        limit_cents,
179        access_until,
180    })
181}
182
183async fn fetch_live(
184    client: &reqwest::Client,
185    endpoints: &Endpoints,
186    api_key: &str,
187) -> Result<(UsageResponse, SubscriptionResponse)> {
188    let usage_fut = fetch_one::<UsageResponse>(client, &endpoints.usage, api_key);
189    let subscription_fut =
190        fetch_one::<SubscriptionResponse>(client, &endpoints.subscription, api_key);
191    let (usage, subscription) = tokio::join!(usage_fut, subscription_fut);
192    Ok((usage?, subscription?))
193}
194
195async fn fetch_one<T: for<'de> serde::Deserialize<'de>>(
196    client: &reqwest::Client,
197    url: &str,
198    api_key: &str,
199) -> Result<T> {
200    let resp = tokio::time::timeout(
201        HTTP_TIMEOUT,
202        client
203            .get(url)
204            .header("Authorization", format!("Bearer {api_key}"))
205            .send(),
206    )
207    .await
208    .map_err(|_| AppError::Transport(format!("orcarouter timeout: {url}")))??;
209
210    let status = resp.status();
211    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
212
213    if !status.is_success() {
214        let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
215        return Err(AppError::Http {
216            status: status.as_u16(),
217            body,
218        });
219    }
220    // one-api lineage errors arrive as HTTP 200 wrapped in an OpenAI error
221    // envelope. Detected before the typed parse so it surfaces as a failure
222    // carrying the vendor's own message — never as a confident zero.
223    if let Some(message) = error_envelope(&bytes) {
224        return Err(AppError::Schema(format!(
225            "orcarouter {url}: error envelope: {message}"
226        )));
227    }
228    serde_json::from_slice(&bytes).map_err(|e| AppError::Schema(format!("orcarouter {url}: {e}")))
229}
230
231/// `{"error":{"message":…}}` at HTTP 200, or `None` for any other body. The
232/// message is capped the way error bodies are elsewhere.
233fn error_envelope(bytes: &[u8]) -> Option<String> {
234    let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
235    let message = v.get("error")?.get("message")?.as_str()?;
236    Some(message.chars().take(200).collect())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use tempfile::TempDir;
243
244    const USAGE_BODY: &str = r#"{"object":"list","total_usage":275}"#;
245    const SUBSCRIPTION_BODY: &str = r#"{
246        "object":"billing_subscription",
247        "has_payment_method":true,
248        "soft_limit_usd":12.5,
249        "hard_limit_usd":12.5,
250        "system_hard_limit_usd":12.5,
251        "access_until":1790000000
252    }"#;
253
254    fn cache_fixture() -> (TempDir, Cache) {
255        let td = TempDir::new().unwrap();
256        let cache = Cache::at(td.path().join("orcarouter"));
257        cache.ensure_dir().unwrap();
258        (td, cache)
259    }
260
261    fn endpoints_for(server: &mockito::Server) -> Endpoints {
262        Endpoints {
263            usage: format!("{}/v1/dashboard/billing/usage", server.url()),
264            subscription: format!("{}/v1/dashboard/billing/subscription", server.url()),
265        }
266    }
267
268    async fn mock_both(server: &mut mockito::Server, usage: &str, subscription: &str) {
269        server
270            .mock("GET", "/v1/dashboard/billing/usage")
271            .match_header("authorization", "Bearer sk-orca-test")
272            .with_status(200)
273            .with_body(usage)
274            .create_async()
275            .await;
276        server
277            .mock("GET", "/v1/dashboard/billing/subscription")
278            .match_header("authorization", "Bearer sk-orca-test")
279            .with_status(200)
280            .with_body(subscription)
281            .create_async()
282            .await;
283    }
284
285    #[tokio::test]
286    async fn live_fetch_combines_both_endpoints() {
287        let mut server = mockito::Server::new_async().await;
288        mock_both(&mut server, USAGE_BODY, SUBSCRIPTION_BODY).await;
289
290        let (_td, cache) = cache_fixture();
291        let out = fetch_snapshot(
292            &reqwest::Client::new(),
293            "sk-orca-test",
294            &cache,
295            &endpoints_for(&server),
296            Duration::from_secs(0),
297        )
298        .await
299        .unwrap();
300        // Cents, not dollars: 275 cents = $2.75 of a $12.50 limit.
301        assert_eq!(out.snapshot.spent_cents, 275);
302        assert_eq!(out.snapshot.limit_cents, Some(1250));
303        assert_eq!(out.snapshot.remaining_cents(), Some(975));
304        assert!(out.snapshot.access_until.is_some());
305        assert!(!out.stale);
306    }
307
308    /// The unlimited sentinel must arrive as a spend-only card, never a
309    /// $100M wallet.
310    #[tokio::test]
311    async fn unlimited_sentinel_yields_a_spend_only_snapshot() {
312        let mut server = mockito::Server::new_async().await;
313        mock_both(
314            &mut server,
315            USAGE_BODY,
316            r#"{"soft_limit_usd":100000000,"hard_limit_usd":100000000,
317                "system_hard_limit_usd":100000000,"access_until":0}"#,
318        )
319        .await;
320
321        let (_td, cache) = cache_fixture();
322        let out = fetch_snapshot(
323            &reqwest::Client::new(),
324            "sk-orca-test",
325            &cache,
326            &endpoints_for(&server),
327            Duration::from_secs(0),
328        )
329        .await
330        .unwrap();
331        assert_eq!(out.snapshot.limit_cents, None);
332        assert_eq!(out.snapshot.remaining_cents(), None);
333        assert_eq!(out.snapshot.consumed_pct(), None);
334        assert_eq!(out.snapshot.spent_cents, 275);
335        assert!(out.snapshot.access_until.is_none());
336    }
337
338    /// one-api deployments report auth failures as HTTP 200 + an OpenAI error
339    /// envelope. That must surface as a failure, not deserialize into a
340    /// zero-spend card that overwrites a good cache.
341    #[tokio::test]
342    async fn http_200_error_envelope_is_a_failure_not_a_zero() {
343        let mut server = mockito::Server::new_async().await;
344        mock_both(
345            &mut server,
346            r#"{"error":{"message":"Invalid API key provided","type":"invalid_request_error"}}"#,
347            SUBSCRIPTION_BODY,
348        )
349        .await;
350
351        let (_td, cache) = cache_fixture();
352        let err = fetch_snapshot(
353            &reqwest::Client::new(),
354            "sk-orca-test",
355            &cache,
356            &endpoints_for(&server),
357            Duration::from_secs(0),
358        )
359        .await
360        .unwrap_err();
361        let message = err.to_string();
362        assert!(
363            matches!(err, AppError::Schema(_)),
364            "expected schema failure, got {err:?}"
365        );
366        assert!(message.contains("error envelope"), "{message}");
367        assert!(message.contains("Invalid API key"), "{message}");
368    }
369
370    /// With a cold cache there is no figure to show, so the real status must
371    /// survive — the openrouter lesson.
372    #[tokio::test]
373    async fn an_http_error_with_no_cache_surfaces_the_status() {
374        let mut server = mockito::Server::new_async().await;
375        server
376            .mock("GET", "/v1/dashboard/billing/usage")
377            .with_status(401)
378            .with_body(r#"{"error":"unauthorized"}"#)
379            .create_async()
380            .await;
381        server
382            .mock("GET", "/v1/dashboard/billing/subscription")
383            .with_status(401)
384            .with_body(r#"{"error":"unauthorized"}"#)
385            .create_async()
386            .await;
387
388        let (_td, cache) = cache_fixture();
389        let err = fetch_snapshot(
390            &reqwest::Client::new(),
391            "sk-orca-test",
392            &cache,
393            &endpoints_for(&server),
394            Duration::from_secs(0),
395        )
396        .await
397        .unwrap_err();
398        assert!(
399            matches!(err, AppError::Http { status: 401, .. }),
400            "expected the 401 to survive, got {err:?}"
401        );
402    }
403
404    /// 401 with a warm cache: the last good card is shown stale, the status
405    /// rides along via `write_last_error`, and the 401 body is replaced by the
406    /// shared auth-failure message — never the upstream text.
407    #[tokio::test]
408    async fn http_401_falls_back_to_cache_with_a_redacted_body() {
409        let mut server = mockito::Server::new_async().await;
410        server
411            .mock("GET", "/v1/dashboard/billing/usage")
412            .with_status(401)
413            .with_body(r#"{"error":{"message":"sk-orca-test leaked"}}"#)
414            .create_async()
415            .await;
416        server
417            .mock("GET", "/v1/dashboard/billing/subscription")
418            .with_status(401)
419            .with_body(r#"{"error":"unauthorized"}}"#)
420            .create_async()
421            .await;
422
423        let (_td, cache) = cache_fixture();
424        let endpoints = endpoints_for(&server);
425        let seed = serde_json::json!({
426            "target": target_key(&endpoints, "sk-orca-test"),
427            "snapshot": { "spent_cents": 900, "limit_cents": 1250, "access_until": null },
428        });
429        cache
430            .write_payload(&serde_json::to_vec(&seed).unwrap())
431            .unwrap();
432
433        let out = fetch_snapshot(
434            &reqwest::Client::new(),
435            "sk-orca-test",
436            &cache,
437            &endpoints,
438            Duration::from_secs(0),
439        )
440        .await
441        .unwrap();
442        assert!(out.stale);
443        assert_eq!(out.snapshot.spent_cents, 900);
444        assert_eq!(out.snapshot.remaining_cents(), Some(350));
445        let (code, body) = out.last_error.expect("error recorded alongside the figure");
446        assert_eq!(code, 401);
447        assert!(
448            !body.contains("sk-orca-test") && !body.contains("leaked"),
449            "401 body must be redacted, got {body:?}"
450        );
451    }
452
453    /// Replacing the configured key can select another account. A fresh cache
454    /// from the previous key must not cross that boundary.
455    #[tokio::test]
456    async fn cache_from_another_key_is_rejected() {
457        let mut server = mockito::Server::new_async().await;
458        server
459            .mock("GET", "/v1/dashboard/billing/usage")
460            .match_header("authorization", "Bearer sk-orca-new")
461            .with_status(200)
462            .with_body(USAGE_BODY)
463            .expect(1)
464            .create_async()
465            .await;
466        server
467            .mock("GET", "/v1/dashboard/billing/subscription")
468            .match_header("authorization", "Bearer sk-orca-new")
469            .with_status(200)
470            .with_body(SUBSCRIPTION_BODY)
471            .expect(1)
472            .create_async()
473            .await;
474
475        let (_td, cache) = cache_fixture();
476        let endpoints = endpoints_for(&server);
477        let seed = serde_json::json!({
478            "target": target_key(&endpoints, "sk-orca-old"),
479            "snapshot": { "spent_cents": 99999, "limit_cents": 100000, "access_until": null },
480        });
481        cache
482            .write_payload(&serde_json::to_vec(&seed).unwrap())
483            .unwrap();
484
485        // A long TTL: the payload IS fresh, it just belongs to another key.
486        let out = fetch_snapshot(
487            &reqwest::Client::new(),
488            "sk-orca-new",
489            &cache,
490            &endpoints,
491            Duration::from_secs(3600),
492        )
493        .await
494        .unwrap();
495        assert_eq!(out.snapshot.spent_cents, 275, "refetched, not 99999");
496
497        let stored = std::fs::read_to_string(cache.payload_path()).unwrap();
498        assert!(!stored.contains("sk-orca-new"), "cache leaked the API key");
499    }
500
501    #[test]
502    fn cache_round_trips_and_validates() {
503        let endpoints = Endpoints::default();
504        let target = target_key(&endpoints, "k");
505        let snap = OrcaRouterSnapshot {
506            spent_cents: 275,
507            limit_cents: Some(1250),
508            access_until: chrono::DateTime::from_timestamp(1_790_000_000, 0),
509        };
510        let bytes = serde_json::to_vec(&serde_json::json!({
511            "target": target,
512            "snapshot": serde_repr(&snap),
513        }))
514        .unwrap();
515        assert_eq!(parse_cache(&bytes, &target).unwrap(), snap);
516
517        // A spend-only card round-trips too (sentinel or absent limit).
518        let spend_only = OrcaRouterSnapshot {
519            spent_cents: 275,
520            limit_cents: None,
521            access_until: None,
522        };
523        let bytes = serde_json::to_vec(&serde_json::json!({
524            "target": target,
525            "snapshot": serde_repr(&spend_only),
526        }))
527        .unwrap();
528        assert_eq!(parse_cache(&bytes, &target).unwrap(), spend_only);
529
530        // Missing or negative spend is drift, not zero.
531        for bad in [
532            serde_json::json!({"target": target, "snapshot": {}}),
533            serde_json::json!({"target": target, "snapshot": {"spent_cents": -1}}),
534            serde_json::json!({"target": target, "snapshot": {"spent_cents": "275"}}),
535        ] {
536            let err = parse_cache(&serde_json::to_vec(&bad).unwrap(), &target);
537            assert!(err.is_err(), "{bad} must not parse");
538        }
539        // A foreign target is refused.
540        let foreign = serde_json::to_vec(&serde_json::json!({
541            "target": "somewhere-else",
542            "snapshot": {"spent_cents": 1, "limit_cents": null, "access_until": null},
543        }))
544        .unwrap();
545        assert!(parse_cache(&foreign, &target).is_err());
546    }
547}