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:
@@ -40,6 +40,7 @@ hiddenimports = [
|
|||||||
'daemon.providers',
|
'daemon.providers',
|
||||||
'daemon.providers.base',
|
'daemon.providers.base',
|
||||||
'daemon.providers.anthropic',
|
'daemon.providers.anthropic',
|
||||||
|
'daemon.providers.openai_codex',
|
||||||
# The exact winrt media modules read_now_playing() pulls in.
|
# The exact winrt media modules read_now_playing() pulls in.
|
||||||
'winrt.windows.media',
|
'winrt.windows.media',
|
||||||
'winrt.windows.media.control',
|
'winrt.windows.media.control',
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .base import Provider, ProviderStatus, StubProvider
|
from .base import Provider, ProviderStatus, StubProvider
|
||||||
from .anthropic import AnthropicProvider
|
from .anthropic import AnthropicProvider
|
||||||
|
from .openai_codex import OpenAICodexProvider
|
||||||
|
|
||||||
# Brand accents / labels for the not-yet-implemented providers, so the stub still
|
# Brand accents / labels for the not-yet-implemented providers, so the stub still
|
||||||
# carries the right theme hint to the watch once firmware theming lands.
|
# carries the right theme hint to the watch once firmware theming lands.
|
||||||
_STUB_META = {
|
_STUB_META = {
|
||||||
"openai": ("OpenAI Codex", "10a37f"),
|
|
||||||
"zai": ("z.ai GLM", "3859ff"),
|
"zai": ("z.ai GLM", "3859ff"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +21,13 @@ _STUB_META = {
|
|||||||
def get_provider(pid: str) -> Provider:
|
def get_provider(pid: str) -> Provider:
|
||||||
if pid == "anthropic":
|
if pid == "anthropic":
|
||||||
return AnthropicProvider()
|
return AnthropicProvider()
|
||||||
|
if pid == "openai":
|
||||||
|
return OpenAICodexProvider()
|
||||||
label, accent = _STUB_META.get(pid, (pid, "d97757"))
|
label, accent = _STUB_META.get(pid, (pid, "d97757"))
|
||||||
return StubProvider(pid, label=label, accent=accent)
|
return StubProvider(pid, label=label, accent=accent)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Provider", "ProviderStatus", "StubProvider", "AnthropicProvider", "get_provider"]
|
__all__ = [
|
||||||
|
"Provider", "ProviderStatus", "StubProvider",
|
||||||
|
"AnthropicProvider", "OpenAICodexProvider", "get_provider",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""v3 M2 — OpenAI Codex provider: read rate-limit % from the Codex session rollouts.
|
||||||
|
|
||||||
|
Codex writes a token_count event per turn whose payload.rate_limits mirrors
|
||||||
|
Claude's model (primary=5h, secondary=weekly). The provider surfaces the freshest
|
||||||
|
such snapshot; a window whose reset time has passed is reported as a fresh 0%.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from daemon.providers.openai_codex import OpenAICodexProvider
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.get_event_loop().run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_rollout(home, name, rate_limits_list, mtime=None):
|
||||||
|
"""Write a rollout JSONL with one token_count event per rate_limits dict."""
|
||||||
|
d = home / "sessions" / "2026" / "07" / "10"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
p = d / f"rollout-{name}.jsonl"
|
||||||
|
lines = []
|
||||||
|
for i, rl in enumerate(rate_limits_list):
|
||||||
|
lines.append(json.dumps({
|
||||||
|
"timestamp": f"2026-07-10T00:00:0{i}Z",
|
||||||
|
"type": "event_msg",
|
||||||
|
"payload": {"type": "token_count",
|
||||||
|
"info": {"total_token_usage": {"total_tokens": 1}},
|
||||||
|
"rate_limits": rl},
|
||||||
|
}))
|
||||||
|
p.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
if mtime is not None:
|
||||||
|
os.utime(p, (mtime, mtime))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _rl(pri_used, pri_reset, sec_used, sec_reset, plan="plus", reached=None):
|
||||||
|
return {"limit_id": "codex",
|
||||||
|
"primary": {"used_percent": pri_used, "window_minutes": 300, "resets_at": pri_reset},
|
||||||
|
"secondary": {"used_percent": sec_used, "window_minutes": 10080, "resets_at": sec_reset},
|
||||||
|
"plan_type": plan, "rate_limit_reached_type": reached}
|
||||||
|
|
||||||
|
|
||||||
|
def test_maps_windows_with_future_reset(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(42.0, now + 3600, 10.0, now + 7200)])
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.ok is True
|
||||||
|
assert st.s == 42.0 and 58 <= st.sr <= 60
|
||||||
|
assert st.w == 10.0 and 118 <= st.wr <= 120
|
||||||
|
assert st.st == "Plus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_past_reset_is_fresh_zero(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
# primary window already elapsed => 0% fresh, unknown reset; secondary still open
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(99.0, now - 100, 55.0, now + 600)])
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.s == 0.0 and st.sr == -1
|
||||||
|
assert st.w == 55.0 and 8 <= st.wr <= 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_reached_marks_limited(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(100.0, now + 60, 80.0, now + 600, reached="primary")])
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.st == "limited" and st.ok is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_snapshot_in_file_wins(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "a", [_rl(10.0, now + 600, 1.0, now + 600),
|
||||||
|
_rl(73.0, now + 600, 2.0, now + 600)])
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.s == 73.0 # the later event, not the first
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_file_wins(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
_write_rollout(tmp_path, "old", [_rl(11.0, now + 600, 0.0, now + 600)], mtime=now - 500)
|
||||||
|
_write_rollout(tmp_path, "new", [_rl(88.0, now + 600, 0.0, now + 600)], mtime=now)
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.s == 88.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_file_without_rate_limits(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
# newest file has a token_count with null rate_limits; older file has the data
|
||||||
|
_write_rollout(tmp_path, "older", [_rl(64.0, now + 600, 0.0, now + 600)], mtime=now - 500)
|
||||||
|
p = _write_rollout(tmp_path, "newer", [], mtime=now)
|
||||||
|
p.write_text(json.dumps({
|
||||||
|
"timestamp": "2026-07-10T01:00:00Z", "type": "event_msg",
|
||||||
|
"payload": {"type": "token_count", "info": {}, "rate_limits": None},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
os.utime(p, (now, now))
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.ok is True and st.s == 64.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_sessions_dir_reports_no_data(tmp_path):
|
||||||
|
st = _run(OpenAICodexProvider(codex_home=str(tmp_path)).poll())
|
||||||
|
assert st.ok is False and st.st == "no data"
|
||||||
@@ -9,7 +9,8 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from daemon import config
|
from daemon import config
|
||||||
from daemon.providers import get_provider, AnthropicProvider, StubProvider, ProviderStatus
|
from daemon.providers import (
|
||||||
|
get_provider, AnthropicProvider, OpenAICodexProvider, StubProvider, ProviderStatus)
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -68,9 +69,12 @@ def test_enabled_providers_respects_order_and_flag(tmp_path, monkeypatch):
|
|||||||
def test_registry_types():
|
def test_registry_types():
|
||||||
a = get_provider("anthropic")
|
a = get_provider("anthropic")
|
||||||
assert isinstance(a, AnthropicProvider) and a.id == "anthropic"
|
assert isinstance(a, AnthropicProvider) and a.id == "anthropic"
|
||||||
stub = get_provider("openai")
|
o = get_provider("openai")
|
||||||
|
assert isinstance(o, OpenAICodexProvider)
|
||||||
|
assert o.id == "openai" and o.accent == "10a37f"
|
||||||
|
stub = get_provider("zai") # z.ai is still a stub until M3
|
||||||
assert isinstance(stub, StubProvider)
|
assert isinstance(stub, StubProvider)
|
||||||
assert stub.id == "openai" and stub.accent == "10a37f"
|
assert stub.id == "zai" and stub.accent == "3859ff"
|
||||||
|
|
||||||
|
|
||||||
def test_stub_provider_poll_is_safe():
|
def test_stub_provider_poll_is_safe():
|
||||||
|
|||||||
Reference in New Issue
Block a user