Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions agent/account_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,18 @@ def redeem_codex_reset_credit(
remaining = max(0, available - 1)
plural = "s" if remaining != 1 else ""
if code == "reset":
# The redeemed reset restores the account's quota upstream — lift any
# persisted pool cooldowns so Hermes doesn't keep the credential
# frozen behind the now-stale ``last_error_reset_at`` (issue #43747).
try:
from hermes_cli.auth import clear_codex_pool_quota_cooldowns

clear_codex_pool_quota_cooldowns()
except Exception:
logger.debug(
"Failed to clear Codex pool cooldowns after reset redemption",
exc_info=True,
)
return CodexResetRedeemResult(
status="reset",
message=(
Expand Down
50 changes: 49 additions & 1 deletion agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1484,6 +1484,43 @@ def _refresh_entry_impl(
self._sync_device_code_entry_to_auth_store(updated)
return updated

def _codex_quota_restored_upstream(self, entry: PooledCredential) -> bool:
"""Live-check whether an exhausted Codex entry's quota reset early.

A Codex 429 persists a ``last_error_reset_at`` that can be days in
the future (weekly windows), but the upstream window can reopen
before then — the user redeems a banked rate-limit reset via the
Codex CLI / ChatGPT UI, upgrades their plan, or OpenAI resets the
window. Without this check the pool keeps the credential frozen
until the stale timestamp elapses even though the account is
usable (issue #43747).

Only fires for openai-codex entries frozen by a 429/quota-shaped
error. The underlying probe is throttled per token (5 min) so this
is safe on the hot selection path.
"""
if self.provider != "openai-codex" or entry.last_status != STATUS_EXHAUSTED:
return False
if not auth_mod._is_codex_rate_limit_shaped(
entry.last_error_code,
entry.last_error_reason,
entry.last_error_message,
):
return False
token = entry.access_token or ""
if not token:
return False
try:
return bool(
auth_mod._probe_codex_quota_restored(
token,
base_url=entry.base_url,
)
)
except Exception:
logger.debug("Codex quota-restored probe failed", exc_info=True)
return False

def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if entry.auth_type != AUTH_TYPE_OAUTH:
return False
Expand Down Expand Up @@ -1605,7 +1642,18 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal
if entry.last_status == STATUS_EXHAUSTED:
exhausted_until = _exhausted_until(entry)
if exhausted_until is not None and now < exhausted_until:
continue
# Codex quota windows can reopen EARLY: the user redeems a
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
# their plan, or OpenAI resets the window. The persisted
# ``last_error_reset_at`` can then be days in the future
# while the account is already usable again — a throttled
# live probe of the Codex usage endpoint detects that and
# lifts the stale cooldown (issue #43747).
if not (
clear_expired
and self._codex_quota_restored_upstream(entry)
):
continue
if clear_expired:
cleared = replace(
entry,
Expand Down
214 changes: 214 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3774,6 +3774,34 @@ def resolve_codex_runtime_credentials(
}
pool_rate_limit = _codex_pool_rate_limit_status()
if pool_rate_limit:
# Before surfacing the persisted cooldown, ask the Codex usage
# endpoint whether the quota actually reset early (banked reset
# redeemed, plan upgraded, window reset upstream). The persisted
# ``last_error_reset_at`` can be days in the future while the
# account is already usable again — see issue #43747.
stale_token = str(pool_rate_limit.get("access_token") or "").strip()
if stale_token and _probe_codex_quota_restored(
stale_token,
base_url=pool_rate_limit.get("base_url"),
):
logger.info(
"Codex quota restored upstream — clearing stale pool cooldown(s)."
)
clear_codex_pool_quota_cooldowns()
pool_token = _pool_codex_access_token()
if pool_token:
base_url = (
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
or DEFAULT_CODEX_BASE_URL
)
return {
"provider": "openai-codex",
"base_url": base_url,
"api_key": pool_token,
"source": "credential_pool",
"last_refresh": None,
"auth_mode": "chatgpt",
}
reset_at = pool_rate_limit.get("reset_at")
if isinstance(reset_at, (int, float)) and reset_at > time.time():
remaining = int(reset_at - time.time())
Expand Down Expand Up @@ -3838,6 +3866,190 @@ def resolve_codex_runtime_credentials(
}


def _is_codex_rate_limit_shaped(
code: Any,
reason: Any,
message: Any,
) -> bool:
"""True when persisted pool-entry error metadata describes a 429/quota stop."""
reason_l = str(reason or "").lower()
message_l = str(message or "").lower()
return (
code == 429
or "rate_limit" in reason_l
or "usage_limit" in reason_l
or "quota" in reason_l
or "rate limit" in message_l
or "usage limit" in message_l
or "quota" in message_l
)


# Throttle for the live Codex quota probe below. The probe runs on the hot
# credential-selection path while the pool is exhausted, so without a floor a
# busy gateway would hammer the usage endpoint on every model/auxiliary call.
CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS = 300 # 5 minutes
_codex_quota_probe_cache: Dict[str, Tuple[float, Optional[bool]]] = {}
_codex_quota_probe_lock = threading.Lock()


def _codex_usage_probe_url(base_url: Optional[str]) -> str:
"""Resolve the Codex usage endpoint for a probe.

Mirrors the Codex CLI's PathStyle split (codex-rs backend-client, same
logic as ``agent.account_usage._codex_backend_urls``): base URLs
containing ``/backend-api`` use the ChatGPT ``/wham/usage`` path;
everything else uses ``/api/codex/usage``. Kept local so this low-level
auth module doesn't import the auxiliary account-usage module.
"""
normalized = str(base_url or "").strip().rstrip("/")
if not normalized:
normalized = (
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
or DEFAULT_CODEX_BASE_URL
)
if normalized.endswith("/codex"):
normalized = normalized[: -len("/codex")]
prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex")
return prefix + "/usage"


def _probe_codex_quota_restored(
access_token: Any,
*,
base_url: Optional[str] = None,
min_interval_seconds: float = CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS,
) -> Optional[bool]:
"""Ask the Codex usage endpoint whether this account's quota is usable again.

Hermes persists a Codex 429's ``reset_at`` locally and freezes the
credential until it elapses — but the upstream window can reopen EARLY
(the user redeems a banked rate-limit reset via the Codex CLI/ChatGPT UI,
upgrades their plan, or OpenAI resets the window). This probe detects
that: it GETs the same ``/usage`` endpoint the Codex CLI uses and checks
the reported windows.

Returns:
* ``True`` — every reported rate-limit window is below 100% used;
the account can serve requests again and stale local cooldowns
should be lifted.
* ``False`` — a window is still fully used (or the probe itself 429'd);
keep the cooldown.
* ``None`` — indeterminate (no token, network error, unexpected
payload/status); keep the cooldown.

Probes are throttled per access token (module-local cache) so the hot
selection path can fire this freely.
"""
token = str(access_token or "").strip()
if not token:
return None
# Real Codex access tokens are JWTs. Refusing to probe non-JWT tokens
# avoids pointless network calls for corrupt/placeholder entries (and
# keeps hermetic test fixtures with dummy tokens offline).
if not _decode_jwt_claims(token):
return None
cache_key = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
now = time.monotonic()
with _codex_quota_probe_lock:
cached = _codex_quota_probe_cache.get(cache_key)
if cached is not None and (now - cached[0]) < min_interval_seconds:
return cached[1]
# Reserve the slot immediately so concurrent selectors don't stampede
# the endpoint while this probe is in flight.
_codex_quota_probe_cache[cache_key] = (now, None)

result: Optional[bool] = None
try:
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
# Best-effort ChatGPT-Account-Id from the JWT (the backend requires it
# for some account shapes; harmless to omit for others).
claims = _decode_jwt_claims(token)
account_id = (
claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
if isinstance(claims.get("https://api.openai.com/auth"), dict)
else None
)
if isinstance(account_id, str) and account_id.strip():
headers["ChatGPT-Account-Id"] = account_id.strip()
with httpx.Client(timeout=10.0) as client:
response = client.get(_codex_usage_probe_url(base_url), headers=headers)
if response.status_code == 200:
payload = response.json() or {}
rate_limit = payload.get("rate_limit") or {}
worst_used: Optional[float] = None
for key in ("primary_window", "secondary_window"):
used = (rate_limit.get(key) or {}).get("used_percent")
if isinstance(used, (int, float)):
worst_used = max(worst_used or 0.0, float(used))
if worst_used is not None:
result = worst_used < 100.0
elif response.status_code == 429:
result = False
except Exception:
logger.debug("Codex quota probe failed", exc_info=True)
result = None

with _codex_quota_probe_lock:
_codex_quota_probe_cache[cache_key] = (now, result)
return result


def clear_codex_pool_quota_cooldowns(access_token: Optional[str] = None) -> int:
"""Clear rate-limit cooldowns on persisted openai-codex pool entries.

Called after the upstream quota is KNOWN to be restored (a successful
``/usage reset`` redemption, or a positive live probe) so auth.json stops
freezing credentials behind a stale ``last_error_reset_at``. Only lifts
``exhausted`` entries whose error metadata is 429/quota-shaped — DEAD
(terminal auth) entries and non-rate-limit failures are untouched.

When *access_token* is given, only the matching entry is cleared;
otherwise every rate-limited entry clears (a redeemed banked reset
restores the whole account, and any entry that is genuinely still
exhausted just re-freezes with fresh metadata on its next 429).

Returns the number of entries cleared.
"""
cleared = 0
try:
with _auth_store_lock():
auth_store = _load_auth_store()
pool = auth_store.get("credential_pool")
entries = pool.get("openai-codex") if isinstance(pool, dict) else None
if not isinstance(entries, list):
return 0
for entry in entries:
if not isinstance(entry, dict):
continue
if entry.get("last_status") != "exhausted":
continue
if access_token and str(entry.get("access_token") or "") != access_token:
continue
if not _is_codex_rate_limit_shaped(
entry.get("last_error_code"),
entry.get("last_error_reason"),
entry.get("last_error_message"),
):
continue
entry["last_status"] = None
entry["last_status_at"] = None
entry["last_error_code"] = None
entry["last_error_reason"] = None
entry["last_error_message"] = None
entry["last_error_reset_at"] = None
cleared += 1
if cleared:
_save_auth_store(auth_store)
except Exception:
logger.debug("Failed to clear Codex pool quota cooldowns", exc_info=True)
return cleared


def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]:
"""Return metadata for a pool-only Codex credential in quota cooldown."""
def _parse_reset_at(value: Any) -> Optional[float]:
Expand Down Expand Up @@ -3905,6 +4117,8 @@ def _parse_reset_at(value: Any) -> Optional[float]:
"reset_at": reset_at,
"reason": entry.get("last_error_reason"),
"message": entry.get("last_error_message"),
"access_token": token.strip(),
"base_url": entry.get("base_url"),
}
except Exception:
logger.debug("Codex pool rate-limit lookup failed", exc_info=True)
Expand Down
Loading
Loading