Skip to main content

claude_api/admin/
usage_report.rs

1//! Usage reports: messages + `claude_code`.
2
3use serde::{Deserialize, Serialize};
4
5use crate::client::Client;
6use crate::error::Result;
7
8// =====================================================================
9// Common dimensions / filters
10// =====================================================================
11
12/// Time-bucket granularity for the messages usage report.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[non_exhaustive]
15pub enum BucketWidth {
16    /// One-minute buckets.
17    #[serde(rename = "1m")]
18    Minute,
19    /// One-hour buckets.
20    #[serde(rename = "1h")]
21    Hour,
22    /// One-day buckets.
23    #[serde(rename = "1d")]
24    Day,
25}
26
27/// Service-tier categories used in the messages usage report.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum ServiceTier {
32    /// Standard.
33    Standard,
34    /// Batch.
35    Batch,
36    /// Priority.
37    Priority,
38    /// On-demand priority.
39    PriorityOnDemand,
40    /// Flex.
41    Flex,
42    /// Discounted flex.
43    FlexDiscount,
44}
45
46/// Context-window classification.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub enum ContextWindow {
50    /// 0-200k tokens.
51    #[serde(rename = "0-200k")]
52    Up200k,
53    /// 200k-1M tokens.
54    #[serde(rename = "200k-1M")]
55    Up1M,
56}
57
58/// Inference geo classification used in usage rows.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61#[non_exhaustive]
62pub enum InferenceGeo {
63    /// Global routing.
64    Global,
65    /// US-only.
66    Us,
67    /// Model doesn't expose `inference_geo`.
68    NotAvailable,
69}
70
71/// Group-by dimensions for the messages usage report.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74#[non_exhaustive]
75pub enum MessagesGroupBy {
76    /// Group by API key ID.
77    ApiKeyId,
78    /// Group by workspace ID.
79    WorkspaceId,
80    /// Group by model.
81    Model,
82    /// Group by service tier.
83    ServiceTier,
84    /// Group by context window bucket.
85    ContextWindow,
86    /// Group by inference geo.
87    InferenceGeo,
88    /// Group by speed (`fast-mode-2026-02-01` beta).
89    Speed,
90    /// Group by user account.
91    AccountId,
92    /// Group by service account.
93    ServiceAccountId,
94}
95
96/// Inference speed (for `group_by=speed`).
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99#[non_exhaustive]
100pub enum Speed {
101    /// Default speed.
102    Standard,
103    /// Premium-priced fast inference.
104    Fast,
105}
106
107// =====================================================================
108// Messages usage report
109// =====================================================================
110
111/// Cache-creation token breakdown.
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct CacheCreationTokens {
115    /// Tokens used to create 5-minute cache entries.
116    pub ephemeral_5m_input_tokens: u64,
117    /// Tokens used to create 1-hour cache entries.
118    pub ephemeral_1h_input_tokens: u64,
119}
120
121/// Server-side tool-use breakdown.
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct ServerToolUse {
125    /// Web-search request count.
126    pub web_search_requests: u64,
127}
128
129/// One usage row inside a messages usage report bucket.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[non_exhaustive]
132pub struct MessagesUsageRow {
133    /// Cumulative cache-creation tokens.
134    pub cache_creation: CacheCreationTokens,
135    /// Cache-read input tokens.
136    pub cache_read_input_tokens: u64,
137    /// Uncached input tokens.
138    pub uncached_input_tokens: u64,
139    /// Output tokens.
140    pub output_tokens: u64,
141    /// Server-tool counts.
142    pub server_tool_use: ServerToolUse,
143
144    /// Account dimension (set when grouping by `account_id`).
145    #[serde(default)]
146    pub account_id: Option<String>,
147    /// Service-account dimension.
148    #[serde(default)]
149    pub service_account_id: Option<String>,
150    /// API-key dimension.
151    #[serde(default)]
152    pub api_key_id: Option<String>,
153    /// Workspace dimension.
154    #[serde(default)]
155    pub workspace_id: Option<String>,
156    /// Model dimension.
157    #[serde(default)]
158    pub model: Option<String>,
159    /// Service-tier dimension.
160    #[serde(default)]
161    pub service_tier: Option<ServiceTier>,
162    /// Context-window dimension.
163    #[serde(default)]
164    pub context_window: Option<ContextWindow>,
165    /// Inference-geo dimension. May be the literal `"not_available"`
166    /// for models that don't expose `inference_geo`.
167    #[serde(default)]
168    pub inference_geo: Option<String>,
169}
170
171/// One time bucket in a messages usage report.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[non_exhaustive]
174pub struct MessagesUsageBucket {
175    /// Inclusive start (RFC3339).
176    pub starting_at: String,
177    /// Exclusive end (RFC3339).
178    pub ending_at: String,
179    /// Per-row breakdown for the bucket.
180    pub results: Vec<MessagesUsageRow>,
181}
182
183/// Response shape for `GET /v1/organizations/usage_report/messages`.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[non_exhaustive]
186pub struct MessagesUsageReport {
187    /// Time-bucket data.
188    pub data: Vec<MessagesUsageBucket>,
189    /// Whether more pages exist.
190    #[serde(default)]
191    pub has_more: bool,
192    /// Opaque cursor for the next page.
193    #[serde(default)]
194    pub next_page: Option<String>,
195}
196
197/// Filters for [`UsageReport::messages`].
198#[derive(Debug, Clone, Default)]
199#[non_exhaustive]
200pub struct MessagesUsageParams {
201    /// RFC3339 start (inclusive). Required.
202    pub starting_at: String,
203    /// RFC3339 end (exclusive).
204    pub ending_at: Option<String>,
205    /// Bucket width.
206    pub bucket_width: Option<BucketWidth>,
207    /// Restrict to specific account IDs.
208    pub account_ids: Vec<String>,
209    /// Restrict to specific API key IDs.
210    pub api_key_ids: Vec<String>,
211    /// Restrict to specific service-account IDs.
212    pub service_account_ids: Vec<String>,
213    /// Restrict to specific workspace IDs.
214    pub workspace_ids: Vec<String>,
215    /// Restrict to specific models.
216    pub models: Vec<String>,
217    /// Restrict to specific context windows.
218    pub context_window: Vec<ContextWindow>,
219    /// Restrict to specific service tiers.
220    pub service_tiers: Vec<ServiceTier>,
221    /// Restrict to specific inference geos.
222    pub inference_geos: Vec<InferenceGeo>,
223    /// Restrict to specific speeds (research preview, requires
224    /// `fast-mode-2026-02-01` beta).
225    pub speeds: Vec<Speed>,
226    /// Group rows by these dimensions.
227    pub group_by: Vec<MessagesGroupBy>,
228    /// Maximum buckets per page.
229    pub limit: Option<u32>,
230    /// Pagination cursor.
231    pub page: Option<String>,
232}
233
234impl MessagesUsageParams {
235    /// Build a new params with the required `starting_at` field.
236    #[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// =====================================================================
337// Claude Code usage report
338// =====================================================================
339
340/// Actor on a [`ClaudeCodeRow`].
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(tag = "type", rename_all = "snake_case")]
343#[non_exhaustive]
344pub enum ClaudeCodeActor {
345    /// User actor.
346    UserActor {
347        /// Email of the user.
348        email_address: String,
349    },
350    /// API-key actor.
351    ApiActor {
352        /// Name of the API key.
353        api_key_name: String,
354    },
355}
356
357/// Lines-of-code statistics.
358#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
359#[non_exhaustive]
360pub struct LinesOfCode {
361    /// Lines added.
362    pub added: u64,
363    /// Lines removed.
364    pub removed: u64,
365}
366
367/// Per-actor productivity metrics.
368#[derive(Debug, Clone, Default, Serialize, Deserialize)]
369#[non_exhaustive]
370pub struct ClaudeCodeCoreMetrics {
371    /// Distinct sessions.
372    pub num_sessions: u64,
373    /// Lines added/removed.
374    pub lines_of_code: LinesOfCode,
375    /// Commits authored via Claude Code's commit feature.
376    pub commits_by_claude_code: u64,
377    /// PRs authored via Claude Code's PR feature.
378    pub pull_requests_by_claude_code: u64,
379}
380
381/// Estimated cost amount.
382#[derive(Debug, Clone, Serialize, Deserialize)]
383#[non_exhaustive]
384pub struct CostAmount {
385    /// Amount in minor currency units (e.g. cents).
386    pub amount: f64,
387    /// Currency code (`"USD"`).
388    pub currency: String,
389}
390
391/// Per-model token usage.
392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393#[non_exhaustive]
394pub struct ClaudeCodeTokens {
395    /// Cache-creation tokens.
396    pub cache_creation: u64,
397    /// Cache-read tokens.
398    pub cache_read: u64,
399    /// Input tokens.
400    pub input: u64,
401    /// Output tokens.
402    pub output: u64,
403}
404
405/// Per-model breakdown row.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407#[non_exhaustive]
408pub struct ClaudeCodeModelBreakdown {
409    /// Model name.
410    pub model: String,
411    /// Token usage.
412    pub tokens: ClaudeCodeTokens,
413    /// Estimated cost.
414    pub estimated_cost: CostAmount,
415}
416
417/// Tool action accept/reject counts.
418#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
419#[non_exhaustive]
420pub struct ToolActionCounts {
421    /// Number of accepted proposals.
422    pub accepted: u64,
423    /// Number of rejected proposals.
424    pub rejected: u64,
425}
426
427/// Customer type.
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430#[non_exhaustive]
431pub enum CustomerType {
432    /// API customer.
433    Api,
434    /// Subscription (Pro / Team).
435    Subscription,
436}
437
438/// Subscription tier.
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(rename_all = "snake_case")]
441#[non_exhaustive]
442pub enum SubscriptionType {
443    /// Enterprise tier.
444    Enterprise,
445    /// Team tier.
446    Team,
447}
448
449/// One row in the Claude Code usage report.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451#[non_exhaustive]
452pub struct ClaudeCodeRow {
453    /// UTC date in YYYY-MM-DD.
454    pub date: String,
455    /// Organization ID.
456    pub organization_id: String,
457    /// Customer type.
458    pub customer_type: CustomerType,
459    /// Subscription tier (when `customer_type=subscription`).
460    #[serde(default)]
461    pub subscription_type: Option<SubscriptionType>,
462    /// Actor.
463    pub actor: ClaudeCodeActor,
464    /// Productivity metrics.
465    pub core_metrics: ClaudeCodeCoreMetrics,
466    /// Per-model breakdown.
467    pub model_breakdown: Vec<ClaudeCodeModelBreakdown>,
468    /// Terminal type.
469    pub terminal_type: String,
470    /// Tool-action acceptance map (tool name → counts).
471    #[serde(default)]
472    pub tool_actions: std::collections::HashMap<String, ToolActionCounts>,
473}
474
475/// Response shape for `GET /v1/organizations/usage_report/claude_code`.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477#[non_exhaustive]
478pub struct ClaudeCodeUsageReport {
479    /// Daily rows.
480    pub data: Vec<ClaudeCodeRow>,
481    /// Whether more pages exist.
482    #[serde(default)]
483    pub has_more: bool,
484    /// Opaque next-page cursor.
485    #[serde(default)]
486    pub next_page: Option<String>,
487}
488
489/// Filters for [`UsageReport::claude_code`].
490#[derive(Debug, Clone, Default)]
491#[non_exhaustive]
492pub struct ClaudeCodeUsageParams {
493    /// UTC date in YYYY-MM-DD. Required (single-day report).
494    pub starting_at: String,
495    /// Page size (default 20, max 1000).
496    pub limit: Option<u32>,
497    /// Pagination cursor.
498    pub page: Option<String>,
499}
500
501impl ClaudeCodeUsageParams {
502    /// Build with the required date.
503    #[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
524// =====================================================================
525// Namespace handle
526// =====================================================================
527
528/// Namespace handle for the usage-report endpoints.
529pub 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    /// `GET /v1/organizations/usage_report/messages`.
539    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    /// `GET /v1/organizations/usage_report/claude_code`.
559    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}