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 cache.write_last_error(status, &body);
147 let diag = (status, body.clone());
148 fallback_with_error(
149 cache,
150 Some(diag),
151 limit,
152 &month,
153 &target,
154 AppError::Http { status, body },
155 )
156 }
157 Err(e) => {
158 cache.mark_stale();
159 cache.write_last_error(0, &e.to_string());
160 let diag = (0, e.to_string());
161 fallback_with_error(cache, Some(diag), limit, &month, &target, e)
162 }
163 }
164}
165
166fn fallback_silent(
167 cache: &Cache,
168 limit: Option<f64>,
169 month: &str,
170 target: &str,
171 original: AppError,
172) -> Result<FetchOutcome> {
173 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
174 return Err(original);
175 };
176 reuse_cache(&bytes, cache, true, limit, month, target)
177}
178
179fn fallback_with_error(
184 cache: &Cache,
185 last_error: Option<(u16, String)>,
186 limit: Option<f64>,
187 month: &str,
188 target: &str,
189 original: AppError,
190) -> Result<FetchOutcome> {
191 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
192 return Err(original);
193 };
194 let Ok(mut outcome) = reuse_cache(&bytes, cache, true, limit, month, target) else {
197 return Err(original);
198 };
199 outcome.last_error = last_error;
200 Ok(outcome)
201}
202
203fn reuse_cache(
204 bytes: &[u8],
205 cache: &Cache,
206 stale: bool,
207 limit: Option<f64>,
208 month: &str,
209 target: &str,
210) -> Result<FetchOutcome> {
211 let spent = parse_cached_spent(bytes, month, target)?;
214 Ok(FetchOutcome {
215 snapshot: AnthropicApiSnapshot { spent, limit },
216 stale,
217 last_error: cache.read_last_error(),
218 cache_age: cache.payload_age(),
219 })
220}
221
222fn parse_cached_spent(bytes: &[u8], month: &str, target: &str) -> Result<f64> {
223 let v: serde_json::Value = serde_json::from_slice(bytes)?;
224 let cached_month = v.get("month").and_then(serde_json::Value::as_str);
227 if cached_month != Some(month) {
228 return Err(AppError::Schema(format!(
229 "anthropic-api cache is for a different month ({}); refetching",
230 cached_month.unwrap_or("unknown")
231 )));
232 }
233 let cached_target = v.get("target").and_then(serde_json::Value::as_str);
234 if cached_target != Some(target) {
235 return Err(AppError::Schema(
236 "anthropic-api cache belongs to a different Admin key; refetching".into(),
237 ));
238 }
239 let s = v
240 .get("snapshot")
241 .ok_or_else(|| AppError::Schema("anthropic-api cache missing 'snapshot'".into()))?;
242 let spent = s["spent"]
243 .as_f64()
244 .ok_or_else(|| AppError::Schema("anthropic-api cache missing 'spent'".into()))?;
245 crate::usage::finite_amount("anthropic-api cache", "spent", spent)
246}
247
248async fn fetch_live(
249 client: &reqwest::Client,
250 endpoints: &Endpoints,
251 admin_key: &str,
252 now: DateTime<Utc>,
253) -> Result<f64> {
254 let starting_at = month_start_rfc3339(now);
255 let mut total = 0.0;
256 let mut page: Option<String> = None;
257 let mut seen_pages: Vec<String> = Vec::new();
258
259 for _ in 0..MAX_PAGES {
260 let mut req = client
261 .get(&endpoints.cost_report)
262 .header("x-api-key", admin_key)
263 .header("anthropic-version", ANTHROPIC_VERSION)
264 .query(&[
265 ("starting_at", starting_at.as_str()),
266 ("bucket_width", "1d"),
267 ]);
268 if let Some(p) = &page {
269 req = req.query(&[("page", p.as_str())]);
270 }
271
272 let resp = tokio::time::timeout(HTTP_TIMEOUT, req.send())
273 .await
274 .map_err(|_| {
275 AppError::Transport(format!("anthropic-api timeout: {}", endpoints.cost_report))
276 })??;
277
278 let status = resp.status();
279 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
280 if !status.is_success() {
281 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
282 return Err(AppError::Http {
283 status: status.as_u16(),
284 body,
285 });
286 }
287
288 let report: CostReport = serde_json::from_slice(&bytes)
289 .map_err(|e| AppError::Schema(format!("anthropic-api cost_report: {e}")))?;
290 total = finite_amount(
291 "anthropic-api",
292 "cost_report running total",
293 total + page_dollars(&report)?,
294 )?;
295
296 match (report.has_more, report.next_page) {
300 (false, _) => return Ok(total),
301 (true, None) => {
302 return Err(AppError::Schema(
303 "anthropic-api cost_report: has_more is true but next_page is missing; \
304 refusing to report a partial month"
305 .into(),
306 ));
307 }
308 (true, Some(p)) if p.trim().is_empty() => {
309 return Err(AppError::Schema(
310 "anthropic-api cost_report: has_more is true but next_page is empty; \
311 refusing to report a partial month"
312 .into(),
313 ));
314 }
315 (true, Some(p)) => {
316 if seen_pages.contains(&p) {
317 return Err(AppError::Schema(format!(
318 "anthropic-api cost_report: pagination repeated cursor {p:?}; \
319 refusing to report a partial month"
320 )));
321 }
322 seen_pages.push(p.clone());
323 page = Some(p);
324 }
325 }
326 }
327 Err(AppError::Schema(format!(
328 "anthropic-api cost_report: more than {MAX_PAGES} pages for one month; \
329 refusing to report a partial month"
330 )))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use chrono::TimeZone;
337 use tempfile::TempDir;
338
339 fn cache_fixture() -> (TempDir, Cache) {
340 let td = TempDir::new().unwrap();
341 let cache = Cache::at(td.path().join("anthropic_api"));
342 cache.ensure_dir().unwrap();
343 (td, cache)
344 }
345
346 #[test]
347 fn month_start_is_first_of_month_utc() {
348 let now = Utc.with_ymd_and_hms(2026, 7, 19, 15, 8, 0).unwrap();
349 assert_eq!(month_start_rfc3339(now), "2026-07-01T00:00:00Z");
350 }
351
352 #[tokio::test]
353 async fn live_fetch_sums_month_to_date_and_divides_by_100() {
354 let mut server = mockito::Server::new_async().await;
355 server
356 .mock("GET", "/v1/organizations/cost_report")
357 .match_header("x-api-key", "sk-ant-admin01-test")
358 .match_query(mockito::Matcher::Any)
359 .with_status(200)
360 .with_body(
361 r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"},
362 {"amount":"34.0","currency":"USD"}]}],
363 "has_more":false,"next_page":null}"#,
364 )
365 .create_async()
366 .await;
367
368 let (_td, cache) = cache_fixture();
369 let client = reqwest::Client::new();
370 let endpoints = Endpoints {
371 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
372 };
373 let out = fetch_snapshot(
374 &client,
375 "sk-ant-admin01-test",
376 &cache,
377 &endpoints,
378 Duration::from_secs(0),
379 Some(1000.0),
380 )
381 .await
382 .unwrap();
383 assert!((out.snapshot.spent - 1.34).abs() < 1e-9);
385 assert_eq!(out.snapshot.limit, Some(1000.0));
386 assert!(!out.stale);
387 }
388
389 #[tokio::test]
390 async fn http_401_falls_back_to_cache_when_present() {
391 let mut server = mockito::Server::new_async().await;
392 server
393 .mock("GET", "/v1/organizations/cost_report")
394 .match_query(mockito::Matcher::Any)
395 .with_status(401)
396 .with_body(r#"{"error":{"message":"invalid x-api-key"}}"#)
397 .create_async()
398 .await;
399
400 let (_td, cache) = cache_fixture();
401 let now = at(2026, 7, 19);
402 cache
403 .write_payload(
404 serde_json::json!({
405 "month": month_start_rfc3339(now),
406 "target": target_key("k"),
407 "snapshot": { "spent": 2.5, "limit": null },
408 })
409 .to_string()
410 .as_bytes(),
411 )
412 .unwrap();
413
414 let client = reqwest::Client::new();
415 let endpoints = Endpoints {
416 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
417 };
418 let out = fetch_snapshot_at(
419 &client,
420 "k",
421 &cache,
422 &endpoints,
423 Duration::from_secs(0),
424 Some(50.0),
425 now,
426 )
427 .await
428 .unwrap();
429 assert!(out.stale);
430 assert!((out.snapshot.spent - 2.5).abs() < 1e-9);
431 assert_eq!(out.snapshot.limit, Some(50.0));
433 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
434 }
435
436 fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
438 chrono::NaiveDate::from_ymd_opt(y, m, d)
439 .unwrap()
440 .and_hms_opt(12, 0, 0)
441 .unwrap()
442 .and_utc()
443 }
444
445 fn ok_body(cents: &str) -> String {
446 format!(
447 r#"{{"data":[{{"results":[{{"amount":"{cents}","currency":"USD"}}]}}],"has_more":false}}"#
448 )
449 }
450
451 #[tokio::test]
452 async fn month_rollover_refetches_instead_of_showing_last_month() {
453 let mut server = mockito::Server::new_async().await;
456 server
457 .mock("GET", "/v1/organizations/cost_report")
458 .match_query(mockito::Matcher::Any)
459 .with_status(200)
460 .with_body(ok_body("250.0"))
461 .create_async()
462 .await;
463
464 let (_td, cache) = cache_fixture();
465 cache
466 .write_payload(
467 serde_json::json!({
468 "month": month_start_rfc3339(at(2026, 6, 30)),
469 "target": target_key("k"),
470 "snapshot": { "spent": 987.0, "limit": null },
471 })
472 .to_string()
473 .as_bytes(),
474 )
475 .unwrap();
476
477 let client = reqwest::Client::new();
478 let endpoints = Endpoints {
479 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
480 };
481 let out = fetch_snapshot_at(
483 &client,
484 "k",
485 &cache,
486 &endpoints,
487 Duration::from_secs(3600),
488 None,
489 at(2026, 7, 1),
490 )
491 .await
492 .unwrap();
493 assert!((out.snapshot.spent - 2.5).abs() < 1e-9);
494 assert!(!out.stale);
495 }
496
497 #[tokio::test]
498 async fn last_months_cache_is_not_served_during_an_outage() {
499 let mut server = mockito::Server::new_async().await;
502 server
503 .mock("GET", "/v1/organizations/cost_report")
504 .match_query(mockito::Matcher::Any)
505 .with_status(500)
506 .with_body("upstream boom")
507 .create_async()
508 .await;
509
510 let (_td, cache) = cache_fixture();
511 cache
512 .write_payload(
513 serde_json::json!({
514 "month": month_start_rfc3339(at(2026, 6, 30)),
515 "target": target_key("k"),
516 "snapshot": { "spent": 987.0, "limit": null },
517 })
518 .to_string()
519 .as_bytes(),
520 )
521 .unwrap();
522
523 let client = reqwest::Client::new();
524 let endpoints = Endpoints {
525 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
526 };
527 let out = fetch_snapshot_at(
528 &client,
529 "k",
530 &cache,
531 &endpoints,
532 Duration::from_secs(0),
533 None,
534 at(2026, 7, 1),
535 )
536 .await;
537 assert!(out.is_err(), "expected an error, got {out:?}");
538 }
539
540 #[tokio::test]
541 async fn first_run_auth_failure_preserves_the_original_error() {
542 let mut server = mockito::Server::new_async().await;
545 server
546 .mock("GET", "/v1/organizations/cost_report")
547 .match_query(mockito::Matcher::Any)
548 .with_status(401)
549 .with_body(r#"{"error":{"message":"invalid x-api-key"}}"#)
550 .create_async()
551 .await;
552
553 let (_td, cache) = cache_fixture();
554 let client = reqwest::Client::new();
555 let endpoints = Endpoints {
556 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
557 };
558 let err = fetch_snapshot_at(
559 &client,
560 "k",
561 &cache,
562 &endpoints,
563 Duration::from_secs(0),
564 None,
565 at(2026, 7, 19),
566 )
567 .await
568 .unwrap_err();
569 assert!(
570 matches!(err, AppError::Http { status: 401, .. }),
571 "original error must survive, got {err:?}"
572 );
573 assert!(err.to_string().contains("invalid x-api-key"));
574 }
575
576 #[tokio::test]
577 async fn has_more_without_next_page_is_an_error_not_a_partial_month() {
578 let mut server = mockito::Server::new_async().await;
579 server
580 .mock("GET", "/v1/organizations/cost_report")
581 .match_query(mockito::Matcher::Any)
582 .with_status(200)
583 .with_body(
584 r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"}]}],
585 "has_more":true}"#,
586 )
587 .create_async()
588 .await;
589
590 let (_td, cache) = cache_fixture();
591 let client = reqwest::Client::new();
592 let endpoints = Endpoints {
593 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
594 };
595 let out = fetch_snapshot_at(
596 &client,
597 "k",
598 &cache,
599 &endpoints,
600 Duration::from_secs(0),
601 None,
602 at(2026, 7, 19),
603 )
604 .await;
605 assert!(out.is_err(), "partial month must not be reported: {out:?}");
606 }
607
608 #[tokio::test]
609 async fn repeated_pagination_cursor_is_an_error() {
610 let mut server = mockito::Server::new_async().await;
613 server
614 .mock("GET", "/v1/organizations/cost_report")
615 .match_query(mockito::Matcher::Any)
616 .with_status(200)
617 .with_body(
618 r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"}]}],
619 "has_more":true,"next_page":"same"}"#,
620 )
621 .expect_at_least(1)
622 .create_async()
623 .await;
624
625 let (_td, cache) = cache_fixture();
626 let client = reqwest::Client::new();
627 let endpoints = Endpoints {
628 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
629 };
630 let out = fetch_snapshot_at(
631 &client,
632 "k",
633 &cache,
634 &endpoints,
635 Duration::from_secs(0),
636 None,
637 at(2026, 7, 19),
638 )
639 .await;
640 assert!(out.is_err(), "cursor loop must not be reported: {out:?}");
641 }
642
643 #[tokio::test]
644 async fn malformed_200_is_not_cached_as_zero_spend() {
645 let mut server = mockito::Server::new_async().await;
646 server
647 .mock("GET", "/v1/organizations/cost_report")
648 .match_query(mockito::Matcher::Any)
649 .with_status(200)
650 .with_body(r#"{"error":{"message":"permission_error"}}"#)
651 .create_async()
652 .await;
653
654 let (_td, cache) = cache_fixture();
655 let client = reqwest::Client::new();
656 let endpoints = Endpoints {
657 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
658 };
659 let out = fetch_snapshot_at(
660 &client,
661 "k",
662 &cache,
663 &endpoints,
664 Duration::from_secs(0),
665 None,
666 at(2026, 7, 19),
667 )
668 .await;
669 assert!(out.is_err(), "expected a schema error, got {out:?}");
670 assert!(cache.maybe_payload().unwrap().is_none());
672 }
673
674 #[tokio::test]
675 async fn a_month_with_no_spend_is_cached_as_a_real_zero() {
676 let mut server = mockito::Server::new_async().await;
678 server
679 .mock("GET", "/v1/organizations/cost_report")
680 .match_query(mockito::Matcher::Any)
681 .with_status(200)
682 .with_body(r#"{"data":[],"has_more":false,"next_page":null}"#)
683 .create_async()
684 .await;
685
686 let (_td, cache) = cache_fixture();
687 let client = reqwest::Client::new();
688 let endpoints = Endpoints {
689 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
690 };
691 let out = fetch_snapshot_at(
692 &client,
693 "k",
694 &cache,
695 &endpoints,
696 Duration::from_secs(0),
697 None,
698 at(2026, 7, 19),
699 )
700 .await
701 .unwrap();
702 assert_eq!(out.snapshot.spent, 0.0);
703 assert!(!out.stale);
704 assert!(cache.maybe_payload().unwrap().is_some());
705 }
706
707 #[tokio::test]
708 async fn switching_admin_key_refetches_instead_of_reusing_another_organization() {
709 let mut server = mockito::Server::new_async().await;
710 let first = server
711 .mock("GET", "/v1/organizations/cost_report")
712 .match_header("x-api-key", "org-a-key")
713 .match_query(mockito::Matcher::Any)
714 .with_status(200)
715 .with_body(ok_body("100.0"))
716 .expect(1)
717 .create_async()
718 .await;
719 let second = server
720 .mock("GET", "/v1/organizations/cost_report")
721 .match_header("x-api-key", "org-b-key")
722 .match_query(mockito::Matcher::Any)
723 .with_status(200)
724 .with_body(ok_body("250.0"))
725 .expect(1)
726 .create_async()
727 .await;
728
729 let (_td, cache) = cache_fixture();
730 let client = reqwest::Client::new();
731 let endpoints = Endpoints {
732 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
733 };
734 let now = at(2026, 7, 19);
735 let a = fetch_snapshot_at(
736 &client,
737 "org-a-key",
738 &cache,
739 &endpoints,
740 Duration::ZERO,
741 None,
742 now,
743 )
744 .await
745 .unwrap();
746 assert_eq!(a.snapshot.spent, 1.0);
747
748 let b = fetch_snapshot_at(
750 &client,
751 "org-b-key",
752 &cache,
753 &endpoints,
754 Duration::from_secs(3600),
755 None,
756 now,
757 )
758 .await
759 .unwrap();
760 assert_eq!(b.snapshot.spent, 2.5);
761 first.assert_async().await;
762 second.assert_async().await;
763 }
764
765 #[tokio::test]
766 async fn invalid_monthly_limits_fail_before_network_or_cache_access() {
767 let client = reqwest::Client::new();
768 let cache = Cache::at(std::path::PathBuf::from("unused-invalid-limit-cache"));
769 for limit in [0.0, -1.0, f64::INFINITY, f64::NAN] {
770 let err = fetch_snapshot_at(
771 &client,
772 "key",
773 &cache,
774 &Endpoints::default(),
775 Duration::ZERO,
776 Some(limit),
777 at(2026, 7, 19),
778 )
779 .await
780 .unwrap_err();
781 assert!(err.to_string().contains("monthly_limit"), "{err:?}");
782 }
783 assert!(!cache.dir().exists());
784 }
785}