Skip to main content

claude_api/admin/
cost_report.rs

1//! Cost report.
2
3use serde::{Deserialize, Serialize};
4
5use crate::client::Client;
6use crate::error::Result;
7
8use super::usage_report::{ContextWindow, ServiceTier};
9
10/// Cost-row category.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum CostType {
15    /// Token-based cost.
16    Tokens,
17    /// Web-search request cost.
18    WebSearch,
19    /// Code-execution server-tool cost.
20    CodeExecution,
21    /// Managed Agents session-runtime cost.
22    SessionUsage,
23}
24
25/// Token sub-type for token costs.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[non_exhaustive]
28pub enum TokenType {
29    /// Uncached input.
30    #[serde(rename = "uncached_input_tokens")]
31    UncachedInput,
32    /// Output.
33    #[serde(rename = "output_tokens")]
34    Output,
35    /// Cache reads.
36    #[serde(rename = "cache_read_input_tokens")]
37    CacheRead,
38    /// Cache creation, 1h TTL.
39    #[serde(rename = "cache_creation.ephemeral_1h_input_tokens")]
40    CacheCreate1h,
41    /// Cache creation, 5m TTL.
42    #[serde(rename = "cache_creation.ephemeral_5m_input_tokens")]
43    CacheCreate5m,
44}
45
46/// Group-by dimensions for the cost report.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum CostGroupBy {
51    /// Group by workspace.
52    WorkspaceId,
53    /// Group by description (`cost_type` / `token_type` / etc.).
54    Description,
55}
56
57/// Day-bucket width sentinel (only `1d` is supported on the cost
58/// report).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[non_exhaustive]
61pub enum CostBucketWidth {
62    /// One day.
63    #[serde(rename = "1d")]
64    Day,
65}
66
67/// One row inside a cost report bucket.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[non_exhaustive]
70pub struct CostRow {
71    /// Amount in lowest currency units (e.g. cents) as a decimal
72    /// string. `"123.45"` USD = $1.2345.
73    pub amount: String,
74    /// Currency code (`"USD"`).
75    pub currency: String,
76    /// Cost category. `None` when not grouping by description.
77    #[serde(default)]
78    pub cost_type: Option<CostType>,
79    /// Token-type subdivision. `None` when not grouping or for
80    /// non-token costs.
81    #[serde(default)]
82    pub token_type: Option<TokenType>,
83    /// Description of the cost item. `None` when not grouping by
84    /// description.
85    #[serde(default)]
86    pub description: Option<String>,
87    /// Workspace dimension.
88    #[serde(default)]
89    pub workspace_id: Option<String>,
90    /// Model dimension.
91    #[serde(default)]
92    pub model: Option<String>,
93    /// Context-window dimension.
94    #[serde(default)]
95    pub context_window: Option<ContextWindow>,
96    /// Service-tier dimension.
97    #[serde(default)]
98    pub service_tier: Option<ServiceTier>,
99    /// Inference-geo dimension.
100    #[serde(default)]
101    pub inference_geo: Option<String>,
102}
103
104/// One time bucket in a cost report.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[non_exhaustive]
107pub struct CostBucket {
108    /// Inclusive start (RFC3339).
109    pub starting_at: String,
110    /// Exclusive end (RFC3339).
111    pub ending_at: String,
112    /// Per-row breakdown.
113    pub results: Vec<CostRow>,
114}
115
116/// Response shape for `GET /v1/organizations/cost_report`.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[non_exhaustive]
119pub struct CostReport {
120    /// Time-bucket data.
121    pub data: Vec<CostBucket>,
122    /// Whether more pages exist.
123    #[serde(default)]
124    pub has_more: bool,
125    /// Opaque next-page cursor.
126    #[serde(default)]
127    pub next_page: Option<String>,
128}
129
130/// Filters for [`Cost::report`].
131#[derive(Debug, Clone, Default)]
132#[non_exhaustive]
133pub struct CostReportParams {
134    /// RFC3339 start. Required.
135    pub starting_at: String,
136    /// RFC3339 end.
137    pub ending_at: Option<String>,
138    /// Bucket width (only `1d`).
139    pub bucket_width: Option<CostBucketWidth>,
140    /// Group-by dimensions.
141    pub group_by: Vec<CostGroupBy>,
142    /// Page size.
143    pub limit: Option<u32>,
144    /// Pagination cursor.
145    pub page: Option<String>,
146}
147
148impl CostReportParams {
149    /// Build with the required `starting_at`.
150    #[must_use]
151    pub fn starting_at(starting_at: impl Into<String>) -> Self {
152        Self {
153            starting_at: starting_at.into(),
154            ..Self::default()
155        }
156    }
157
158    fn to_query(&self) -> Vec<(&'static str, String)> {
159        let mut q = Vec::new();
160        q.push(("starting_at", self.starting_at.clone()));
161        if let Some(e) = &self.ending_at {
162            q.push(("ending_at", e.clone()));
163        }
164        if let Some(_b) = self.bucket_width {
165            q.push(("bucket_width", "1d".into()));
166        }
167        for g in &self.group_by {
168            let s = match g {
169                CostGroupBy::WorkspaceId => "workspace_id",
170                CostGroupBy::Description => "description",
171            };
172            q.push(("group_by[]", s.into()));
173        }
174        if let Some(l) = self.limit {
175            q.push(("limit", l.to_string()));
176        }
177        if let Some(p) = &self.page {
178            q.push(("page", p.clone()));
179        }
180        q
181    }
182}
183
184/// Namespace handle for the cost-report endpoint.
185pub struct Cost<'a> {
186    client: &'a Client,
187}
188
189impl<'a> Cost<'a> {
190    pub(crate) fn new(client: &'a Client) -> Self {
191        Self { client }
192    }
193
194    /// `GET /v1/organizations/cost_report`.
195    pub async fn report(&self, params: CostReportParams) -> Result<CostReport> {
196        let query = params.to_query();
197        self.client
198            .execute_with_retry(
199                || {
200                    let mut req = self
201                        .client
202                        .request_builder(reqwest::Method::GET, "/v1/organizations/cost_report");
203                    for (k, v) in &query {
204                        req = req.query(&[(k, v)]);
205                    }
206                    req
207                },
208                &[],
209            )
210            .await
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use serde_json::json;
218    use wiremock::matchers::{method, path};
219    use wiremock::{Mock, MockServer, ResponseTemplate};
220
221    fn client_for(mock: &MockServer) -> Client {
222        Client::builder()
223            .api_key("sk-ant-admin-test")
224            .base_url(mock.uri())
225            .build()
226            .unwrap()
227    }
228
229    #[tokio::test]
230    async fn cost_report_groups_by_description_and_decodes_token_type() {
231        let mock = MockServer::start().await;
232        Mock::given(method("GET"))
233            .and(path("/v1/organizations/cost_report"))
234            .and(wiremock::matchers::query_param(
235                "starting_at",
236                "2026-05-01T00:00:00Z",
237            ))
238            .and(wiremock::matchers::query_param("group_by[]", "description"))
239            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
240                "data": [{
241                    "starting_at": "2026-05-01T00:00:00Z",
242                    "ending_at": "2026-05-02T00:00:00Z",
243                    "results": [{
244                        "amount": "123.45",
245                        "currency": "USD",
246                        "cost_type": "tokens",
247                        "token_type": "uncached_input_tokens",
248                        "description": "Sonnet 4.6 input",
249                        "model": "claude-sonnet-4-6"
250                    }]
251                }],
252                "has_more": false,
253                "next_page": null
254            })))
255            .mount(&mock)
256            .await;
257        let client = client_for(&mock);
258        let r = client
259            .admin()
260            .cost()
261            .report(CostReportParams {
262                starting_at: "2026-05-01T00:00:00Z".into(),
263                group_by: vec![CostGroupBy::Description],
264                ..Default::default()
265            })
266            .await
267            .unwrap();
268        assert_eq!(r.data.len(), 1);
269        let row = &r.data[0].results[0];
270        assert_eq!(row.amount, "123.45");
271        assert_eq!(row.cost_type, Some(CostType::Tokens));
272        assert_eq!(row.token_type, Some(TokenType::UncachedInput));
273    }
274}