Skip to content

Commit 2f48787

Browse files
committed
fix(codex): add hierarchical quota waterfall to codex-global exhaustion
Previously codex-global only mirrored primary window (5h-limit) exhaustion. When a secondary window (weekly-limit or monthly-limit) was exhausted, the cooldown was applied only to the display group — not codex-global. This left the credential selectable by CooldownChecker and showed "mixed" status instead of "exhausted". Port the same tier hierarchy pattern from opencode_go_provider: walk tiers from highest (monthly > weekly > 5h) and block codex-global on the first exhausted tier. Pro-tier credentials with no monthly window now correctly exhaust when weekly-limit is consumed.
1 parent 6026ff2 commit 2f48787

1 file changed

Lines changed: 111 additions & 42 deletions

File tree

src/rotator_library/providers/utilities/codex_quota_tracker.py

Lines changed: 111 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434

3535
lib_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

Comments
 (0)