Skip to main content

ai_usagebar/anthropic_api/
fetch.rs

1//! Anthropic Admin API fetch — sums the current month's `cost_report` buckets
2//! into a month-to-date spend, under the shared cache + flock primitives.
3//!
4//! Auth is a Console **Admin key** (`sk-ant-admin01-…`, distinct from an
5//! inference key) in the `x-api-key` header. The monthly `limit` is NOT part of
6//! the API response — it's supplied from config and carried in the snapshot so
7//! the renderer can show spend-vs-limit.
8
9use std::time::Duration;
10
11use chrono::{DateTime, Datelike, Utc};
12
13use crate::cache::{Cache, MAX_STALE, acquire_lock_async};
14use crate::error::{AppError, Result};
15use crate::usage::{AnthropicApiSnapshot, finite_amount};
16use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
17
18use super::types::{CostReport, page_dollars};
19
20pub const BASE_URL: &str = "https://api.anthropic.com";
21pub const ANTHROPIC_VERSION: &str = "2023-06-01";
22const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
23const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
24/// Safety cap on pagination — a single month is at most 31 daily buckets, so a
25/// handful of pages is plenty; this just bounds a runaway `next_page` loop.
26const MAX_PAGES: usize = 12;
27
28#[derive(Debug, Clone)]
29pub struct Endpoints {
30    pub cost_report: String,
31}
32
33impl Default for Endpoints {
34    fn default() -> Self {
35        Self {
36            cost_report: format!("{BASE_URL}/v1/organizations/cost_report"),
37        }
38    }
39}
40
41#[derive(Debug, Clone)]
42pub struct FetchOutcome {
43    pub snapshot: AnthropicApiSnapshot,
44    pub stale: bool,
45    pub last_error: Option<(u16, String)>,
46    pub cache_age: Option<Duration>,
47}
48
49/// First instant of `now`'s calendar month, as an RFC-3339 UTC string.
50fn month_start_rfc3339(now: DateTime<Utc>) -> String {
51    format!("{:04}-{:02}-01T00:00:00Z", now.year(), now.month())
52}
53
54/// Identity of the organization whose spend is cached. The Admin API does not
55/// return an organization id in the cost report, so the key itself is the only
56/// zero-round-trip identity available. Store only a fingerprint: it is a cache
57/// change detector, not an authentication secret. If Rust ever changes the
58/// hasher algorithm, the harmless result is one extra refetch after upgrade.
59fn target_key(admin_key: &str) -> String {
60    use std::hash::{Hash, Hasher};
61    let mut hasher = std::collections::hash_map::DefaultHasher::new();
62    admin_key.hash(&mut hasher);
63    format!("key:{:016x}", hasher.finish())
64}
65
66fn validate_limit(limit: Option<f64>) -> Result<Option<f64>> {
67    if let Some(value) = limit
68        && (!value.is_finite() || value <= 0.0)
69    {
70        return Err(AppError::Schema(
71            "anthropic-api monthly_limit must be finite and greater than zero; \
72             remove it to show spend without a limit"
73                .into(),
74        ));
75    }
76    Ok(limit)
77}
78
79/// `limit` is the user-configured monthly USD limit (from config, not the API);
80/// it's threaded through so the cached snapshot reflects the current config.
81pub async fn fetch_snapshot(
82    client: &reqwest::Client,
83    admin_key: &str,
84    cache: &Cache,
85    endpoints: &Endpoints,
86    cache_ttl: Duration,
87    limit: Option<f64>,
88) -> Result<FetchOutcome> {
89    fetch_snapshot_at(
90        client,
91        admin_key,
92        cache,
93        endpoints,
94        cache_ttl,
95        limit,
96        Utc::now(),
97    )
98    .await
99}
100
101/// Same as [`fetch_snapshot`] with an injected clock — the seam month-rollover
102/// tests use, so they never depend on the wall clock.
103pub async fn fetch_snapshot_at(
104    client: &reqwest::Client,
105    admin_key: &str,
106    cache: &Cache,
107    endpoints: &Endpoints,
108    cache_ttl: Duration,
109    limit: Option<f64>,
110    now: DateTime<Utc>,
111) -> Result<FetchOutcome> {
112    let limit = validate_limit(limit)?;
113    cache.ensure_dir()?;
114    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
115
116    // The query always starts at the current month, so a payload written last
117    // month is a *different* figure — not a stale version of this one.
118    let month = month_start_rfc3339(now);
119    let target = target_key(admin_key);
120
121    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
122        && let Ok(outcome) = reuse_cache(&bytes, cache, false, limit, &month, &target)
123    {
124        return Ok(outcome);
125    }
126
127    match fetch_live(client, endpoints, admin_key, now).await {
128        Ok(spent) => {
129            let snap = AnthropicApiSnapshot { spent, limit };
130            let bytes = serde_json::to_vec(&serde_json::json!({
131                "month": month,
132                "target": target,
133                "snapshot": { "spent": snap.spent, "limit": snap.limit },
134            }))?;
135            cache.write_payload(&bytes)?;
136            Ok(FetchOutcome {
137                snapshot: snap,
138                stale: false,
139                last_error: None,
140                cache_age: Some(Duration::ZERO),
141            })
142        }
143        Err(e) if e.is_transient() => fallback_silent(cache, limit, &month, &target, e),
144        Err(AppError::Http { status, body }) => {
145            cache.mark_stale();
146            let diag = cache.write_last_error(status, &body);
147            fallback_with_error(
148                cache,
149                Some(diag),
150                limit,
151                &month,
152                &target,
153                AppError::Http { status, body },
154            )
155        }
156        Err(e) => {
157            cache.mark_stale();
158            let diag = cache.write_last_error(0, &e.to_string());
159            fallback_with_error(cache, Some(diag), limit, &month, &target, e)
160        }
161    }
162}
163
164fn fallback_silent(
165    cache: &Cache,
166    limit: Option<f64>,
167    month: &str,
168    target: &str,
169    original: AppError,
170) -> Result<FetchOutcome> {
171    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
172        return Err(original);
173    };
174    reuse_cache(&bytes, cache, true, limit, month, target)
175}
176
177/// On failure we show the last good figure with the error alongside it. With
178/// nothing usable cached there is nothing to show, so the **original** error is
179/// returned — a first-run 401/403 or schema error must reach the user with its
180/// Admin-key guidance intact, not as a generic "no usable cache".
181fn fallback_with_error(
182    cache: &Cache,
183    last_error: Option<(u16, String)>,
184    limit: Option<f64>,
185    month: &str,
186    target: &str,
187    original: AppError,
188) -> Result<FetchOutcome> {
189    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
190        return Err(original);
191    };
192    // Last month's spend is not this month's, so during an outage it is better
193    // to report the error than to display the wrong month as current.
194    let Ok(mut outcome) = reuse_cache(&bytes, cache, true, limit, month, target) else {
195        return Err(original);
196    };
197    outcome.last_error = last_error;
198    Ok(outcome)
199}
200
201fn reuse_cache(
202    bytes: &[u8],
203    cache: &Cache,
204    stale: bool,
205    limit: Option<f64>,
206    month: &str,
207    target: &str,
208) -> Result<FetchOutcome> {
209    // The cached spend is authoritative; the limit always comes from the
210    // current config so editing it takes effect without a refetch.
211    let spent = parse_cached_spent(bytes, month, target)?;
212    Ok(FetchOutcome {
213        snapshot: AnthropicApiSnapshot { spent, limit },
214        stale,
215        last_error: cache.read_last_error(),
216        cache_age: cache.payload_age(),
217    })
218}
219
220fn parse_cached_spent(bytes: &[u8], month: &str, target: &str) -> Result<f64> {
221    let v: serde_json::Value = serde_json::from_slice(bytes)?;
222    // Payloads written before the month was recorded cannot be attributed to
223    // one, so they are refetched rather than shown as the current month.
224    let cached_month = v.get("month").and_then(serde_json::Value::as_str);
225    if cached_month != Some(month) {
226        return Err(AppError::Schema(format!(
227            "anthropic-api cache is for a different month ({}); refetching",
228            cached_month.unwrap_or("unknown")
229        )));
230    }
231    let cached_target = v.get("target").and_then(serde_json::Value::as_str);
232    if cached_target != Some(target) {
233        return Err(AppError::Schema(
234            "anthropic-api cache belongs to a different Admin key; refetching".into(),
235        ));
236    }
237    let s = v
238        .get("snapshot")
239        .ok_or_else(|| AppError::Schema("anthropic-api cache missing 'snapshot'".into()))?;
240    let spent = s["spent"]
241        .as_f64()
242        .ok_or_else(|| AppError::Schema("anthropic-api cache missing 'spent'".into()))?;
243    crate::usage::finite_amount("anthropic-api cache", "spent", spent)
244}
245
246async fn fetch_live(
247    client: &reqwest::Client,
248    endpoints: &Endpoints,
249    admin_key: &str,
250    now: DateTime<Utc>,
251) -> Result<f64> {
252    let starting_at = month_start_rfc3339(now);
253    let mut total = 0.0;
254    let mut page: Option<String> = None;
255    let mut seen_pages: Vec<String> = Vec::new();
256
257    for _ in 0..MAX_PAGES {
258        let mut req = client
259            .get(&endpoints.cost_report)
260            .header("x-api-key", admin_key)
261            .header("anthropic-version", ANTHROPIC_VERSION)
262            .query(&[
263                ("starting_at", starting_at.as_str()),
264                ("bucket_width", "1d"),
265            ]);
266        if let Some(p) = &page {
267            req = req.query(&[("page", p.as_str())]);
268        }
269
270        let resp = tokio::time::timeout(HTTP_TIMEOUT, req.send())
271            .await
272            .map_err(|_| {
273                AppError::Transport(format!("anthropic-api timeout: {}", endpoints.cost_report))
274            })??;
275
276        let status = resp.status();
277        let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
278        if !status.is_success() {
279            let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
280            return Err(AppError::Http {
281                status: status.as_u16(),
282                body,
283            });
284        }
285
286        let report: CostReport = serde_json::from_slice(&bytes)
287            .map_err(|e| AppError::Schema(format!("anthropic-api cost_report: {e}")))?;
288        total = finite_amount(
289            "anthropic-api",
290            "cost_report running total",
291            total + page_dollars(&report)?,
292        )?;
293
294        // A partial total is indistinguishable from a genuinely smaller spend
295        // once cached, so every way pagination can go wrong is an error rather
296        // than an early `break` with whatever was summed so far.
297        match (report.has_more, report.next_page) {
298            (false, _) => return Ok(total),
299            (true, None) => {
300                return Err(AppError::Schema(
301                    "anthropic-api cost_report: has_more is true but next_page is missing; \
302                     refusing to report a partial month"
303                        .into(),
304                ));
305            }
306            (true, Some(p)) if p.trim().is_empty() => {
307                return Err(AppError::Schema(
308                    "anthropic-api cost_report: has_more is true but next_page is empty; \
309                     refusing to report a partial month"
310                        .into(),
311                ));
312            }
313            (true, Some(p)) => {
314                if seen_pages.contains(&p) {
315                    return Err(AppError::Schema(format!(
316                        "anthropic-api cost_report: pagination repeated cursor {p:?}; \
317                         refusing to report a partial month"
318                    )));
319                }
320                seen_pages.push(p.clone());
321                page = Some(p);
322            }
323        }
324    }
325    Err(AppError::Schema(format!(
326        "anthropic-api cost_report: more than {MAX_PAGES} pages for one month; \
327         refusing to report a partial month"
328    )))
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use chrono::TimeZone;
335    use tempfile::TempDir;
336
337    fn cache_fixture() -> (TempDir, Cache) {
338        let td = TempDir::new().unwrap();
339        let cache = Cache::at(td.path().join("anthropic_api"));
340        cache.ensure_dir().unwrap();
341        (td, cache)
342    }
343
344    #[test]
345    fn month_start_is_first_of_month_utc() {
346        let now = Utc.with_ymd_and_hms(2026, 7, 19, 15, 8, 0).unwrap();
347        assert_eq!(month_start_rfc3339(now), "2026-07-01T00:00:00Z");
348    }
349
350    #[tokio::test]
351    async fn live_fetch_sums_month_to_date_and_divides_by_100() {
352        let mut server = mockito::Server::new_async().await;
353        server
354            .mock("GET", "/v1/organizations/cost_report")
355            .match_header("x-api-key", "sk-ant-admin01-test")
356            .match_query(mockito::Matcher::Any)
357            .with_status(200)
358            .with_body(
359                r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"},
360                    {"amount":"34.0","currency":"USD"}]}],
361                    "has_more":false,"next_page":null}"#,
362            )
363            .create_async()
364            .await;
365
366        let (_td, cache) = cache_fixture();
367        let client = reqwest::Client::new();
368        let endpoints = Endpoints {
369            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
370        };
371        let out = fetch_snapshot(
372            &client,
373            "sk-ant-admin01-test",
374            &cache,
375            &endpoints,
376            Duration::from_secs(0),
377            Some(1000.0),
378        )
379        .await
380        .unwrap();
381        // 134 cents = $1.34
382        assert!((out.snapshot.spent - 1.34).abs() < 1e-9);
383        assert_eq!(out.snapshot.limit, Some(1000.0));
384        assert!(!out.stale);
385    }
386
387    #[tokio::test]
388    async fn http_401_falls_back_to_cache_when_present() {
389        let mut server = mockito::Server::new_async().await;
390        server
391            .mock("GET", "/v1/organizations/cost_report")
392            .match_query(mockito::Matcher::Any)
393            .with_status(401)
394            .with_body(r#"{"error":{"message":"invalid x-api-key"}}"#)
395            .create_async()
396            .await;
397
398        let (_td, cache) = cache_fixture();
399        let now = at(2026, 7, 19);
400        cache
401            .write_payload(
402                serde_json::json!({
403                    "month": month_start_rfc3339(now),
404                    "target": target_key("k"),
405                    "snapshot": { "spent": 2.5, "limit": null },
406                })
407                .to_string()
408                .as_bytes(),
409            )
410            .unwrap();
411
412        let client = reqwest::Client::new();
413        let endpoints = Endpoints {
414            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
415        };
416        let out = fetch_snapshot_at(
417            &client,
418            "k",
419            &cache,
420            &endpoints,
421            Duration::from_secs(0),
422            Some(50.0),
423            now,
424        )
425        .await
426        .unwrap();
427        assert!(out.stale);
428        assert!((out.snapshot.spent - 2.5).abs() < 1e-9);
429        // limit comes from the current call, not the (null) cache.
430        assert_eq!(out.snapshot.limit, Some(50.0));
431        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
432    }
433
434    /// Fixed instant helper — tests never read the wall clock.
435    fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
436        chrono::NaiveDate::from_ymd_opt(y, m, d)
437            .unwrap()
438            .and_hms_opt(12, 0, 0)
439            .unwrap()
440            .and_utc()
441    }
442
443    fn ok_body(cents: &str) -> String {
444        format!(
445            r#"{{"data":[{{"results":[{{"amount":"{cents}","currency":"USD"}}]}}],"has_more":false}}"#
446        )
447    }
448
449    #[tokio::test]
450    async fn month_rollover_refetches_instead_of_showing_last_month() {
451        // June's spend must never be displayed as July's, even while the
452        // payload is still inside the TTL.
453        let mut server = mockito::Server::new_async().await;
454        server
455            .mock("GET", "/v1/organizations/cost_report")
456            .match_query(mockito::Matcher::Any)
457            .with_status(200)
458            .with_body(ok_body("250.0"))
459            .create_async()
460            .await;
461
462        let (_td, cache) = cache_fixture();
463        cache
464            .write_payload(
465                serde_json::json!({
466                    "month": month_start_rfc3339(at(2026, 6, 30)),
467                    "target": target_key("k"),
468                    "snapshot": { "spent": 987.0, "limit": null },
469                })
470                .to_string()
471                .as_bytes(),
472            )
473            .unwrap();
474
475        let client = reqwest::Client::new();
476        let endpoints = Endpoints {
477            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
478        };
479        // Long TTL: the payload IS fresh, it is just the wrong month.
480        let out = fetch_snapshot_at(
481            &client,
482            "k",
483            &cache,
484            &endpoints,
485            Duration::from_secs(3600),
486            None,
487            at(2026, 7, 1),
488        )
489        .await
490        .unwrap();
491        assert!((out.snapshot.spent - 2.5).abs() < 1e-9);
492        assert!(!out.stale);
493    }
494
495    #[tokio::test]
496    async fn last_months_cache_is_not_served_during_an_outage() {
497        // With the API down and only June cached, reporting June as the current
498        // month is worse than surfacing the error.
499        let mut server = mockito::Server::new_async().await;
500        server
501            .mock("GET", "/v1/organizations/cost_report")
502            .match_query(mockito::Matcher::Any)
503            .with_status(500)
504            .with_body("upstream boom")
505            .create_async()
506            .await;
507
508        let (_td, cache) = cache_fixture();
509        cache
510            .write_payload(
511                serde_json::json!({
512                    "month": month_start_rfc3339(at(2026, 6, 30)),
513                    "target": target_key("k"),
514                    "snapshot": { "spent": 987.0, "limit": null },
515                })
516                .to_string()
517                .as_bytes(),
518            )
519            .unwrap();
520
521        let client = reqwest::Client::new();
522        let endpoints = Endpoints {
523            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
524        };
525        let out = fetch_snapshot_at(
526            &client,
527            "k",
528            &cache,
529            &endpoints,
530            Duration::from_secs(0),
531            None,
532            at(2026, 7, 1),
533        )
534        .await;
535        assert!(out.is_err(), "expected an error, got {out:?}");
536    }
537
538    #[tokio::test]
539    async fn first_run_auth_failure_preserves_the_original_error() {
540        // No cache: the actionable Admin-key message must reach the user
541        // instead of a generic "no usable cache".
542        let mut server = mockito::Server::new_async().await;
543        server
544            .mock("GET", "/v1/organizations/cost_report")
545            .match_query(mockito::Matcher::Any)
546            .with_status(401)
547            .with_body(r#"{"error":{"message":"invalid x-api-key"}}"#)
548            .create_async()
549            .await;
550
551        let (_td, cache) = cache_fixture();
552        let client = reqwest::Client::new();
553        let endpoints = Endpoints {
554            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
555        };
556        let err = fetch_snapshot_at(
557            &client,
558            "k",
559            &cache,
560            &endpoints,
561            Duration::from_secs(0),
562            None,
563            at(2026, 7, 19),
564        )
565        .await
566        .unwrap_err();
567        assert!(
568            matches!(err, AppError::Http { status: 401, .. }),
569            "original error must survive, got {err:?}"
570        );
571        assert!(err.to_string().contains("invalid x-api-key"));
572    }
573
574    #[tokio::test]
575    async fn has_more_without_next_page_is_an_error_not_a_partial_month() {
576        let mut server = mockito::Server::new_async().await;
577        server
578            .mock("GET", "/v1/organizations/cost_report")
579            .match_query(mockito::Matcher::Any)
580            .with_status(200)
581            .with_body(
582                r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"}]}],
583                    "has_more":true}"#,
584            )
585            .create_async()
586            .await;
587
588        let (_td, cache) = cache_fixture();
589        let client = reqwest::Client::new();
590        let endpoints = Endpoints {
591            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
592        };
593        let out = fetch_snapshot_at(
594            &client,
595            "k",
596            &cache,
597            &endpoints,
598            Duration::from_secs(0),
599            None,
600            at(2026, 7, 19),
601        )
602        .await;
603        assert!(out.is_err(), "partial month must not be reported: {out:?}");
604    }
605
606    #[tokio::test]
607    async fn repeated_pagination_cursor_is_an_error() {
608        // A server that always hands back the same cursor would otherwise be
609        // summed MAX_PAGES times and cached as a wildly inflated spend.
610        let mut server = mockito::Server::new_async().await;
611        server
612            .mock("GET", "/v1/organizations/cost_report")
613            .match_query(mockito::Matcher::Any)
614            .with_status(200)
615            .with_body(
616                r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"}]}],
617                    "has_more":true,"next_page":"same"}"#,
618            )
619            .expect_at_least(1)
620            .create_async()
621            .await;
622
623        let (_td, cache) = cache_fixture();
624        let client = reqwest::Client::new();
625        let endpoints = Endpoints {
626            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
627        };
628        let out = fetch_snapshot_at(
629            &client,
630            "k",
631            &cache,
632            &endpoints,
633            Duration::from_secs(0),
634            None,
635            at(2026, 7, 19),
636        )
637        .await;
638        assert!(out.is_err(), "cursor loop must not be reported: {out:?}");
639    }
640
641    #[tokio::test]
642    async fn malformed_200_is_not_cached_as_zero_spend() {
643        let mut server = mockito::Server::new_async().await;
644        server
645            .mock("GET", "/v1/organizations/cost_report")
646            .match_query(mockito::Matcher::Any)
647            .with_status(200)
648            .with_body(r#"{"error":{"message":"permission_error"}}"#)
649            .create_async()
650            .await;
651
652        let (_td, cache) = cache_fixture();
653        let client = reqwest::Client::new();
654        let endpoints = Endpoints {
655            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
656        };
657        let out = fetch_snapshot_at(
658            &client,
659            "k",
660            &cache,
661            &endpoints,
662            Duration::from_secs(0),
663            None,
664            at(2026, 7, 19),
665        )
666        .await;
667        assert!(out.is_err(), "expected a schema error, got {out:?}");
668        // And nothing was written to the cache.
669        assert!(cache.maybe_payload().unwrap().is_none());
670    }
671
672    #[tokio::test]
673    async fn a_month_with_no_spend_is_cached_as_a_real_zero() {
674        // The legitimate zero must still work end to end.
675        let mut server = mockito::Server::new_async().await;
676        server
677            .mock("GET", "/v1/organizations/cost_report")
678            .match_query(mockito::Matcher::Any)
679            .with_status(200)
680            .with_body(r#"{"data":[],"has_more":false,"next_page":null}"#)
681            .create_async()
682            .await;
683
684        let (_td, cache) = cache_fixture();
685        let client = reqwest::Client::new();
686        let endpoints = Endpoints {
687            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
688        };
689        let out = fetch_snapshot_at(
690            &client,
691            "k",
692            &cache,
693            &endpoints,
694            Duration::from_secs(0),
695            None,
696            at(2026, 7, 19),
697        )
698        .await
699        .unwrap();
700        assert_eq!(out.snapshot.spent, 0.0);
701        assert!(!out.stale);
702        assert!(cache.maybe_payload().unwrap().is_some());
703    }
704
705    #[tokio::test]
706    async fn switching_admin_key_refetches_instead_of_reusing_another_organization() {
707        let mut server = mockito::Server::new_async().await;
708        let first = server
709            .mock("GET", "/v1/organizations/cost_report")
710            .match_header("x-api-key", "org-a-key")
711            .match_query(mockito::Matcher::Any)
712            .with_status(200)
713            .with_body(ok_body("100.0"))
714            .expect(1)
715            .create_async()
716            .await;
717        let second = server
718            .mock("GET", "/v1/organizations/cost_report")
719            .match_header("x-api-key", "org-b-key")
720            .match_query(mockito::Matcher::Any)
721            .with_status(200)
722            .with_body(ok_body("250.0"))
723            .expect(1)
724            .create_async()
725            .await;
726
727        let (_td, cache) = cache_fixture();
728        let client = reqwest::Client::new();
729        let endpoints = Endpoints {
730            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
731        };
732        let now = at(2026, 7, 19);
733        let a = fetch_snapshot_at(
734            &client,
735            "org-a-key",
736            &cache,
737            &endpoints,
738            Duration::ZERO,
739            None,
740            now,
741        )
742        .await
743        .unwrap();
744        assert_eq!(a.snapshot.spent, 1.0);
745
746        // The first payload is fresh, but belongs to a different Admin key.
747        let b = fetch_snapshot_at(
748            &client,
749            "org-b-key",
750            &cache,
751            &endpoints,
752            Duration::from_secs(3600),
753            None,
754            now,
755        )
756        .await
757        .unwrap();
758        assert_eq!(b.snapshot.spent, 2.5);
759        first.assert_async().await;
760        second.assert_async().await;
761    }
762
763    #[tokio::test]
764    async fn invalid_monthly_limits_fail_before_network_or_cache_access() {
765        let client = reqwest::Client::new();
766        let cache = Cache::at(std::path::PathBuf::from("unused-invalid-limit-cache"));
767        for limit in [0.0, -1.0, f64::INFINITY, f64::NAN] {
768            let err = fetch_snapshot_at(
769                &client,
770                "key",
771                &cache,
772                &Endpoints::default(),
773                Duration::ZERO,
774                Some(limit),
775                at(2026, 7, 19),
776            )
777            .await
778            .unwrap_err();
779            assert!(err.to_string().contains("monthly_limit"), "{err:?}");
780        }
781        assert!(!cache.dir().exists());
782    }
783}