ai_usagebar/anthropic_api/
types.rs1use serde::Deserialize;
18
19use crate::error::{AppError, Result};
20use crate::usage::parse_amount;
21
22#[derive(Debug, Clone, Deserialize)]
30pub struct CostReport {
31 pub data: Vec<Bucket>,
32 pub has_more: bool,
33 #[serde(default)]
34 pub next_page: Option<String>,
35}
36
37#[derive(Debug, Clone, Deserialize)]
40pub struct Bucket {
41 #[serde(default)]
42 pub ending_at: Option<String>,
43 pub results: Vec<CostResult>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47pub struct CostResult {
48 pub amount: String,
50 pub currency: String,
54}
55
56pub fn page_dollars(report: &CostReport) -> Result<f64> {
61 let mut cents = 0.0_f64;
62 for r in report.data.iter().flat_map(|b| b.results.iter()) {
63 if r.currency != "USD" {
64 return Err(AppError::Schema(format!(
65 "anthropic-api: unsupported cost_report currency {:?}; expected USD",
66 r.currency
67 )));
68 }
69 cents += parse_amount("anthropic-api", "cost_report.amount", &r.amount)?;
70 }
71 crate::usage::finite_amount("anthropic-api", "cost_report total", cents)?;
73 Ok(cents / 100.0)
74}
75
76pub fn page_covered_through(report: &CostReport) -> Result<Option<chrono::NaiveDate>> {
79 report
80 .data
81 .iter()
82 .filter_map(|bucket| bucket.ending_at.as_deref())
83 .map(|raw| {
84 chrono::DateTime::parse_from_rfc3339(raw)
85 .map_err(|e| {
86 AppError::Schema(format!(
87 "anthropic-api: invalid cost_report ending_at {raw:?}: {e}"
88 ))
89 })
90 .and_then(|ending| {
91 ending
92 .with_timezone(&chrono::Utc)
93 .date_naive()
94 .pred_opt()
95 .ok_or_else(|| {
96 AppError::Schema(
97 "anthropic-api: cost_report ending_at underflow".into(),
98 )
99 })
100 })
101 })
102 .collect::<Result<Vec<_>>>()
103 .map(|dates| dates.into_iter().max())
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 const BODY: &str = r#"{
112 "data": [
113 { "starting_at": "2026-07-01T00:00:00Z", "ending_at": "2026-07-02T00:00:00Z",
114 "results": [
115 { "amount": "100.0", "currency": "USD", "cost_type": "tokens" },
116 { "amount": "34.5", "currency": "USD", "cost_type": "web_search" }
117 ] }
118 ],
119 "has_more": false,
120 "next_page": null
121 }"#;
122
123 #[test]
124 fn sums_cents_and_converts_to_dollars() {
125 let report: CostReport = serde_json::from_str(BODY).unwrap();
126 assert!((page_dollars(&report).unwrap() - 1.345).abs() < 1e-9);
128 assert!(!report.has_more);
129 assert_eq!(
130 page_covered_through(&report).unwrap(),
131 Some(chrono::NaiveDate::from_ymd_opt(2026, 7, 1).unwrap())
132 );
133 }
134
135 #[test]
136 fn invalid_bucket_end_is_schema_drift_not_a_false_coverage_claim() {
137 let report: CostReport = serde_json::from_str(
138 r#"{"data":[{"ending_at":"today","results":[]}],"has_more":false}"#,
139 )
140 .unwrap();
141 assert!(page_covered_through(&report).is_err());
142 }
143
144 #[test]
145 fn a_month_with_no_usage_is_genuinely_zero() {
146 let report: CostReport =
148 serde_json::from_str(r#"{"data":[],"has_more":false,"next_page":null}"#).unwrap();
149 assert_eq!(page_dollars(&report).unwrap(), 0.0);
150 let report2: CostReport =
152 serde_json::from_str(r#"{"data":[{"results":[]}],"has_more":false}"#).unwrap();
153 assert_eq!(page_dollars(&report2).unwrap(), 0.0);
154 }
155
156 #[test]
157 fn malformed_envelope_is_rejected_rather_than_read_as_zero_spend() {
158 assert!(serde_json::from_str::<CostReport>("{}").is_err());
161 assert!(
163 serde_json::from_str::<CostReport>(r#"{"error":{"message":"invalid x-api-key"}}"#)
164 .is_err()
165 );
166 assert!(serde_json::from_str::<CostReport>(r#"{"data":[]}"#).is_err());
168 assert!(serde_json::from_str::<CostReport>(r#"{"data":[{}],"has_more":false}"#).is_err());
170 assert!(
172 serde_json::from_str::<CostReport>(
173 r#"{"data":[{"results":[{"cost":"1"}]}],"has_more":false}"#
174 )
175 .is_err()
176 );
177 }
178
179 #[test]
180 fn non_numeric_amount_is_a_schema_error() {
181 for bad in [r#""""#, r#""n/a""#, r#"" ""#] {
182 let body = format!(
183 r#"{{"data":[{{"results":[{{"amount":{bad},"currency":"USD"}}]}}],"has_more":false}}"#
184 );
185 let report: CostReport = serde_json::from_str(&body).unwrap();
186 assert!(
187 page_dollars(&report).is_err(),
188 "amount {bad} should not parse as spend"
189 );
190 }
191 }
192
193 #[test]
194 fn non_finite_amount_is_rejected() {
195 let body = r#"{"data":[{"results":[{"amount":"inf","currency":"USD"}]}],"has_more":false}"#;
197 let report: CostReport = serde_json::from_str(body).unwrap();
198 assert!(page_dollars(&report).is_err());
199 }
200
201 #[test]
202 fn non_usd_or_missing_currency_is_rejected() {
203 let non_usd =
204 r#"{"data":[{"results":[{"amount":"100","currency":"EUR"}]}],"has_more":false}"#;
205 let report: CostReport = serde_json::from_str(non_usd).unwrap();
206 assert!(page_dollars(&report).is_err());
207
208 let missing = r#"{"data":[{"results":[{"amount":"100"}]}],"has_more":false}"#;
209 assert!(serde_json::from_str::<CostReport>(missing).is_err());
210 }
211}