v3 M2: OpenAI Codex provider — real rate-limit % from local Codex rollouts

OpenAICodexProvider replaces the openai stub. It reads the rate-limit snapshot
the Codex CLI already records locally (no second auth): each turn Codex writes a
token_count event into $CODEX_HOME/sessions/**/rollout-*.jsonl whose
payload.rate_limits mirrors Claude's model — primary (5h) + secondary (weekly),
each with used_percent + resets_at + plan_type. The provider surfaces the
freshest such snapshot; a window whose reset time has passed is reported as a
fresh 0% (local read, so current in-session and self-heals between sessions).

primary -> s/sr, secondary -> w/wr, plan_type -> status ("Plus"/…),
rate_limit_reached_type -> "limited". No per-token cost (subscription), so the
two rate-limit bars are the metric, per the locked v3 decision.

Verified against the real ~/.codex: full daemon payload with openai active =
{pv:openai, pnm:"OpenAI Codex", ac:0x10a37f, st:"Plus", ok:true, weekly reset}.
registry test updated (openai now real, zai still stub); +7 provider tests.
113 passed, 2 Linux-only failures (baseline). spec: hiddenimport added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wenil
2026-07-10 07:14:15 +03:00
co-authored by Claude Opus 4.8
parent a975eb434b
commit b63a6a62ae
5 changed files with 247 additions and 5 deletions
+127
View File
@@ -0,0 +1,127 @@
"""OpenAI Codex provider (v3 M2).
Reads the ChatGPT/Codex rate-limit utilization the Codex CLI already records
locally, so there's no second auth to maintain: every turn, Codex writes a
``token_count`` event into its session rollout (``$CODEX_HOME/sessions/**/rollout-*.jsonl``)
whose ``info.rate_limits`` snapshot mirrors Claude's model — a primary window
(5h) and a secondary window (weekly), each with a used-percent and a reset time::
"rate_limits": {"primary": {"used_percent": 1.0, "window_minutes": 300, "resets_at": 178...},
"secondary": {"used_percent": 0.0, "window_minutes": 10080, "resets_at": 178...},
"plan_type": "plus", "rate_limit_reached_type": null}
We surface the freshest such snapshot. It's a local read (no network, no token),
so it's current while Codex is in use and goes stale between sessions — when a
window's ``resets_at`` has already passed we report that window as a fresh 0%.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from pathlib import Path
from .base import Provider, ProviderStatus
# Newest-first cap: the active session's rollout has the latest reading, so we
# rarely look past the first file — but scan a few in case the newest is a
# just-opened session with no turns (hence no rate_limits) yet.
_MAX_ROLLOUTS_SCANNED = 12
def _codex_home() -> Path:
return Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
class OpenAICodexProvider(Provider):
id = "openai"
label = "OpenAI Codex"
accent = "10a37f" # brand green
def __init__(self, codex_home: str | os.PathLike | None = None) -> None:
self._home = Path(codex_home) if codex_home else _codex_home()
async def poll(self) -> ProviderStatus:
from daemon.claude_usage_daemon_windows import log
try:
snap = await asyncio.to_thread(self._latest_rate_limits)
except Exception as e: # never break the loop (SC#5)
log(f"Codex poll error: {e!r}")
return ProviderStatus(ok=False, st="error")
if snap is None:
# Configured but nothing to read (Codex never run / logged out).
return ProviderStatus(ok=False, st="no data")
return self._to_status(snap)
# -- local rollout reading -------------------------------------------------
def _latest_rate_limits(self) -> dict | None:
"""The freshest non-empty ``rate_limits`` object from the most recently
written session rollout. Pure/sync — run via asyncio.to_thread."""
sessions = self._home / "sessions"
if not sessions.is_dir():
return None
try:
files = sorted(sessions.rglob("rollout-*.jsonl"),
key=lambda p: p.stat().st_mtime, reverse=True)
except OSError:
return None
for path in files[:_MAX_ROLLOUTS_SCANNED]:
found = None
try:
with path.open(encoding="utf-8", errors="replace") as fh:
for line in fh:
if '"rate_limits"' not in line:
continue
try:
obj = json.loads(line)
except ValueError:
continue
# token_count events carry rate_limits as a sibling of
# info under payload; tolerate the info-nested shape too.
payload = obj.get("payload") or {}
rl = payload.get("rate_limits")
if not isinstance(rl, dict):
rl = (payload.get("info") or {}).get("rate_limits")
if isinstance(rl, dict) and (rl.get("primary") or rl.get("secondary")):
found = rl # keep the LAST one in the file
except OSError:
continue
if found is not None:
return found # newest file that has a reading wins
return None
# -- mapping ---------------------------------------------------------------
@staticmethod
def _window(w: dict | None) -> tuple[float, int]:
"""(used_percent 0-100, minutes-to-reset) for one window. A reset time
already in the past means the window rolled over since the snapshot, so
report it as a fresh 0% with an unknown reset."""
if not isinstance(w, dict):
return 0.0, -1
used = float(w.get("used_percent") or 0.0)
resets_at = w.get("resets_at")
if isinstance(resets_at, (int, float)):
delta = resets_at - time.time()
if delta <= 0:
return 0.0, -1
return used, int(delta // 60)
return used, -1
def _to_status(self, rl: dict) -> ProviderStatus:
s, sr = self._window(rl.get("primary"))
w, wr = self._window(rl.get("secondary"))
reached = rl.get("rate_limit_reached_type")
plan = rl.get("plan_type")
if reached:
st = "limited"
elif plan:
st = str(plan).capitalize() # "Plus" / "Pro" / "Team" / ...
else:
st = "allowed"
# Subscription plan → no per-token cost; the two rate-limit bars are the
# metric (matches the locked v3 decision). tokens/cost left at 0.
return ProviderStatus(s=s, sr=sr, w=w, wr=wr, st=st, ok=True)