codex-cli-usage 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v4
17
+ - run: uv build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-cli-usage
3
+ Version: 0.1.0
4
+ Summary: Codex CLI usage monitor — fetches rate limits from OpenAI's ChatGPT backend API
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+
8
+ # codex-cli-usage
9
+
10
+ Codex CLI usage monitor. Fetches your rate limit data from OpenAI's ChatGPT backend API and displays it in the terminal.
11
+
12
+ ## Example output
13
+
14
+ `codex-cli-usage` command:
15
+
16
+ ```
17
+ Plan: plus
18
+ Session (5h) 39% resets 1h26m
19
+ Week (7d) 15% resets 143h26m
20
+ ```
21
+
22
+ Codex statusline (self-caching — refreshes from API when stale, no daemon needed):
23
+
24
+ ```
25
+ 5h:39% 7d:15% plus reset:1h26m
26
+ ```
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ uv tool install codex-cli-usage
32
+ ```
33
+
34
+ Then run:
35
+
36
+ ```bash
37
+ # Check usage once
38
+ codex-cli-usage
39
+
40
+ # Run the daemon (keeps usage-limits.json updated)
41
+ codex-cli-usage daemon
42
+ ```
43
+
44
+ ## Commands
45
+
46
+ | Command | Description |
47
+ |---------|-------------|
48
+ | `codex-cli-usage` | Show current usage (colored terminal output) |
49
+ | `codex-cli-usage json` | Print raw JSON |
50
+ | `codex-cli-usage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
51
+ | `codex-cli-usage statusline` | Compact statusline (self-caching, no daemon needed) |
52
+ | `codex-cli-usage install` | Print setup instructions |
53
+
54
+ ## How Codex CLI rate limiting works
55
+
56
+ Discovered by inspecting the Codex CLI and its authentication flow.
57
+
58
+ ### Data sources
59
+
60
+ Codex CLI gets rate limit data from:
61
+
62
+ 1. **`/backend-api/codex/usage` endpoint** — Returns utilization percentages and reset times for each rate limit window (primary 5h, secondary 7d, plus per-model and code review limits).
63
+
64
+ ### Rate limit types
65
+
66
+ | Type | Description |
67
+ |------|-------------|
68
+ | `primary_window` | Rolling 5-hour session window |
69
+ | `secondary_window` | Rolling 7-day all-models window |
70
+ | `additional_rate_limits` | Per-model limits (e.g. specific model caps) |
71
+ | `code_review_rate_limit` | Code review usage limit |
72
+
73
+ ### Authentication
74
+
75
+ The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
76
+
77
+ The access token expires roughly hourly. codex-cli-usage refreshes it automatically using the stored refresh token.
78
+
79
+ ### Local files
80
+
81
+ | File | Written by | Contains |
82
+ |------|-----------|----------|
83
+ | `~/.codex/auth.json` | Codex CLI | OAuth tokens (access, refresh, id_token) |
84
+ | `~/.codex/usage-limits.json` | codex-cli-usage daemon | Cached API usage data (this tool) |
@@ -0,0 +1,77 @@
1
+ # codex-cli-usage
2
+
3
+ Codex CLI usage monitor. Fetches your rate limit data from OpenAI's ChatGPT backend API and displays it in the terminal.
4
+
5
+ ## Example output
6
+
7
+ `codex-cli-usage` command:
8
+
9
+ ```
10
+ Plan: plus
11
+ Session (5h) 39% resets 1h26m
12
+ Week (7d) 15% resets 143h26m
13
+ ```
14
+
15
+ Codex statusline (self-caching — refreshes from API when stale, no daemon needed):
16
+
17
+ ```
18
+ 5h:39% 7d:15% plus reset:1h26m
19
+ ```
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ uv tool install codex-cli-usage
25
+ ```
26
+
27
+ Then run:
28
+
29
+ ```bash
30
+ # Check usage once
31
+ codex-cli-usage
32
+
33
+ # Run the daemon (keeps usage-limits.json updated)
34
+ codex-cli-usage daemon
35
+ ```
36
+
37
+ ## Commands
38
+
39
+ | Command | Description |
40
+ |---------|-------------|
41
+ | `codex-cli-usage` | Show current usage (colored terminal output) |
42
+ | `codex-cli-usage json` | Print raw JSON |
43
+ | `codex-cli-usage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
44
+ | `codex-cli-usage statusline` | Compact statusline (self-caching, no daemon needed) |
45
+ | `codex-cli-usage install` | Print setup instructions |
46
+
47
+ ## How Codex CLI rate limiting works
48
+
49
+ Discovered by inspecting the Codex CLI and its authentication flow.
50
+
51
+ ### Data sources
52
+
53
+ Codex CLI gets rate limit data from:
54
+
55
+ 1. **`/backend-api/codex/usage` endpoint** — Returns utilization percentages and reset times for each rate limit window (primary 5h, secondary 7d, plus per-model and code review limits).
56
+
57
+ ### Rate limit types
58
+
59
+ | Type | Description |
60
+ |------|-------------|
61
+ | `primary_window` | Rolling 5-hour session window |
62
+ | `secondary_window` | Rolling 7-day all-models window |
63
+ | `additional_rate_limits` | Per-model limits (e.g. specific model caps) |
64
+ | `code_review_rate_limit` | Code review usage limit |
65
+
66
+ ### Authentication
67
+
68
+ The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
69
+
70
+ The access token expires roughly hourly. codex-cli-usage refreshes it automatically using the stored refresh token.
71
+
72
+ ### Local files
73
+
74
+ | File | Written by | Contains |
75
+ |------|-----------|----------|
76
+ | `~/.codex/auth.json` | Codex CLI | OAuth tokens (access, refresh, id_token) |
77
+ | `~/.codex/usage-limits.json` | codex-cli-usage daemon | Cached API usage data (this tool) |
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "codex-cli-usage"
3
+ version = "0.1.0"
4
+ description = "Codex CLI usage monitor — fetches rate limits from OpenAI's ChatGPT backend API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+
9
+ [tool.uv]
10
+ package = true
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/codex_cli_usage"]
18
+
19
+ [project.scripts]
20
+ codex-cli-usage = "codex_cli_usage:main"
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env python3
2
+ """codex-cli-usage - Codex CLI usage monitor.
3
+
4
+ Fetches rate limit data from OpenAI's ChatGPT backend API
5
+ using your Codex CLI OAuth token. Zero external dependencies.
6
+
7
+ Usage:
8
+ codex-cli-usage Show current usage (colored)
9
+ codex-cli-usage status Same as above
10
+ codex-cli-usage json Print raw JSON
11
+ codex-cli-usage daemon Run in foreground, refresh every 5 min, write to ~/.codex/usage-limits.json
12
+ codex-cli-usage statusline Codex statusline command (reads cache)
13
+ codex-cli-usage install Print setup instructions
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import signal
19
+ import sys
20
+ import time
21
+ import urllib.parse
22
+ import urllib.request
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ CODEX_DIR = Path.home() / ".codex"
27
+ AUTH_FILE = CODEX_DIR / "auth.json"
28
+ USAGE_FILE = CODEX_DIR / "usage-limits.json"
29
+ DAEMON_INTERVAL = 300 # 5 minutes
30
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
31
+ USAGE_URL = "https://chatgpt.com/backend-api/codex/usage"
32
+ TOKEN_URL = "https://auth.openai.com/oauth/token"
33
+
34
+
35
+ def get_auth() -> dict | None:
36
+ """Read OAuth credentials from Codex's auth file."""
37
+ try:
38
+ return json.loads(AUTH_FILE.read_text())
39
+ except (FileNotFoundError, json.JSONDecodeError):
40
+ return None
41
+
42
+
43
+ def get_plan(auth: dict | None = None) -> str:
44
+ """Return plan type from auth tokens (decoded from id_token JWT)."""
45
+ if auth is None:
46
+ auth = get_auth()
47
+ if not auth:
48
+ return "unknown"
49
+ # Plan type comes from the usage API, fall back to checking JWT claims
50
+ tokens = auth.get("tokens", {})
51
+ # Try to decode the id_token payload (JWT middle segment)
52
+ id_token = tokens.get("id_token", "")
53
+ try:
54
+ import base64
55
+ payload = id_token.split(".")[1]
56
+ payload += "=" * (4 - len(payload) % 4)
57
+ claims = json.loads(base64.urlsafe_b64decode(payload))
58
+ return claims.get("https://api.openai.com/auth", {}).get("chatgpt_plan_type", "unknown")
59
+ except Exception:
60
+ return "unknown"
61
+
62
+
63
+ def refresh_access_token(auth: dict) -> str:
64
+ """Refresh the access token using the refresh token."""
65
+ tokens = auth.get("tokens", {})
66
+ refresh_token = tokens.get("refresh_token")
67
+ if not refresh_token:
68
+ raise RuntimeError("No refresh token in auth.json")
69
+
70
+ data = urllib.parse.urlencode({
71
+ "grant_type": "refresh_token",
72
+ "refresh_token": refresh_token,
73
+ "client_id": CLIENT_ID,
74
+ }).encode()
75
+
76
+ req = urllib.request.Request(
77
+ TOKEN_URL,
78
+ data=data,
79
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
80
+ )
81
+ with urllib.request.urlopen(req, timeout=10) as resp:
82
+ result = json.loads(resp.read())
83
+
84
+ return result["access_token"]
85
+
86
+
87
+ def fetch_usage() -> dict:
88
+ """Fetch usage from ChatGPT's backend API.
89
+
90
+ Returns the raw API response with rate limit windows, plan info, etc.
91
+ """
92
+ auth = get_auth()
93
+ if not auth:
94
+ raise RuntimeError("No auth at ~/.codex/auth.json — run `codex` first")
95
+
96
+ tokens = auth.get("tokens", {})
97
+ access_token = tokens.get("access_token")
98
+ if not access_token:
99
+ raise RuntimeError("No access token in auth.json")
100
+
101
+ account_id = tokens.get("account_id", "")
102
+
103
+ # Try with current token, refresh if needed
104
+ for attempt in range(2):
105
+ req = urllib.request.Request(
106
+ USAGE_URL,
107
+ headers={
108
+ "Authorization": f"Bearer {access_token}",
109
+ "chatgpt-account-id": account_id,
110
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
111
+ "Accept": "application/json",
112
+ },
113
+ )
114
+ try:
115
+ with urllib.request.urlopen(req, timeout=10) as resp:
116
+ return json.loads(resp.read())
117
+ except urllib.error.HTTPError as e:
118
+ if e.code == 401 and attempt == 0:
119
+ access_token = refresh_access_token(auth)
120
+ else:
121
+ raise
122
+
123
+ raise RuntimeError("Failed to fetch usage after token refresh")
124
+
125
+
126
+ def build_usage_json(api_data: dict) -> dict:
127
+ """Transform API response into our cached format."""
128
+ plan = api_data.get("plan_type", "unknown")
129
+ rl = api_data.get("rate_limit", {})
130
+
131
+ result = {
132
+ "plan": plan,
133
+ "source": "api",
134
+ "updated_at": datetime.now(timezone.utc).isoformat(),
135
+ }
136
+
137
+ primary = rl.get("primary_window")
138
+ if primary:
139
+ result["5h"] = {
140
+ "pct": primary["used_percent"],
141
+ "resets_at": datetime.fromtimestamp(primary["reset_at"], tz=timezone.utc).isoformat() if primary.get("reset_at") else None,
142
+ "window_secs": primary.get("limit_window_seconds"),
143
+ }
144
+
145
+ secondary = rl.get("secondary_window")
146
+ if secondary:
147
+ result["7d"] = {
148
+ "pct": secondary["used_percent"],
149
+ "resets_at": datetime.fromtimestamp(secondary["reset_at"], tz=timezone.utc).isoformat() if secondary.get("reset_at") else None,
150
+ "window_secs": secondary.get("limit_window_seconds"),
151
+ }
152
+
153
+ # Additional rate limits (e.g. per-model limits)
154
+ additional = api_data.get("additional_rate_limits", [])
155
+ if additional:
156
+ result["additional"] = []
157
+ for item in additional:
158
+ name = item.get("limit_name", "")
159
+ sub_rl = item.get("rate_limit", {})
160
+ entry = {"name": name}
161
+ p = sub_rl.get("primary_window")
162
+ if p:
163
+ entry["primary"] = {
164
+ "pct": p["used_percent"],
165
+ "resets_at": datetime.fromtimestamp(p["reset_at"], tz=timezone.utc).isoformat() if p.get("reset_at") else None,
166
+ }
167
+ s = sub_rl.get("secondary_window")
168
+ if s:
169
+ entry["secondary"] = {
170
+ "pct": s["used_percent"],
171
+ "resets_at": datetime.fromtimestamp(s["reset_at"], tz=timezone.utc).isoformat() if s.get("reset_at") else None,
172
+ }
173
+ result["additional"].append(entry)
174
+
175
+ # Code review limits
176
+ cr = api_data.get("code_review_rate_limit", {})
177
+ cr_primary = cr.get("primary_window")
178
+ if cr_primary and cr_primary.get("used_percent", 0) > 0:
179
+ result["code_review"] = {
180
+ "pct": cr_primary["used_percent"],
181
+ "resets_at": datetime.fromtimestamp(cr_primary["reset_at"], tz=timezone.utc).isoformat() if cr_primary.get("reset_at") else None,
182
+ }
183
+
184
+ # Credits
185
+ credits = api_data.get("credits", {})
186
+ if credits and credits.get("has_credits"):
187
+ result["credits"] = credits
188
+
189
+ return result
190
+
191
+
192
+ def write_usage_file(data: dict):
193
+ """Write usage data to ~/.codex/usage-limits.json."""
194
+ USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
195
+
196
+
197
+ # -- CLI commands --
198
+
199
+ def cmd_status(raw_json=False):
200
+ """Fetch and display current usage."""
201
+ api_data = fetch_usage()
202
+ data = build_usage_json(api_data)
203
+
204
+ if raw_json:
205
+ print(json.dumps(data, indent=2))
206
+ return
207
+
208
+ R = "\033[0;31m"
209
+ Y = "\033[0;33m"
210
+ G = "\033[0;32m"
211
+ D = "\033[0;90m"
212
+ RST = "\033[0m"
213
+
214
+ def color_pct(pct):
215
+ p = int(pct)
216
+ c = R if p >= 70 else Y if p >= 50 else G
217
+ return f"{c}{p}%{RST}"
218
+
219
+ def fmt_reset(iso):
220
+ if not iso:
221
+ return ""
222
+ try:
223
+ reset = datetime.fromisoformat(iso)
224
+ now = datetime.now(timezone.utc)
225
+ secs = int((reset - now).total_seconds())
226
+ if secs <= 0:
227
+ return ""
228
+ m = secs // 60
229
+ if m >= 60:
230
+ return f" resets {m // 60}h{m % 60}m"
231
+ return f" resets {m}m"
232
+ except Exception:
233
+ return ""
234
+
235
+ print(f"Plan: {data.get('plan', '?')}")
236
+ for label, key in [
237
+ ("Session (5h)", "5h"),
238
+ ("Week (7d)", "7d"),
239
+ ]:
240
+ bucket = data.get(key)
241
+ if bucket:
242
+ pct = bucket["pct"]
243
+ reset = fmt_reset(bucket.get("resets_at"))
244
+ print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
245
+
246
+ # Additional per-model limits
247
+ for item in data.get("additional", []):
248
+ name = item["name"]
249
+ for wlabel, wkey in [("5h", "primary"), ("7d", "secondary")]:
250
+ w = item.get(wkey)
251
+ if w and w.get("pct", 0) > 0:
252
+ label = f"{name} ({wlabel})"
253
+ print(f" {label:20s} {color_pct(w['pct'])}{D}{fmt_reset(w.get('resets_at'))}{RST}")
254
+
255
+ cr = data.get("code_review")
256
+ if cr:
257
+ print(f" {'Code Review':20s} {color_pct(cr['pct'])}{D}{fmt_reset(cr.get('resets_at'))}{RST}")
258
+
259
+
260
+ def cmd_daemon(interval: int = DAEMON_INTERVAL):
261
+ """Run in foreground, refresh every `interval` seconds."""
262
+ signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
263
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
264
+
265
+ print(f"codex-cli-usage daemon started (refreshing every {interval}s)")
266
+ print(f"Writing to {USAGE_FILE}")
267
+
268
+ while True:
269
+ try:
270
+ api_data = fetch_usage()
271
+ data = build_usage_json(api_data)
272
+ write_usage_file(data)
273
+ pcts = []
274
+ for key in ("5h", "7d"):
275
+ b = data.get(key)
276
+ if b:
277
+ pcts.append(f"{key}:{int(b['pct'])}%")
278
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
279
+ except Exception as e:
280
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] Error: {e}", file=sys.stderr)
281
+
282
+ time.sleep(interval)
283
+
284
+
285
+ def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
286
+ """Read cached usage, refreshing from API if stale or missing."""
287
+ try:
288
+ usage = json.loads(USAGE_FILE.read_text())
289
+ updated = datetime.fromisoformat(usage["updated_at"])
290
+ age = (datetime.now(timezone.utc) - updated).total_seconds()
291
+ if age < max_age:
292
+ return usage
293
+ except Exception:
294
+ pass
295
+ try:
296
+ api_data = fetch_usage()
297
+ usage = build_usage_json(api_data)
298
+ write_usage_file(usage)
299
+ return usage
300
+ except Exception:
301
+ try:
302
+ return json.loads(USAGE_FILE.read_text())
303
+ except Exception:
304
+ return {}
305
+
306
+
307
+ def cmd_statusline():
308
+ """Statusline command. Reads cached usage and prints compact summary."""
309
+ R = "\033[0;31m"
310
+ Y = "\033[0;33m"
311
+ G = "\033[0;32m"
312
+ D = "\033[0;90m"
313
+ RST = "\033[0m"
314
+
315
+ def color_pct(pct: int) -> str:
316
+ c = R if pct >= 70 else Y if pct >= 50 else G
317
+ return f"{c}{pct}%{RST}"
318
+
319
+ def fmt_reset(iso: str | None) -> str:
320
+ if not iso:
321
+ return ""
322
+ try:
323
+ reset = datetime.fromisoformat(iso)
324
+ secs = int((reset - datetime.now(timezone.utc)).total_seconds())
325
+ if secs <= 0:
326
+ return ""
327
+ m = secs // 60
328
+ if m >= 60:
329
+ return f"{m // 60}h{m % 60}m"
330
+ return f"{m}m"
331
+ except Exception:
332
+ return ""
333
+
334
+ usage = _get_cached_usage()
335
+
336
+ plan = usage.get("plan", "?")
337
+ five_h = usage.get("5h", {})
338
+ seven_d = usage.get("7d", {})
339
+
340
+ parts = []
341
+
342
+ if five_h:
343
+ parts.append(f"5h:{color_pct(int(five_h.get('pct', 0)))}")
344
+ if seven_d:
345
+ parts.append(f"7d:{color_pct(int(seven_d.get('pct', 0)))}")
346
+
347
+ parts.append(f"{D}{plan}{RST}")
348
+
349
+ reset = fmt_reset(five_h.get("resets_at"))
350
+ if reset:
351
+ parts.append(f"{D}reset:{reset}{RST}")
352
+
353
+ print(" ".join(parts))
354
+
355
+
356
+ def cmd_install():
357
+ """Print setup instructions."""
358
+ print("""codex-cli-usage setup
359
+ ================
360
+
361
+ 1. Run the daemon (in a terminal, tmux, or systemd):
362
+ codex-cli-usage daemon
363
+
364
+ 2. Quick check:
365
+ codex-cli-usage # show current usage
366
+ codex-cli-usage json # raw JSON output
367
+
368
+ 3. The daemon writes to ~/.codex/usage-limits.json every 5 minutes.
369
+ """)
370
+
371
+
372
+ def main():
373
+ parser = argparse.ArgumentParser(description="Codex CLI usage monitor")
374
+ sub = parser.add_subparsers(dest="command")
375
+ sub.add_parser("status", help="Show current usage (default)")
376
+ sub.add_parser("json", help="Print raw JSON")
377
+ daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
378
+ daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
379
+ help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
380
+ sub.add_parser("statusline", help="Compact statusline (reads cache)")
381
+ sub.add_parser("install", help="Print setup instructions")
382
+ args = parser.parse_args()
383
+
384
+ cmd = args.command or "status"
385
+ if cmd == "status":
386
+ cmd_status()
387
+ elif cmd == "json":
388
+ cmd_status(raw_json=True)
389
+ elif cmd == "daemon":
390
+ cmd_daemon(interval=args.interval)
391
+ elif cmd == "statusline":
392
+ cmd_statusline()
393
+ elif cmd == "install":
394
+ cmd_install()
395
+
396
+
397
+ if __name__ == "__main__":
398
+ main()
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 2
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "codexusage"
7
+ version = "0.1.0"
8
+ source = { editable = "." }