use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum BucketWidth {
#[serde(rename = "1m")]
Minute,
#[serde(rename = "1h")]
Hour,
#[serde(rename = "1d")]
Day,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ServiceTier {
Standard,
Batch,
Priority,
PriorityOnDemand,
Flex,
FlexDiscount,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ContextWindow {
#[serde(rename = "0-200k")]
Up200k,
#[serde(rename = "200k-1M")]
Up1M,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum InferenceGeo {
Global,
Us,
NotAvailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MessagesGroupBy {
ApiKeyId,
WorkspaceId,
Model,
ServiceTier,
ContextWindow,
InferenceGeo,
Speed,
AccountId,
ServiceAccountId,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Speed {
Standard,
Fast,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CacheCreationTokens {
pub ephemeral_5m_input_tokens: u64,
pub ephemeral_1h_input_tokens: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ServerToolUse {
pub web_search_requests: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MessagesUsageRow {
pub cache_creation: CacheCreationTokens,
pub cache_read_input_tokens: u64,
pub uncached_input_tokens: u64,
pub output_tokens: u64,
pub server_tool_use: ServerToolUse,
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub service_account_id: Option<String>,
#[serde(default)]
pub api_key_id: Option<String>,
#[serde(default)]
pub workspace_id: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub service_tier: Option<ServiceTier>,
#[serde(default)]
pub context_window: Option<ContextWindow>,
#[serde(default)]
pub inference_geo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MessagesUsageBucket {
pub starting_at: String,
pub ending_at: String,
pub results: Vec<MessagesUsageRow>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MessagesUsageReport {
pub data: Vec<MessagesUsageBucket>,
#[serde(default)]
pub has_more: bool,
#[serde(default)]
pub next_page: Option<String>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct MessagesUsageParams {
pub starting_at: String,
pub ending_at: Option<String>,
pub bucket_width: Option<BucketWidth>,
pub account_ids: Vec<String>,
pub api_key_ids: Vec<String>,
pub service_account_ids: Vec<String>,
pub workspace_ids: Vec<String>,
pub models: Vec<String>,
pub context_window: Vec<ContextWindow>,
pub service_tiers: Vec<ServiceTier>,
pub inference_geos: Vec<InferenceGeo>,
pub speeds: Vec<Speed>,
pub group_by: Vec<MessagesGroupBy>,
pub limit: Option<u32>,
pub page: Option<String>,
}
impl MessagesUsageParams {
#[must_use]
pub fn starting_at(starting_at: impl Into<String>) -> Self {
Self {
starting_at: starting_at.into(),
..Self::default()
}
}
fn to_query(&self) -> Vec<(&'static str, String)> {
let mut q = Vec::new();
q.push(("starting_at", self.starting_at.clone()));
if let Some(e) = &self.ending_at {
q.push(("ending_at", e.clone()));
}
if let Some(b) = self.bucket_width {
q.push((
"bucket_width",
match b {
BucketWidth::Minute => "1m".into(),
BucketWidth::Hour => "1h".into(),
BucketWidth::Day => "1d".into(),
},
));
}
for v in &self.account_ids {
q.push(("account_ids[]", v.clone()));
}
for v in &self.api_key_ids {
q.push(("api_key_ids[]", v.clone()));
}
for v in &self.service_account_ids {
q.push(("service_account_ids[]", v.clone()));
}
for v in &self.workspace_ids {
q.push(("workspace_ids[]", v.clone()));
}
for v in &self.models {
q.push(("models[]", v.clone()));
}
for v in &self.context_window {
q.push((
"context_window[]",
match v {
ContextWindow::Up200k => "0-200k".into(),
ContextWindow::Up1M => "200k-1M".into(),
},
));
}
for v in &self.service_tiers {
let s = match v {
ServiceTier::Standard => "standard",
ServiceTier::Batch => "batch",
ServiceTier::Priority => "priority",
ServiceTier::PriorityOnDemand => "priority_on_demand",
ServiceTier::Flex => "flex",
ServiceTier::FlexDiscount => "flex_discount",
};
q.push(("service_tiers[]", s.into()));
}
for v in &self.inference_geos {
let s = match v {
InferenceGeo::Global => "global",
InferenceGeo::Us => "us",
InferenceGeo::NotAvailable => "not_available",
};
q.push(("inference_geos[]", s.into()));
}
for v in &self.speeds {
q.push((
"speeds[]",
match v {
Speed::Standard => "standard".into(),
Speed::Fast => "fast".into(),
},
));
}
for v in &self.group_by {
let s = match v {
MessagesGroupBy::ApiKeyId => "api_key_id",
MessagesGroupBy::WorkspaceId => "workspace_id",
MessagesGroupBy::Model => "model",
MessagesGroupBy::ServiceTier => "service_tier",
MessagesGroupBy::ContextWindow => "context_window",
MessagesGroupBy::InferenceGeo => "inference_geo",
MessagesGroupBy::Speed => "speed",
MessagesGroupBy::AccountId => "account_id",
MessagesGroupBy::ServiceAccountId => "service_account_id",
};
q.push(("group_by[]", s.into()));
}
if let Some(l) = self.limit {
q.push(("limit", l.to_string()));
}
if let Some(p) = &self.page {
q.push(("page", p.clone()));
}
q
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ClaudeCodeActor {
UserActor {
email_address: String,
},
ApiActor {
api_key_name: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LinesOfCode {
pub added: u64,
pub removed: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ClaudeCodeCoreMetrics {
pub num_sessions: u64,
pub lines_of_code: LinesOfCode,
pub commits_by_claude_code: u64,
pub pull_requests_by_claude_code: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CostAmount {
pub amount: f64,
pub currency: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ClaudeCodeTokens {
pub cache_creation: u64,
pub cache_read: u64,
pub input: u64,
pub output: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ClaudeCodeModelBreakdown {
pub model: String,
pub tokens: ClaudeCodeTokens,
pub estimated_cost: CostAmount,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ToolActionCounts {
pub accepted: u64,
pub rejected: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CustomerType {
Api,
Subscription,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SubscriptionType {
Enterprise,
Team,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ClaudeCodeRow {
pub date: String,
pub organization_id: String,
pub customer_type: CustomerType,
#[serde(default)]
pub subscription_type: Option<SubscriptionType>,
pub actor: ClaudeCodeActor,
pub core_metrics: ClaudeCodeCoreMetrics,
pub model_breakdown: Vec<ClaudeCodeModelBreakdown>,
pub terminal_type: String,
#[serde(default)]
pub tool_actions: std::collections::HashMap<String, ToolActionCounts>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ClaudeCodeUsageReport {
pub data: Vec<ClaudeCodeRow>,
#[serde(default)]
pub has_more: bool,
#[serde(default)]
pub next_page: Option<String>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct ClaudeCodeUsageParams {
pub starting_at: String,
pub limit: Option<u32>,
pub page: Option<String>,
}
impl ClaudeCodeUsageParams {
#[must_use]
pub fn for_date(date: impl Into<String>) -> Self {
Self {
starting_at: date.into(),
..Self::default()
}
}
fn to_query(&self) -> Vec<(&'static str, String)> {
let mut q = Vec::new();
q.push(("starting_at", self.starting_at.clone()));
if let Some(l) = self.limit {
q.push(("limit", l.to_string()));
}
if let Some(p) = &self.page {
q.push(("page", p.clone()));
}
q
}
}
pub struct UsageReport<'a> {
client: &'a Client,
}
impl<'a> UsageReport<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
pub async fn messages(&self, params: MessagesUsageParams) -> Result<MessagesUsageReport> {
let query = params.to_query();
self.client
.execute_with_retry(
|| {
let mut req = self.client.request_builder(
reqwest::Method::GET,
"/v1/organizations/usage_report/messages",
);
for (k, v) in &query {
req = req.query(&[(k, v)]);
}
req
},
&[],
)
.await
}
pub async fn claude_code(
&self,
params: ClaudeCodeUsageParams,
) -> Result<ClaudeCodeUsageReport> {
let query = params.to_query();
self.client
.execute_with_retry(
|| {
let mut req = self.client.request_builder(
reqwest::Method::GET,
"/v1/organizations/usage_report/claude_code",
);
for (k, v) in &query {
req = req.query(&[(k, v)]);
}
req
},
&[],
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client_for(mock: &MockServer) -> Client {
Client::builder()
.api_key("sk-ant-admin-test")
.base_url(mock.uri())
.build()
.unwrap()
}
#[tokio::test]
async fn messages_usage_report_decodes_typed_buckets() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/organizations/usage_report/messages"))
.and(wiremock::matchers::query_param(
"starting_at",
"2026-05-01T00:00:00Z",
))
.and(wiremock::matchers::query_param("bucket_width", "1d"))
.and(wiremock::matchers::query_param("group_by[]", "model"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{
"starting_at": "2026-05-01T00:00:00Z",
"ending_at": "2026-05-02T00:00:00Z",
"results": [{
"cache_creation": {
"ephemeral_5m_input_tokens": 0,
"ephemeral_1h_input_tokens": 0
},
"cache_read_input_tokens": 0,
"uncached_input_tokens": 100,
"output_tokens": 50,
"server_tool_use": {"web_search_requests": 0},
"model": "claude-sonnet-4-6"
}]
}],
"has_more": false,
"next_page": null
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let r = client
.admin()
.usage_report()
.messages(MessagesUsageParams {
starting_at: "2026-05-01T00:00:00Z".into(),
bucket_width: Some(BucketWidth::Day),
group_by: vec![MessagesGroupBy::Model],
..Default::default()
})
.await
.unwrap();
assert_eq!(r.data.len(), 1);
assert_eq!(
r.data[0].results[0].model.as_deref(),
Some("claude-sonnet-4-6")
);
}
#[tokio::test]
async fn claude_code_usage_report_decodes_user_actor_row() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/organizations/usage_report/claude_code"))
.and(wiremock::matchers::query_param("starting_at", "2026-05-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{
"date": "2026-05-01",
"organization_id": "org_01",
"customer_type": "api",
"actor": {"type": "user_actor", "email_address": "u@example.com"},
"core_metrics": {
"num_sessions": 4,
"lines_of_code": {"added": 100, "removed": 20},
"commits_by_claude_code": 2,
"pull_requests_by_claude_code": 1
},
"model_breakdown": [],
"terminal_type": "iterm",
"tool_actions": {
"edit": {"accepted": 3, "rejected": 1}
}
}],
"has_more": false,
"next_page": null
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let r = client
.admin()
.usage_report()
.claude_code(ClaudeCodeUsageParams::for_date("2026-05-01"))
.await
.unwrap();
assert_eq!(r.data.len(), 1);
match &r.data[0].actor {
ClaudeCodeActor::UserActor { email_address } => {
assert_eq!(email_address, "u@example.com");
}
ClaudeCodeActor::ApiActor { .. } => panic!("expected user actor"),
}
assert_eq!(r.data[0].core_metrics.num_sessions, 4);
}
}