Skip to content

Commit a97c769

Browse files
author
Mateusz
committed
feat(openai-codex): desktop alerts for low remaining quota from x-codex headers
Add configurable thresholds (default 25%% and 10%% remaining) under managed_oauth, env overrides, and evaluation on each response with x-codex-* headers. Reuse INotificationService with latch/re-arm semantics per account and primary vs weekly window. Made-with: Cursor
1 parent 66b8cdd commit a97c769

8 files changed

Lines changed: 558 additions & 3 deletions

File tree

config/config.example.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,12 @@ backends:
483483
# Max sleep iterations when every account is rate-limited (each slice up to
484484
# max_rate_limit_wait_seconds).
485485
max_rate_limit_idle_polls: 48
486+
# Desktop alerts when remaining Codex quota (100 - x-codex-*-used-percent) is low.
487+
quota_remaining_alerts_enabled: true
488+
# Notify when remaining quota drops strictly below each percent (defaults: 25 then 10).
489+
quota_remaining_alert_thresholds_percent: [25, 10]
490+
# Optional env overrides: OPENAI_CODEX_QUOTA_REMAINING_ALERTS_ENABLED,
491+
# OPENAI_CODEX_QUOTA_REMAINING_THRESHOLDS (JSON array e.g. [25,10] or comma-separated).
486492
# For Responses API clients (Profile B), use:
487493
# default_capabilities:
488494
# protocol: openai-responses
@@ -522,6 +528,8 @@ backends:
522528
max_rate_limit_wait_seconds: 300
523529
rate_limit_local_cooldown_cap_seconds: 1800
524530
max_rate_limit_idle_polls: 48
531+
quota_remaining_alerts_enabled: true
532+
quota_remaining_alert_thresholds_percent: [25, 10]
525533

526534
# Model-specific defaults
527535
model_defaults:

src/connectors/_openai_codex_connector.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,14 +1682,38 @@ def update_quota_headers(self, headers: Mapping[str, Any]) -> None:
16821682
record_raw = getattr(
16831683
self._credential_manager, "record_codex_quota_headers", None
16841684
)
1685-
if not callable(record_raw):
1686-
return
1687-
record = cast(Callable[..., Awaitable[None]], record_raw)
1685+
eval_raw = getattr(
1686+
self._credential_manager,
1687+
"evaluate_codex_remaining_quota_notifications",
1688+
None,
1689+
)
1690+
has_codex_quota = any(str(k).lower().startswith("x-codex-") for k in headers)
16881691
try:
16891692
loop = asyncio.get_running_loop()
16901693
except RuntimeError:
16911694
return
16921695

1696+
if has_codex_quota and callable(eval_raw):
1697+
evaluate = cast(Callable[..., Awaitable[None]], eval_raw)
1698+
1699+
async def _remaining_alerts() -> None:
1700+
try:
1701+
await evaluate(headers, managed_oauth_account_id=None)
1702+
except Exception as exc:
1703+
logger.warning(
1704+
"OpenAI Codex evaluate_codex_remaining_quota_notifications failed: %s",
1705+
exc,
1706+
exc_info=True,
1707+
)
1708+
1709+
alert_task = loop.create_task(_remaining_alerts())
1710+
self._codex_quota_persist_tasks.add(alert_task)
1711+
alert_task.add_done_callback(self._codex_quota_persist_tasks.discard)
1712+
1713+
if not callable(record_raw):
1714+
return
1715+
record = cast(Callable[..., Awaitable[None]], record_raw)
1716+
16931717
async def _persist() -> None:
16941718
try:
16951719
await record(headers, force=False)

src/connectors/openai_codex/codex_quota_notifications.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import math
67
import time
78
from collections.abc import Mapping
89
from typing import TYPE_CHECKING, Any
@@ -20,6 +21,7 @@
2021
logger = logging.getLogger(__name__)
2122

2223
_CODEX_QUOTA_NOTIFICATION_TITLE = "OpenAI Codex: Quota reached"
24+
_CODEX_QUOTA_LOW_REMAINING_TITLE = "OpenAI Codex: Low quota remaining"
2325

2426
# Dedupe key component when every managed account is simultaneously unavailable:
2527
# one desktop alert per quota window, not one per account that hits 429 last.
@@ -161,3 +163,161 @@ async def maybe_notify_codex_quota_reached(
161163
return
162164

163165
dedupe_keys.add(key)
166+
167+
168+
def codex_limit_kind_display(limit_kind: str) -> str:
169+
"""Human-readable Codex rolling window label for remaining-quota alerts."""
170+
if limit_kind == "secondary":
171+
return "weekly rolling window"
172+
return "5 hour rolling window"
173+
174+
175+
def parse_codex_used_percent_from_headers(
176+
headers: Mapping[str, Any],
177+
*,
178+
header_key: str,
179+
) -> float | None:
180+
"""Return used-percent (0-100) from headers, or None if missing/invalid."""
181+
target = header_key.lower()
182+
for raw_k, raw_v in headers.items():
183+
if str(raw_k).lower() != target:
184+
continue
185+
try:
186+
used = float(str(raw_v).strip())
187+
except (TypeError, ValueError):
188+
return None
189+
if not math.isfinite(used):
190+
return None
191+
return used
192+
return None
193+
194+
195+
def codex_remaining_percent_from_used(used_percent: float) -> float:
196+
"""Remaining quota percent from upstream ``*-used-percent`` (clamped)."""
197+
remaining = 100.0 - float(used_percent)
198+
if remaining < 0.0:
199+
return 0.0
200+
if remaining > 100.0:
201+
return 100.0
202+
return remaining
203+
204+
205+
def collect_codex_remaining_pairs(
206+
headers: Mapping[str, Any]
207+
) -> list[tuple[str, float]]:
208+
"""Build (limit_kind, remaining_percent) pairs from Codex quota headers."""
209+
pairs: list[tuple[str, float]] = []
210+
used_primary = parse_codex_used_percent_from_headers(
211+
headers,
212+
header_key="x-codex-primary-used-percent",
213+
)
214+
if used_primary is not None:
215+
pairs.append(
216+
("primary", codex_remaining_percent_from_used(used_primary)),
217+
)
218+
used_secondary = parse_codex_used_percent_from_headers(
219+
headers,
220+
header_key="x-codex-secondary-used-percent",
221+
)
222+
if used_secondary is not None:
223+
pairs.append(
224+
("secondary", codex_remaining_percent_from_used(used_secondary)),
225+
)
226+
return pairs
227+
228+
229+
def build_codex_low_remaining_notification_message(
230+
*,
231+
email: str | None,
232+
managed_account_id: str | None = None,
233+
chatgpt_account_id: str | None = None,
234+
limit_kind: str,
235+
threshold_percent: float,
236+
remaining_percent: float,
237+
) -> str:
238+
"""Body text for a Codex low remaining-quota desktop notification."""
239+
account = _format_account_label(
240+
email,
241+
managed_account_id=managed_account_id,
242+
chatgpt_account_id=chatgpt_account_id,
243+
)
244+
window = codex_limit_kind_display(limit_kind)
245+
rem_rounded = round(float(remaining_percent), 1)
246+
thr = float(threshold_percent)
247+
thr_display = int(thr) if thr == int(thr) else thr
248+
return (
249+
f"Codex quota running low. Account: {account}, limit: {window}. "
250+
f"Remaining is below your {thr_display}% threshold "
251+
f"(about {rem_rounded}% of this window still available)."
252+
)
253+
254+
255+
def _normalize_threshold_latch_key(threshold_percent: float) -> float:
256+
return round(float(threshold_percent), 6)
257+
258+
259+
async def maybe_notify_codex_quota_remaining_low(
260+
notification_service: INotificationService | None,
261+
latch_keys: set[tuple[str, str, float]],
262+
*,
263+
managed_account_id: str,
264+
email: str | None,
265+
chatgpt_account_id: str | None = None,
266+
threshold_percents: list[float],
267+
remaining_by_limit: list[tuple[str, float]],
268+
) -> None:
269+
"""Notify when remaining quota drops strictly below configured thresholds.
270+
271+
``latch_keys`` uses tuples ``(managed_account_id, limit_kind, threshold)``.
272+
A key is cleared when remaining rises back to ``>=`` that threshold so alerts
273+
can re-fire after recovery.
274+
"""
275+
if notification_service is None or not notification_service.is_enabled:
276+
return
277+
if not threshold_percents or not remaining_by_limit:
278+
return
279+
280+
thresholds = sorted(
281+
{_normalize_threshold_latch_key(t) for t in threshold_percents if t > 0},
282+
reverse=True,
283+
)
284+
if not thresholds:
285+
return
286+
287+
for limit_kind, remaining in remaining_by_limit:
288+
if not isinstance(limit_kind, str) or not limit_kind.strip():
289+
continue
290+
kind = limit_kind.strip().lower()
291+
if kind not in ("primary", "secondary"):
292+
continue
293+
if not math.isfinite(float(remaining)):
294+
continue
295+
rem = float(remaining)
296+
for thr in thresholds:
297+
key = (managed_account_id, kind, thr)
298+
if rem >= thr:
299+
latch_keys.discard(key)
300+
continue
301+
if key in latch_keys:
302+
continue
303+
message = build_codex_low_remaining_notification_message(
304+
email=email,
305+
managed_account_id=managed_account_id,
306+
chatgpt_account_id=chatgpt_account_id,
307+
limit_kind=kind,
308+
threshold_percent=thr,
309+
remaining_percent=rem,
310+
)
311+
try:
312+
await notification_service.send_notification(
313+
title=_CODEX_QUOTA_LOW_REMAINING_TITLE,
314+
message=message,
315+
)
316+
except Exception as exc:
317+
logger.warning(
318+
"Codex low remaining quota notification failed: %s",
319+
exc,
320+
exc_info=True,
321+
)
322+
continue
323+
latch_keys.add(key)

src/connectors/openai_codex/credentials.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
from watchdog.observers import Observer
2828

2929
from src.connectors.openai_codex.codex_quota_notifications import (
30+
collect_codex_remaining_pairs,
3031
maybe_notify_codex_quota_reached,
32+
maybe_notify_codex_quota_remaining_low,
3133
)
3234
from src.connectors.openai_codex.codex_rate_limit_logging import (
3335
emit_openai_codex_managed_oauth_rate_limit,
@@ -385,6 +387,7 @@ def __init__(
385387
self._http_client = http_client
386388
self._notification_service = notification_service
387389
self._codex_quota_notification_dedupe: set[tuple[str, str, str]] = set()
390+
self._codex_remaining_quota_latch: set[tuple[str, str, float]] = set()
388391
self._auth_path: Path | None = None
389392
self._auth_credentials: dict[str, Any] | None = None
390393
self._last_modified: float = 0.0
@@ -1113,6 +1116,75 @@ async def record_codex_quota_headers(
11131116
):
11141117
self._managed_current_account = updated
11151118

1119+
async def evaluate_codex_remaining_quota_notifications(
1120+
self,
1121+
headers: Mapping[str, Any],
1122+
*,
1123+
managed_oauth_account_id: str | None = None,
1124+
) -> None:
1125+
"""Desktop alerts when x-codex-*-used-percent implies low remaining quota."""
1126+
cfg = self._managed_config
1127+
if not cfg.quota_remaining_alerts_enabled:
1128+
return
1129+
thresholds = list(cfg.quota_remaining_alert_thresholds_percent)
1130+
if not thresholds:
1131+
return
1132+
remaining_pairs = collect_codex_remaining_pairs(headers)
1133+
if not remaining_pairs:
1134+
return
1135+
1136+
async with self._codex_telemetry_lock:
1137+
account_id = "legacy-openai-codex"
1138+
email: str | None = None
1139+
chatgpt_account_id: str | None = None
1140+
resolved_managed = False
1141+
if self._managed_enabled():
1142+
aid = (
1143+
str(managed_oauth_account_id).strip()
1144+
if isinstance(managed_oauth_account_id, str)
1145+
and managed_oauth_account_id.strip()
1146+
else ""
1147+
)
1148+
current = (
1149+
self._managed_selector.get_account_by_id(aid)
1150+
if aid
1151+
else self._managed_selector.get_current_account()
1152+
)
1153+
if current is not None:
1154+
account_id = current.account_id
1155+
email = current.email
1156+
chatgpt_account_id = current.chatgpt_account_id
1157+
resolved_managed = True
1158+
1159+
if not resolved_managed and isinstance(self._auth_credentials, Mapping):
1160+
managed = self._auth_credentials.get("managed_oauth")
1161+
if isinstance(managed, Mapping):
1162+
mid = managed.get("account_id")
1163+
if isinstance(mid, str) and mid.strip():
1164+
account_id = mid.strip()
1165+
cg = managed.get("chatgpt_account_id")
1166+
if isinstance(cg, str) and cg.strip():
1167+
chatgpt_account_id = cg.strip()
1168+
if account_id == "legacy-openai-codex":
1169+
top_aid = self._auth_credentials.get("account_id")
1170+
if isinstance(top_aid, str) and top_aid.strip():
1171+
account_id = top_aid.strip()
1172+
user = self._auth_credentials.get("user")
1173+
if isinstance(user, Mapping):
1174+
raw_email = user.get("email")
1175+
if isinstance(raw_email, str) and raw_email.strip():
1176+
email = raw_email.strip()
1177+
1178+
await maybe_notify_codex_quota_remaining_low(
1179+
self._notification_service,
1180+
self._codex_remaining_quota_latch,
1181+
managed_account_id=account_id,
1182+
email=email,
1183+
chatgpt_account_id=chatgpt_account_id,
1184+
threshold_percents=thresholds,
1185+
remaining_by_limit=remaining_pairs,
1186+
)
1187+
11161188
async def handle_rate_limit(
11171189
self,
11181190
retry_after_seconds: float | None,

src/connectors/openai_codex/managed_oauth_models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import math
56
import re
67
import time
78
from datetime import datetime, timezone
@@ -246,6 +247,28 @@ class ManagedOAuthConfig(BaseModel):
246247
#: Max idle polls (sleeps) while all accounts are rate-limited before giving up
247248
#: (only when ``get_next_account`` is invoked with ``wait_for_rate_limit_recovery=True``).
248249
max_rate_limit_idle_polls: int = 48
250+
#: Desktop notifications when remaining Codex quota (100 - used%%) drops below thresholds.
251+
quota_remaining_alerts_enabled: bool = True
252+
quota_remaining_alert_thresholds_percent: list[float] = Field(
253+
default_factory=lambda: [25.0, 10.0],
254+
description=(
255+
"Alert when remaining quota is strictly below each value (percent of limit left). "
256+
"Compared against x-codex-primary-used-percent / x-codex-secondary-used-percent."
257+
),
258+
)
259+
260+
@field_validator("quota_remaining_alert_thresholds_percent", mode="after")
261+
@classmethod
262+
def _normalize_quota_remaining_thresholds(cls, v: list[float]) -> list[float]:
263+
cleaned: list[float] = []
264+
for raw in v:
265+
if not isinstance(raw, int | float) or not math.isfinite(float(raw)):
266+
continue
267+
x = float(raw)
268+
if 0.0 < x < 100.0:
269+
cleaned.append(round(x, 6))
270+
uniq = sorted(set(cleaned), reverse=True)
271+
return uniq if uniq else [25.0, 10.0]
249272

250273
@classmethod
251274
def from_mapping(

0 commit comments

Comments
 (0)