1use 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
33pub type FetchOutcome = crate::outcome::Outcome<OpenRouterSnapshot>;
36
37pub 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 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 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
103fn 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 let credits_fut = fetch_one::<CreditsData>(client, &endpoints.credits, api_key);
193 let key_fut = fetch_one::<KeyData>(client, &endpoints.key, api_key);
194 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 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 #[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 #[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 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 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 #[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 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 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 .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 #[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 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}