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