Skip to content

Commit 44c13bf

Browse files
committed
fix: read current Codex quota from desktop logs
1 parent 9174381 commit 44c13bf

2 files changed

Lines changed: 212 additions & 3 deletions

File tree

codex_loader.py

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ def _session_total_tokens(entries: list[UsageEntry]) -> int:
9494

9595

9696
def load_rate_limits() -> CodexRateLimits | None:
97+
sqlite_limits = _load_sqlite_rate_limits()
98+
if sqlite_limits is not None:
99+
return sqlite_limits
97100
if not SESSIONS_DIR.is_dir():
98101
return None
99102
models = _load_thread_models()
@@ -105,6 +108,132 @@ def load_rate_limits() -> CodexRateLimits | None:
105108
return None
106109

107110

111+
def _load_sqlite_rate_limits() -> CodexRateLimits | None:
112+
if not LOGS_DB.exists():
113+
return None
114+
query = (
115+
"SELECT ts, feedback_log_body FROM logs "
116+
"WHERE target = 'codex_api::endpoint::responses_websocket' "
117+
"AND feedback_log_body LIKE '%websocket event:%' "
118+
"AND (feedback_log_body LIKE '%\"type\":\"codex.rate_limits\"%' "
119+
"OR feedback_log_body LIKE '%\"type\":\"error\"%usage_limit_reached%') "
120+
"ORDER BY ts DESC, ts_nanos DESC, id DESC LIMIT 50"
121+
)
122+
try:
123+
with sqlite3.connect(f"file:{LOGS_DB}?mode=ro", uri=True) as conn:
124+
rows = conn.execute(query).fetchall()
125+
except (OSError, sqlite3.Error):
126+
if os.environ.get("USAGE_DEBUG") == "1":
127+
logger.warning("codex sqlite rate limits load failed", exc_info=True)
128+
return None
129+
130+
for ts, body in rows:
131+
parsed = _parse_sqlite_rate_limits_row(ts, body)
132+
if parsed is not None:
133+
return parsed
134+
return None
135+
136+
137+
def _parse_sqlite_rate_limits_row(ts: Any, body: Any) -> CodexRateLimits | None:
138+
if not isinstance(body, str):
139+
return None
140+
event = _websocket_event_payload(body)
141+
if not event:
142+
return None
143+
if event.get("type") == "codex.rate_limits":
144+
return _rate_limits_from_websocket_event(event, body, ts)
145+
if event.get("type") == "error":
146+
return _rate_limits_from_websocket_error(event, body, ts)
147+
return None
148+
149+
150+
def _websocket_event_payload(body: str) -> dict[str, Any]:
151+
marker = "websocket event: "
152+
index = body.find(marker)
153+
if index < 0:
154+
return {}
155+
try:
156+
data = json.loads(body[index + len(marker):])
157+
except json.JSONDecodeError:
158+
return {}
159+
return data if isinstance(data, dict) else {}
160+
161+
162+
def _rate_limits_from_websocket_event(
163+
event: dict[str, Any],
164+
body: str,
165+
ts: Any,
166+
) -> CodexRateLimits | None:
167+
rate_limits = _as_dict(event.get("rate_limits"))
168+
primary = _as_dict(rate_limits.get("primary"))
169+
secondary = _as_dict(rate_limits.get("secondary"))
170+
return _build_rate_limits(
171+
primary_pct=_as_optional_float(primary.get("used_percent")),
172+
primary_reset=_as_optional_float(primary.get("reset_at")),
173+
secondary_pct=_as_optional_float(secondary.get("used_percent")),
174+
secondary_reset=_as_optional_float(secondary.get("reset_at")),
175+
model=_event_value(body, "model") or "unknown",
176+
updated_at=_timestamp_from_log_ts(ts),
177+
)
178+
179+
180+
def _rate_limits_from_websocket_error(
181+
event: dict[str, Any],
182+
body: str,
183+
ts: Any,
184+
) -> CodexRateLimits | None:
185+
headers = _as_dict(event.get("headers"))
186+
primary_reset = _as_optional_float(headers.get("X-Codex-Primary-Reset-At"))
187+
secondary_reset = _as_optional_float(headers.get("X-Codex-Secondary-Reset-At"))
188+
now_ts = datetime.now(UTC).timestamp()
189+
if primary_reset is None:
190+
primary_reset_after = _as_optional_float(headers.get("X-Codex-Primary-Reset-After-Seconds"))
191+
primary_reset = now_ts + primary_reset_after if primary_reset_after is not None else None
192+
if secondary_reset is None:
193+
secondary_reset_after = _as_optional_float(
194+
headers.get("X-Codex-Secondary-Reset-After-Seconds")
195+
)
196+
secondary_reset = (
197+
now_ts + secondary_reset_after if secondary_reset_after is not None else None
198+
)
199+
return _build_rate_limits(
200+
primary_pct=_as_optional_float(headers.get("X-Codex-Primary-Used-Percent")),
201+
primary_reset=primary_reset,
202+
secondary_pct=_as_optional_float(headers.get("X-Codex-Secondary-Used-Percent")),
203+
secondary_reset=secondary_reset,
204+
model=_event_value(body, "model") or "unknown",
205+
updated_at=_timestamp_from_log_ts(ts),
206+
)
207+
208+
209+
def _build_rate_limits(
210+
*,
211+
primary_pct: float | None,
212+
primary_reset: float | None,
213+
secondary_pct: float | None,
214+
secondary_reset: float | None,
215+
model: str,
216+
updated_at: datetime | None,
217+
) -> CodexRateLimits | None:
218+
now_ts = datetime.now(UTC).timestamp()
219+
if primary_reset is not None and primary_reset < now_ts:
220+
primary_pct = None
221+
primary_reset = None
222+
if secondary_reset is not None and secondary_reset < now_ts:
223+
secondary_pct = None
224+
secondary_reset = None
225+
if primary_pct is None and secondary_pct is None:
226+
return None
227+
return CodexRateLimits(
228+
five_hour_pct=primary_pct,
229+
five_hour_resets_at=primary_reset,
230+
seven_day_pct=secondary_pct,
231+
seven_day_resets_at=secondary_reset,
232+
model=model,
233+
updated_at=updated_at.isoformat() if updated_at is not None else "",
234+
)
235+
236+
108237
def _load_thread_models() -> dict[str, str]:
109238
return {
110239
thread_id: metadata.model
@@ -225,7 +354,7 @@ def _parse_sqlite_log_row(
225354
def _event_value(body: str, key: str) -> str:
226355
pattern = _EVENT_VALUE_RE_CACHE.get(key)
227356
if pattern is None:
228-
pattern = re.compile(rf'(?:^|\s){re.escape(key)}=(?:"([^"]*)"|([^\s]+))')
357+
pattern = re.compile(rf'(?:^|[\s{{]){re.escape(key)}=(?:"([^"]*)"|([^\s}}]+))')
229358
_EVENT_VALUE_RE_CACHE[key] = pattern
230359
match = pattern.search(body)
231360
if match is None:

tests/test_codex_loader.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,12 @@ def _create_state_db(path: Path, rows: list[tuple[str, str, str]]) -> None:
228228
conn.executemany("INSERT INTO threads (id, model, cwd) VALUES (?, ?, ?)", rows)
229229

230230

231-
def _create_logs_db(path: Path, rows: list[tuple[int, int, str]]) -> None:
231+
def _create_logs_db(
232+
path: Path,
233+
rows: list[tuple[int, int, str]],
234+
*,
235+
target: str = "codex_otel.trace_safe",
236+
) -> None:
232237
with sqlite3.connect(path) as conn:
233238
conn.execute(
234239
"CREATE TABLE logs ("
@@ -249,7 +254,7 @@ def _create_logs_db(path: Path, rows: list[tuple[int, int, str]]) -> None:
249254
conn.execute(
250255
"INSERT INTO logs (id, ts, ts_nanos, target, feedback_log_body) "
251256
"VALUES (?, ?, ?, ?, ?)",
252-
(row_id, ts, row_id * 10, "codex_otel.trace_safe", body),
257+
(row_id, ts, row_id * 10, target, body),
253258
)
254259

255260

@@ -466,6 +471,81 @@ def test_load_rate_limits_reads_primary_and_secondary_windows(
466471
)
467472

468473

474+
def test_load_rate_limits_prefers_sqlite_websocket_rate_limits(
475+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
476+
) -> None:
477+
sessions_dir = tmp_path / "sessions"
478+
logs_db = tmp_path / "logs.sqlite"
479+
now = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
480+
stale_limits = _rate_limits()
481+
stale_limits["primary"]["used_percent"] = 9
482+
_write_rate_limit_session(
483+
sessions_dir / "rate.jsonl",
484+
now.isoformat(),
485+
stale_limits,
486+
now.timestamp(),
487+
)
488+
body = (
489+
"session_loop{thread_id=session-sqlite}:turn{model=gpt-5.5}: "
490+
'websocket event: {"type":"codex.rate_limits","plan_type":"plus",'
491+
'"rate_limits":{"allowed":true,"limit_reached":false,'
492+
'"primary":{"used_percent":40,"window_minutes":300,"reset_at":9999999999},'
493+
'"secondary":{"used_percent":6,"window_minutes":10080,"reset_at":9999999998}},'
494+
'"code_review_rate_limits":null}'
495+
)
496+
_create_logs_db(
497+
logs_db,
498+
[(1, int(now.timestamp()), body)],
499+
target="codex_api::endpoint::responses_websocket",
500+
)
501+
monkeypatch.setattr(codex_loader, "SESSIONS_DIR", sessions_dir)
502+
monkeypatch.setattr(codex_loader, "LOGS_DB", logs_db)
503+
504+
result = codex_loader.load_rate_limits()
505+
506+
assert result == codex_loader.CodexRateLimits(
507+
five_hour_pct=40.0,
508+
five_hour_resets_at=9999999999.0,
509+
seven_day_pct=6.0,
510+
seven_day_resets_at=9999999998.0,
511+
model="gpt-5.5",
512+
updated_at=now.isoformat(),
513+
)
514+
515+
516+
def test_load_rate_limits_reads_sqlite_usage_limit_error_headers(
517+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
518+
) -> None:
519+
logs_db = tmp_path / "logs.sqlite"
520+
now = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
521+
body = (
522+
"session_loop{thread_id=session-error}:turn{model=gpt-5.4}: "
523+
'websocket event: {"type":"error","error":{"type":"usage_limit_reached"},'
524+
'"headers":{"X-Codex-Primary-Used-Percent":"100",'
525+
'"X-Codex-Secondary-Used-Percent":"47",'
526+
'"X-Codex-Primary-Reset-At":"9999999999",'
527+
'"X-Codex-Secondary-Reset-At":"9999999998"}}'
528+
)
529+
_create_logs_db(
530+
logs_db,
531+
[(1, int(now.timestamp()), body)],
532+
target="codex_api::endpoint::responses_websocket",
533+
)
534+
monkeypatch.setattr(codex_loader, "SESSIONS_DIR", tmp_path / "missing-sessions")
535+
monkeypatch.setattr(codex_loader, "LOGS_DB", logs_db)
536+
537+
result = codex_loader.load_rate_limits()
538+
539+
assert result == codex_loader.CodexRateLimits(
540+
five_hour_pct=100.0,
541+
five_hour_resets_at=9999999999.0,
542+
seven_day_pct=47.0,
543+
seven_day_resets_at=9999999998.0,
544+
model="gpt-5.4",
545+
updated_at=now.isoformat(),
546+
)
547+
548+
469549
def test_load_rate_limits_clears_expired_primary_window(
470550
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
471551
) -> None:

0 commit comments

Comments
 (0)