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    pub results: Vec<CostResult>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
45pub struct CostResult {
46    /// Cost in the currency's lowest unit (cents), as a decimal string.
47    pub amount: String,
48    /// The API currently documents every cost result as USD. Keep this required
49    /// and validate it before summing so a future currency change cannot be
50    /// silently rendered with a dollar sign.
51    pub currency: String,
52}
53
54/// Sum every result's cents on one page and return dollars. A missing,
55/// non-numeric, or non-finite `amount` raises `AppError::Schema` rather than
56/// silently coercing to 0 — reporting a bogus $0.00 spend, and caching it as
57/// authoritative, is the failure this guards against.
58pub 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    // Guard the running total too: enough large values can overflow to inf.
70    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    // Verbatim shape from the docs (amount in cents).
79    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        // 100.0 + 34.5 = 134.5 cents = $1.345
95        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        // The legitimate zero case: a well-formed report with no buckets.
102        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        // A bucket with no results (a day with no spend) is equally valid.
106        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        // The regression this guards: `{}` used to deserialize into an empty
114        // report and display as a genuine $0.00 month.
115        assert!(serde_json::from_str::<CostReport>("{}").is_err());
116        // A 200 error envelope.
117        assert!(
118            serde_json::from_str::<CostReport>(r#"{"error":{"message":"invalid x-api-key"}}"#)
119                .is_err()
120        );
121        // Drifted shape: `data` present but `has_more` gone.
122        assert!(serde_json::from_str::<CostReport>(r#"{"data":[]}"#).is_err());
123        // Drifted bucket: `results` gone.
124        assert!(serde_json::from_str::<CostReport>(r#"{"data":[{}],"has_more":false}"#).is_err());
125        // Drifted result: `amount` renamed away.
126        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        // "inf" parses as f64::INFINITY — it must not become a displayed spend.
151        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}