← Files Codex UsageARCHIVED FILE

skills/codex-usage/scripts/codex_usage.py

15.9 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Read allowlisted Codex usage metadata; standard library only."""

import argparse
import datetime as dt
import json
import math
import os
from pathlib import Path
import queue
import shutil
import stat
import subprocess
import sys
import threading
import time
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

UTC = dt.timezone.utc
MAX_LINE_BYTES = 2 * 1024 * 1024
READER_VERSION = "0.1.2"


def desktop_codex_candidates():
    if sys.platform != "darwin":
        return []
    return [root / app / "Contents/Resources/codex"
            for root in (Path("/Applications"), Path.home() / "Applications")
            for app in ("ChatGPT.app", "Codex.app")]


def find_codex():
    executable = shutil.which("codex")
    if executable:
        return executable
    for candidate in desktop_codex_candidates():
        if candidate.is_file() and os.access(candidate, os.X_OK):
            return str(candidate)
    return None


def number(value):
    return (isinstance(value, (int, float)) and not isinstance(value, bool)
            and math.isfinite(value))


def pick(obj, snake, camel):
    return obj.get(snake) if snake in obj else obj.get(camel)


def timestamp(value):
    try:
        if number(value):
            return dt.datetime.fromtimestamp(value, UTC)
        if isinstance(value, str):
            value = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
            return value.astimezone(UTC) if value.tzinfo else None
    except (ValueError, OverflowError, OSError):
        pass
    return None


def display_time(value, timezone):
    parsed = timestamp(value)
    return parsed.astimezone(timezone).isoformat(timespec="seconds") if parsed else None


def window(raw, slot, now, timezone):
    if not isinstance(raw, dict):
        return None
    used = pick(raw, "used_percent", "usedPercent")
    if not number(used) or used < 0:
        return None
    minutes = pick(raw, "window_minutes", "windowDurationMins")
    if not number(minutes) or minutes <= 0:
        minutes = None
    reset = pick(raw, "resets_at", "resetsAt")
    reset_time = timestamp(reset)
    return {
        "kind": {10080: "weekly", 300: "five_hour"}.get(minutes, "other"),
        "slot": slot,
        "duration_minutes": minutes,
        "used_percent": used,
        "remaining_percent": max(0, 100 - used),
        "resets_at": reset_time.isoformat() if reset_time else None,
        "resets_at_local": display_time(reset, timezone),
        "snapshot_window_expired": reset_time <= now if reset_time else None,
    }


def reset_cards(raw, timezone):
    if not isinstance(raw, dict):
        return {"status": "unknown", "available_count": None, "details": None}
    count = pick(raw, "available_count", "availableCount")
    if not isinstance(count, int) or isinstance(count, bool) or count < 0:
        return {"status": "unknown", "available_count": None, "details": None}
    details = None
    if isinstance(raw.get("credits"), list):
        details = []
        for card in raw["credits"]:
            if not isinstance(card, dict):
                continue
            state = card.get("status")
            details.append({
                "status": state if state in {"available", "redeeming", "redeemed", "unknown"} else "unknown",
                "granted_at_local": display_time(pick(card, "granted_at", "grantedAt"), timezone),
                "expires_at_local": display_time(pick(card, "expires_at", "expiresAt"), timezone),
            })
    return {"status": "known", "available_count": count, "details": details}


def normalize_limit(raw, now, timezone):
    if not isinstance(raw, dict):
        return None
    windows = [w for slot in ("primary", "secondary")
               if (w := window(raw.get(slot), slot, now, timezone)) is not None]
    if not windows:
        return None
    paid = raw.get("credits")
    paid = paid if isinstance(paid, dict) else {}
    balance = paid.get("balance")
    # Numeric balances only. Never forward arbitrary backend strings.
    if isinstance(balance, str):
        try:
            balance = float(balance)
        except ValueError:
            balance = None
    if not number(balance):
        balance = None
    plan = pick(raw, "plan_type", "planType")
    limit_id = pick(raw, "limit_id", "limitId") or "codex"
    if not isinstance(limit_id, str) or len(limit_id) > 100:
        limit_id = "unknown"
    if not isinstance(plan, str) or len(plan) > 100:
        plan = None
    return {
        "limit_id": limit_id,
        "plan_type": plan,
        "windows": windows,
        "paid_credits": {
            "balance": balance,
            "has_credits": pick(paid, "has_credits", "hasCredits") if isinstance(pick(paid, "has_credits", "hasCredits"), bool) else None,
            "unlimited": paid.get("unlimited") if isinstance(paid.get("unlimited"), bool) else None,
        },
    }


def usage_event(record):
    """Never parse strings embedded in messages, tool output or quoted examples."""
    if not isinstance(record, dict):
        return None
    payload = record.get("payload")
    if (record.get("type") == "event_msg" and isinstance(payload, dict)
            and payload.get("type") == "token_count"):
        return {"rateLimits": payload.get("rate_limits"),
                "rateLimitResetCredits": pick(payload, "rate_limit_reset_credits", "rateLimitResetCredits")}
    if record.get("method") == "account/rateLimits/updated":
        params = record.get("params")
        return params if isinstance(params, dict) else None
    return None


def bounded_lines(stream):
    while True:
        line = stream.readline(MAX_LINE_BYTES + 1)
        if not line:
            return
        if len(line) > MAX_LINE_BYTES:
            while line and not line.endswith(b"\n"):
                line = stream.readline(MAX_LINE_BYTES + 1)
            yield None
        else:
            yield line


def local_usage(home, now, timezone):
    latest = {}
    cards = None
    unreadable = 0
    diagnostics = {
        "codex_home": str(home),
        "codex_cli": find_codex(),
        "home_exists": False,
        "history_directories_found": 0,
        "session_files_found": 0,
        "session_files_read": 0,
        "usage_events_found": 0,
        "unreadable_directories": 0,
    }
    try:
        diagnostics["home_exists"] = stat.S_ISDIR(home.stat().st_mode)
    except FileNotFoundError:
        pass
    except OSError:
        diagnostics["unreadable_directories"] += 1

    def walk_error(_error):
        diagnostics["unreadable_directories"] += 1

    for folder in (home / "sessions", home / "archived_sessions"):
        try:
            if not stat.S_ISDIR(folder.stat().st_mode):
                continue
        except FileNotFoundError:
            continue
        except OSError:
            diagnostics["unreadable_directories"] += 1
            continue
        diagnostics["history_directories_found"] += 1
        paths = (Path(directory) / name
                 for directory, _, names in os.walk(folder, onerror=walk_error)
                 for name in names if name.endswith(".jsonl"))
        for path in paths:
            if path.is_symlink():
                continue
            diagnostics["session_files_found"] += 1
            try:
                with path.open("rb") as stream:
                    for line_no, line in enumerate(bounded_lines(stream), 1):
                        if not line or not any(key in line for key in (b'"rate_limits"', b'"rateLimits"', b'"rateLimitResetCredits"')):
                            continue
                        try:
                            record = json.loads(line)
                        except (ValueError, UnicodeError):
                            continue
                        event = usage_event(record)
                        recorded = timestamp(record.get("timestamp")) if isinstance(record, dict) else None
                        if not event or not recorded or recorded > now + dt.timedelta(minutes=5):
                            continue
                        diagnostics["usage_events_found"] += 1
                        source = {"file": str(path), "line": line_no}
                        meta = {"source": source, "recorded_at": recorded.isoformat(),
                                "recorded_at_local": recorded.astimezone(timezone).isoformat(timespec="seconds"),
                                "age_seconds": max(0, int((now - recorded).total_seconds()))}
                        bucket = normalize_limit(event.get("rateLimits"), now, timezone)
                        if bucket:
                            key = bucket["limit_id"]
                            if key not in latest or recorded > latest[key][0]:
                                latest[key] = (recorded, dict(bucket, **meta))
                        summary = reset_cards(event.get("rateLimitResetCredits"), timezone)
                        if summary["status"] == "known" and (cards is None or recorded > cards[0]):
                            cards = (recorded, dict(summary, **meta))
                diagnostics["session_files_read"] += 1
            except OSError:
                unreadable += 1
    incomplete = unreadable or diagnostics["unreadable_directories"]
    if latest:
        local_status = "partial" if incomplete else "ok"
    elif incomplete:
        local_status = "history_unreadable"
    elif not diagnostics["home_exists"]:
        local_status = "codex_home_missing"
    elif not diagnostics["history_directories_found"]:
        local_status = "session_directories_missing"
    elif not diagnostics["session_files_found"]:
        local_status = "no_session_files"
    else:
        local_status = "no_rate_limit_snapshots"
    return {
        "source_type": "local_history",
        "local_status": local_status,
        "diagnostics": diagnostics,
        "account_identity": "not_verified_from_local_history",
        "limits": [v[1] for _, v in sorted(latest.items())],
        "reset_cards": cards[1] if cards else reset_cards(None, timezone),
        "unreadable_files": unreadable,
    }


def live_read(timeout=12, codex_home=None):
    executable = find_codex()
    if not executable:
        return None, "codex_cli_unavailable"
    process = None
    responses = queue.Queue()
    try:
        process = subprocess.Popen([executable, "app-server", "--stdio"],
                                   stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                                   stderr=subprocess.DEVNULL,
                                   env=dict(os.environ, CODEX_HOME=str(codex_home)) if codex_home else None)
        def receive():
            for line in bounded_lines(process.stdout):
                if not line:
                    continue
                try:
                    message = json.loads(line)
                    if isinstance(message, dict) and message.get("id") in (1, 2):
                        responses.put(message)
                except (ValueError, UnicodeError):
                    pass
            responses.put(None)
        reader = threading.Thread(target=receive, daemon=True)
        reader.start()
        deadline = time.monotonic() + timeout
        def send(message):
            process.stdin.write((json.dumps(message) + "\n").encode())
            process.stdin.flush()
        def wait(expected):
            while True:
                response = responses.get(timeout=max(0, deadline - time.monotonic()))
                if response is None:
                    raise EOFError
                if response.get("id") == expected:
                    return response
        send({"id": 1, "method": "initialize", "params": {
            "clientInfo": {"name": "codex_usage", "version": READER_VERSION}}})
        if "error" in wait(1):
            return None, "initialization_failed"
        send({"method": "initialized"})
        send({"id": 2, "method": "account/rateLimits/read"})
        response = wait(2)
        if "error" in response:
            # Classify internally; never expose arbitrary error messages.
            message = str(response["error"]).lower()
            return None, "chatgpt_authentication_unavailable" if "auth" in message else "account_read_failed"
        result = response.get("result")
        return (result, None) if isinstance(result, dict) else (None, "invalid_response")
    except queue.Empty:
        return None, "timeout"
    except (OSError, EOFError, ValueError):
        return None, "connection_failed"
    finally:
        if process is not None:
            process.terminate()
            try:
                process.wait(timeout=2)
            except subprocess.TimeoutExpired:
                process.kill()
                process.wait(timeout=2)
            if process.stdin:
                process.stdin.close()
            if process.stdout:
                process.stdout.close()


def report(home, now, timezone, live=False):
    result = local_usage(home, now, timezone)
    result.update({"reader_version": READER_VERSION,
                   "queried_at": now.isoformat(), "queried_at_local": now.astimezone(timezone).isoformat(timespec="seconds"),
                   "live_status": "not_requested"})
    if live:
        raw, error = live_read(codex_home=home)
        result["live_status"] = error or "ok"
        if raw is not None:
            live_now = dt.datetime.now(UTC)
            buckets = raw.get("rateLimitsByLimitId")
            buckets = list(buckets.values()) if isinstance(buckets, dict) and buckets else [raw.get("rateLimits")]
            normalized = [b for item in buckets if (b := normalize_limit(item, live_now, timezone))]
            # Keep live CLI data separate: its account can differ from historical sessions.
            result["live"] = {
                "source_type": "codex_app_server",
                "queried_at": live_now.isoformat(),
                "queried_at_local": live_now.astimezone(timezone).isoformat(timespec="seconds"),
                "limits": normalized,
                "reset_cards": reset_cards(raw.get("rateLimitResetCredits"), timezone),
            }
    return result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--codex-home", type=Path, default=Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex"))
    parser.add_argument("--timezone", help="IANA timezone; default: local machine timezone")
    parser.add_argument("--live", action="store_true", help="Also read through existing Codex CLI auth; never signs in or redeems cards")
    parser.add_argument("--json", action="store_true", help="Print structured metadata")
    args = parser.parse_args()
    try:
        timezone = ZoneInfo(args.timezone) if args.timezone else None
    except (ZoneInfoNotFoundError, ValueError):
        parser.error("Timezone unavailable. Use an installed IANA timezone or omit --timezone.")
    result = report(args.codex_home.expanduser().resolve(), dt.datetime.now(UTC), timezone, args.live)
    if args.json:
        print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False))
    else:
        for bucket in result["limits"]:
            print(f"{bucket['limit_id']} / {bucket['plan_type']} — snapshot {bucket['recorded_at_local']}")
            for w in bucket["windows"]:
                state = "EXPIRED SNAPSHOT" if w["snapshot_window_expired"] else "recorded"
                print(f"  {w['kind']}: {w['remaining_percent']:g}% remaining, {w['used_percent']:g}% used ({state}); reset {w['resets_at_local']}")
            print(f"  Source: {bucket['source']['file']}:{bucket['source']['line']}")
        if not result["limits"]:
            print("Local usage unavailable: " + result["local_status"])
            print("Checked Codex home: " + result["diagnostics"]["codex_home"])
        cards = result["reset_cards"]
        print("Local reset cards: " + (str(cards["available_count"]) if cards["status"] == "known" else "unknown (not recorded)"))
        if args.live:
            print("Live query: " + result["live_status"])
            if "live" in result:
                print(json.dumps(result["live"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

SHA-256: ad70ed99b857388856dcbef7973f499b88af07f46f0b10094ffcc042add9e62d