1use serde::{Deserialize, Serialize};
4
5use crate::client::Client;
6use crate::error::Result;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[non_exhaustive]
15pub enum BucketWidth {
16 #[serde(rename = "1m")]
18 Minute,
19 #[serde(rename = "1h")]
21 Hour,
22 #[serde(rename = "1d")]
24 Day,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum ServiceTier {
32 Standard,
34 Batch,
36 Priority,
38 PriorityOnDemand,
40 Flex,
42 FlexDiscount,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub enum ContextWindow {
50 #[serde(rename = "0-200k")]
52 Up200k,
53 #[serde(rename = "200k-1M")]
55 Up1M,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61#[non_exhaustive]
62pub enum InferenceGeo {
63 Global,
65 Us,
67 NotAvailable,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74#[non_exhaustive]
75pub enum MessagesGroupBy {
76 ApiKeyId,
78 WorkspaceId,
80 Model,
82 ServiceTier,
84 ContextWindow,
86 InferenceGeo,
88 Speed,
90 AccountId,
92 ServiceAccountId,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99#[non_exhaustive]
100pub enum Speed {
101 Standard,
103 Fast,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct CacheCreationTokens {
115 pub ephemeral_5m_input_tokens: u64,
117 pub ephemeral_1h_input_tokens: u64,
119}
120
121#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct ServerToolUse {
125 pub web_search_requests: u64,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131#[non_exhaustive]
132pub struct MessagesUsageRow {
133 pub cache_creation: CacheCreationTokens,
135 pub cache_read_input_tokens: u64,
137 pub uncached_input_tokens: u64,
139 pub output_tokens: u64,
141 pub server_tool_use: ServerToolUse,
143
144 #[serde(default)]
146 pub account_id: Option<String>,
147 #[serde(default)]
149 pub service_account_id: Option<String>,
150 #[serde(default)]
152 pub api_key_id: Option<String>,
153 #[serde(default)]
155 pub workspace_id: Option<String>,
156 #[serde(default)]
158 pub model: Option<String>,
159 #[serde(default)]
161 pub service_tier: Option<ServiceTier>,
162 #[serde(default)]
164 pub context_window: Option<ContextWindow>,
165 #[serde(default)]
168 pub inference_geo: Option<String>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173#[non_exhaustive]
174pub struct MessagesUsageBucket {
175 pub starting_at: String,
177 pub ending_at: String,
179 pub results: Vec<MessagesUsageRow>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185#[non_exhaustive]
186pub struct MessagesUsageReport {
187 pub data: Vec<MessagesUsageBucket>,
189 #[serde(default)]
191 pub has_more: bool,
192 #[serde(default)]
194 pub next_page: Option<String>,
195}
196
197#[derive(Debug, Clone, Default)]
199#[non_exhaustive]
200pub struct MessagesUsageParams {
201 pub starting_at: String,
203 pub ending_at: Option<String>,
205 pub bucket_width: Option<BucketWidth>,
207 pub account_ids: Vec<String>,
209 pub api_key_ids: Vec<String>,
211 pub service_account_ids: Vec<String>,
213 pub workspace_ids: Vec<String>,
215 pub models: Vec<String>,
217 pub context_window: Vec<ContextWindow>,
219 pub service_tiers: Vec<ServiceTier>,
221 pub inference_geos: Vec<InferenceGeo>,
223 pub speeds: Vec<Speed>,
226 pub group_by: Vec<MessagesGroupBy>,
228 pub limit: Option<u32>,
230 pub page: Option<String>,
232}
233
234impl MessagesUsageParams {
235 #[must_use]
237 pub fn starting_at(starting_at: impl Into<String>) -> Self {
238 Self {
239 starting_at: starting_at.into(),
240 ..Self::default()
241 }
242 }
243
244 fn to_query(&self) -> Vec<(&'static str, String)> {
245 let mut q = Vec::new();
246 q.push(("starting_at", self.starting_at.clone()));
247 if let Some(e) = &self.ending_at {
248 q.push(("ending_at", e.clone()));
249 }
250 if let Some(b) = self.bucket_width {
251 q.push((
252 "bucket_width",
253 match b {
254 BucketWidth::Minute => "1m".into(),
255 BucketWidth::Hour => "1h".into(),
256 BucketWidth::Day => "1d".into(),
257 },
258 ));
259 }
260 for v in &self.account_ids {
261 q.push(("account_ids[]", v.clone()));
262 }
263 for v in &self.api_key_ids {
264 q.push(("api_key_ids[]", v.clone()));
265 }
266 for v in &self.service_account_ids {
267 q.push(("service_account_ids[]", v.clone()));
268 }
269 for v in &self.workspace_ids {
270 q.push(("workspace_ids[]", v.clone()));
271 }
272 for v in &self.models {
273 q.push(("models[]", v.clone()));
274 }
275 for v in &self.context_window {
276 q.push((
277 "context_window[]",
278 match v {
279 ContextWindow::Up200k => "0-200k".into(),
280 ContextWindow::Up1M => "200k-1M".into(),
281 },
282 ));
283 }
284 for v in &self.service_tiers {
285 let s = match v {
286 ServiceTier::Standard => "standard",
287 ServiceTier::Batch => "batch",
288 ServiceTier::Priority => "priority",
289 ServiceTier::PriorityOnDemand => "priority_on_demand",
290 ServiceTier::Flex => "flex",
291 ServiceTier::FlexDiscount => "flex_discount",
292 };
293 q.push(("service_tiers[]", s.into()));
294 }
295 for v in &self.inference_geos {
296 let s = match v {
297 InferenceGeo::Global => "global",
298 InferenceGeo::Us => "us",
299 InferenceGeo::NotAvailable => "not_available",
300 };
301 q.push(("inference_geos[]", s.into()));
302 }
303 for v in &self.speeds {
304 q.push((
305 "speeds[]",
306 match v {
307 Speed::Standard => "standard".into(),
308 Speed::Fast => "fast".into(),
309 },
310 ));
311 }
312 for v in &self.group_by {
313 let s = match v {
314 MessagesGroupBy::ApiKeyId => "api_key_id",
315 MessagesGroupBy::WorkspaceId => "workspace_id",
316 MessagesGroupBy::Model => "model",
317 MessagesGroupBy::ServiceTier => "service_tier",
318 MessagesGroupBy::ContextWindow => "context_window",
319 MessagesGroupBy::InferenceGeo => "inference_geo",
320 MessagesGroupBy::Speed => "speed",
321 MessagesGroupBy::AccountId => "account_id",
322 MessagesGroupBy::ServiceAccountId => "service_account_id",
323 };
324 q.push(("group_by[]", s.into()));
325 }
326 if let Some(l) = self.limit {
327 q.push(("limit", l.to_string()));
328 }
329 if let Some(p) = &self.page {
330 q.push(("page", p.clone()));
331 }
332 q
333 }
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(tag = "type", rename_all = "snake_case")]
343#[non_exhaustive]
344pub enum ClaudeCodeActor {
345 UserActor {
347 email_address: String,
349 },
350 ApiActor {
352 api_key_name: String,
354 },
355}
356
357#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
359#[non_exhaustive]
360pub struct LinesOfCode {
361 pub added: u64,
363 pub removed: u64,
365}
366
367#[derive(Debug, Clone, Default, Serialize, Deserialize)]
369#[non_exhaustive]
370pub struct ClaudeCodeCoreMetrics {
371 pub num_sessions: u64,
373 pub lines_of_code: LinesOfCode,
375 pub commits_by_claude_code: u64,
377 pub pull_requests_by_claude_code: u64,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
383#[non_exhaustive]
384pub struct CostAmount {
385 pub amount: f64,
387 pub currency: String,
389}
390
391#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393#[non_exhaustive]
394pub struct ClaudeCodeTokens {
395 pub cache_creation: u64,
397 pub cache_read: u64,
399 pub input: u64,
401 pub output: u64,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
407#[non_exhaustive]
408pub struct ClaudeCodeModelBreakdown {
409 pub model: String,
411 pub tokens: ClaudeCodeTokens,
413 pub estimated_cost: CostAmount,
415}
416
417#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
419#[non_exhaustive]
420pub struct ToolActionCounts {
421 pub accepted: u64,
423 pub rejected: u64,
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430#[non_exhaustive]
431pub enum CustomerType {
432 Api,
434 Subscription,
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(rename_all = "snake_case")]
441#[non_exhaustive]
442pub enum SubscriptionType {
443 Enterprise,
445 Team,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451#[non_exhaustive]
452pub struct ClaudeCodeRow {
453 pub date: String,
455 pub organization_id: String,
457 pub customer_type: CustomerType,
459 #[serde(default)]
461 pub subscription_type: Option<SubscriptionType>,
462 pub actor: ClaudeCodeActor,
464 pub core_metrics: ClaudeCodeCoreMetrics,
466 pub model_breakdown: Vec<ClaudeCodeModelBreakdown>,
468 pub terminal_type: String,
470 #[serde(default)]
472 pub tool_actions: std::collections::HashMap<String, ToolActionCounts>,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477#[non_exhaustive]
478pub struct ClaudeCodeUsageReport {
479 pub data: Vec<ClaudeCodeRow>,
481 #[serde(default)]
483 pub has_more: bool,
484 #[serde(default)]
486 pub next_page: Option<String>,
487}
488
489#[derive(Debug, Clone, Default)]
491#[non_exhaustive]
492pub struct ClaudeCodeUsageParams {
493 pub starting_at: String,
495 pub limit: Option<u32>,
497 pub page: Option<String>,
499}
500
501impl ClaudeCodeUsageParams {
502 #[must_use]
504 pub fn for_date(date: impl Into<String>) -> Self {
505 Self {
506 starting_at: date.into(),
507 ..Self::default()
508 }
509 }
510
511 fn to_query(&self) -> Vec<(&'static str, String)> {
512 let mut q = Vec::new();
513 q.push(("starting_at", self.starting_at.clone()));
514 if let Some(l) = self.limit {
515 q.push(("limit", l.to_string()));
516 }
517 if let Some(p) = &self.page {
518 q.push(("page", p.clone()));
519 }
520 q
521 }
522}
523
524pub struct UsageReport<'a> {
530 client: &'a Client,
531}
532
533impl<'a> UsageReport<'a> {
534 pub(crate) fn new(client: &'a Client) -> Self {
535 Self { client }
536 }
537
538 pub async fn messages(&self, params: MessagesUsageParams) -> Result<MessagesUsageReport> {
540 let query = params.to_query();
541 self.client
542 .execute_with_retry(
543 || {
544 let mut req = self.client.request_builder(
545 reqwest::Method::GET,
546 "/v1/organizations/usage_report/messages",
547 );
548 for (k, v) in &query {
549 req = req.query(&[(k, v)]);
550 }
551 req
552 },
553 &[],
554 )
555 .await
556 }
557
558 pub async fn claude_code(
560 &self,
561 params: ClaudeCodeUsageParams,
562 ) -> Result<ClaudeCodeUsageReport> {
563 let query = params.to_query();
564 self.client
565 .execute_with_retry(
566 || {
567 let mut req = self.client.request_builder(
568 reqwest::Method::GET,
569 "/v1/organizations/usage_report/claude_code",
570 );
571 for (k, v) in &query {
572 req = req.query(&[(k, v)]);
573 }
574 req
575 },
576 &[],
577 )
578 .await
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use serde_json::json;
586 use wiremock::matchers::{method, path};
587 use wiremock::{Mock, MockServer, ResponseTemplate};
588
589 fn client_for(mock: &MockServer) -> Client {
590 Client::builder()
591 .api_key("sk-ant-admin-test")
592 .base_url(mock.uri())
593 .build()
594 .unwrap()
595 }
596
597 #[tokio::test]
598 async fn messages_usage_report_decodes_typed_buckets() {
599 let mock = MockServer::start().await;
600 Mock::given(method("GET"))
601 .and(path("/v1/organizations/usage_report/messages"))
602 .and(wiremock::matchers::query_param(
603 "starting_at",
604 "2026-05-01T00:00:00Z",
605 ))
606 .and(wiremock::matchers::query_param("bucket_width", "1d"))
607 .and(wiremock::matchers::query_param("group_by[]", "model"))
608 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
609 "data": [{
610 "starting_at": "2026-05-01T00:00:00Z",
611 "ending_at": "2026-05-02T00:00:00Z",
612 "results": [{
613 "cache_creation": {
614 "ephemeral_5m_input_tokens": 0,
615 "ephemeral_1h_input_tokens": 0
616 },
617 "cache_read_input_tokens": 0,
618 "uncached_input_tokens": 100,
619 "output_tokens": 50,
620 "server_tool_use": {"web_search_requests": 0},
621 "model": "claude-sonnet-4-6"
622 }]
623 }],
624 "has_more": false,
625 "next_page": null
626 })))
627 .mount(&mock)
628 .await;
629 let client = client_for(&mock);
630 let r = client
631 .admin()
632 .usage_report()
633 .messages(MessagesUsageParams {
634 starting_at: "2026-05-01T00:00:00Z".into(),
635 bucket_width: Some(BucketWidth::Day),
636 group_by: vec![MessagesGroupBy::Model],
637 ..Default::default()
638 })
639 .await
640 .unwrap();
641 assert_eq!(r.data.len(), 1);
642 assert_eq!(
643 r.data[0].results[0].model.as_deref(),
644 Some("claude-sonnet-4-6")
645 );
646 }
647
648 #[tokio::test]
649 async fn claude_code_usage_report_decodes_user_actor_row() {
650 let mock = MockServer::start().await;
651 Mock::given(method("GET"))
652 .and(path("/v1/organizations/usage_report/claude_code"))
653 .and(wiremock::matchers::query_param("starting_at", "2026-05-01"))
654 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
655 "data": [{
656 "date": "2026-05-01",
657 "organization_id": "org_01",
658 "customer_type": "api",
659 "actor": {"type": "user_actor", "email_address": "u@example.com"},
660 "core_metrics": {
661 "num_sessions": 4,
662 "lines_of_code": {"added": 100, "removed": 20},
663 "commits_by_claude_code": 2,
664 "pull_requests_by_claude_code": 1
665 },
666 "model_breakdown": [],
667 "terminal_type": "iterm",
668 "tool_actions": {
669 "edit": {"accepted": 3, "rejected": 1}
670 }
671 }],
672 "has_more": false,
673 "next_page": null
674 })))
675 .mount(&mock)
676 .await;
677 let client = client_for(&mock);
678 let r = client
679 .admin()
680 .usage_report()
681 .claude_code(ClaudeCodeUsageParams::for_date("2026-05-01"))
682 .await
683 .unwrap();
684 assert_eq!(r.data.len(), 1);
685 match &r.data[0].actor {
686 ClaudeCodeActor::UserActor { email_address } => {
687 assert_eq!(email_address, "u@example.com");
688 }
689 ClaudeCodeActor::ApiActor { .. } => panic!("expected user actor"),
690 }
691 assert_eq!(r.data[0].core_metrics.num_sessions, 4);
692 }
693}