use std::fmt;
pub const API_URL : &str = "https://api.anthropic.com/v1/messages";
pub const ANTHROPIC_BETA : &str = "oauth-2025-04-20";
pub const ANTHROPIC_VERSION : &str = "2023-06-01";
#[ derive( Debug ) ]
pub struct RateLimitData
{
pub utilization_5h : f64,
pub reset_5h : u64,
pub utilization_7d : f64,
pub reset_7d : u64,
pub status : String,
}
#[ derive( Debug ) ]
pub enum QuotaError
{
HttpTransport( String ),
MissingHeader( String ),
MalformedHeader( String ),
ResponseParse( String ),
}
impl fmt::Display for QuotaError
{
#[ inline ]
fn fmt( &self, f : &mut fmt::Formatter< '_ > ) -> fmt::Result
{
match self
{
Self::HttpTransport( msg ) =>
write!( f, "HTTP transport error: {msg}" ),
Self::MissingHeader( name ) =>
write!( f, "rate-limit header missing: {name}" ),
Self::MalformedHeader( ctx ) =>
write!( f, "rate-limit header malformed: {ctx}" ),
Self::ResponseParse( field ) =>
write!( f, "OAuth usage response parse error: missing or malformed field '{field}'" ),
}
}
}
impl std::error::Error for QuotaError {}
#[ inline ]
#[ allow( clippy::similar_names ) ]
pub fn parse_headers< F >( get : F ) -> Result< RateLimitData, QuotaError >
where
F : Fn( &str ) -> Option< String >,
{
let require = |name : &str| -> Result< String, QuotaError >
{
get( name ).ok_or_else( || QuotaError::MissingHeader( name.to_string() ) )
};
let utilization_5h = require( "anthropic-ratelimit-unified-5h-utilization" )?
.parse::< f64 >().map_err( |e|
QuotaError::MalformedHeader( format!( "5h-utilization: {e}" ) )
)?;
let reset_5h = require( "anthropic-ratelimit-unified-5h-reset" )?
.parse::< u64 >().map_err( |e|
QuotaError::MalformedHeader( format!( "5h-reset: {e}" ) )
)?;
let utilization_7d = require( "anthropic-ratelimit-unified-7d-utilization" )?
.parse::< f64 >().map_err( |e|
QuotaError::MalformedHeader( format!( "7d-utilization: {e}" ) )
)?;
let reset_7d = require( "anthropic-ratelimit-unified-7d-reset" )?
.parse::< u64 >().map_err( |e|
QuotaError::MalformedHeader( format!( "7d-reset: {e}" ) )
)?;
let status = require( "anthropic-ratelimit-unified-status" )?;
Ok( RateLimitData
{
utilization_5h,
reset_5h,
utilization_7d,
reset_7d,
status,
} )
}
#[ cfg( feature = "enabled" ) ]
#[ inline ]
fn http_agent() -> ureq::Agent
{
let config = ureq::Agent::config_builder()
.timeout_recv_body( Some( core::time::Duration::from_secs( 10 ) ) )
.timeout_connect( Some( core::time::Duration::from_secs( 5 ) ) )
.http_status_as_error( false )
.build();
ureq::Agent::new_with_config( config )
}
#[ cfg( feature = "enabled" ) ]
#[ inline ]
pub fn fetch_rate_limits( token : &str ) -> Result< RateLimitData, QuotaError >
{
let body = r#"{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"quota"}]}"#;
let resp = http_agent()
.post( API_URL )
.header( "Authorization", &format!( "Bearer {token}" ) )
.header( "anthropic-beta", ANTHROPIC_BETA )
.header( "anthropic-version", ANTHROPIC_VERSION )
.header( "Content-Type", "application/json" )
.send( body )
.map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
parse_headers( |name|
resp.headers().get( name )
.and_then( |v| v.to_str().ok() )
.map( str::to_string )
)
}
pub const OAUTH_USAGE_URL : &str = "https://api.anthropic.com/api/oauth/usage";
#[ derive( Debug ) ]
pub struct PeriodUsage
{
pub utilization : f64,
pub resets_at : Option< String >,
}
#[ derive( Debug ) ]
pub struct OauthUsageData
{
pub five_hour : Option< PeriodUsage >,
pub seven_day : Option< PeriodUsage >,
pub seven_day_sonnet : Option< PeriodUsage >,
}
#[ inline ]
#[ must_use ]
pub fn iso_to_unix_secs( s : &str ) -> Option< u64 >
{
if s.len() < 19 { return None; }
let t_pos = s.find( 'T' )?;
let date_part = &s[ ..t_pos ];
let time_part = &s[ t_pos + 1 .. ];
if date_part.len() < 10 { return None; }
let year = date_part[ 0..4 ].parse::< u64 >().ok()?;
let month = date_part[ 5..7 ].parse::< u64 >().ok()?;
let day = date_part[ 8..10 ].parse::< u64 >().ok()?;
if !( 1..=12 ).contains( &month ) || !( 1..=31 ).contains( &day ) { return None; }
if time_part.len() < 8 { return None; }
let hour = time_part[ 0..2 ].parse::< u64 >().ok()?;
let min = time_part[ 3..5 ].parse::< u64 >().ok()?;
let sec = time_part[ 6..8 ].parse::< u64 >().ok()?;
if hour > 23 || min > 59 || sec > 59 { return None; }
let is_leap = |y : u64| ( y % 4 == 0 && y % 100 != 0 ) || y % 400 == 0;
let mut days : u64 = 0;
for y in 1970..year
{
days += if is_leap( y ) { 366 } else { 365 };
}
let days_in_month = [ 31u64, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
for m in 1..month
{
let extra = u64::from( m == 2 && is_leap( year ) );
days += days_in_month[ usize::try_from( m - 1 ).unwrap_or( 0 ) ] + extra;
}
days += day - 1;
Some( days * 86_400 + hour * 3_600 + min * 60 + sec )
}
#[ inline ]
pub fn parse_oauth_usage( body : &str ) -> Result< OauthUsageData, QuotaError >
{
if !body.contains( "\"five_hour\"" )
&& !body.contains( "\"seven_day\"" )
&& !body.contains( "\"seven_day_sonnet\"" )
{
return Err( QuotaError::ResponseParse( "five_hour/seven_day/seven_day_sonnet".to_string() ) );
}
let five_hour = parse_period( body, "five_hour" )?;
let seven_day = parse_period( body, "seven_day" )?;
let seven_day_sonnet = parse_period( body, "seven_day_sonnet" )?;
let seven_day_sonnet = seven_day_sonnet
.or_else( || scan_limits_for_kind( body, &[ "weekly_sonnet", "sonnet" ] ) );
Ok( OauthUsageData { five_hour, seven_day, seven_day_sonnet } )
}
fn extract_object_block( s : &str ) -> Option< &str >
{
if !s.starts_with( '{' ) { return None; }
let mut depth = 0_i32;
for ( i, c ) in s.char_indices()
{
match c
{
'{' => depth += 1,
'}' =>
{
depth -= 1;
if depth == 0 { return Some( &s[ ..=i ] ); }
}
_ => {}
}
}
None
}
fn parse_period( body : &str, key : &str ) -> Result< Option< PeriodUsage >, QuotaError >
{
let needle = format!( "\"{key}\":" );
let after_key = body
.find( needle.as_str() )
.map( |pos| &body[ pos + needle.len() .. ] )
.ok_or_else( || QuotaError::ResponseParse( key.to_string() ) )?;
let value_start = after_key.trim_start();
if value_start.starts_with( "null" )
{
return Ok( None );
}
if !value_start.starts_with( '{' )
{
return Err( QuotaError::ResponseParse( format!( "{key}: expected object or null" ) ) );
}
let block = extract_object_block( value_start )
.ok_or_else( || QuotaError::ResponseParse( format!( "{key}: unclosed object" ) ) )?;
let utilization = parse_f64_in_block( block, "utilization" )
.ok_or_else( || QuotaError::ResponseParse( format!( "{key}.utilization" ) ) )?;
let resets_at = parse_optional_string_in_block( block, "resets_at" );
Ok( Some( PeriodUsage { utilization, resets_at } ) )
}
fn parse_f64_in_block( block : &str, key : &str ) -> Option< f64 >
{
let needle = format!( "\"{key}\":" );
let after_key = block.find( needle.as_str() ).map( |p| &block[ p + needle.len() .. ] )?;
let value = after_key.trim_start();
if value.starts_with( '"' ) { return None; }
let end = value
.find( |c : char| !c.is_ascii_digit() && c != '.' && c != '-' && c != 'e' && c != 'E' && c != '+' )
.unwrap_or( value.len() );
value[ ..end ].parse::< f64 >().ok()
}
fn parse_optional_string_in_block( block : &str, key : &str ) -> Option< String >
{
let needle = format!( "\"{key}\":" );
let after_key = block.find( needle.as_str() ).map( |p| &block[ p + needle.len() .. ] )?;
let value = after_key.trim_start();
if value.starts_with( "null" ) { return None; }
if let Some( inner ) = value.strip_prefix( '"' )
{
let end = inner.find( '"' )?;
return Some( inner[ ..end ].to_string() );
}
None
}
fn scan_limits_for_kind( body : &str, kind_needles : &[ &str ] ) -> Option< PeriodUsage >
{
let needle = "\"limits\":";
let pos = body.find( needle )?;
let after = body[ pos + needle.len() .. ].trim_start();
if !after.starts_with( '[' ) { return None; }
let mut rest = after[ 1.. ].trim_start(); loop
{
rest = rest.trim_start();
if rest.starts_with( ']' ) || rest.is_empty() { break; }
if rest.starts_with( ',' ) { rest = &rest[ 1.. ]; continue; }
let block = extract_object_block( rest )?;
rest = &rest[ block.len().. ];
let kind_val = parse_optional_string_in_block( block, "kind" ).unwrap_or_default();
let scope_val = parse_optional_string_in_block( block, "scope" ).unwrap_or_default();
let matched = kind_needles
.iter()
.any( |n| kind_val.contains( *n ) || scope_val.contains( *n ) );
if !matched { continue; }
let utilization = parse_f64_in_block( block, "percent" )?;
let resets_at = parse_optional_string_in_block( block, "resets_at" );
return Some( PeriodUsage { utilization, resets_at } );
}
None
}
#[ cfg( feature = "enabled" ) ]
#[ inline ]
pub fn fetch_oauth_usage( token : &str ) -> Result< OauthUsageData, QuotaError >
{
let mut resp = http_agent()
.get( OAUTH_USAGE_URL )
.header( "Authorization", &format!( "Bearer {token}" ) )
.call()
.map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
let status = resp.status().as_u16();
if status >= 400
{
return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
}
let body = resp
.body_mut()
.read_to_string()
.map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
parse_oauth_usage( &body )
}
pub const OAUTH_ACCOUNT_URL : &str = "https://api.anthropic.com/api/oauth/account";
#[ derive( Debug ) ]
pub struct OauthAccountData
{
pub tagged_id : String,
pub uuid : String,
pub email_address : String,
pub full_name : String,
pub display_name : String,
pub billing_type : String,
pub has_max : bool,
pub capabilities : Vec< String >,
pub rate_limit_tier : String,
pub org_created_at : String,
pub memberships : Vec< MembershipRaw >,
}
#[ derive( Debug ) ]
pub struct MembershipRaw
{
pub index : usize,
pub billing_type : String,
pub has_max : bool,
pub capabilities : Vec< String >,
pub org_created_at : String,
pub rate_limit_tier : String,
}
#[ inline ]
#[ must_use ]
pub fn select_membership_index( memberships : &[ MembershipRaw ] ) -> usize
{
if let Some( m ) = memberships.iter().find( |m| m.billing_type == "stripe_subscription" && m.has_max )
{
return m.index;
}
if let Some( m ) = memberships.iter().find( |m| m.billing_type == "stripe_subscription" )
{
return m.index;
}
0
}
fn parse_string_array( block : &str, key : &str ) -> Vec< String >
{
let needle = format!( "\"{key}\":" );
let Some( pos ) = block.find( needle.as_str() ) else { return vec![]; };
let rest = block[ pos + needle.len() .. ].trim_start();
let Some( arr_start ) = rest.find( '[' ) else { return vec![]; };
let inner = &rest[ arr_start + 1 .. ];
let Some( arr_end ) = inner.find( ']' ) else { return vec![]; };
let array_content = &inner[ ..arr_end ];
let mut caps = Vec::new();
let mut scan = array_content;
while let Some( start ) = scan.find( '"' )
{
scan = &scan[ start + 1 .. ];
let Some( end ) = scan.find( '"' ) else { break; };
let token = &scan[ ..end ];
if !token.is_empty() { caps.push( token.to_string() ); }
scan = &scan[ end + 1 .. ];
}
caps
}
fn parse_membership_list( body : &str ) -> Result< Vec< MembershipRaw >, QuotaError >
{
let mem_label = "\"memberships\":";
let mem_pos = body
.find( mem_label )
.ok_or_else( || QuotaError::ResponseParse( "memberships".to_string() ) )?;
let after_label = &body[ mem_pos + mem_label.len() .. ];
let arr_offset = after_label
.find( '[' )
.ok_or_else( || QuotaError::ResponseParse( "memberships: no array".to_string() ) )?;
let array_body = &after_label[ arr_offset + 1 .. ];
let mut memberships = Vec::new();
let mut pos = 0_usize;
let mut idx = 0_usize;
loop
{
let rest = &array_body[ pos .. ];
let close_pos = rest.find( ']' ).unwrap_or( rest.len() );
let obj_pos = match rest.find( '{' )
{
Some( p ) if p < close_pos => p,
_ => break,
};
let obj_slice = &rest[ obj_pos .. ];
let Some( membership_block ) = extract_object_block( obj_slice ) else { break };
if let Some( org_label_pos ) = membership_block.find( "\"organization\":" )
{
let org_label = "\"organization\":";
let after_org = membership_block[ org_label_pos + org_label.len() .. ].trim_start();
if let Some( org_block ) = extract_object_block( after_org )
{
let billing_type = parse_optional_string_in_block( org_block, "billing_type" )
.unwrap_or_default();
let has_max = org_block.contains( "\"claude_max\"" );
let org_created_at = parse_optional_string_in_block( org_block, "created_at" )
.unwrap_or_default();
let capabilities = parse_string_array( org_block, "capabilities" );
let rate_limit_tier = parse_optional_string_in_block( org_block, "rate_limit_tier" )
.unwrap_or_default();
memberships.push( MembershipRaw { index: idx, billing_type, has_max, capabilities, org_created_at, rate_limit_tier } );
}
}
pos += obj_pos + membership_block.len();
idx += 1;
}
if memberships.is_empty()
{
return Err( QuotaError::ResponseParse( "memberships: empty or missing organization".to_string() ) );
}
Ok( memberships )
}
#[ inline ]
pub fn parse_oauth_account( body : &str ) -> Result< OauthAccountData, QuotaError >
{
let tagged_id = parse_optional_string_in_block( body, "tagged_id" )
.unwrap_or_default();
let uuid = parse_optional_string_in_block( body, "uuid" )
.unwrap_or_default();
let email_address = parse_optional_string_in_block( body, "email_address" )
.unwrap_or_default();
let full_name = parse_optional_string_in_block( body, "full_name" )
.unwrap_or_default();
let display_name = parse_optional_string_in_block( body, "display_name" )
.unwrap_or_default();
let memberships = parse_membership_list( body )?;
let sel = select_membership_index( &memberships );
let m = &memberships[ sel ];
if m.billing_type.is_empty()
{
return Err( QuotaError::ResponseParse( "organization.billing_type".to_string() ) );
}
Ok( OauthAccountData
{
tagged_id,
uuid,
email_address,
full_name,
display_name,
billing_type : m.billing_type.clone(),
has_max : m.has_max,
capabilities : m.capabilities.clone(),
rate_limit_tier : m.rate_limit_tier.clone(),
org_created_at : m.org_created_at.clone(),
memberships,
} )
}
#[ cfg( feature = "enabled" ) ]
#[ inline ]
pub fn fetch_oauth_account( token : &str ) -> Result< OauthAccountData, QuotaError >
{
let mut resp = http_agent()
.get( OAUTH_ACCOUNT_URL )
.header( "Authorization", &format!( "Bearer {token}" ) )
.header( "anthropic-version", ANTHROPIC_VERSION )
.call()
.map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
let status = resp.status().as_u16();
if status >= 400
{
return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
}
let body = resp
.body_mut()
.read_to_string()
.map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
parse_oauth_account( &body )
}
pub const CLAUDE_CLI_ROLES_URL : &str = "https://api.anthropic.com/api/oauth/claude_cli/roles";
#[ derive( Debug ) ]
pub struct ClaudeCliRolesData
{
pub organization_uuid : String,
pub organization_name : String,
pub organization_role : String,
pub workspace_uuid : String,
pub workspace_name : String,
}
#[ inline ]
pub fn parse_claude_cli_roles( body : &str ) -> Result< ClaudeCliRolesData, QuotaError >
{
let organization_uuid = parse_optional_string_in_block( body, "organization_uuid" )
.ok_or_else( || QuotaError::ResponseParse( "organization_uuid".to_string() ) )?;
let organization_name = parse_optional_string_in_block( body, "organization_name" )
.ok_or_else( || QuotaError::ResponseParse( "organization_name".to_string() ) )?;
let organization_role = parse_optional_string_in_block( body, "organization_role" )
.unwrap_or_default();
let workspace_uuid = parse_optional_string_in_block( body, "workspace_uuid" )
.unwrap_or_default();
let workspace_name = parse_optional_string_in_block( body, "workspace_name" )
.unwrap_or_default();
Ok( ClaudeCliRolesData
{
organization_uuid,
organization_name,
organization_role,
workspace_uuid,
workspace_name,
} )
}
#[ cfg( feature = "enabled" ) ]
#[ inline ]
pub fn fetch_claude_cli_roles( token : &str ) -> Result< ClaudeCliRolesData, QuotaError >
{
let mut resp = http_agent()
.get( CLAUDE_CLI_ROLES_URL )
.header( "Authorization", &format!( "Bearer {token}" ) )
.header( "anthropic-version", ANTHROPIC_VERSION )
.call()
.map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
let status = resp.status().as_u16();
if status >= 400
{
return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
}
let body = resp
.body_mut()
.read_to_string()
.map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
parse_claude_cli_roles( &body )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
#[ doc = "`bug_reproducer(237)`" ]
fn mre_bug237_multi_membership_selects_stripe_max_over_none()
{
let body = r#"{
"tagged_id": "user_01ABC",
"uuid": "aaaa-bbbb",
"email_address": "alice@acme.com",
"full_name": "Alice",
"display_name": "Alice",
"memberships": [
{ "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } },
{ "role": "admin", "organization": { "billing_type": "stripe_subscription", "capabilities": ["claude_max","chat"], "rate_limit_tier": "default_claude_max_20x", "created_at": "2024-02-01T00:00:00Z" } }
]
}"#;
let result = parse_oauth_account( body ).expect( "should parse" );
assert_eq!( result.billing_type, "stripe_subscription", "must select membership[1] (stripe+max), not membership[0] (none)" );
assert!( result.has_max, "membership[1] has claude_max capability" );
assert_eq!( result.org_created_at, "2024-02-01T00:00:00Z" );
assert_eq!( result.tagged_id, "user_01ABC" );
assert_eq!( result.uuid, "aaaa-bbbb" );
assert_eq!( result.email_address, "alice@acme.com" );
assert_eq!( result.full_name, "Alice" );
assert_eq!( result.display_name, "Alice" );
assert_eq!( result.capabilities, vec![ "claude_max", "chat" ] );
assert_eq!( result.rate_limit_tier, "default_claude_max_20x" );
assert_eq!( result.memberships.len(), 2, "all memberships preserved" );
}
#[ test ]
#[ doc = "`bug_reproducer(237)`" ]
fn mre_bug237_multi_membership_selects_stripe_over_none_no_max()
{
let body = r#"{
"tagged_id": "user_02XYZ",
"uuid": "cccc-dddd",
"email_address": "bob@example.com",
"memberships": [
{ "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } },
{ "role": "admin", "organization": { "billing_type": "stripe_subscription", "capabilities": ["chat"], "created_at": "2024-03-01T00:00:00Z" } }
]
}"#;
let result = parse_oauth_account( body ).expect( "should parse" );
assert_eq!( result.billing_type, "stripe_subscription" );
assert!( !result.has_max, "no claude_max in membership[1]" );
assert_eq!( result.org_created_at, "2024-03-01T00:00:00Z" );
assert_eq!( result.tagged_id, "user_02XYZ" );
assert_eq!( result.email_address, "bob@example.com" );
assert!( result.rate_limit_tier.is_empty(), "no rate_limit_tier in fixture" );
}
#[ test ]
#[ doc = "`bug_reproducer(237)`" ]
fn mre_bug237_single_membership_fallback_unchanged()
{
let body = r#"{
"tagged_id": "user_03QRS",
"uuid": "eeee-ffff",
"email_address": "carol@example.com",
"full_name": "Carol",
"display_name": "Carol",
"memberships": [
{ "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } }
]
}"#;
let result = parse_oauth_account( body ).expect( "should parse" );
assert_eq!( result.billing_type, "none", "single membership always selected via Priority 3" );
assert!( !result.has_max );
assert_eq!( result.memberships.len(), 1, "single membership preserved" );
assert_eq!( result.tagged_id, "user_03QRS" );
assert_eq!( result.full_name, "Carol" );
}
}