1use serde::{Deserialize, Serialize};
4
5use crate::client::Client;
6use crate::error::Result;
7
8use super::usage_report::{ContextWindow, ServiceTier};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum CostType {
15 Tokens,
17 WebSearch,
19 CodeExecution,
21 SessionUsage,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[non_exhaustive]
28pub enum TokenType {
29 #[serde(rename = "uncached_input_tokens")]
31 UncachedInput,
32 #[serde(rename = "output_tokens")]
34 Output,
35 #[serde(rename = "cache_read_input_tokens")]
37 CacheRead,
38 #[serde(rename = "cache_creation.ephemeral_1h_input_tokens")]
40 CacheCreate1h,
41 #[serde(rename = "cache_creation.ephemeral_5m_input_tokens")]
43 CacheCreate5m,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum CostGroupBy {
51 WorkspaceId,
53 Description,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[non_exhaustive]
61pub enum CostBucketWidth {
62 #[serde(rename = "1d")]
64 Day,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69#[non_exhaustive]
70pub struct CostRow {
71 pub amount: String,
74 pub currency: String,
76 #[serde(default)]
78 pub cost_type: Option<CostType>,
79 #[serde(default)]
82 pub token_type: Option<TokenType>,
83 #[serde(default)]
86 pub description: Option<String>,
87 #[serde(default)]
89 pub workspace_id: Option<String>,
90 #[serde(default)]
92 pub model: Option<String>,
93 #[serde(default)]
95 pub context_window: Option<ContextWindow>,
96 #[serde(default)]
98 pub service_tier: Option<ServiceTier>,
99 #[serde(default)]
101 pub inference_geo: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106#[non_exhaustive]
107pub struct CostBucket {
108 pub starting_at: String,
110 pub ending_at: String,
112 pub results: Vec<CostRow>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118#[non_exhaustive]
119pub struct CostReport {
120 pub data: Vec<CostBucket>,
122 #[serde(default)]
124 pub has_more: bool,
125 #[serde(default)]
127 pub next_page: Option<String>,
128}
129
130#[derive(Debug, Clone, Default)]
132#[non_exhaustive]
133pub struct CostReportParams {
134 pub starting_at: String,
136 pub ending_at: Option<String>,
138 pub bucket_width: Option<CostBucketWidth>,
140 pub group_by: Vec<CostGroupBy>,
142 pub limit: Option<u32>,
144 pub page: Option<String>,
146}
147
148impl CostReportParams {
149 #[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
184pub 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 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}