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 pub results: Vec<CostResult>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
45pub struct CostResult {
46 pub amount: String,
48 pub currency: String,
52}
53
54pub fn page_dollars(report: &CostReport) -> Result<f64> {
59 let mut cents = 0.0_f64;
60 for r in report.data.iter().flat_map(|b| b.results.iter()) {
61 if r.currency != "USD" {
62 return Err(AppError::Schema(format!(
63 "anthropic-api: unsupported cost_report currency {:?}; expected USD",
64 r.currency
65 )));
66 }
67 cents += parse_amount("anthropic-api", "cost_report.amount", &r.amount)?;
68 }
69 crate::usage::finite_amount("anthropic-api", "cost_report total", cents)?;
71 Ok(cents / 100.0)
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 const BODY: &str = r#"{
80 "data": [
81 { "starting_at": "2026-07-01T00:00:00Z", "ending_at": "2026-07-02T00:00:00Z",
82 "results": [
83 { "amount": "100.0", "currency": "USD", "cost_type": "tokens" },
84 { "amount": "34.5", "currency": "USD", "cost_type": "web_search" }
85 ] }
86 ],
87 "has_more": false,
88 "next_page": null
89 }"#;
90
91 #[test]
92 fn sums_cents_and_converts_to_dollars() {
93 let report: CostReport = serde_json::from_str(BODY).unwrap();
94 assert!((page_dollars(&report).unwrap() - 1.345).abs() < 1e-9);
96 assert!(!report.has_more);
97 }
98
99 #[test]
100 fn a_month_with_no_usage_is_genuinely_zero() {
101 let report: CostReport =
103 serde_json::from_str(r#"{"data":[],"has_more":false,"next_page":null}"#).unwrap();
104 assert_eq!(page_dollars(&report).unwrap(), 0.0);
105 let report2: CostReport =
107 serde_json::from_str(r#"{"data":[{"results":[]}],"has_more":false}"#).unwrap();
108 assert_eq!(page_dollars(&report2).unwrap(), 0.0);
109 }
110
111 #[test]
112 fn malformed_envelope_is_rejected_rather_than_read_as_zero_spend() {
113 assert!(serde_json::from_str::<CostReport>("{}").is_err());
116 assert!(
118 serde_json::from_str::<CostReport>(r#"{"error":{"message":"invalid x-api-key"}}"#)
119 .is_err()
120 );
121 assert!(serde_json::from_str::<CostReport>(r#"{"data":[]}"#).is_err());
123 assert!(serde_json::from_str::<CostReport>(r#"{"data":[{}],"has_more":false}"#).is_err());
125 assert!(
127 serde_json::from_str::<CostReport>(
128 r#"{"data":[{"results":[{"cost":"1"}]}],"has_more":false}"#
129 )
130 .is_err()
131 );
132 }
133
134 #[test]
135 fn non_numeric_amount_is_a_schema_error() {
136 for bad in [r#""""#, r#""n/a""#, r#"" ""#] {
137 let body = format!(
138 r#"{{"data":[{{"results":[{{"amount":{bad},"currency":"USD"}}]}}],"has_more":false}}"#
139 );
140 let report: CostReport = serde_json::from_str(&body).unwrap();
141 assert!(
142 page_dollars(&report).is_err(),
143 "amount {bad} should not parse as spend"
144 );
145 }
146 }
147
148 #[test]
149 fn non_finite_amount_is_rejected() {
150 let body = r#"{"data":[{"results":[{"amount":"inf","currency":"USD"}]}],"has_more":false}"#;
152 let report: CostReport = serde_json::from_str(body).unwrap();
153 assert!(page_dollars(&report).is_err());
154 }
155
156 #[test]
157 fn non_usd_or_missing_currency_is_rejected() {
158 let non_usd =
159 r#"{"data":[{"results":[{"amount":"100","currency":"EUR"}]}],"has_more":false}"#;
160 let report: CostReport = serde_json::from_str(non_usd).unwrap();
161 assert!(page_dollars(&report).is_err());
162
163 let missing = r#"{"data":[{"results":[{"amount":"100"}]}],"has_more":false}"#;
164 assert!(serde_json::from_str::<CostReport>(missing).is_err());
165 }
166}