claude_quota 0.3.1

Anthropic API rate-limit HTTP transport — Layer * standalone primitive.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
//! `claude_quota` — Anthropic API quota HTTP transports.
//!
//! Provides rate-limit header types and OAuth usage endpoint types as library
//! types (always available), with network functions gated behind the `enabled` feature.
//!
//! # Feature Flags
//!
//! | Feature   | Adds                                                    | Extra dep |
//! |-----------|---------------------------------------------------------|-----------|
//! | (none)    | `RateLimitData`, `OauthUsageData`, `OauthAccountData`, `MembershipRaw`, `ClaudeCliRolesData`, `PeriodUsage`, `QuotaError` | — |
//! | (none)    | `parse_headers`, `parse_oauth_usage`, `parse_oauth_account`, `parse_claude_cli_roles`, `select_membership_index`, `iso_to_unix_secs` | — |
//! | `enabled` | `fetch_rate_limits(token)`, `fetch_oauth_usage(token)`, `fetch_oauth_account(token)`, `fetch_claude_cli_roles(token)` | `ureq` |
//!
//! # Testability
//!
//! [`parse_headers`] accepts `Fn(&str) -> Option<String>` so unit tests pass a
//! `HashMap`-backed closure — no live network, no `ureq` in dev-dependencies.
//! [`parse_oauth_usage`] operates on a raw `&str` body for the same reason.

use std::fmt;

// ── Constants ─────────────────────────────────────────────────────────────────

/// Anthropic messages endpoint used for quota checks.
pub const API_URL : &str = "https://api.anthropic.com/v1/messages";

/// OAuth beta header value — must match the Claude binary's OAuth implementation.
///
/// # Pitfall
///
/// This string is not documented in public Anthropic API docs; it was discovered
/// via `strings $(which claude)`. If live tests fail with "OAuth authentication is
/// currently not supported", the Claude binary was updated. Re-run
/// `strings $(which claude) | grep oauth` to find the new value.
pub const ANTHROPIC_BETA    : &str = "oauth-2025-04-20";

/// Anthropic API version header value.
pub const ANTHROPIC_VERSION : &str = "2023-06-01";

// ── RateLimitData ─────────────────────────────────────────────────────────────

/// Rate-limit utilization data parsed from Anthropic API response headers.
#[ derive( Debug ) ]
pub struct RateLimitData
{
  /// 5-hour session window utilization (0.0–1.0).
  pub utilization_5h : f64,
  /// 5-hour session window reset time (Unix timestamp, seconds).
  pub reset_5h       : u64,
  /// 7-day all-model utilization (0.0–1.0).
  pub utilization_7d : f64,
  /// 7-day all-model reset time (Unix timestamp, seconds).
  pub reset_7d       : u64,
  /// Rate-limit status: `allowed`, `allowed_warning`, or `rejected`.
  pub status         : String,
}

// ── QuotaError ────────────────────────────────────────────────────────────────

/// Errors produced by the quota HTTP transport.
#[ derive( Debug ) ]
pub enum QuotaError
{
  /// HTTP transport failure (network error, TLS error, etc.).
  HttpTransport( String ),
  /// A required rate-limit header was absent from the API response.
  MissingHeader( String ),
  /// A required rate-limit header was present but could not be parsed.
  MalformedHeader( String ),
  /// The OAuth usage JSON response was absent or a required field was missing/malformed.
  /// The inner `String` names the missing or malformed field (e.g. `"utilization"`).
  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 {}

// ── parse_headers ─────────────────────────────────────────────────────────────

/// Parse rate-limit utilization headers using a closure-based header accessor.
///
/// Accepts `Fn(&str) -> Option<String>` (owned) so callers can pass either a live
/// `|name| resp.header(name).map(str::to_string)` or a test `HashMap`-backed
/// closure — no network access required for unit tests.
///
/// The owned-return design avoids lifetime coupling between the header-accessor
/// return value and either the header-name input or the live HTTP response object.
///
/// # Errors
///
/// Returns [`QuotaError::MissingHeader`] if a required header is absent, or
/// [`QuotaError::MalformedHeader`] if a present header cannot be parsed.
#[ 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,
  } )
}

// ── http_agent ───────────────────────────────────────────────────────────────

/// Build an HTTP agent with explicit read and connect timeouts.
///
/// # Fix(BUG-172)
///
/// Root cause: bare ureq convenience functions use the global agent whose
/// `timeout_recv_body` defaults to `None` (indefinite), causing ~75–99s hangs when
/// a server TCP-connects but stalls the response body.
/// Pitfall: all new HTTP call sites must use this helper, not bare ureq calls.
#[ 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 )
}

// ── fetch_rate_limits ─────────────────────────────────────────────────────────

/// Fetch rate-limit utilization data from the Anthropic API.
///
/// Makes a lightweight `POST /v1/messages` (`max_tokens: 1`) using the provided
/// OAuth access token. Rate-limit headers are returned on **all** responses,
/// including HTTP error codes — `http_status_as_error(false)` on the agent
/// ensures 4xx/5xx responses return `Ok(resp)` so headers are always accessible.
///
/// # Fix(issue-oauth-beta-stale)
///
/// Root cause: the `anthropic-beta` value `oauth-2023-09-22` was stale; the API
/// rejected it with 401 ("OAuth authentication is currently not supported"), so
/// rate-limit headers were never returned.
/// Pitfall: the beta string is not in public docs — confirm via
/// `strings $(which claude) | grep oauth` whenever Claude Code updates.
///
/// # Errors
///
/// Returns [`QuotaError::HttpTransport`] on network failure, or parsing errors
/// from [`parse_headers`] if required headers are absent or malformed.
#[ 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() ) )?;

  // Rate-limit headers are present on ALL responses, including HTTP error codes.
  // http_status_as_error(false) on the agent ensures 4xx/5xx responses return
  // Ok(resp) so headers are always accessible regardless of HTTP status.
  parse_headers( |name|
    resp.headers().get( name )
      .and_then( |v| v.to_str().ok() )
      .map( str::to_string )
  )
}

// ── OauthUsageData / PeriodUsage ──────────────────────────────────────────────

/// OAuth usage URL — GET endpoint returning per-period quota buckets.
pub const OAUTH_USAGE_URL : &str = "https://api.anthropic.com/api/oauth/usage";

/// Per-period quota bucket from the OAuth usage endpoint.
///
/// `utilization` is 0.0–100.0 (consumed percent).
/// `resets_at` is an ISO-8601 UTC string (may be `None` if the server returns `null`).
#[ derive( Debug ) ]
pub struct PeriodUsage
{
  /// Consumed quota as a percentage (0.0–100.0).
  pub utilization : f64,
  /// ISO-8601 UTC reset timestamp, e.g. `"2026-05-20T04:00:00+00:00"`. `None` when server returns `null`.
  pub resets_at   : Option< String >,
}

/// Response from `GET /api/oauth/usage` — three period buckets.
///
/// Each field is `None` when the server returns `null` (e.g. for non-subscription accounts).
#[ derive( Debug ) ]
pub struct OauthUsageData
{
  /// 5-hour session quota bucket.
  pub five_hour        : Option< PeriodUsage >,
  /// 7-day all-model quota bucket.
  pub seven_day        : Option< PeriodUsage >,
  /// 7-day Sonnet-only quota bucket.
  pub seven_day_sonnet : Option< PeriodUsage >,
}

// ── iso_to_unix_secs ──────────────────────────────────────────────────────────

/// Convert an ISO-8601 UTC timestamp to Unix seconds.
///
/// Parses `"YYYY-MM-DDTHH:MM:SS[.ffffff][+HH:MM|Z]"` using hand-rolled Gregorian
/// calendar arithmetic — no external dependencies.
///
/// Only the date part (`YYYY-MM-DD`) and the time part (`HH:MM:SS`) are used;
/// fractional seconds and UTC offset are ignored (offset is assumed to be `+00:00`
/// for quota-reset purposes — all Anthropic timestamps are UTC).
///
/// Returns `None` on any parse failure (wrong format, non-numeric fields, etc.).
#[ inline ]
#[ must_use ]
pub fn iso_to_unix_secs( s : &str ) -> Option< u64 >
{
  // Require at least "YYYY-MM-DDTHH:MM:SS" (19 chars)
  if s.len() < 19 { return None; }

  // Split on 'T'
  let t_pos = s.find( 'T' )?;
  let date_part = &s[ ..t_pos ];
  let time_part = &s[ t_pos + 1 .. ];

  // Parse date: "YYYY-MM-DD"
  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; }

  // Parse time: "HH:MM:SS" — ignore fractional seconds and timezone
  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; }

  // Days from 1970-01-01 to YYYY-01-01
  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 };
  }

  // Days for completed months in this year
  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 )
}

// ── parse_oauth_usage ─────────────────────────────────────────────────────────

/// Parse the body of `GET /api/oauth/usage` into [`OauthUsageData`].
///
/// Uses string-needle scanning (no `serde_json`) so it is always available
/// regardless of feature flags.
///
/// # Errors
///
/// Returns [`QuotaError::ResponseParse`] if the body does not contain the
/// expected top-level keys, or a present period object is missing the required
/// `"utilization"` field or contains a non-numeric value.
#[ inline ]
pub fn parse_oauth_usage( body : &str ) -> Result< OauthUsageData, QuotaError >
{
  // Body must contain at least one of the three period keys.
  // Invalid JSON (e.g. "not json") will fail to find any needle → ResponseParse.
  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" )?;

  // Phase 2: limits-array fallback — runs only when Phase 1 returned None.
  // Handles post-2026-06-25 API format where `seven_day_sonnet` is permanently null.
  // When Anthropic re-enables per-model `limits` entries, this auto-populates the field.
  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 } )
}

/// Extract a `{...}` object block from the start of `s` using brace counting.
///
/// `s` must start with `'{'`. Returns the slice `s[..end]` including both braces,
/// or `None` if the input doesn't start with `'{'` or has unmatched braces.
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
}

/// Extract a single period bucket from the usage JSON body.
///
/// Finds `"key":` needle, inspects the value:
/// - `null` → `None`
/// - `{...}` block → parse `utilization` (required) and `resets_at` (optional)
///
/// Returns `Err(ResponseParse)` if `utilization` is missing or non-numeric,
/// or if the JSON structure is unexpected.
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();

  // null → None
  if value_start.starts_with( "null" )
  {
    return Ok( None );
  }

  // Must be an object starting with '{'
  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" ) ) )?;

  // Parse `utilization` (required f64)
  let utilization = parse_f64_in_block( block, "utilization" )
    .ok_or_else( || QuotaError::ResponseParse( format!( "{key}.utilization" ) ) )?;

  // Parse `resets_at` (optional string; may be null)
  let resets_at = parse_optional_string_in_block( block, "resets_at" );

  Ok( Some( PeriodUsage { utilization, resets_at } ) )
}

/// Find and parse a `f64` value for `"key":` inside a JSON object fragment.
///
/// Returns `None` if the key is absent or the value is not a valid `f64`.
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();

  // Reject string values (start with '"')
  if value.starts_with( '"' ) { return None; }

  // Collect leading numeric characters: digits, '.', '-', 'e', 'E', '+'
  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()
}

/// Find and parse an optional string value for `"key":` inside a JSON object fragment.
///
/// Returns `None` if the key is absent, the value is `null`, or parsing fails.
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();

  // null → None
  if value.starts_with( "null" ) { return None; }

  // String value — extract until closing '"' (simple, no escape handling needed for timestamps)
  if let Some( inner ) = value.strip_prefix( '"' )
  {
    let end = inner.find( '"' )?;
    return Some( inner[ ..end ].to_string() );
  }

  None
}

/// Scan the `"limits":[...]` array for a quota boundary whose `"kind"` or `"scope"` field
/// value contains any of the given needles.
///
/// Returns the first match as a [`PeriodUsage`]:
/// - `utilization` = `"percent"` value cast to `f64` (both fields measure consumed %, 0–100)
/// - `resets_at`   = `"resets_at"` string value (`None` when absent or `null`)
///
/// Returns `None` when the `limits` key is absent, the array is empty, or no entry matches.
fn scan_limits_for_kind( body : &str, kind_needles : &[ &str ] ) -> Option< PeriodUsage >
{
  // Find "limits":[ in body
  let needle = "\"limits\":";
  let pos    = body.find( needle )?;
  let after  = body[ pos + needle.len() .. ].trim_start();
  if !after.starts_with( '[' ) { return None; }

  // Walk the array: extract each {...} object block
  let mut rest = after[ 1.. ].trim_start(); // skip '['
  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().. ];

    // Check if "kind" or "scope" value contains any needle
    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; }

    // Extract percent as utilization (cast — same scale: 0–100 consumed %)
    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
}

// ── fetch_oauth_usage ─────────────────────────────────────────────────────────

/// Fetch OAuth usage data from the Anthropic API.
///
/// Makes a `GET /api/oauth/usage` request using the provided OAuth access token.
///
/// # Errors
///
/// Returns [`QuotaError::HttpTransport`] on network failure, or
/// [`QuotaError::ResponseParse`] if the response body cannot be parsed.
#[ 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 )
}

// ── OauthAccountData ──────────────────────────────────────────────────────────

/// OAuth account URL — GET endpoint returning account identity and org membership.
pub const OAUTH_ACCOUNT_URL : &str = "https://api.anthropic.com/api/oauth/account";

/// Account identity and subscription state parsed from `GET /api/oauth/account`.
///
/// User-level identity fields (`tagged_id`, `uuid`, `email_address`, `full_name`,
/// `display_name`) come from the top level of the endpoint 002 response.
/// Billing fields are populated from the priority-selected membership
/// (see [`select_membership_index`]). The full membership list is preserved for
/// `.account.inspect` display.
///
/// # Pitfall
///
/// `credentials.json` `subscriptionType` is written at OAuth-token-creation time and
/// goes stale after subscription changes. `billing_type` from this endpoint is the
/// authoritative current state — prefer it over the cached credential field.
#[ derive( Debug ) ]
pub struct OauthAccountData
{
  /// Stable user identifier (e.g. `"user_01ABCDEFGhijklmnopqrstuvwx"`).
  pub tagged_id       : String,
  /// User UUID.
  pub uuid            : String,
  /// Primary email address.
  pub email_address   : String,
  /// Full name from the account profile.
  pub full_name       : String,
  /// Display name from the account profile.
  pub display_name    : String,
  /// Current subscription status: `"stripe_subscription"` = active, `"none"` = cancelled.
  pub billing_type    : String,
  /// Whether the account has Claude Max capability (`"claude_max"` in org capabilities array).
  pub has_max         : bool,
  /// Raw capability strings from the selected membership's org `capabilities` array.
  pub capabilities    : Vec< String >,
  /// Rate-limit tier from the selected membership (e.g. `"default_claude_max_20x"`).
  pub rate_limit_tier : String,
  /// ISO-8601 UTC org creation timestamp — Stripe billing cycle anchor date.
  pub org_created_at  : String,
  /// All memberships from endpoint 002 — used by `.account.inspect` for full display.
  pub memberships     : Vec< MembershipRaw >,
}

/// One membership entry parsed from `GET /api/oauth/account`.
///
/// Used by [`select_membership_index`] to pick the billing-relevant membership,
/// and by `.account.inspect` to display all memberships with a selection indicator.
#[ derive( Debug ) ]
pub struct MembershipRaw
{
  /// Zero-based position in the `memberships` array.
  pub index          : usize,
  /// Billing status: `"stripe_subscription"` or `"none"`.
  pub billing_type   : String,
  /// Whether `"claude_max"` appears in this membership's org `capabilities` array.
  pub has_max        : bool,
  /// Raw capability strings from this membership's org `capabilities` array.
  pub capabilities   : Vec< String >,
  /// ISO-8601 UTC org creation timestamp (billing cycle anchor), or empty string if absent.
  pub org_created_at   : String,
  /// Rate-limit tier string (e.g. `"default_claude_max_20x"`), or empty string if absent.
  pub rate_limit_tier  : String,
}

/// Return the index of the highest-priority membership in `memberships`.
///
/// Priority (descending):
/// 1. `billing_type == "stripe_subscription"` **and** `has_max == true`
/// 2. `billing_type == "stripe_subscription"` (any)
/// 3. `0` (index fallback — always valid because memberships is non-empty)
///
/// # Panics
///
/// Does not panic; returns `0` for an empty slice.
#[ inline ]
#[ must_use ]
pub fn select_membership_index( memberships : &[ MembershipRaw ] ) -> usize
{
  // Priority 1: stripe + max
  if let Some( m ) = memberships.iter().find( |m| m.billing_type == "stripe_subscription" && m.has_max )
  {
    return m.index;
  }
  // Priority 2: stripe any
  if let Some( m ) = memberships.iter().find( |m| m.billing_type == "stripe_subscription" )
  {
    return m.index;
  }
  // Priority 3: fallback to first
  0
}

/// Extract all string elements from a JSON array field inside a block.
///
/// Finds `"key": [...]` inside `block` and returns each quoted string token.
/// Returns an empty `Vec` if the key is absent or the array is empty.
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
}

/// Parse all membership objects from the `"memberships"` array of a JSON body.
///
/// Iterates membership `{...}` objects using brace-balanced scanning, extracts the
/// nested `"organization":` block from each, and returns one [`MembershipRaw`] per entry.
///
/// Returns `Err(ResponseParse)` if the `"memberships":` key or its opening `[` is absent,
/// or if no membership objects with an `"organization"` block are found.
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 .. ];
    // Find next '{' or stop at ']' (end of memberships array)
    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 };

    // Extract the nested "organization": { ... } block
    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 )
}

/// Parse the body of `GET /api/oauth/account` into [`OauthAccountData`].
///
/// Extracts user-level identity fields (`tagged_id`, `uuid`, `email_address`,
/// `full_name`, `display_name`) from the body top level, then iterates ALL membership
/// objects using brace-balanced scanning, applies [`select_membership_index`] to pick
/// the billing-relevant entry, and populates billing fields from the selected
/// membership's `organization` block.
///
/// # Errors
///
/// Returns [`QuotaError::ResponseParse`] if `memberships`, a valid `organization` block,
/// or `billing_type` are absent.
///
/// # Fix(BUG-237)
///
/// Previously used `str::find("\"organization\":")` on the full memberships string,
/// which always resolved to `memberships[0]`'s organization regardless of which
/// membership held the active subscription.
///
/// # Fix(BUG-295)
///
/// Identity fields now come from endpoint 002 top-level — the fabricated
/// `/api/oauth/userinfo` endpoint (HTTP 404) has been removed.
#[ inline ]
pub fn parse_oauth_account( body : &str ) -> Result< OauthAccountData, QuotaError >
{
  // User-level identity from body top-level
  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,
  } )
}

/// Fetch account identity and subscription state from the Anthropic OAuth account endpoint.
///
/// Makes a `GET /api/oauth/account` request using the provided OAuth access token.
///
/// # Errors
///
/// Returns [`QuotaError::HttpTransport`] on network failure, or
/// [`QuotaError::ResponseParse`] if the response body cannot be parsed.
#[ 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 )
}

// ── ClaudeCliRolesData ────────────────────────────────────────────────────────

/// Claude CLI roles URL — GET endpoint returning org and workspace identity.
pub const CLAUDE_CLI_ROLES_URL : &str = "https://api.anthropic.com/api/oauth/claude_cli/roles";

/// Org identity snapshot parsed from `GET /api/oauth/claude_cli/roles`.
///
/// Personal accounts have empty `workspace_uuid` and `workspace_name` (API returns `null`).
/// Enterprise accounts have non-null workspace fields.
#[ derive( Debug ) ]
pub struct ClaudeCliRolesData
{
  /// Organisation UUID.
  pub organization_uuid : String,
  /// Organisation display name.
  pub organization_name : String,
  /// User's role within the organisation (e.g., `"admin"`, `"member"`).
  pub organization_role : String,
  /// Workspace UUID — empty string for personal accounts (API returns `null`).
  pub workspace_uuid    : String,
  /// Workspace display name — empty string for personal accounts (API returns `null`).
  pub workspace_name    : String,
}

/// Parse the body of `GET /api/oauth/claude_cli/roles` into [`ClaudeCliRolesData`].
///
/// Uses string-needle scanning (no `serde_json`) so it is always available
/// regardless of feature flags.
///
/// Nullable fields (`workspace_uuid`, `workspace_name`) become empty strings when
/// the server returns `null`.
///
/// # Errors
///
/// Returns [`QuotaError::ResponseParse`] if `organization_uuid` or `organization_name`
/// are absent or the body is not valid JSON.
#[ 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,
  } )
}

/// Fetch org identity from the Claude CLI roles endpoint.
///
/// Makes a `GET /api/oauth/claude_cli/roles` request using the provided OAuth access token.
///
/// # Errors
///
/// Returns [`QuotaError::HttpTransport`] on network failure, or
/// [`QuotaError::ResponseParse`] if the response body cannot be parsed.
#[ 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 )
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[ cfg( test ) ]
mod tests
{
  use super::*;

  // ── BUG-237 MRE: multi-membership selection ─────────────────────────────────

  #[ test ]
  #[ doc = "`bug_reproducer(237)`" ]
  /// `parse_oauth_account` selects the stripe+max membership over a none-billing entry.
  ///
  /// # Root Cause
  /// `str::find("\"organization\":")` always resolves to `memberships[0]`'s organization
  /// block. Accounts with a paid subscription at index > 0 were silently misclassified as
  /// having no subscription.
  ///
  /// # Why Not Caught
  /// All test fixtures used single-membership bodies. Multi-membership accounts require
  /// separate Anthropic org entities — uncommon in CI fixtures.
  ///
  /// # Fix Applied
  /// `parse_oauth_account` now calls `parse_membership_list` which iterates ALL membership
  /// objects using brace-balanced scanning, then `select_membership_index` picks the
  /// highest-priority entry.
  ///
  /// # Prevention
  /// This test must FAIL before the fix (memberships[0] is "none") and PASS after.
  ///
  /// # Pitfall
  /// Always use brace-balanced extraction when iterating JSON arrays containing nested
  /// objects — `str::find` on a needle will collide with nested occurrences of the same key.
  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" );
    // BUG-295: identity fields from body top-level
    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)`" ]
  /// `parse_oauth_account` selects stripe (no max) over none when no max tier is present.
  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)`" ]
  /// Single-membership body: index 0 is always selected (Priority 3 fallback unchanged).
  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" );
  }
}