1use 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);
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
41pub type FetchOutcome = crate::outcome::Outcome<AnthropicApiSnapshot>;
44
45fn month_start_rfc3339(now: DateTime<Utc>) -> String {
47 format!("{:04}-{:02}-01T00:00:00Z", now.year(), now.month())
48}
49
50fn 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
75pub 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
97pub 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 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
167fn 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
199fn 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 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 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 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 assert_eq!(out.snapshot.limit, Some(50.0));
422 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
423 }
424
425 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 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 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 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 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 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 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 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 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}