Skip to main content

ai_usagebar/anthropic_api/
types.rs

1//! Wire types for the Anthropic Admin API cost report
2//! (`GET /v1/organizations/cost_report`).
3//!
4//! Confirmed against the official docs
5//! (<https://platform.claude.com/docs/en/api/admin-api/usage-cost/get-cost-report>):
6//! `amount` is a decimal STRING in the currency's LOWEST unit (cents) —
7//! `"123.45"` USD represents `$1.23` — so divide by 100 for dollars. There is
8//! no API for the remaining prepaid credit balance (Console dashboard only), so
9//! this vendor reports **month-to-date spend** instead.
10//!
11//! Anthropic documents that this endpoint **excludes Priority Tier costs**
12//! (<https://platform.claude.com/docs/en/manage-claude/usage-cost-api>), so for
13//! an organization on Priority Tier the total here is below its real spend. The
14//! tooltip, the TUI panel, and the README all say so — the number must never be
15//! presented as complete spend.
16
17use serde::Deserialize;
18
19use crate::error::{AppError, Result};
20use crate::usage::parse_amount;
21
22/// The documented envelope. `data` and `has_more` are **required**: a 200 error
23/// envelope, or a response whose shape drifted, must not deserialize into an
24/// empty report that reads as genuine zero spend. Only `next_page` is optional,
25/// because the docs return it as `null` on the last page.
26///
27/// An empty `data: []` remains the legitimate zero-cost case — a real month with
28/// no usage — and is preserved as such.
29#[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/// A time bucket. `results` is required and may legitimately be empty (a day
38/// with no spend).
39#[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    /// Cost in the currency's lowest unit (cents), as a decimal string.
49    pub amount: String,
50    /// The API currently documents every cost result as USD. Keep this required
51    /// and validate it before summing so a future currency change cannot be
52    /// silently rendered with a dollar sign.
53    pub currency: String,
54}
55
56/// Sum every result's cents on one page and return dollars. A missing,
57/// non-numeric, or non-finite `amount` raises `AppError::Schema` rather than
58/// silently coercing to 0 — reporting a bogus $0.00 spend, and caching it as
59/// authoritative, is the failure this guards against.
60pub 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    // Guard the running total too: enough large values can overflow to inf.
72    crate::usage::finite_amount("anthropic-api", "cost_report total", cents)?;
73    Ok(cents / 100.0)
74}
75
76/// Last UTC day wholly represented by this page's daily buckets. A bucket
77/// ending at midnight on Oct 18 covers Oct 17, not the still-running 18th.
78pub 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    // Verbatim shape from the docs (amount in cents).
111    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        // 100.0 + 34.5 = 134.5 cents = $1.345
127        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        // The legitimate zero case: a well-formed report with no buckets.
147        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        // A bucket with no results (a day with no spend) is equally valid.
151        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        // The regression this guards: `{}` used to deserialize into an empty
159        // report and display as a genuine $0.00 month.
160        assert!(serde_json::from_str::<CostReport>("{}").is_err());
161        // A 200 error envelope.
162        assert!(
163            serde_json::from_str::<CostReport>(r#"{"error":{"message":"invalid x-api-key"}}"#)
164                .is_err()
165        );
166        // Drifted shape: `data` present but `has_more` gone.
167        assert!(serde_json::from_str::<CostReport>(r#"{"data":[]}"#).is_err());
168        // Drifted bucket: `results` gone.
169        assert!(serde_json::from_str::<CostReport>(r#"{"data":[{}],"has_more":false}"#).is_err());
170        // Drifted result: `amount` renamed away.
171        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        // "inf" parses as f64::INFINITY — it must not become a displayed spend.
196        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}