Skip to main content

claude_quota/
lib.rs

1//! `claude_quota` — Anthropic API quota HTTP transports.
2//!
3//! Provides rate-limit header types and OAuth usage endpoint types as library
4//! types (always available), with network functions gated behind the `enabled` feature.
5//!
6//! # Feature Flags
7//!
8//! | Feature   | Adds                                                    | Extra dep |
9//! |-----------|---------------------------------------------------------|-----------|
10//! | (none)    | `RateLimitData`, `OauthUsageData`, `OauthAccountData`, `MembershipRaw`, `ClaudeCliRolesData`, `PeriodUsage`, `QuotaError` | — |
11//! | (none)    | `parse_headers`, `parse_oauth_usage`, `parse_oauth_account`, `parse_claude_cli_roles`, `select_membership_index`, `iso_to_unix_secs` | — |
12//! | `enabled` | `fetch_rate_limits(token)`, `fetch_oauth_usage(token)`, `fetch_oauth_account(token)`, `fetch_claude_cli_roles(token)` | `ureq` |
13//!
14//! # Testability
15//!
16//! [`parse_headers`] accepts `Fn(&str) -> Option<String>` so unit tests pass a
17//! `HashMap`-backed closure — no live network, no `ureq` in dev-dependencies.
18//! [`parse_oauth_usage`] operates on a raw `&str` body for the same reason.
19
20use std::fmt;
21
22// ── Constants ─────────────────────────────────────────────────────────────────
23
24/// Anthropic messages endpoint used for quota checks.
25pub const API_URL : &str = "https://api.anthropic.com/v1/messages";
26
27/// OAuth beta header value — must match the Claude binary's OAuth implementation.
28///
29/// # Pitfall
30///
31/// This string is not documented in public Anthropic API docs; it was discovered
32/// via `strings $(which claude)`. If live tests fail with "OAuth authentication is
33/// currently not supported", the Claude binary was updated. Re-run
34/// `strings $(which claude) | grep oauth` to find the new value.
35pub const ANTHROPIC_BETA    : &str = "oauth-2025-04-20";
36
37/// Anthropic API version header value.
38pub const ANTHROPIC_VERSION : &str = "2023-06-01";
39
40// ── RateLimitData ─────────────────────────────────────────────────────────────
41
42/// Rate-limit utilization data parsed from Anthropic API response headers.
43#[ derive( Debug ) ]
44pub struct RateLimitData
45{
46  /// 5-hour session window utilization (0.0–1.0).
47  pub utilization_5h : f64,
48  /// 5-hour session window reset time (Unix timestamp, seconds).
49  pub reset_5h       : u64,
50  /// 7-day all-model utilization (0.0–1.0).
51  pub utilization_7d : f64,
52  /// 7-day all-model reset time (Unix timestamp, seconds).
53  pub reset_7d       : u64,
54  /// Rate-limit status: `allowed`, `allowed_warning`, or `rejected`.
55  pub status         : String,
56}
57
58// ── QuotaError ────────────────────────────────────────────────────────────────
59
60/// Errors produced by the quota HTTP transport.
61#[ derive( Debug ) ]
62pub enum QuotaError
63{
64  /// HTTP transport failure (network error, TLS error, etc.).
65  HttpTransport( String ),
66  /// A required rate-limit header was absent from the API response.
67  MissingHeader( String ),
68  /// A required rate-limit header was present but could not be parsed.
69  MalformedHeader( String ),
70  /// The OAuth usage JSON response was absent or a required field was missing/malformed.
71  /// The inner `String` names the missing or malformed field (e.g. `"utilization"`).
72  ResponseParse( String ),
73}
74
75impl fmt::Display for QuotaError
76{
77  #[ inline ]
78  fn fmt( &self, f : &mut fmt::Formatter< '_ > ) -> fmt::Result
79  {
80    match self
81    {
82      Self::HttpTransport( msg ) =>
83        write!( f, "HTTP transport error: {msg}" ),
84      Self::MissingHeader( name ) =>
85        write!( f, "rate-limit header missing: {name}" ),
86      Self::MalformedHeader( ctx ) =>
87        write!( f, "rate-limit header malformed: {ctx}" ),
88      Self::ResponseParse( field ) =>
89        write!( f, "OAuth usage response parse error: missing or malformed field '{field}'" ),
90    }
91  }
92}
93
94impl std::error::Error for QuotaError {}
95
96// ── parse_headers ─────────────────────────────────────────────────────────────
97
98/// Parse rate-limit utilization headers using a closure-based header accessor.
99///
100/// Accepts `Fn(&str) -> Option<String>` (owned) so callers can pass either a live
101/// `|name| resp.header(name).map(str::to_string)` or a test `HashMap`-backed
102/// closure — no network access required for unit tests.
103///
104/// The owned-return design avoids lifetime coupling between the header-accessor
105/// return value and either the header-name input or the live HTTP response object.
106///
107/// # Errors
108///
109/// Returns [`QuotaError::MissingHeader`] if a required header is absent, or
110/// [`QuotaError::MalformedHeader`] if a present header cannot be parsed.
111#[ inline ]
112#[ allow( clippy::similar_names ) ]
113pub fn parse_headers< F >( get : F ) -> Result< RateLimitData, QuotaError >
114where
115  F : Fn( &str ) -> Option< String >,
116{
117  let require = |name : &str| -> Result< String, QuotaError >
118  {
119    get( name ).ok_or_else( || QuotaError::MissingHeader( name.to_string() ) )
120  };
121
122  let utilization_5h = require( "anthropic-ratelimit-unified-5h-utilization" )?
123    .parse::< f64 >().map_err( |e|
124      QuotaError::MalformedHeader( format!( "5h-utilization: {e}" ) )
125    )?;
126  let reset_5h = require( "anthropic-ratelimit-unified-5h-reset" )?
127    .parse::< u64 >().map_err( |e|
128      QuotaError::MalformedHeader( format!( "5h-reset: {e}" ) )
129    )?;
130  let utilization_7d = require( "anthropic-ratelimit-unified-7d-utilization" )?
131    .parse::< f64 >().map_err( |e|
132      QuotaError::MalformedHeader( format!( "7d-utilization: {e}" ) )
133    )?;
134  let reset_7d = require( "anthropic-ratelimit-unified-7d-reset" )?
135    .parse::< u64 >().map_err( |e|
136      QuotaError::MalformedHeader( format!( "7d-reset: {e}" ) )
137    )?;
138  let status = require( "anthropic-ratelimit-unified-status" )?;
139
140  Ok( RateLimitData
141  {
142    utilization_5h,
143    reset_5h,
144    utilization_7d,
145    reset_7d,
146    status,
147  } )
148}
149
150// ── http_agent ───────────────────────────────────────────────────────────────
151
152/// Build an HTTP agent with explicit read and connect timeouts.
153///
154/// # Fix(BUG-172)
155///
156/// Root cause: bare ureq convenience functions use the global agent whose
157/// `timeout_recv_body` defaults to `None` (indefinite), causing ~75–99s hangs when
158/// a server TCP-connects but stalls the response body.
159/// Pitfall: all new HTTP call sites must use this helper, not bare ureq calls.
160#[ cfg( feature = "enabled" ) ]
161#[ inline ]
162fn http_agent() -> ureq::Agent
163{
164  let config = ureq::Agent::config_builder()
165    .timeout_recv_body( Some( core::time::Duration::from_secs( 10 ) ) )
166    .timeout_connect( Some( core::time::Duration::from_secs( 5 ) ) )
167    .http_status_as_error( false )
168    .build();
169  ureq::Agent::new_with_config( config )
170}
171
172// ── fetch_rate_limits ─────────────────────────────────────────────────────────
173
174/// Fetch rate-limit utilization data from the Anthropic API.
175///
176/// Makes a lightweight `POST /v1/messages` (`max_tokens: 1`) using the provided
177/// OAuth access token. Rate-limit headers are returned on **all** responses,
178/// including HTTP error codes — `http_status_as_error(false)` on the agent
179/// ensures 4xx/5xx responses return `Ok(resp)` so headers are always accessible.
180///
181/// # Fix(issue-oauth-beta-stale)
182///
183/// Root cause: the `anthropic-beta` value `oauth-2023-09-22` was stale; the API
184/// rejected it with 401 ("OAuth authentication is currently not supported"), so
185/// rate-limit headers were never returned.
186/// Pitfall: the beta string is not in public docs — confirm via
187/// `strings $(which claude) | grep oauth` whenever Claude Code updates.
188///
189/// # Errors
190///
191/// Returns [`QuotaError::HttpTransport`] on network failure, or parsing errors
192/// from [`parse_headers`] if required headers are absent or malformed.
193#[ cfg( feature = "enabled" ) ]
194#[ inline ]
195pub fn fetch_rate_limits( token : &str ) -> Result< RateLimitData, QuotaError >
196{
197  let body = r#"{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"quota"}]}"#;
198
199  let resp = http_agent()
200    .post( API_URL )
201    .header( "Authorization",     &format!( "Bearer {token}" ) )
202    .header( "anthropic-beta",    ANTHROPIC_BETA )
203    .header( "anthropic-version", ANTHROPIC_VERSION )
204    .header( "Content-Type",      "application/json" )
205    .send( body )
206    .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
207
208  // Rate-limit headers are present on ALL responses, including HTTP error codes.
209  // http_status_as_error(false) on the agent ensures 4xx/5xx responses return
210  // Ok(resp) so headers are always accessible regardless of HTTP status.
211  parse_headers( |name|
212    resp.headers().get( name )
213      .and_then( |v| v.to_str().ok() )
214      .map( str::to_string )
215  )
216}
217
218// ── OauthUsageData / PeriodUsage ──────────────────────────────────────────────
219
220/// OAuth usage URL — GET endpoint returning per-period quota buckets.
221pub const OAUTH_USAGE_URL : &str = "https://api.anthropic.com/api/oauth/usage";
222
223/// Per-period quota bucket from the OAuth usage endpoint.
224///
225/// `utilization` is 0.0–100.0 (consumed percent).
226/// `resets_at` is an ISO-8601 UTC string (may be `None` if the server returns `null`).
227#[ derive( Debug ) ]
228pub struct PeriodUsage
229{
230  /// Consumed quota as a percentage (0.0–100.0).
231  pub utilization : f64,
232  /// ISO-8601 UTC reset timestamp, e.g. `"2026-05-20T04:00:00+00:00"`. `None` when server returns `null`.
233  pub resets_at   : Option< String >,
234}
235
236/// Response from `GET /api/oauth/usage` — three period buckets.
237///
238/// Each field is `None` when the server returns `null` (e.g. for non-subscription accounts).
239#[ derive( Debug ) ]
240pub struct OauthUsageData
241{
242  /// 5-hour session quota bucket.
243  pub five_hour        : Option< PeriodUsage >,
244  /// 7-day all-model quota bucket.
245  pub seven_day        : Option< PeriodUsage >,
246  /// 7-day Sonnet-only quota bucket.
247  pub seven_day_sonnet : Option< PeriodUsage >,
248}
249
250// ── iso_to_unix_secs ──────────────────────────────────────────────────────────
251
252/// Convert an ISO-8601 UTC timestamp to Unix seconds.
253///
254/// Parses `"YYYY-MM-DDTHH:MM:SS[.ffffff][+HH:MM|Z]"` using hand-rolled Gregorian
255/// calendar arithmetic — no external dependencies.
256///
257/// Only the date part (`YYYY-MM-DD`) and the time part (`HH:MM:SS`) are used;
258/// fractional seconds and UTC offset are ignored (offset is assumed to be `+00:00`
259/// for quota-reset purposes — all Anthropic timestamps are UTC).
260///
261/// Returns `None` on any parse failure (wrong format, non-numeric fields, etc.).
262#[ inline ]
263#[ must_use ]
264pub fn iso_to_unix_secs( s : &str ) -> Option< u64 >
265{
266  // Require at least "YYYY-MM-DDTHH:MM:SS" (19 chars)
267  if s.len() < 19 { return None; }
268
269  // Split on 'T'
270  let t_pos = s.find( 'T' )?;
271  let date_part = &s[ ..t_pos ];
272  let time_part = &s[ t_pos + 1 .. ];
273
274  // Parse date: "YYYY-MM-DD"
275  if date_part.len() < 10 { return None; }
276  let year  = date_part[ 0..4 ].parse::< u64 >().ok()?;
277  let month = date_part[ 5..7 ].parse::< u64 >().ok()?;
278  let day   = date_part[ 8..10 ].parse::< u64 >().ok()?;
279  if !( 1..=12 ).contains( &month ) || !( 1..=31 ).contains( &day ) { return None; }
280
281  // Parse time: "HH:MM:SS" — ignore fractional seconds and timezone
282  if time_part.len() < 8 { return None; }
283  let hour = time_part[ 0..2 ].parse::< u64 >().ok()?;
284  let min  = time_part[ 3..5 ].parse::< u64 >().ok()?;
285  let sec  = time_part[ 6..8 ].parse::< u64 >().ok()?;
286  if hour > 23 || min > 59 || sec > 59 { return None; }
287
288  // Days from 1970-01-01 to YYYY-01-01
289  let is_leap = |y : u64| ( y % 4 == 0 && y % 100 != 0 ) || y % 400 == 0;
290  let mut days : u64 = 0;
291  for y in 1970..year
292  {
293    days += if is_leap( y ) { 366 } else { 365 };
294  }
295
296  // Days for completed months in this year
297  let days_in_month = [ 31u64, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
298  for m in 1..month
299  {
300    let extra = u64::from( m == 2 && is_leap( year ) );
301    days += days_in_month[ usize::try_from( m - 1 ).unwrap_or( 0 ) ] + extra;
302  }
303
304  days += day - 1;
305
306  Some( days * 86_400 + hour * 3_600 + min * 60 + sec )
307}
308
309// ── parse_oauth_usage ─────────────────────────────────────────────────────────
310
311/// Parse the body of `GET /api/oauth/usage` into [`OauthUsageData`].
312///
313/// Uses string-needle scanning (no `serde_json`) so it is always available
314/// regardless of feature flags.
315///
316/// # Errors
317///
318/// Returns [`QuotaError::ResponseParse`] if the body does not contain the
319/// expected top-level keys, or a present period object is missing the required
320/// `"utilization"` field or contains a non-numeric value.
321#[ inline ]
322pub fn parse_oauth_usage( body : &str ) -> Result< OauthUsageData, QuotaError >
323{
324  // Body must contain at least one of the three period keys.
325  // Invalid JSON (e.g. "not json") will fail to find any needle → ResponseParse.
326  if !body.contains( "\"five_hour\"" )
327    && !body.contains( "\"seven_day\"" )
328    && !body.contains( "\"seven_day_sonnet\"" )
329  {
330    return Err( QuotaError::ResponseParse( "five_hour/seven_day/seven_day_sonnet".to_string() ) );
331  }
332
333  let five_hour        = parse_period( body, "five_hour" )?;
334  let seven_day        = parse_period( body, "seven_day" )?;
335  let seven_day_sonnet = parse_period( body, "seven_day_sonnet" )?;
336
337  // Phase 2: limits-array fallback — runs only when Phase 1 returned None.
338  // Handles post-2026-06-25 API format where `seven_day_sonnet` is permanently null.
339  // When Anthropic re-enables per-model `limits` entries, this auto-populates the field.
340  let seven_day_sonnet = seven_day_sonnet
341    .or_else( || scan_limits_for_kind( body, &[ "weekly_sonnet", "sonnet" ] ) );
342
343  Ok( OauthUsageData { five_hour, seven_day, seven_day_sonnet } )
344}
345
346/// Extract a `{...}` object block from the start of `s` using brace counting.
347///
348/// `s` must start with `'{'`. Returns the slice `s[..end]` including both braces,
349/// or `None` if the input doesn't start with `'{'` or has unmatched braces.
350fn extract_object_block( s : &str ) -> Option< &str >
351{
352  if !s.starts_with( '{' ) { return None; }
353  let mut depth = 0_i32;
354  for ( i, c ) in s.char_indices()
355  {
356    match c
357    {
358      '{' => depth += 1,
359      '}' =>
360      {
361        depth -= 1;
362        if depth == 0 { return Some( &s[ ..=i ] ); }
363      }
364      _ => {}
365    }
366  }
367  None
368}
369
370/// Extract a single period bucket from the usage JSON body.
371///
372/// Finds `"key":` needle, inspects the value:
373/// - `null` → `None`
374/// - `{...}` block → parse `utilization` (required) and `resets_at` (optional)
375///
376/// Returns `Err(ResponseParse)` if `utilization` is missing or non-numeric,
377/// or if the JSON structure is unexpected.
378fn parse_period( body : &str, key : &str ) -> Result< Option< PeriodUsage >, QuotaError >
379{
380  let needle = format!( "\"{key}\":" );
381  let after_key = body
382    .find( needle.as_str() )
383    .map( |pos| &body[ pos + needle.len() .. ] )
384    .ok_or_else( || QuotaError::ResponseParse( key.to_string() ) )?;
385
386  let value_start = after_key.trim_start();
387
388  // null → None
389  if value_start.starts_with( "null" )
390  {
391    return Ok( None );
392  }
393
394  // Must be an object starting with '{'
395  if !value_start.starts_with( '{' )
396  {
397    return Err( QuotaError::ResponseParse( format!( "{key}: expected object or null" ) ) );
398  }
399
400  let block = extract_object_block( value_start )
401    .ok_or_else( || QuotaError::ResponseParse( format!( "{key}: unclosed object" ) ) )?;
402
403  // Parse `utilization` (required f64)
404  let utilization = parse_f64_in_block( block, "utilization" )
405    .ok_or_else( || QuotaError::ResponseParse( format!( "{key}.utilization" ) ) )?;
406
407  // Parse `resets_at` (optional string; may be null)
408  let resets_at = parse_optional_string_in_block( block, "resets_at" );
409
410  Ok( Some( PeriodUsage { utilization, resets_at } ) )
411}
412
413/// Find and parse a `f64` value for `"key":` inside a JSON object fragment.
414///
415/// Returns `None` if the key is absent or the value is not a valid `f64`.
416fn parse_f64_in_block( block : &str, key : &str ) -> Option< f64 >
417{
418  let needle     = format!( "\"{key}\":" );
419  let after_key  = block.find( needle.as_str() ).map( |p| &block[ p + needle.len() .. ] )?;
420  let value      = after_key.trim_start();
421
422  // Reject string values (start with '"')
423  if value.starts_with( '"' ) { return None; }
424
425  // Collect leading numeric characters: digits, '.', '-', 'e', 'E', '+'
426  let end = value
427    .find( |c : char| !c.is_ascii_digit() && c != '.' && c != '-' && c != 'e' && c != 'E' && c != '+' )
428    .unwrap_or( value.len() );
429  value[ ..end ].parse::< f64 >().ok()
430}
431
432/// Find and parse an optional string value for `"key":` inside a JSON object fragment.
433///
434/// Returns `None` if the key is absent, the value is `null`, or parsing fails.
435fn parse_optional_string_in_block( block : &str, key : &str ) -> Option< String >
436{
437  let needle    = format!( "\"{key}\":" );
438  let after_key = block.find( needle.as_str() ).map( |p| &block[ p + needle.len() .. ] )?;
439  let value     = after_key.trim_start();
440
441  // null → None
442  if value.starts_with( "null" ) { return None; }
443
444  // String value — extract until closing '"' (simple, no escape handling needed for timestamps)
445  if let Some( inner ) = value.strip_prefix( '"' )
446  {
447    let end = inner.find( '"' )?;
448    return Some( inner[ ..end ].to_string() );
449  }
450
451  None
452}
453
454/// Scan the `"limits":[...]` array for a quota boundary whose `"kind"` or `"scope"` field
455/// value contains any of the given needles.
456///
457/// Returns the first match as a [`PeriodUsage`]:
458/// - `utilization` = `"percent"` value cast to `f64` (both fields measure consumed %, 0–100)
459/// - `resets_at`   = `"resets_at"` string value (`None` when absent or `null`)
460///
461/// Returns `None` when the `limits` key is absent, the array is empty, or no entry matches.
462fn scan_limits_for_kind( body : &str, kind_needles : &[ &str ] ) -> Option< PeriodUsage >
463{
464  // Find "limits":[ in body
465  let needle = "\"limits\":";
466  let pos    = body.find( needle )?;
467  let after  = body[ pos + needle.len() .. ].trim_start();
468  if !after.starts_with( '[' ) { return None; }
469
470  // Walk the array: extract each {...} object block
471  let mut rest = after[ 1.. ].trim_start(); // skip '['
472  loop
473  {
474    rest = rest.trim_start();
475    if rest.starts_with( ']' ) || rest.is_empty() { break; }
476    if rest.starts_with( ',' ) { rest = &rest[ 1.. ]; continue; }
477
478    let block = extract_object_block( rest )?;
479    rest      = &rest[ block.len().. ];
480
481    // Check if "kind" or "scope" value contains any needle
482    let kind_val  = parse_optional_string_in_block( block, "kind"  ).unwrap_or_default();
483    let scope_val = parse_optional_string_in_block( block, "scope" ).unwrap_or_default();
484    let matched   = kind_needles
485      .iter()
486      .any( |n| kind_val.contains( *n ) || scope_val.contains( *n ) );
487    if !matched { continue; }
488
489    // Extract percent as utilization (cast — same scale: 0–100 consumed %)
490    let utilization = parse_f64_in_block( block, "percent" )?;
491    let resets_at   = parse_optional_string_in_block( block, "resets_at" );
492    return Some( PeriodUsage { utilization, resets_at } );
493  }
494  None
495}
496
497// ── fetch_oauth_usage ─────────────────────────────────────────────────────────
498
499/// Fetch OAuth usage data from the Anthropic API.
500///
501/// Makes a `GET /api/oauth/usage` request using the provided OAuth access token.
502///
503/// # Errors
504///
505/// Returns [`QuotaError::HttpTransport`] on network failure, or
506/// [`QuotaError::ResponseParse`] if the response body cannot be parsed.
507#[ cfg( feature = "enabled" ) ]
508#[ inline ]
509pub fn fetch_oauth_usage( token : &str ) -> Result< OauthUsageData, QuotaError >
510{
511  let mut resp = http_agent()
512    .get( OAUTH_USAGE_URL )
513    .header( "Authorization", &format!( "Bearer {token}" ) )
514    .call()
515    .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
516
517  let status = resp.status().as_u16();
518  if status >= 400
519  {
520    return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
521  }
522
523  let body = resp
524    .body_mut()
525    .read_to_string()
526    .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
527
528  parse_oauth_usage( &body )
529}
530
531// ── OauthAccountData ──────────────────────────────────────────────────────────
532
533/// OAuth account URL — GET endpoint returning account identity and org membership.
534pub const OAUTH_ACCOUNT_URL : &str = "https://api.anthropic.com/api/oauth/account";
535
536/// Account identity and subscription state parsed from `GET /api/oauth/account`.
537///
538/// User-level identity fields (`tagged_id`, `uuid`, `email_address`, `full_name`,
539/// `display_name`) come from the top level of the endpoint 002 response.
540/// Billing fields are populated from the priority-selected membership
541/// (see [`select_membership_index`]). The full membership list is preserved for
542/// `.account.inspect` display.
543///
544/// # Pitfall
545///
546/// `credentials.json` `subscriptionType` is written at OAuth-token-creation time and
547/// goes stale after subscription changes. `billing_type` from this endpoint is the
548/// authoritative current state — prefer it over the cached credential field.
549#[ derive( Debug ) ]
550pub struct OauthAccountData
551{
552  /// Stable user identifier (e.g. `"user_01ABCDEFGhijklmnopqrstuvwx"`).
553  pub tagged_id       : String,
554  /// User UUID.
555  pub uuid            : String,
556  /// Primary email address.
557  pub email_address   : String,
558  /// Full name from the account profile.
559  pub full_name       : String,
560  /// Display name from the account profile.
561  pub display_name    : String,
562  /// Current subscription status: `"stripe_subscription"` = active, `"none"` = cancelled.
563  pub billing_type    : String,
564  /// Whether the account has Claude Max capability (`"claude_max"` in org capabilities array).
565  pub has_max         : bool,
566  /// Raw capability strings from the selected membership's org `capabilities` array.
567  pub capabilities    : Vec< String >,
568  /// Rate-limit tier from the selected membership (e.g. `"default_claude_max_20x"`).
569  pub rate_limit_tier : String,
570  /// ISO-8601 UTC org creation timestamp — Stripe billing cycle anchor date.
571  pub org_created_at  : String,
572  /// All memberships from endpoint 002 — used by `.account.inspect` for full display.
573  pub memberships     : Vec< MembershipRaw >,
574}
575
576/// One membership entry parsed from `GET /api/oauth/account`.
577///
578/// Used by [`select_membership_index`] to pick the billing-relevant membership,
579/// and by `.account.inspect` to display all memberships with a selection indicator.
580#[ derive( Debug ) ]
581pub struct MembershipRaw
582{
583  /// Zero-based position in the `memberships` array.
584  pub index          : usize,
585  /// Billing status: `"stripe_subscription"` or `"none"`.
586  pub billing_type   : String,
587  /// Whether `"claude_max"` appears in this membership's org `capabilities` array.
588  pub has_max        : bool,
589  /// Raw capability strings from this membership's org `capabilities` array.
590  pub capabilities   : Vec< String >,
591  /// ISO-8601 UTC org creation timestamp (billing cycle anchor), or empty string if absent.
592  pub org_created_at   : String,
593  /// Rate-limit tier string (e.g. `"default_claude_max_20x"`), or empty string if absent.
594  pub rate_limit_tier  : String,
595}
596
597/// Return the index of the highest-priority membership in `memberships`.
598///
599/// Priority (descending):
600/// 1. `billing_type == "stripe_subscription"` **and** `has_max == true`
601/// 2. `billing_type == "stripe_subscription"` (any)
602/// 3. `0` (index fallback — always valid because memberships is non-empty)
603///
604/// # Panics
605///
606/// Does not panic; returns `0` for an empty slice.
607#[ inline ]
608#[ must_use ]
609pub fn select_membership_index( memberships : &[ MembershipRaw ] ) -> usize
610{
611  // Priority 1: stripe + max
612  if let Some( m ) = memberships.iter().find( |m| m.billing_type == "stripe_subscription" && m.has_max )
613  {
614    return m.index;
615  }
616  // Priority 2: stripe any
617  if let Some( m ) = memberships.iter().find( |m| m.billing_type == "stripe_subscription" )
618  {
619    return m.index;
620  }
621  // Priority 3: fallback to first
622  0
623}
624
625/// Extract all string elements from a JSON array field inside a block.
626///
627/// Finds `"key": [...]` inside `block` and returns each quoted string token.
628/// Returns an empty `Vec` if the key is absent or the array is empty.
629fn parse_string_array( block : &str, key : &str ) -> Vec< String >
630{
631  let needle = format!( "\"{key}\":" );
632  let Some( pos ) = block.find( needle.as_str() ) else { return vec![]; };
633  let rest = block[ pos + needle.len() .. ].trim_start();
634  let Some( arr_start ) = rest.find( '[' ) else { return vec![]; };
635  let inner = &rest[ arr_start + 1 .. ];
636  let Some( arr_end ) = inner.find( ']' ) else { return vec![]; };
637  let array_content = &inner[ ..arr_end ];
638  let mut caps = Vec::new();
639  let mut scan = array_content;
640  while let Some( start ) = scan.find( '"' )
641  {
642    scan = &scan[ start + 1 .. ];
643    let Some( end ) = scan.find( '"' ) else { break; };
644    let token = &scan[ ..end ];
645    if !token.is_empty() { caps.push( token.to_string() ); }
646    scan = &scan[ end + 1 .. ];
647  }
648  caps
649}
650
651/// Parse all membership objects from the `"memberships"` array of a JSON body.
652///
653/// Iterates membership `{...}` objects using brace-balanced scanning, extracts the
654/// nested `"organization":` block from each, and returns one [`MembershipRaw`] per entry.
655///
656/// Returns `Err(ResponseParse)` if the `"memberships":` key or its opening `[` is absent,
657/// or if no membership objects with an `"organization"` block are found.
658fn parse_membership_list( body : &str ) -> Result< Vec< MembershipRaw >, QuotaError >
659{
660  let mem_label = "\"memberships\":";
661  let mem_pos   = body
662    .find( mem_label )
663    .ok_or_else( || QuotaError::ResponseParse( "memberships".to_string() ) )?;
664  let after_label = &body[ mem_pos + mem_label.len() .. ];
665  let arr_offset  = after_label
666    .find( '[' )
667    .ok_or_else( || QuotaError::ResponseParse( "memberships: no array".to_string() ) )?;
668  let array_body = &after_label[ arr_offset + 1 .. ];
669
670  let mut memberships = Vec::new();
671  let mut pos = 0_usize;
672  let mut idx = 0_usize;
673
674  loop
675  {
676    let rest = &array_body[ pos .. ];
677    // Find next '{' or stop at ']' (end of memberships array)
678    let close_pos = rest.find( ']' ).unwrap_or( rest.len() );
679    let obj_pos   = match rest.find( '{' )
680    {
681      Some( p ) if p < close_pos => p,
682      _                          => break,
683    };
684    let obj_slice     = &rest[ obj_pos .. ];
685    let Some( membership_block ) = extract_object_block( obj_slice ) else { break };
686
687    // Extract the nested "organization": { ... } block
688    if let Some( org_label_pos ) = membership_block.find( "\"organization\":" )
689    {
690      let org_label   = "\"organization\":";
691      let after_org   = membership_block[ org_label_pos + org_label.len() .. ].trim_start();
692      if let Some( org_block ) = extract_object_block( after_org )
693      {
694        let billing_type   = parse_optional_string_in_block( org_block, "billing_type" )
695          .unwrap_or_default();
696        let has_max        = org_block.contains( "\"claude_max\"" );
697        let org_created_at = parse_optional_string_in_block( org_block, "created_at" )
698          .unwrap_or_default();
699        let capabilities    = parse_string_array( org_block, "capabilities" );
700        let rate_limit_tier = parse_optional_string_in_block( org_block, "rate_limit_tier" )
701          .unwrap_or_default();
702        memberships.push( MembershipRaw { index: idx, billing_type, has_max, capabilities, org_created_at, rate_limit_tier } );
703      }
704    }
705
706    pos += obj_pos + membership_block.len();
707    idx += 1;
708  }
709
710  if memberships.is_empty()
711  {
712    return Err( QuotaError::ResponseParse( "memberships: empty or missing organization".to_string() ) );
713  }
714  Ok( memberships )
715}
716
717/// Parse the body of `GET /api/oauth/account` into [`OauthAccountData`].
718///
719/// Extracts user-level identity fields (`tagged_id`, `uuid`, `email_address`,
720/// `full_name`, `display_name`) from the body top level, then iterates ALL membership
721/// objects using brace-balanced scanning, applies [`select_membership_index`] to pick
722/// the billing-relevant entry, and populates billing fields from the selected
723/// membership's `organization` block.
724///
725/// # Errors
726///
727/// Returns [`QuotaError::ResponseParse`] if `memberships`, a valid `organization` block,
728/// or `billing_type` are absent.
729///
730/// # Fix(BUG-237)
731///
732/// Previously used `str::find("\"organization\":")` on the full memberships string,
733/// which always resolved to `memberships[0]`'s organization regardless of which
734/// membership held the active subscription.
735///
736/// # Fix(BUG-295)
737///
738/// Identity fields now come from endpoint 002 top-level — the fabricated
739/// `/api/oauth/userinfo` endpoint (HTTP 404) has been removed.
740#[ inline ]
741pub fn parse_oauth_account( body : &str ) -> Result< OauthAccountData, QuotaError >
742{
743  // User-level identity from body top-level
744  let tagged_id     = parse_optional_string_in_block( body, "tagged_id" )
745    .unwrap_or_default();
746  let uuid          = parse_optional_string_in_block( body, "uuid" )
747    .unwrap_or_default();
748  let email_address = parse_optional_string_in_block( body, "email_address" )
749    .unwrap_or_default();
750  let full_name     = parse_optional_string_in_block( body, "full_name" )
751    .unwrap_or_default();
752  let display_name  = parse_optional_string_in_block( body, "display_name" )
753    .unwrap_or_default();
754
755  let memberships = parse_membership_list( body )?;
756  let sel         = select_membership_index( &memberships );
757  let m           = &memberships[ sel ];
758  if m.billing_type.is_empty()
759  {
760    return Err( QuotaError::ResponseParse( "organization.billing_type".to_string() ) );
761  }
762  Ok( OauthAccountData
763  {
764    tagged_id,
765    uuid,
766    email_address,
767    full_name,
768    display_name,
769    billing_type    : m.billing_type.clone(),
770    has_max         : m.has_max,
771    capabilities    : m.capabilities.clone(),
772    rate_limit_tier : m.rate_limit_tier.clone(),
773    org_created_at  : m.org_created_at.clone(),
774    memberships,
775  } )
776}
777
778/// Fetch account identity and subscription state from the Anthropic OAuth account endpoint.
779///
780/// Makes a `GET /api/oauth/account` request using the provided OAuth access token.
781///
782/// # Errors
783///
784/// Returns [`QuotaError::HttpTransport`] on network failure, or
785/// [`QuotaError::ResponseParse`] if the response body cannot be parsed.
786#[ cfg( feature = "enabled" ) ]
787#[ inline ]
788pub fn fetch_oauth_account( token : &str ) -> Result< OauthAccountData, QuotaError >
789{
790  let mut resp = http_agent()
791    .get( OAUTH_ACCOUNT_URL )
792    .header( "Authorization",     &format!( "Bearer {token}" ) )
793    .header( "anthropic-version", ANTHROPIC_VERSION )
794    .call()
795    .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
796
797  let status = resp.status().as_u16();
798  if status >= 400
799  {
800    return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
801  }
802
803  let body = resp
804    .body_mut()
805    .read_to_string()
806    .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
807
808  parse_oauth_account( &body )
809}
810
811// ── ClaudeCliRolesData ────────────────────────────────────────────────────────
812
813/// Claude CLI roles URL — GET endpoint returning org and workspace identity.
814pub const CLAUDE_CLI_ROLES_URL : &str = "https://api.anthropic.com/api/oauth/claude_cli/roles";
815
816/// Org identity snapshot parsed from `GET /api/oauth/claude_cli/roles`.
817///
818/// Personal accounts have empty `workspace_uuid` and `workspace_name` (API returns `null`).
819/// Enterprise accounts have non-null workspace fields.
820#[ derive( Debug ) ]
821pub struct ClaudeCliRolesData
822{
823  /// Organisation UUID.
824  pub organization_uuid : String,
825  /// Organisation display name.
826  pub organization_name : String,
827  /// User's role within the organisation (e.g., `"admin"`, `"member"`).
828  pub organization_role : String,
829  /// Workspace UUID — empty string for personal accounts (API returns `null`).
830  pub workspace_uuid    : String,
831  /// Workspace display name — empty string for personal accounts (API returns `null`).
832  pub workspace_name    : String,
833}
834
835/// Parse the body of `GET /api/oauth/claude_cli/roles` into [`ClaudeCliRolesData`].
836///
837/// Uses string-needle scanning (no `serde_json`) so it is always available
838/// regardless of feature flags.
839///
840/// Nullable fields (`workspace_uuid`, `workspace_name`) become empty strings when
841/// the server returns `null`.
842///
843/// # Errors
844///
845/// Returns [`QuotaError::ResponseParse`] if `organization_uuid` or `organization_name`
846/// are absent or the body is not valid JSON.
847#[ inline ]
848pub fn parse_claude_cli_roles( body : &str ) -> Result< ClaudeCliRolesData, QuotaError >
849{
850  let organization_uuid = parse_optional_string_in_block( body, "organization_uuid" )
851    .ok_or_else( || QuotaError::ResponseParse( "organization_uuid".to_string() ) )?;
852  let organization_name = parse_optional_string_in_block( body, "organization_name" )
853    .ok_or_else( || QuotaError::ResponseParse( "organization_name".to_string() ) )?;
854  let organization_role = parse_optional_string_in_block( body, "organization_role" )
855    .unwrap_or_default();
856  let workspace_uuid    = parse_optional_string_in_block( body, "workspace_uuid" )
857    .unwrap_or_default();
858  let workspace_name    = parse_optional_string_in_block( body, "workspace_name" )
859    .unwrap_or_default();
860
861  Ok( ClaudeCliRolesData
862  {
863    organization_uuid,
864    organization_name,
865    organization_role,
866    workspace_uuid,
867    workspace_name,
868  } )
869}
870
871/// Fetch org identity from the Claude CLI roles endpoint.
872///
873/// Makes a `GET /api/oauth/claude_cli/roles` request using the provided OAuth access token.
874///
875/// # Errors
876///
877/// Returns [`QuotaError::HttpTransport`] on network failure, or
878/// [`QuotaError::ResponseParse`] if the response body cannot be parsed.
879#[ cfg( feature = "enabled" ) ]
880#[ inline ]
881pub fn fetch_claude_cli_roles( token : &str ) -> Result< ClaudeCliRolesData, QuotaError >
882{
883  let mut resp = http_agent()
884    .get( CLAUDE_CLI_ROLES_URL )
885    .header( "Authorization",     &format!( "Bearer {token}" ) )
886    .header( "anthropic-version", ANTHROPIC_VERSION )
887    .call()
888    .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
889
890  let status = resp.status().as_u16();
891  if status >= 400
892  {
893    return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
894  }
895
896  let body = resp
897    .body_mut()
898    .read_to_string()
899    .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
900
901  parse_claude_cli_roles( &body )
902}
903
904// ── Tests ─────────────────────────────────────────────────────────────────────
905
906#[ cfg( test ) ]
907mod tests
908{
909  use super::*;
910
911  // ── BUG-237 MRE: multi-membership selection ─────────────────────────────────
912
913  #[ test ]
914  #[ doc = "`bug_reproducer(237)`" ]
915  /// `parse_oauth_account` selects the stripe+max membership over a none-billing entry.
916  ///
917  /// # Root Cause
918  /// `str::find("\"organization\":")` always resolves to `memberships[0]`'s organization
919  /// block. Accounts with a paid subscription at index > 0 were silently misclassified as
920  /// having no subscription.
921  ///
922  /// # Why Not Caught
923  /// All test fixtures used single-membership bodies. Multi-membership accounts require
924  /// separate Anthropic org entities — uncommon in CI fixtures.
925  ///
926  /// # Fix Applied
927  /// `parse_oauth_account` now calls `parse_membership_list` which iterates ALL membership
928  /// objects using brace-balanced scanning, then `select_membership_index` picks the
929  /// highest-priority entry.
930  ///
931  /// # Prevention
932  /// This test must FAIL before the fix (memberships[0] is "none") and PASS after.
933  ///
934  /// # Pitfall
935  /// Always use brace-balanced extraction when iterating JSON arrays containing nested
936  /// objects — `str::find` on a needle will collide with nested occurrences of the same key.
937  fn mre_bug237_multi_membership_selects_stripe_max_over_none()
938  {
939    let body = r#"{
940      "tagged_id": "user_01ABC",
941      "uuid": "aaaa-bbbb",
942      "email_address": "alice@acme.com",
943      "full_name": "Alice",
944      "display_name": "Alice",
945      "memberships": [
946        { "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } },
947        { "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" } }
948      ]
949    }"#;
950    let result = parse_oauth_account( body ).expect( "should parse" );
951    assert_eq!( result.billing_type, "stripe_subscription", "must select membership[1] (stripe+max), not membership[0] (none)" );
952    assert!( result.has_max, "membership[1] has claude_max capability" );
953    assert_eq!( result.org_created_at, "2024-02-01T00:00:00Z" );
954    // BUG-295: identity fields from body top-level
955    assert_eq!( result.tagged_id, "user_01ABC" );
956    assert_eq!( result.uuid, "aaaa-bbbb" );
957    assert_eq!( result.email_address, "alice@acme.com" );
958    assert_eq!( result.full_name, "Alice" );
959    assert_eq!( result.display_name, "Alice" );
960    assert_eq!( result.capabilities, vec![ "claude_max", "chat" ] );
961    assert_eq!( result.rate_limit_tier, "default_claude_max_20x" );
962    assert_eq!( result.memberships.len(), 2, "all memberships preserved" );
963  }
964
965  #[ test ]
966  #[ doc = "`bug_reproducer(237)`" ]
967  /// `parse_oauth_account` selects stripe (no max) over none when no max tier is present.
968  fn mre_bug237_multi_membership_selects_stripe_over_none_no_max()
969  {
970    let body = r#"{
971      "tagged_id": "user_02XYZ",
972      "uuid": "cccc-dddd",
973      "email_address": "bob@example.com",
974      "memberships": [
975        { "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } },
976        { "role": "admin",  "organization": { "billing_type": "stripe_subscription", "capabilities": ["chat"], "created_at": "2024-03-01T00:00:00Z" } }
977      ]
978    }"#;
979    let result = parse_oauth_account( body ).expect( "should parse" );
980    assert_eq!( result.billing_type, "stripe_subscription" );
981    assert!( !result.has_max, "no claude_max in membership[1]" );
982    assert_eq!( result.org_created_at, "2024-03-01T00:00:00Z" );
983    assert_eq!( result.tagged_id, "user_02XYZ" );
984    assert_eq!( result.email_address, "bob@example.com" );
985    assert!( result.rate_limit_tier.is_empty(), "no rate_limit_tier in fixture" );
986  }
987
988  #[ test ]
989  #[ doc = "`bug_reproducer(237)`" ]
990  /// Single-membership body: index 0 is always selected (Priority 3 fallback unchanged).
991  fn mre_bug237_single_membership_fallback_unchanged()
992  {
993    let body = r#"{
994      "tagged_id": "user_03QRS",
995      "uuid": "eeee-ffff",
996      "email_address": "carol@example.com",
997      "full_name": "Carol",
998      "display_name": "Carol",
999      "memberships": [
1000        { "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } }
1001      ]
1002    }"#;
1003    let result = parse_oauth_account( body ).expect( "should parse" );
1004    assert_eq!( result.billing_type, "none", "single membership always selected via Priority 3" );
1005    assert!( !result.has_max );
1006    assert_eq!( result.memberships.len(), 1, "single membership preserved" );
1007    assert_eq!( result.tagged_id, "user_03QRS" );
1008    assert_eq!( result.full_name, "Carol" );
1009  }
1010}