diff --git a/clawdmeter.spec b/clawdmeter.spec index 85a226b..aa5f4c3 100644 --- a/clawdmeter.spec +++ b/clawdmeter.spec @@ -40,6 +40,7 @@ hiddenimports = [ 'daemon.providers', 'daemon.providers.base', 'daemon.providers.anthropic', + 'daemon.providers.openai_codex', # The exact winrt media modules read_now_playing() pulls in. 'winrt.windows.media', 'winrt.windows.media.control', diff --git a/daemon/providers/__init__.py b/daemon/providers/__init__.py index bd3101c..7b99826 100644 --- a/daemon/providers/__init__.py +++ b/daemon/providers/__init__.py @@ -9,11 +9,11 @@ from __future__ import annotations from .base import Provider, ProviderStatus, StubProvider from .anthropic import AnthropicProvider +from .openai_codex import OpenAICodexProvider # 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. _STUB_META = { - "openai": ("OpenAI Codex", "10a37f"), "zai": ("z.ai GLM", "3859ff"), } @@ -21,8 +21,13 @@ _STUB_META = { def get_provider(pid: str) -> Provider: if pid == "anthropic": return AnthropicProvider() + if pid == "openai": + return OpenAICodexProvider() label, accent = _STUB_META.get(pid, (pid, "d97757")) return StubProvider(pid, label=label, accent=accent) -__all__ = ["Provider", "ProviderStatus", "StubProvider", "AnthropicProvider", "get_provider"] +__all__ = [ + "Provider", "ProviderStatus", "StubProvider", + "AnthropicProvider", "OpenAICodexProvider", "get_provider", +] diff --git a/daemon/providers/openai_codex.py b/daemon/providers/openai_codex.py new file mode 100644 index 0000000..301c05d --- /dev/null +++ b/daemon/providers/openai_codex.py @@ -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) diff --git a/daemon/tests/test_openai_codex.py b/daemon/tests/test_openai_codex.py new file mode 100644 index 0000000..98e0e3e --- /dev/null +++ b/daemon/tests/test_openai_codex.py @@ -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" diff --git a/daemon/tests/test_providers.py b/daemon/tests/test_providers.py index b46e916..2186262 100644 --- a/daemon/tests/test_providers.py +++ b/daemon/tests/test_providers.py @@ -9,7 +9,8 @@ import asyncio import json 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): @@ -68,9 +69,12 @@ def test_enabled_providers_respects_order_and_flag(tmp_path, monkeypatch): def test_registry_types(): a = get_provider("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 stub.id == "openai" and stub.accent == "10a37f" + assert stub.id == "zai" and stub.accent == "3859ff" def test_stub_provider_poll_is_safe():