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::{ActivityItem, 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    pub activity: String,
21}
22
23impl Default for Endpoints {
24    fn default() -> Self {
25        Self {
26            credits: format!("{BASE_URL}/credits"),
27            key: format!("{BASE_URL}/key"),
28            activity: format!("{BASE_URL}/activity"),
29        }
30    }
31}
32
33/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
34/// specialised to its snapshot.
35pub type FetchOutcome = crate::outcome::Outcome<OpenRouterSnapshot>;
36
37/// Cache-aware fetch. Mirrors `anthropic::fetch::fetch_snapshot` semantics:
38/// fresh cache short-circuits; on failure, fall back to cache + mark stale.
39/// `management_key` is the optional OpenRouter management key: only
40/// `/activity` uses it, and when it is `None` that request is skipped
41/// entirely instead of fired at a 401 with the inference key.
42pub async fn fetch_snapshot(
43    client: &reqwest::Client,
44    api_key: &str,
45    management_key: Option<&str>,
46    cache: &Cache,
47    endpoints: &Endpoints,
48    cache_ttl: Duration,
49) -> Result<FetchOutcome> {
50    cache.ensure_dir()?;
51    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
52
53    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
54        && let Ok(outcome) = reuse_cache(bytes, cache, false)
55    {
56        return Ok(outcome);
57    }
58    // Corrupt fresh cache: fall through to live fetch rather than return a
59    // fabricated zero-credit snapshot.
60
61    match fetch_live(client, endpoints, api_key, management_key).await {
62        Ok((credits, key, recent_models)) => {
63            let snap = combine(credits, key, recent_models);
64            // Serialize back to JSON for the cache.
65            let cache_repr = serde_json::json!({
66                "snapshot": serde_repr(&snap),
67            });
68            let bytes = serde_json::to_vec(&cache_repr)?;
69            cache.write_payload(&bytes)?;
70            Ok(crate::outcome::Outcome::fresh(snap))
71        }
72        Err(e) if e.is_transient() => fallback_silent(cache, e),
73        Err(AppError::Http { status, body }) => {
74            cache.mark_stale();
75            let last_error = Some(cache.write_last_error(status, &body));
76            fallback_with_error(cache, last_error, AppError::Http { status, body })
77        }
78        Err(e) => {
79            cache.mark_stale();
80            let last_error = Some(cache.write_last_error(0, &e.to_string()));
81            fallback_with_error(cache, last_error, e)
82        }
83    }
84}
85
86fn fallback_silent(cache: &Cache, original: AppError) -> Result<FetchOutcome> {
87    crate::outcome::fallback(cache, None, original, parse_cache)
88}
89
90fn fallback_with_error(
91    cache: &Cache,
92    last_error: Option<(u16, String)>,
93    original: AppError,
94) -> Result<FetchOutcome> {
95    crate::outcome::fallback(cache, last_error, original, parse_cache)
96}
97
98fn reuse_cache(bytes: Vec<u8>, cache: &Cache, stale: bool) -> Result<FetchOutcome> {
99    let snap = parse_cache(&bytes)?;
100    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
101}
102
103/// Cached money is required, not optional: a truncated or half-written payload
104/// must be refetched rather than rendered as $0.00 with a free-tier badge.
105/// `limit`/`limit_remaining` stay optional — the API itself returns them null.
106fn parse_cache(bytes: &[u8]) -> Result<OpenRouterSnapshot> {
107    let v: serde_json::Value = serde_json::from_slice(bytes)?;
108    let s = v
109        .get("snapshot")
110        .ok_or_else(|| AppError::Schema("openrouter cache missing 'snapshot' field".into()))?;
111    let money = |name: &str| -> Result<f64> {
112        let n = s
113            .get(name)
114            .and_then(serde_json::Value::as_f64)
115            .ok_or_else(|| AppError::Schema(format!("openrouter cache missing '{name}'")))?;
116        if n.is_finite() && n >= 0.0 {
117            Ok(n)
118        } else {
119            Err(AppError::Schema(format!(
120                "openrouter cache '{name}' is not finite and non-negative"
121            )))
122        }
123    };
124    let optional_money = |name: &str, nonnegative: bool| -> Result<Option<f64>> {
125        match s.get(name) {
126            None | Some(serde_json::Value::Null) => Ok(None),
127            Some(value) => {
128                let number = value.as_f64().ok_or_else(|| {
129                    AppError::Schema(format!("openrouter cache '{name}' is not numeric or null"))
130                })?;
131                if number.is_finite() && (!nonnegative || number >= 0.0) {
132                    Ok(Some(number))
133                } else {
134                    Err(AppError::Schema(format!(
135                        "openrouter cache '{name}' is outside its valid range"
136                    )))
137                }
138            }
139        }
140    };
141    Ok(OpenRouterSnapshot {
142        label: s
143            .get("label")
144            .and_then(serde_json::Value::as_str)
145            .ok_or_else(|| AppError::Schema("openrouter cache missing 'label'".into()))?
146            .to_string(),
147        total_credits: money("total_credits")?,
148        total_usage: money("total_usage")?,
149        usage_daily: money("usage_daily")?,
150        usage_weekly: money("usage_weekly")?,
151        usage_monthly: money("usage_monthly")?,
152        is_free_tier: s["is_free_tier"]
153            .as_bool()
154            .ok_or_else(|| AppError::Schema("openrouter cache missing 'is_free_tier'".into()))?,
155        limit: optional_money("limit", true)?,
156        limit_remaining: optional_money("limit_remaining", false)?,
157        recent_models: s
158            .get("recent_models")
159            .and_then(serde_json::Value::as_array)
160            .map(|arr| {
161                arr.iter()
162                    .filter_map(serde_json::Value::as_str)
163                    .map(String::from)
164                    .collect()
165            })
166            .unwrap_or_default(),
167    })
168}
169
170fn serde_repr(snap: &OpenRouterSnapshot) -> serde_json::Value {
171    serde_json::json!({
172        "label": snap.label,
173        "total_credits": snap.total_credits,
174        "total_usage": snap.total_usage,
175        "usage_daily": snap.usage_daily,
176        "usage_weekly": snap.usage_weekly,
177        "usage_monthly": snap.usage_monthly,
178        "is_free_tier": snap.is_free_tier,
179        "limit": snap.limit,
180        "limit_remaining": snap.limit_remaining,
181        "recent_models": snap.recent_models,
182    })
183}
184
185async fn fetch_live(
186    client: &reqwest::Client,
187    endpoints: &Endpoints,
188    api_key: &str,
189    management_key: Option<&str>,
190) -> Result<(CreditsData, KeyData, Vec<String>)> {
191    // Fetch in parallel.
192    let credits_fut = fetch_one::<CreditsData>(client, &endpoints.credits, api_key);
193    let key_fut = fetch_one::<KeyData>(client, &endpoints.key, api_key);
194    // `/activity` answers to a management key only, so without one the
195    // request is not even fired — a guaranteed 401, and the regular key must
196    // never leave for that path.
197    let activity_fut = async {
198        match management_key {
199            Some(key) => {
200                Some(fetch_one::<Vec<ActivityItem>>(client, &endpoints.activity, key).await)
201            }
202            None => None,
203        }
204    };
205    let (credits, key, activity) = tokio::join!(credits_fut, key_fut, activity_fut);
206    let recent_models = match activity {
207        Some(Ok(items)) => extract_recent_models(items),
208        _ => Vec::new(),
209    };
210    Ok((credits?, key?, recent_models))
211}
212
213fn shorten_model_id(name: &str) -> &str {
214    let mut clean = name.strip_suffix("-instruct").unwrap_or(name);
215    clean = clean.strip_suffix("-preview").unwrap_or(clean);
216    clean = clean.strip_suffix("-chat").unwrap_or(clean);
217    while clean.len() > 18 && clean.contains('-') {
218        if let Some((prefix, _)) = clean.rsplit_once('-') {
219            clean = prefix;
220        } else {
221            break;
222        }
223    }
224    clean
225}
226
227fn extract_recent_models(mut items: Vec<ActivityItem>) -> Vec<String> {
228    // "Recent" means recent: newest dates first. The sort is stable, so items
229    // without a `date` (and same-date ties) keep the API's response order.
230    items.sort_by(|a, b| b.date.cmp(&a.date));
231    let mut models: Vec<(String, f64, u64)> = Vec::new();
232    for item in items {
233        if let Some(m) = item.model {
234            if let Some(existing) = models.iter_mut().find(|(name, _, _)| name == &m) {
235                existing.1 += item.usage;
236                existing.2 += item.requests;
237            } else {
238                models.push((m, item.usage, item.requests));
239            }
240        }
241    }
242    models.truncate(2);
243    models
244        .into_iter()
245        .map(|(m, spend, reqs)| {
246            let short_name = m.split('/').next_back().unwrap_or(&m);
247            let clean_name = shorten_model_id(short_name);
248            let cost = crate::format::usd(spend);
249            if spend > 0.0 {
250                format!("{clean_name} ({cost} · {reqs} reqs)")
251            } else {
252                format!("{clean_name} ({reqs} reqs)")
253            }
254        })
255        .collect()
256}
257
258async fn fetch_one<T: for<'de> serde::Deserialize<'de>>(
259    client: &reqwest::Client,
260    url: &str,
261    api_key: &str,
262) -> Result<T> {
263    let resp = tokio::time::timeout(
264        HTTP_TIMEOUT,
265        client
266            .get(url)
267            .header("Authorization", format!("Bearer {api_key}"))
268            .send(),
269    )
270    .await
271    .map_err(|_| AppError::Transport(format!("openrouter timeout: {url}")))??;
272
273    let status = resp.status();
274    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
275
276    if !status.is_success() {
277        let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
278        return Err(AppError::Http {
279            status: status.as_u16(),
280            body,
281        });
282    }
283    let env: OrEnvelope<T> = serde_json::from_slice(&bytes)
284        .map_err(|e| AppError::Schema(format!("openrouter {url}: {e}")))?;
285    Ok(env.data)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use tempfile::TempDir;
292
293    fn cache_fixture() -> (TempDir, Cache) {
294        let td = TempDir::new().unwrap();
295        let cache = Cache::at(td.path().join("openrouter"));
296        cache.ensure_dir().unwrap();
297        (td, cache)
298    }
299
300    #[tokio::test]
301    async fn live_fetch_combines_both_endpoints() {
302        let mut server = mockito::Server::new_async().await;
303        server
304            .mock("GET", "/api/v1/credits")
305            .with_status(200)
306            .with_body(r#"{"data":{"total_credits":100.0,"total_usage":25.5}}"#)
307            .create_async()
308            .await;
309        server
310            .mock("GET", "/api/v1/key")
311            .with_status(200)
312            .with_body(
313                r#"{"data":{"label":"prod","limit":50.0,"limit_remaining":24.5,
314                "usage":25.5,"usage_daily":1.0,"usage_weekly":7.0,"usage_monthly":25.5,
315                "is_free_tier":false}}"#,
316            )
317            .create_async()
318            .await;
319
320        let (_td, cache) = cache_fixture();
321        let client = reqwest::Client::new();
322        let endpoints = Endpoints {
323            credits: format!("{}/api/v1/credits", server.url()),
324            key: format!("{}/api/v1/key", server.url()),
325            activity: format!("{}/api/v1/activity", server.url()),
326        };
327        let out = fetch_snapshot(
328            &client,
329            "sk-or-test",
330            None,
331            &cache,
332            &endpoints,
333            Duration::from_secs(0),
334        )
335        .await
336        .unwrap();
337        assert_eq!(out.snapshot.total_credits, 100.0);
338        assert_eq!(out.snapshot.total_usage, 25.5);
339        assert!((out.snapshot.balance() - 74.5).abs() < 1e-9);
340        assert_eq!(out.snapshot.label, "OpenRouter — prod");
341        assert!(!out.stale);
342    }
343
344    /// With a cache to fall back on, the status rides along as `last_error`
345    /// and the user still sees a figure. With a *cold* cache there is no
346    /// figure, and the error is all the user gets — so it has to be the real
347    /// one. This returned `AppError::Other("openrouter: no usable cache")`
348    /// once, which reads as an internal problem on a first run where the
349    /// actual cause is a key that was never accepted.
350    #[tokio::test]
351    async fn an_http_error_with_no_cache_surfaces_the_status_not_a_cache_message() {
352        let mut server = mockito::Server::new_async().await;
353        server
354            .mock("GET", "/api/v1/credits")
355            .with_status(401)
356            .with_body(r#"{"error":"unauthorized"}"#)
357            .create_async()
358            .await;
359
360        let (_td, cache) = cache_fixture();
361        let endpoints = Endpoints {
362            credits: format!("{}/api/v1/credits", server.url()),
363            key: format!("{}/api/v1/key", server.url()),
364            activity: format!("{}/api/v1/activity", server.url()),
365        };
366        let err = fetch_snapshot(
367            &reqwest::Client::new(),
368            "sk-or-test",
369            None,
370            &cache,
371            &endpoints,
372            Duration::from_secs(0),
373        )
374        .await
375        .unwrap_err();
376
377        assert!(
378            matches!(err, AppError::Http { status: 401, .. }),
379            "expected the 401 to survive, got {err:?}"
380        );
381    }
382
383    /// The fan-out behind `[[openrouter.accounts]]` (#221): every account is
384    /// its own `fetch_snapshot` call with its own key and its own cache
385    /// subdirectory (what `Cache::for_vendor_account` lays out in production),
386    /// so two keys must land as two distinct snapshots that never share a
387    /// payload — not even a stale one.
388    #[tokio::test]
389    async fn two_accounts_fan_out_to_distinct_entries_and_caches() {
390        let mut server = mockito::Server::new_async().await;
391        server
392            .mock("GET", "/api/v1/credits")
393            .match_header("authorization", "Bearer work-key")
394            .with_status(200)
395            .with_body(r#"{"data":{"total_credits":100.0,"total_usage":25.5}}"#)
396            .create_async()
397            .await;
398        server
399            .mock("GET", "/api/v1/key")
400            .match_header("authorization", "Bearer work-key")
401            .with_status(200)
402            .with_body(
403                r#"{"data":{"label":"work","limit":null,"limit_remaining":null,
404                "usage":25.5,"usage_daily":1.0,"usage_weekly":7.0,"usage_monthly":25.5,
405                "is_free_tier":false}}"#,
406            )
407            .create_async()
408            .await;
409        server
410            .mock("GET", "/api/v1/credits")
411            .match_header("authorization", "Bearer personal-key")
412            .with_status(200)
413            .with_body(r#"{"data":{"total_credits":40.0,"total_usage":4.0}}"#)
414            .create_async()
415            .await;
416        server
417            .mock("GET", "/api/v1/key")
418            .match_header("authorization", "Bearer personal-key")
419            .with_status(200)
420            .with_body(
421                r#"{"data":{"label":"home","limit":null,"limit_remaining":null,
422                "usage":4.0,"usage_daily":0.5,"usage_weekly":2.0,"usage_monthly":4.0,
423                "is_free_tier":true}}"#,
424            )
425            .create_async()
426            .await;
427
428        let td = tempfile::TempDir::new().unwrap();
429        let endpoints = Endpoints {
430            credits: format!("{}/api/v1/credits", server.url()),
431            key: format!("{}/api/v1/key", server.url()),
432            activity: format!("{}/api/v1/activity", server.url()),
433        };
434        let mut outcomes = Vec::new();
435        let mut caches = Vec::new();
436        for (label, key) in [("work", "work-key"), ("personal", "personal-key")] {
437            // The same per-account subdirectory `Cache::for_vendor_account`
438            // builds in production, created via the hermetic `Cache::at`.
439            let cache = Cache::at(td.path().join("openrouter").join(label));
440            cache.ensure_dir().unwrap();
441            let outcome = fetch_snapshot(
442                &reqwest::Client::new(),
443                key,
444                None,
445                &cache,
446                &endpoints,
447                Duration::from_secs(0),
448            )
449            .await
450            .unwrap();
451            outcomes.push(outcome);
452            caches.push(cache);
453        }
454
455        let [work, personal] = &outcomes[..] else {
456            panic!("expected exactly two account outcomes");
457        };
458        assert_eq!(work.snapshot.label, "OpenRouter — work");
459        assert!((work.snapshot.balance() - 74.5).abs() < 1e-9);
460        assert_eq!(personal.snapshot.label, "OpenRouter — home");
461        assert!((personal.snapshot.balance() - 36.0).abs() < 1e-9);
462        assert_ne!(work.snapshot, personal.snapshot);
463
464        // Each cache holds its own account's payload, and only its own.
465        let work_bytes = caches[0].maybe_payload().unwrap().unwrap();
466        let personal_bytes = caches[1].maybe_payload().unwrap().unwrap();
467        assert_ne!(work_bytes, personal_bytes);
468        assert_eq!(parse_cache(&work_bytes).unwrap().label, "OpenRouter — work");
469        assert_eq!(
470            parse_cache(&personal_bytes).unwrap().label,
471            "OpenRouter — home"
472        );
473    }
474
475    /// One account's dead key must not take the others down: the fan-out
476    /// runs one fetch per account against one cache per account, so a 401
477    /// stays inside the entry it belongs to (#221).
478    #[tokio::test]
479    async fn a_401_on_one_account_does_not_fail_the_other() {
480        let mut server = mockito::Server::new_async().await;
481        for path in ["/api/v1/credits", "/api/v1/key"] {
482            server
483                .mock("GET", path)
484                .match_header("authorization", "Bearer revoked-key")
485                .with_status(401)
486                .with_body(r#"{"error":"unauthorized"}"#)
487                .create_async()
488                .await;
489        }
490        server
491            .mock("GET", "/api/v1/credits")
492            .match_header("authorization", "Bearer live-key")
493            .with_status(200)
494            .with_body(r#"{"data":{"total_credits":30.0,"total_usage":6.0}}"#)
495            .create_async()
496            .await;
497        server
498            .mock("GET", "/api/v1/key")
499            .match_header("authorization", "Bearer live-key")
500            .with_status(200)
501            .with_body(
502                r#"{"data":{"label":"live","limit":null,"limit_remaining":null,
503                "usage":6.0,"usage_daily":2.0,"usage_weekly":4.0,"usage_monthly":6.0,
504                "is_free_tier":false}}"#,
505            )
506            .create_async()
507            .await;
508
509        let td = tempfile::TempDir::new().unwrap();
510        let endpoints = Endpoints {
511            credits: format!("{}/api/v1/credits", server.url()),
512            key: format!("{}/api/v1/key", server.url()),
513            activity: format!("{}/api/v1/activity", server.url()),
514        };
515        let client = reqwest::Client::new();
516
517        let revoked_cache = Cache::at(td.path().join("openrouter").join("revoked"));
518        revoked_cache.ensure_dir().unwrap();
519        let revoked = fetch_snapshot(
520            &client,
521            "revoked-key",
522            None,
523            &revoked_cache,
524            &endpoints,
525            Duration::from_secs(0),
526        )
527        .await;
528        assert!(
529            matches!(revoked, Err(AppError::Http { status: 401, .. })),
530            "expected the revoked account to fail with its own 401, got {revoked:?}"
531        );
532
533        let live_cache = Cache::at(td.path().join("openrouter").join("live"));
534        live_cache.ensure_dir().unwrap();
535        let live = fetch_snapshot(
536            &client,
537            "live-key",
538            None,
539            &live_cache,
540            &endpoints,
541            Duration::from_secs(0),
542        )
543        .await
544        .unwrap();
545        assert_eq!(live.snapshot.label, "OpenRouter — live");
546        assert!((live.snapshot.balance() - 24.0).abs() < 1e-9);
547        assert!(
548            live_cache.maybe_payload().unwrap().is_some(),
549            "the healthy account's cache must still be written"
550        );
551    }
552
553    #[tokio::test]
554    async fn http_error_falls_back_to_cache_when_present() {
555        let mut server = mockito::Server::new_async().await;
556        server
557            .mock("GET", "/api/v1/credits")
558            .with_status(401)
559            .with_body(r#"{"error":"unauthorized"}"#)
560            .create_async()
561            .await;
562        server
563            .mock("GET", "/api/v1/key")
564            .with_status(401)
565            .with_body(r#"{"error":"unauthorized"}"#)
566            .create_async()
567            .await;
568
569        let (_td, cache) = cache_fixture();
570        // Seed cache with a "snapshot" repr.
571        let seed = serde_json::json!({
572            "snapshot": {
573                "label":"OpenRouter — seed","total_credits": 50.0,
574                "total_usage": 10.0,"usage_daily":1.0,"usage_weekly":3.0,
575                "usage_monthly":10.0,"is_free_tier":false,
576                "limit":null,"limit_remaining":null
577            }
578        });
579        cache.write_payload(seed.to_string().as_bytes()).unwrap();
580
581        let client = reqwest::Client::new();
582        let endpoints = Endpoints {
583            credits: format!("{}/api/v1/credits", server.url()),
584            key: format!("{}/api/v1/key", server.url()),
585            activity: format!("{}/api/v1/activity", server.url()),
586        };
587        let out = fetch_snapshot(
588            &client,
589            "k",
590            None,
591            &cache,
592            &endpoints,
593            Duration::from_secs(0),
594        )
595        .await
596        .unwrap();
597        assert!(out.stale);
598        assert_eq!(out.snapshot.label, "OpenRouter — seed");
599        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
600    }
601
602    /// Shared fixtures for the `/activity` tests: healthy credits + key
603    /// endpoints, so the only variable is the activity call itself.
604    async fn mock_credits_and_key(server: &mut mockito::ServerGuard) {
605        server
606            .mock("GET", "/api/v1/credits")
607            .match_header("authorization", "Bearer sk-or-test")
608            .with_status(200)
609            .with_body(r#"{"data":{"total_credits":100.0,"total_usage":25.5}}"#)
610            .create_async()
611            .await;
612        server
613            .mock("GET", "/api/v1/key")
614            .match_header("authorization", "Bearer sk-or-test")
615            .with_status(200)
616            .with_body(
617                r#"{"data":{"label":"prod","limit":null,"limit_remaining":null,
618                "usage":25.5,"usage_daily":1.0,"usage_weekly":7.0,"usage_monthly":25.5,
619                "is_free_tier":false}}"#,
620            )
621            .create_async()
622            .await;
623    }
624
625    fn endpoints_for(server: &mockito::ServerGuard) -> Endpoints {
626        Endpoints {
627            credits: format!("{}/api/v1/credits", server.url()),
628            key: format!("{}/api/v1/key", server.url()),
629            activity: format!("{}/api/v1/activity", server.url()),
630        }
631    }
632
633    #[tokio::test]
634    async fn activity_feeds_recent_models_with_the_management_key() {
635        let mut server = mockito::Server::new_async().await;
636        mock_credits_and_key(&mut server).await;
637        let activity = server
638            .mock("GET", "/api/v1/activity")
639            // The management key goes to /activity — the regular inference
640            // key must never be sent there.
641            .match_header("authorization", "Bearer sk-or-mgmt")
642            .with_status(200)
643            .with_body(
644                r#"{"data":[
645                {"date":"2026-10-02","model":"openai/gpt-5-codex","usage":1.25,"requests":42},
646                {"date":"2026-10-01","model":"anthropic/claude-sonnet-4.5","usage":0.5,"requests":7}
647                ]}"#,
648            )
649            .expect(1)
650            .create_async()
651            .await;
652
653        let (_td, cache) = cache_fixture();
654        let out = fetch_snapshot(
655            &reqwest::Client::new(),
656            "sk-or-test",
657            Some("sk-or-mgmt"),
658            &cache,
659            &endpoints_for(&server),
660            Duration::from_secs(0),
661        )
662        .await
663        .unwrap();
664        assert_eq!(
665            out.snapshot.recent_models,
666            vec![
667                "gpt-5-codex ($1.25 · 42 reqs)".to_string(),
668                "claude-sonnet-4.5 ($0.50 · 7 reqs)".to_string(),
669            ]
670        );
671        activity.assert_async().await;
672    }
673
674    #[tokio::test]
675    async fn malformed_activity_leaves_recent_models_empty() {
676        let mut server = mockito::Server::new_async().await;
677        mock_credits_and_key(&mut server).await;
678        server
679            .mock("GET", "/api/v1/activity")
680            .with_status(200)
681            .with_body(r#"{"data":"not-an-array"}"#)
682            .create_async()
683            .await;
684
685        let (_td, cache) = cache_fixture();
686        let out = fetch_snapshot(
687            &reqwest::Client::new(),
688            "sk-or-test",
689            Some("sk-or-mgmt"),
690            &cache,
691            &endpoints_for(&server),
692            Duration::from_secs(0),
693        )
694        .await
695        .unwrap();
696        assert!(out.snapshot.recent_models.is_empty());
697        assert!((out.snapshot.balance() - 74.5).abs() < 1e-9);
698    }
699
700    #[tokio::test]
701    async fn a_401_from_activity_leaves_recent_models_empty() {
702        let mut server = mockito::Server::new_async().await;
703        mock_credits_and_key(&mut server).await;
704        server
705            .mock("GET", "/api/v1/activity")
706            .with_status(401)
707            .with_body(r#"{"error":"unauthorized"}"#)
708            .create_async()
709            .await;
710
711        let (_td, cache) = cache_fixture();
712        let out = fetch_snapshot(
713            &reqwest::Client::new(),
714            "sk-or-test",
715            Some("sk-or-mgmt"),
716            &cache,
717            &endpoints_for(&server),
718            Duration::from_secs(0),
719        )
720        .await
721        .unwrap();
722        assert!(out.snapshot.recent_models.is_empty());
723        assert!((out.snapshot.balance() - 74.5).abs() < 1e-9);
724    }
725
726    /// Without a management key the activity endpoint only ever answers 401,
727    /// so the request must not be fired at all — zero hits on the mock.
728    #[tokio::test]
729    async fn without_a_management_key_the_activity_request_is_not_fired() {
730        let mut server = mockito::Server::new_async().await;
731        mock_credits_and_key(&mut server).await;
732        let activity = server
733            .mock("GET", "/api/v1/activity")
734            .expect(0)
735            .create_async()
736            .await;
737
738        let (_td, cache) = cache_fixture();
739        let out = fetch_snapshot(
740            &reqwest::Client::new(),
741            "sk-or-test",
742            None,
743            &cache,
744            &endpoints_for(&server),
745            Duration::from_secs(0),
746        )
747        .await
748        .unwrap();
749        assert!(out.snapshot.recent_models.is_empty());
750        assert!((out.snapshot.balance() - 74.5).abs() < 1e-9);
751        activity.assert_async().await;
752    }
753
754    fn activity_item(
755        date: Option<&str>,
756        model: Option<&str>,
757        usage: f64,
758        requests: u64,
759    ) -> ActivityItem {
760        ActivityItem {
761            date: date.map(str::to_string),
762            model: model.map(str::to_string),
763            usage,
764            requests,
765        }
766    }
767
768    #[test]
769    fn recent_models_sort_by_date_descending_before_deduping() {
770        let items = vec![
771            activity_item(Some("2026-09-28"), Some("a/old-model"), 0.10, 3),
772            activity_item(Some("2026-10-02"), Some("b/new-model"), 0.20, 5),
773            // The older first occurrence must not win: after sorting, the
774            // 2026-10-01 row merges into old-model's first (newest) slot.
775            activity_item(Some("2026-10-01"), Some("a/old-model"), 0.30, 2),
776        ];
777        let models = extract_recent_models(items);
778        assert_eq!(
779            models,
780            vec![
781                "new-model ($0.20 · 5 reqs)".to_string(),
782                "old-model ($0.40 · 5 reqs)".to_string(),
783            ]
784        );
785    }
786
787    #[test]
788    fn recent_models_without_dates_keep_response_order() {
789        let items = vec![
790            activity_item(None, Some("a/first"), 0.0, 1),
791            activity_item(None, Some("b/second"), 0.0, 2),
792            activity_item(None, Some("c/third"), 0.0, 3),
793        ];
794        let models = extract_recent_models(items);
795        assert_eq!(
796            models,
797            vec!["first (1 reqs)".to_string(), "second (2 reqs)".to_string()]
798        );
799    }
800
801    #[test]
802    fn shorten_model_id_cases() {
803        assert_eq!(shorten_model_id("llama-3.3-70b-instruct"), "llama-3.3-70b");
804        assert_eq!(shorten_model_id("gpt-5-preview"), "gpt-5");
805        assert_eq!(shorten_model_id("some-model-chat"), "some-model");
806        assert_eq!(
807            shorten_model_id("claude-3-5-sonnet-20241022"),
808            "claude-3-5-sonnet"
809        );
810        assert_eq!(
811            shorten_model_id("a-very-long-model-name-with-many-parts"),
812            "a-very-long-model"
813        );
814        assert_eq!(shorten_model_id("gpt"), "gpt");
815    }
816}