3434
3535lib_logger = logging .getLogger ("rotator_library" )
3636
37+ # Tier hierarchy: higher-tier exhaustion implies all lower tiers are blocked.
38+ # monthly > weekly > primary (5h). When weekly is exhausted the credential
39+ # cannot be used even if 5h-limit still shows remaining capacity.
40+ # Mirrors opencode_go_provider.QUOTA_TIER_HIERARCHY.
41+ QUOTA_TIER_HIERARCHY = ["5h-limit" , "weekly-limit" , "monthly-limit" ]
42+
3743
3844# =============================================================================
3945# HELPER FUNCTIONS
@@ -550,9 +556,14 @@ def _push_quota_to_usage_manager(
550556
551557 async def _push ():
552558 try :
559+ now = time .time ()
560+ # Collect per-window data for hierarchical exhaustion waterfall.
561+ # Each display window is pushed independently (apply_exhaustion=False);
562+ # blocking is determined by codex-global via the tier hierarchy.
563+ window_tiers : Dict [str , Dict [str , Any ]] = {}
564+
553565 if snapshot .primary :
554566 used_pct = snapshot .primary .used_percent
555- # Convert percentage to a request count on a 100-scale
556567 quota_used = int (used_pct )
557568 primary_label = _window_label_from_seconds (
558569 snapshot .primary .window_minutes * 60 if snapshot .primary .window_minutes else None
@@ -567,24 +578,13 @@ async def _push():
567578 quota_used = quota_used ,
568579 quota_group = primary_label ,
569580 force = True ,
570- apply_exhaustion = snapshot .primary .is_exhausted ,
571- )
572- # Also push to codex-global so the executor's quota display
573- # can find the limit when looking up the model's quota group.
574- # apply_exhaustion MUST also be set here — codex-global is
575- # the key the CooldownChecker uses during credential selection.
576- # Without it, the primary-limit cooldown is invisible to the
577- # selection engine and exhausted credentials keep getting picked.
578- await self ._usage_manager .update_quota_baseline (
579- accessor = credential_path ,
580- model = f"{ provider_prefix } /_global_quota" ,
581- quota_max_requests = 100 ,
582- quota_reset_ts = snapshot .primary .reset_at ,
583- quota_used = quota_used ,
584- quota_group = "codex-global" ,
585- force = True ,
586- apply_exhaustion = snapshot .primary .is_exhausted ,
581+ apply_exhaustion = False ,
587582 )
583+ window_tiers [primary_label ] = {
584+ "used_percent" : used_pct ,
585+ "reset_ts" : snapshot .primary .reset_at or 0 ,
586+ "quota_used" : quota_used ,
587+ }
588588
589589 if snapshot .secondary :
590590 used_pct = snapshot .secondary .used_percent
@@ -602,8 +602,42 @@ async def _push():
602602 quota_used = quota_used ,
603603 quota_group = secondary_label ,
604604 force = True ,
605- apply_exhaustion = snapshot . secondary . is_exhausted ,
605+ apply_exhaustion = False ,
606606 )
607+ window_tiers [secondary_label ] = {
608+ "used_percent" : used_pct ,
609+ "reset_ts" : snapshot .secondary .reset_at or 0 ,
610+ "quota_used" : quota_used ,
611+ }
612+
613+ # Hierarchical exhaustion waterfall for codex-global.
614+ # Walk tiers from highest to lowest; the first exhausted tier
615+ # blocks the credential (its reset_ts becomes the cooldown).
616+ global_exhausted = False
617+ global_reset_ts = None
618+ global_quota_used = 0
619+ for tier_key in reversed (QUOTA_TIER_HIERARCHY ):
620+ wd = window_tiers .get (tier_key )
621+ if wd and wd ["used_percent" ] >= 100.0 and wd ["reset_ts" ] > now :
622+ global_exhausted = True
623+ global_reset_ts = wd ["reset_ts" ]
624+ global_quota_used = 100
625+ break
626+
627+ if not global_exhausted and snapshot .primary :
628+ global_quota_used = int (snapshot .primary .used_percent )
629+ global_reset_ts = snapshot .primary .reset_at
630+
631+ await self ._usage_manager .update_quota_baseline (
632+ accessor = credential_path ,
633+ model = f"{ provider_prefix } /_global_quota" ,
634+ quota_max_requests = 100 ,
635+ quota_reset_ts = global_reset_ts ,
636+ quota_used = global_quota_used ,
637+ quota_group = "codex-global" ,
638+ force = True ,
639+ apply_exhaustion = global_exhausted ,
640+ )
607641 except Exception as e :
608642 lib_logger .debug (
609643 f"Failed to push Codex quota to UsageManager: { e } "
@@ -707,8 +741,11 @@ async def get_all_quota_info(
707741 plan_type_counts .get (snapshot .plan_type , 0 ) + 1
708742 )
709743
710- # Check if exhausted
711- if snapshot .primary and snapshot .primary .is_exhausted :
744+ # Check if exhausted (any tier in the hierarchy blocks the credential)
745+ if (
746+ (snapshot .primary and snapshot .primary .is_exhausted )
747+ or (snapshot .secondary and snapshot .secondary .is_exhausted )
748+ ):
712749 exhausted_count += 1
713750
714751 # Build result entry
@@ -902,11 +939,12 @@ async def _store_baselines_to_usage_manager(
902939 models = getattr (self , "_available_models_for_quota" , [])
903940 provider_prefix = getattr (self , "provider_env_name" , "codex" )
904941
942+ now = time .time ()
943+
905944 for cred_path , quota_data in quota_results .items ():
906945 if quota_data .get ("status" ) != "success" :
907946 continue
908947
909- # Get remaining fraction from primary and secondary windows
910948 primary = quota_data .get ("primary" )
911949 secondary = quota_data .get ("secondary" )
912950
@@ -916,13 +954,16 @@ async def _store_baselines_to_usage_manager(
916954 else :
917955 short_cred = Path (cred_path ).stem
918956
919- # Store primary window under dynamically-named virtual model
957+ # Collect per-window tier data for hierarchical exhaustion.
958+ # Display windows are pushed with apply_exhaustion=False;
959+ # blocking is decided once via codex-global.
960+ window_tiers : Dict [str , Dict [str , Any ]] = {}
961+
920962 if primary :
921963 primary_remaining = primary .get ("remaining_fraction" , 1.0 )
922964 primary_used_pct = primary .get ("used_percent" , 0 )
923965 primary_reset = primary .get ("reset_at" )
924966 primary_window_minutes = primary .get ("window_minutes" )
925- is_exhausted = primary .get ("is_exhausted" , False )
926967 primary_label = _window_label_from_seconds (
927968 primary_window_minutes * 60 if primary_window_minutes else None
928969 )
@@ -937,23 +978,14 @@ async def _store_baselines_to_usage_manager(
937978 quota_used = int (primary_used_pct ),
938979 quota_group = primary_label ,
939980 force = force ,
940- apply_exhaustion = is_exhausted and is_initial_fetch ,
941- )
942- # Also store in codex-global so the executor's quota display
943- # can find the limit when looking up the model's quota group.
944- # apply_exhaustion MUST mirror the primary-limit push — codex-global
945- # is the key the CooldownChecker uses during credential selection.
946- await usage_manager .update_quota_baseline (
947- accessor = cred_path ,
948- model = f"{ provider_prefix } /_global_quota" ,
949- quota_max_requests = 100 ,
950- quota_reset_ts = primary_reset ,
951- quota_used = int (primary_used_pct ),
952- quota_group = "codex-global" ,
953- force = force ,
954- apply_exhaustion = is_exhausted and is_initial_fetch ,
981+ apply_exhaustion = False ,
955982 )
956983 stored_count += 1
984+ window_tiers [primary_label ] = {
985+ "used_percent" : primary_used_pct ,
986+ "reset_ts" : primary_reset or 0 ,
987+ "quota_used" : int (primary_used_pct ),
988+ }
957989 lib_logger .debug (
958990 f"Stored Codex { primary_label } baseline for { short_cred } : "
959991 f"{ primary_remaining * 100 :.1f} % remaining"
@@ -963,13 +995,11 @@ async def _store_baselines_to_usage_manager(
963995 f"Failed to store Codex { primary_label } baseline for { short_cred } : { e } "
964996 )
965997
966- # Store secondary window under dynamically-named virtual model
967998 if secondary :
968999 secondary_remaining = secondary .get ("remaining_fraction" , 1.0 )
9691000 secondary_used_pct = secondary .get ("used_percent" , 0 )
9701001 secondary_reset = secondary .get ("reset_at" )
9711002 secondary_window_minutes = secondary .get ("window_minutes" )
972- is_exhausted = secondary .get ("is_exhausted" , False )
9731003 secondary_label = _window_label_from_seconds (
9741004 secondary_window_minutes * 60 if secondary_window_minutes else None
9751005 )
@@ -984,9 +1014,14 @@ async def _store_baselines_to_usage_manager(
9841014 quota_used = int (secondary_used_pct ),
9851015 quota_group = secondary_label ,
9861016 force = force ,
987- apply_exhaustion = is_exhausted and is_initial_fetch ,
1017+ apply_exhaustion = False ,
9881018 )
9891019 stored_count += 1
1020+ window_tiers [secondary_label ] = {
1021+ "used_percent" : secondary_used_pct ,
1022+ "reset_ts" : secondary_reset or 0 ,
1023+ "quota_used" : int (secondary_used_pct ),
1024+ }
9901025 lib_logger .debug (
9911026 f"Stored Codex { secondary_label } baseline for { short_cred } : "
9921027 f"{ secondary_remaining * 100 :.1f} % remaining"
@@ -996,6 +1031,40 @@ async def _store_baselines_to_usage_manager(
9961031 f"Failed to store Codex { secondary_label } baseline for { short_cred } : { e } "
9971032 )
9981033
1034+ # Hierarchical exhaustion waterfall for codex-global.
1035+ # Walk tiers from highest (monthly) to lowest (5h); the first
1036+ # exhausted tier blocks the credential entirely.
1037+ global_exhausted = False
1038+ global_reset_ts = None
1039+ global_quota_used = 0
1040+ for tier_key in reversed (QUOTA_TIER_HIERARCHY ):
1041+ wd = window_tiers .get (tier_key )
1042+ if wd and wd ["used_percent" ] >= 100.0 and wd ["reset_ts" ] > now :
1043+ global_exhausted = True
1044+ global_reset_ts = wd ["reset_ts" ]
1045+ global_quota_used = 100
1046+ break
1047+
1048+ if not global_exhausted and primary :
1049+ global_quota_used = int (primary .get ("used_percent" , 0 ))
1050+ global_reset_ts = primary .get ("reset_at" )
1051+
1052+ try :
1053+ await usage_manager .update_quota_baseline (
1054+ accessor = cred_path ,
1055+ model = f"{ provider_prefix } /_global_quota" ,
1056+ quota_max_requests = 100 ,
1057+ quota_reset_ts = global_reset_ts ,
1058+ quota_used = global_quota_used ,
1059+ quota_group = "codex-global" ,
1060+ force = force ,
1061+ apply_exhaustion = global_exhausted and is_initial_fetch ,
1062+ )
1063+ except Exception as e :
1064+ lib_logger .warning (
1065+ f"Failed to store Codex global baseline for { short_cred } : { e } "
1066+ )
1067+
9991068 return stored_count
10001069
10011070 async def fetch_initial_baselines (
0 commit comments