1use 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);
24const 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
49fn month_start_rfc3339(now: DateTime<Utc>) -> String {
51 format!("{:04}-{:02}-01T00:00:00Z", now.year(), now.month())
52}
53
54fn 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
79pub 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
101pub 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 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
177fn 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 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 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 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 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 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 assert_eq!(out.snapshot.limit, Some(50.0));
431 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
432 }
433
434 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 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 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 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 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 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 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 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 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}