codex-cli-usage 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

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.
@@ -2,7 +2,8 @@
2
2
  """codex-cli-usage - Codex CLI usage monitor.
3
3
 
4
4
  Fetches rate limit data from OpenAI's ChatGPT backend API
5
- using your Codex CLI OAuth token. Zero external dependencies.
5
+ through Codex's app-server, with a direct HTTP compatibility fallback.
6
+ Zero external Python dependencies.
6
7
 
7
8
  Usage:
8
9
  codex-cli-usage Show current usage (colored)
@@ -15,10 +16,13 @@ Usage:
15
16
 
16
17
  import argparse
17
18
  import json
19
+ import queue
20
+ import shutil
18
21
  import signal
22
+ import subprocess
19
23
  import sys
24
+ import threading
20
25
  import time
21
- import urllib.parse
22
26
  import urllib.request
23
27
  from datetime import datetime, timezone
24
28
  from pathlib import Path
@@ -37,7 +41,6 @@ def _find_codex_path(filename: str) -> Path:
37
41
  return native
38
42
 
39
43
  if sys.platform == "win32":
40
- import subprocess
41
44
  try:
42
45
  result = subprocess.run(
43
46
  ["wsl", "-l", "-q"],
@@ -67,9 +70,21 @@ CODEX_DIR = Path.home() / ".codex"
67
70
  AUTH_FILE = _find_codex_path("auth.json")
68
71
  USAGE_FILE = _find_codex_path("usage-limits.json")
69
72
  DAEMON_INTERVAL = 300 # 5 minutes
70
- CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
71
73
  USAGE_URL = "https://chatgpt.com/backend-api/codex/usage"
72
- TOKEN_URL = "https://auth.openai.com/oauth/token"
74
+ AUTH_REFRESH_ERROR = "Authentication refresh failed. Run codex login again."
75
+ USAGE_403_ERROR = "Usage service temporarily rejected the request (HTTP 403). Try again."
76
+ USAGE_403_RETRY_DELAYS = (0, 0.25, 0.5, 0.75, 1, 1.5, 2)
77
+ APP_SERVER_TIMEOUT = 20
78
+ CLIENT_VERSION = "0.1.8"
79
+ _RPC_EOF = object()
80
+
81
+
82
+ class AuthenticationError(RuntimeError):
83
+ """Authentication could not be recovered automatically."""
84
+
85
+
86
+ class UsageServiceError(RuntimeError):
87
+ """The usage service rejected an otherwise authenticated request."""
73
88
 
74
89
 
75
90
  def get_auth() -> dict | None:
@@ -100,32 +115,209 @@ def get_plan(auth: dict | None = None) -> str:
100
115
  return "unknown"
101
116
 
102
117
 
103
- def refresh_access_token(auth: dict) -> str:
104
- """Refresh the access token using the refresh token."""
105
- tokens = auth.get("tokens", {})
106
- refresh_token = tokens.get("refresh_token")
107
- if not refresh_token:
108
- raise RuntimeError("No refresh token in auth.json")
109
-
110
- data = urllib.parse.urlencode({
111
- "grant_type": "refresh_token",
112
- "refresh_token": refresh_token,
113
- "client_id": CLIENT_ID,
114
- }).encode()
115
-
118
+ def _request_usage(access_token: str, account_id: str) -> dict:
116
119
  req = urllib.request.Request(
117
- TOKEN_URL,
118
- data=data,
119
- headers={"Content-Type": "application/x-www-form-urlencoded"},
120
+ USAGE_URL,
121
+ headers={
122
+ "Authorization": f"Bearer {access_token}",
123
+ "chatgpt-account-id": account_id,
124
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
125
+ "Accept": "application/json",
126
+ },
120
127
  )
121
128
  with urllib.request.urlopen(req, timeout=10) as resp:
122
- result = json.loads(resp.read())
129
+ return json.loads(resp.read())
123
130
 
124
- return result["access_token"]
125
131
 
132
+ def _request_usage_with_403_retry(access_token: str, account_id: str) -> dict:
133
+ """Retry transient 403 responses without refreshing credentials."""
134
+ last_error = None
135
+ transient_html_403 = False
136
+ for delay in (None, *USAGE_403_RETRY_DELAYS):
137
+ if delay is not None:
138
+ time.sleep(delay)
139
+ try:
140
+ return _request_usage(access_token, account_id)
141
+ except urllib.error.HTTPError as error:
142
+ if error.code != 403:
143
+ raise
144
+ last_error = error
145
+ content_type = error.headers.get_content_type() if error.headers else ""
146
+ transient_html_403 = content_type == "text/html"
147
+
148
+ if transient_html_403:
149
+ raise UsageServiceError(USAGE_403_ERROR) from None
150
+ raise last_error
151
+
152
+
153
+ def _read_rpc_lines(stream, messages: queue.Queue) -> None:
154
+ try:
155
+ for line in stream:
156
+ messages.put(line)
157
+ finally:
158
+ messages.put(_RPC_EOF)
159
+
160
+
161
+ def _wait_for_rpc_response(messages: queue.Queue, request_id: int, deadline: float) -> dict | None:
162
+ while True:
163
+ remaining = deadline - time.monotonic()
164
+ if remaining <= 0:
165
+ return None
166
+ try:
167
+ line = messages.get(timeout=remaining)
168
+ except queue.Empty:
169
+ return None
170
+ if line is _RPC_EOF:
171
+ return None
172
+ try:
173
+ message = json.loads(line)
174
+ except (json.JSONDecodeError, TypeError):
175
+ return None
176
+ if not isinstance(message, dict):
177
+ return None
178
+ if message.get("id") == request_id:
179
+ return message
180
+
181
+
182
+ def _stop_app_server(process: subprocess.Popen, deadline: float) -> bool:
183
+ if process.stdin and not process.stdin.closed:
184
+ process.stdin.close()
185
+ try:
186
+ return process.wait(timeout=max(0, deadline - time.monotonic())) == 0
187
+ except subprocess.TimeoutExpired:
188
+ process.terminate()
189
+ try:
190
+ process.wait(timeout=1)
191
+ except subprocess.TimeoutExpired:
192
+ process.kill()
193
+ process.wait()
194
+ return False
195
+
196
+
197
+ def _rate_limit_snapshot(snapshot: dict) -> dict:
198
+ result = {}
199
+ for kind in ("primary", "secondary"):
200
+ window = snapshot.get(kind)
201
+ if not isinstance(window, dict):
202
+ continue
203
+ duration_mins = window.get("windowDurationMins")
204
+ result[f"{kind}_window"] = {
205
+ "used_percent": window.get("usedPercent"),
206
+ "reset_at": window.get("resetsAt"),
207
+ "limit_window_seconds": duration_mins * 60
208
+ if isinstance(duration_mins, int | float) and not isinstance(duration_mins, bool)
209
+ else None,
210
+ }
211
+ return result
212
+
213
+
214
+ def _adapt_app_server_rate_limits(result: dict) -> dict | None:
215
+ aggregate = result.get("rateLimits")
216
+ if not isinstance(aggregate, dict):
217
+ return None
218
+
219
+ api_data = {
220
+ "plan_type": result.get("planType") or get_plan(),
221
+ "rate_limit": _rate_limit_snapshot(aggregate),
222
+ }
223
+ by_limit_id = result.get("rateLimitsByLimitId")
224
+ if not isinstance(by_limit_id, dict):
225
+ return api_data
226
+
227
+ aggregate_id = aggregate.get("limitId") or "codex"
228
+ additional = []
229
+ for limit_id, snapshot in by_limit_id.items():
230
+ if (
231
+ limit_id in {aggregate_id, "codex"}
232
+ or not isinstance(snapshot, dict)
233
+ or snapshot == aggregate
234
+ ):
235
+ continue
236
+ additional.append({
237
+ "limit_name": snapshot.get("limitName") or limit_id,
238
+ "rate_limit": _rate_limit_snapshot(snapshot),
239
+ })
240
+ if additional:
241
+ api_data["additional_rate_limits"] = additional
242
+ return api_data
126
243
 
127
- def fetch_usage() -> dict:
128
- """Fetch usage from ChatGPT's backend API.
244
+
245
+ def _fetch_usage_via_app_server() -> dict | None:
246
+ """Fetch usage through Codex's authenticated JSON-RPC app-server."""
247
+ codex = shutil.which("codex")
248
+ if not codex:
249
+ return None
250
+
251
+ deadline = time.monotonic() + APP_SERVER_TIMEOUT
252
+ try:
253
+ process = subprocess.Popen(
254
+ [codex, "app-server", "--stdio"],
255
+ stdin=subprocess.PIPE,
256
+ stdout=subprocess.PIPE,
257
+ stderr=subprocess.DEVNULL,
258
+ text=True,
259
+ encoding="utf-8",
260
+ bufsize=1,
261
+ )
262
+ except OSError:
263
+ return None
264
+
265
+ stopped = False
266
+ try:
267
+ if process.stdin is None or process.stdout is None:
268
+ return None
269
+ messages = queue.Queue()
270
+ threading.Thread(
271
+ target=_read_rpc_lines,
272
+ args=(process.stdout, messages),
273
+ daemon=True,
274
+ ).start()
275
+
276
+ initialize = {
277
+ "method": "initialize",
278
+ "id": 1,
279
+ "params": {
280
+ "clientInfo": {
281
+ "name": "codex_cli_usage",
282
+ "title": "Codex CLI Usage",
283
+ "version": CLIENT_VERSION,
284
+ },
285
+ },
286
+ }
287
+ process.stdin.write(json.dumps(initialize) + "\n")
288
+ process.stdin.flush()
289
+ initialized = _wait_for_rpc_response(messages, 1, deadline)
290
+ if not initialized or initialized.get("error") or "result" not in initialized:
291
+ return None
292
+
293
+ process.stdin.write(json.dumps({"method": "initialized"}) + "\n")
294
+ process.stdin.write(json.dumps({
295
+ "method": "account/rateLimits/read",
296
+ "id": 2,
297
+ }) + "\n")
298
+ process.stdin.flush()
299
+ response = _wait_for_rpc_response(messages, 2, deadline)
300
+ if not response or response.get("error") or not isinstance(response.get("result"), dict):
301
+ return None
302
+ usage = _adapt_app_server_rate_limits(response["result"])
303
+ if usage is None:
304
+ return None
305
+ stopped = _stop_app_server(process, deadline)
306
+ return usage if stopped else None
307
+ except (BrokenPipeError, OSError, ValueError):
308
+ return None
309
+ finally:
310
+ if not stopped and process.poll() is None:
311
+ process.terminate()
312
+ try:
313
+ process.wait(timeout=1)
314
+ except subprocess.TimeoutExpired:
315
+ process.kill()
316
+ process.wait()
317
+
318
+
319
+ def _fetch_usage_direct() -> dict:
320
+ """Compatibility fallback using auth.json and urllib directly.
129
321
 
130
322
  Returns the raw API response with rate limit windows, plan info, etc.
131
323
  """
@@ -140,80 +332,115 @@ def fetch_usage() -> dict:
140
332
 
141
333
  account_id = tokens.get("account_id", "")
142
334
 
143
- # Try with current token, refresh if needed
144
- for attempt in range(2):
145
- req = urllib.request.Request(
146
- USAGE_URL,
147
- headers={
148
- "Authorization": f"Bearer {access_token}",
149
- "chatgpt-account-id": account_id,
150
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
151
- "Accept": "application/json",
152
- },
153
- )
335
+ try:
336
+ return _request_usage_with_403_retry(access_token, account_id)
337
+ except urllib.error.HTTPError as error:
338
+ if error.code not in (401, 403):
339
+ raise
340
+
341
+ # Codex may have rotated credentials while this process was requesting.
342
+ latest = get_auth()
343
+ latest_tokens = latest.get("tokens", {}) if latest else {}
344
+ latest_access_token = latest_tokens.get("access_token")
345
+ if latest_access_token and latest_access_token != access_token:
346
+ auth = latest
347
+ access_token = latest_access_token
348
+ account_id = latest_tokens.get("account_id", account_id)
154
349
  try:
155
- with urllib.request.urlopen(req, timeout=10) as resp:
156
- return json.loads(resp.read())
157
- except urllib.error.HTTPError as e:
158
- if e.code in (401, 403) and attempt == 0:
159
- access_token = refresh_access_token(auth)
160
- else:
350
+ return _request_usage_with_403_retry(access_token, account_id)
351
+ except urllib.error.HTTPError as error:
352
+ if error.code not in (401, 403):
161
353
  raise
162
354
 
163
- raise RuntimeError("Failed to fetch usage after token refresh")
355
+ raise AuthenticationError(AUTH_REFRESH_ERROR) from None
356
+
357
+
358
+ def fetch_usage() -> dict:
359
+ """Fetch usage through Codex, falling back to direct HTTP for compatibility."""
360
+ usage = _fetch_usage_via_app_server()
361
+ return usage if usage is not None else _fetch_usage_direct()
362
+
363
+
364
+ _WINDOW_CLASSES = (
365
+ ("5h", "5-hour", 5 * 3600),
366
+ ("daily", "Daily", 24 * 3600),
367
+ ("weekly", "Weekly", 7 * 24 * 3600),
368
+ ("monthly", "Monthly", 30 * 24 * 3600),
369
+ ("annual", "Annual", 365 * 24 * 3600),
370
+ )
371
+
372
+
373
+ def classify_window(seconds: int | float | None, kind: str) -> tuple[str, str]:
374
+ """Derive a stable key and display label from a window duration."""
375
+ if not isinstance(seconds, int | float) or isinstance(seconds, bool) or seconds <= 0:
376
+ return kind, kind.title()
377
+ for key, label, expected in _WINDOW_CLASSES:
378
+ if abs(seconds - expected) / expected <= 0.1:
379
+ return key, label
380
+ return kind, kind.title()
381
+
382
+
383
+ def _build_window(kind: str, window: dict) -> dict:
384
+ seconds = window.get("limit_window_seconds")
385
+ key, label = classify_window(seconds, kind)
386
+ reset_at = window.get("reset_at")
387
+ return {
388
+ "kind": kind,
389
+ "pct": window.get("used_percent"),
390
+ "resets_at": datetime.fromtimestamp(reset_at, tz=timezone.utc).isoformat() if reset_at else None,
391
+ "window_secs": seconds,
392
+ "key": key,
393
+ "label": label,
394
+ }
164
395
 
165
396
 
166
397
  def build_usage_json(api_data: dict) -> dict:
167
398
  """Transform API response into our cached format."""
168
399
  plan = api_data.get("plan_type", "unknown")
169
- rl = api_data.get("rate_limit", {})
400
+ rl = api_data.get("rate_limit") or {}
170
401
 
171
402
  result = {
403
+ "schema_version": 2,
172
404
  "plan": plan,
173
405
  "source": "api",
174
406
  "updated_at": datetime.now(timezone.utc).isoformat(),
175
407
  }
176
408
 
177
- primary = rl.get("primary_window")
178
- if primary:
179
- result["5h"] = {
180
- "pct": primary["used_percent"],
181
- "resets_at": datetime.fromtimestamp(primary["reset_at"], tz=timezone.utc).isoformat() if primary.get("reset_at") else None,
182
- "window_secs": primary.get("limit_window_seconds"),
409
+ for kind in ("primary", "secondary"):
410
+ raw_window = rl.get(f"{kind}_window")
411
+ if not isinstance(raw_window, dict):
412
+ continue
413
+ window = _build_window(kind, raw_window)
414
+ result[kind] = {
415
+ "pct": window["pct"],
416
+ "resets_at": window["resets_at"],
417
+ "window_secs": window["window_secs"],
183
418
  }
419
+ legacy_key = {"5h": "5h", "weekly": "7d"}.get(window["key"])
420
+ if legacy_key and legacy_key not in result:
421
+ result[legacy_key] = dict(result[kind])
184
422
 
185
- secondary = rl.get("secondary_window")
186
- if secondary:
187
- result["7d"] = {
188
- "pct": secondary["used_percent"],
189
- "resets_at": datetime.fromtimestamp(secondary["reset_at"], tz=timezone.utc).isoformat() if secondary.get("reset_at") else None,
190
- "window_secs": secondary.get("limit_window_seconds"),
191
- }
192
-
193
- # Additional rate limits (e.g. per-model limits)
194
- additional = api_data.get("additional_rate_limits", [])
423
+ # Preserve additional limits in JSON for compatibility, but normal output
424
+ # intentionally shows aggregate Codex limits only.
425
+ additional = api_data.get("additional_rate_limits") or []
195
426
  if additional:
196
427
  result["additional"] = []
197
428
  for item in additional:
198
- name = item.get("limit_name", "")
199
- sub_rl = item.get("rate_limit", {})
200
- entry = {"name": name}
201
- p = sub_rl.get("primary_window")
202
- if p:
203
- entry["primary"] = {
204
- "pct": p["used_percent"],
205
- "resets_at": datetime.fromtimestamp(p["reset_at"], tz=timezone.utc).isoformat() if p.get("reset_at") else None,
206
- }
207
- s = sub_rl.get("secondary_window")
208
- if s:
209
- entry["secondary"] = {
210
- "pct": s["used_percent"],
211
- "resets_at": datetime.fromtimestamp(s["reset_at"], tz=timezone.utc).isoformat() if s.get("reset_at") else None,
212
- }
429
+ entry = {"name": item.get("limit_name", "")}
430
+ sub_rl = item.get("rate_limit") or {}
431
+ for kind in ("primary", "secondary"):
432
+ raw_window = sub_rl.get(f"{kind}_window")
433
+ if isinstance(raw_window, dict):
434
+ window = _build_window(kind, raw_window)
435
+ entry[kind] = {
436
+ "pct": window["pct"],
437
+ "resets_at": window["resets_at"],
438
+ "window_secs": window["window_secs"],
439
+ }
213
440
  result["additional"].append(entry)
214
441
 
215
442
  # Code review limits
216
- cr = api_data.get("code_review_rate_limit", {})
443
+ cr = api_data.get("code_review_rate_limit") or {}
217
444
  cr_primary = cr.get("primary_window")
218
445
  if cr_primary and cr_primary.get("used_percent", 0) > 0:
219
446
  result["code_review"] = {
@@ -222,13 +449,47 @@ def build_usage_json(api_data: dict) -> dict:
222
449
  }
223
450
 
224
451
  # Credits
225
- credits = api_data.get("credits", {})
452
+ credits = api_data.get("credits") or {}
226
453
  if credits and credits.get("has_credits"):
227
454
  result["credits"] = credits
228
455
 
229
456
  return result
230
457
 
231
458
 
459
+ def usage_windows(usage: dict) -> list[dict]:
460
+ """Enumerate schema-v2 windows, falling back to duration-keyed caches."""
461
+ result = []
462
+ if usage.get("schema_version", 1) >= 2 or any(key in usage for key in ("primary", "secondary")):
463
+ for kind in ("primary", "secondary"):
464
+ bucket = usage.get(kind)
465
+ if not isinstance(bucket, dict):
466
+ continue
467
+ key, label = classify_window(bucket.get("window_secs"), kind)
468
+ result.append({**bucket, "kind": kind, "key": key, "label": label})
469
+ return result
470
+
471
+ for legacy_key, kind, fallback_seconds in (
472
+ ("5h", "primary", 5 * 3600),
473
+ ("7d", "secondary", 7 * 24 * 3600),
474
+ ):
475
+ bucket = usage.get(legacy_key)
476
+ if not isinstance(bucket, dict):
477
+ continue
478
+ seconds = bucket.get("window_secs")
479
+ if not isinstance(seconds, int | float) or isinstance(seconds, bool) or seconds <= 0:
480
+ seconds = fallback_seconds
481
+ key, label = classify_window(seconds, kind)
482
+ result.append({
483
+ "kind": kind,
484
+ "pct": bucket.get("pct"),
485
+ "resets_at": bucket.get("resets_at"),
486
+ "window_secs": seconds,
487
+ "key": key,
488
+ "label": label,
489
+ })
490
+ return result
491
+
492
+
232
493
  def write_usage_file(data: dict):
233
494
  """Write usage data to ~/.codex/usage-limits.json."""
234
495
  USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
@@ -273,24 +534,11 @@ def cmd_status(raw_json=False):
273
534
  return ""
274
535
 
275
536
  print(f"Plan: {data.get('plan', '?')}")
276
- for label, key in [
277
- ("Session (5h)", "5h"),
278
- ("Week (7d)", "7d"),
279
- ]:
280
- bucket = data.get(key)
281
- if bucket:
282
- pct = bucket["pct"]
283
- reset = fmt_reset(bucket.get("resets_at"))
284
- print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
285
-
286
- # Additional per-model limits
287
- for item in data.get("additional", []):
288
- name = item["name"]
289
- for wlabel, wkey in [("5h", "primary"), ("7d", "secondary")]:
290
- w = item.get(wkey)
291
- if w and w.get("pct", 0) > 0:
292
- label = f"{name} ({wlabel})"
293
- print(f" {label:20s} {color_pct(w['pct'])}{D}{fmt_reset(w.get('resets_at'))}{RST}")
537
+ for window in usage_windows(data):
538
+ if window.get("pct") is None:
539
+ continue
540
+ reset = fmt_reset(window.get("resets_at"))
541
+ print(f" {window['label']:20s} {color_pct(window['pct'])}{D}{reset}{RST}")
294
542
 
295
543
  cr = data.get("code_review")
296
544
  if cr:
@@ -312,10 +560,9 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
312
560
  data = build_usage_json(api_data)
313
561
  write_usage_file(data)
314
562
  pcts = []
315
- for key in ("5h", "7d"):
316
- b = data.get(key)
317
- if b:
318
- pcts.append(f"{key}:{int(b['pct'])}%")
563
+ for window in usage_windows(data):
564
+ if window.get("pct") is not None:
565
+ pcts.append(f"{window['key']}:{int(window['pct'])}%")
319
566
  print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
320
567
  except Exception as e:
321
568
  print(f"[{datetime.now().strftime('%H:%M:%S')}] Error: {e}", file=sys.stderr)
@@ -375,19 +622,17 @@ def cmd_statusline():
375
622
  usage = _get_cached_usage()
376
623
 
377
624
  plan = usage.get("plan", "?")
378
- five_h = usage.get("5h", {})
379
- seven_d = usage.get("7d", {})
625
+ windows = usage_windows(usage)
380
626
 
381
627
  parts = []
382
628
 
383
- if five_h:
384
- parts.append(f"5h:{color_pct(int(five_h.get('pct', 0)))}")
385
- if seven_d:
386
- parts.append(f"7d:{color_pct(int(seven_d.get('pct', 0)))}")
629
+ for window in windows:
630
+ if window.get("pct") is not None:
631
+ parts.append(f"{window['key']}:{color_pct(int(window['pct']))}")
387
632
 
388
633
  parts.append(f"{D}{plan}{RST}")
389
634
 
390
- reset = fmt_reset(five_h.get("resets_at"))
635
+ reset = fmt_reset(windows[0].get("resets_at")) if windows else ""
391
636
  if reset:
392
637
  parts.append(f"{D}reset:{reset}{RST}")
393
638
 
@@ -423,17 +668,22 @@ def main():
423
668
  args = parser.parse_args()
424
669
 
425
670
  cmd = args.command or "status"
426
- if cmd == "status":
427
- cmd_status()
428
- elif cmd == "json":
429
- cmd_status(raw_json=True)
430
- elif cmd == "daemon":
431
- cmd_daemon(interval=args.interval)
432
- elif cmd == "statusline":
433
- cmd_statusline()
434
- elif cmd == "install":
435
- cmd_install()
671
+ try:
672
+ if cmd == "status":
673
+ cmd_status()
674
+ elif cmd == "json":
675
+ cmd_status(raw_json=True)
676
+ elif cmd == "daemon":
677
+ cmd_daemon(interval=args.interval)
678
+ elif cmd == "statusline":
679
+ cmd_statusline()
680
+ elif cmd == "install":
681
+ cmd_install()
682
+ except (AuthenticationError, UsageServiceError) as error:
683
+ print(f"Error: {error}", file=sys.stderr)
684
+ return 1
685
+ return 0
436
686
 
437
687
 
438
688
  if __name__ == "__main__":
439
- main()
689
+ raise SystemExit(main())
@@ -1,2 +1,3 @@
1
1
  from codex_cli_usage import main
2
- main()
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-cli-usage
3
+ Version: 0.1.8
4
+ Summary: Codex CLI usage monitor — fetches rate limits through the Codex app-server
5
+ Project-URL: Homepage, https://github.com/wakamex/codex-cli-usage
6
+ Project-URL: Source, https://github.com/wakamex/codex-cli-usage
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+
10
+ # codex-cli-usage
11
+
12
+ Codex CLI usage monitor. Fetches your rate limit data through Codex and displays it in the terminal.
13
+
14
+ ## Example output
15
+
16
+ `codex-cli-usage` command:
17
+
18
+ ```
19
+ Plan: plus
20
+ Weekly 39% resets 143h26m
21
+ ```
22
+
23
+ Codex statusline (self-caching — refreshes from API when stale, no daemon needed):
24
+
25
+ ```
26
+ weekly:39% plus reset:143h26m
27
+ ```
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ uv tool install codex-cli-usage
33
+ ```
34
+
35
+ Then run:
36
+
37
+ ```bash
38
+ # Check usage once
39
+ codex-cli-usage
40
+
41
+ # Run the daemon (keeps usage-limits.json updated)
42
+ codex-cli-usage daemon
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ | Command | Description |
48
+ |---------|-------------|
49
+ | `codex-cli-usage` | Show current usage (colored terminal output) |
50
+ | `codex-cli-usage json` | Print raw JSON |
51
+ | `codex-cli-usage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
52
+ | `codex-cli-usage statusline` | Compact statusline (self-caching, no daemon needed) |
53
+ | `codex-cli-usage install` | Print setup instructions |
54
+
55
+ ## How Codex CLI rate limiting works
56
+
57
+ Discovered by inspecting the Codex CLI and its authentication flow.
58
+
59
+ ### Data sources
60
+
61
+ codex-cli-usage gets rate limit data from:
62
+
63
+ 1. **`codex app-server --stdio`** — Preferred. Codex owns authentication and returns rate limits through `account/rateLimits/read`.
64
+ 2. **`/backend-api/codex/usage` endpoint** — Compatibility fallback when Codex is missing or its app-server does not support the rate-limit method.
65
+
66
+ ### Rate limit types
67
+
68
+ | Type | Description |
69
+ |------|-------------|
70
+ | `primary_window` | Primary window; duration comes from `limit_window_seconds` |
71
+ | `secondary_window` | Secondary window; duration comes from `limit_window_seconds` |
72
+ | `additional_rate_limits` | Per-model limits (e.g. specific model caps) |
73
+ | `code_review_rate_limit` | Code review usage limit |
74
+
75
+ Window names are derived from `limit_window_seconds`, not from whether the
76
+ backend calls a window primary or secondary. Approximately 5-hour, daily,
77
+ weekly, monthly, and annual windows receive duration labels. Other or missing
78
+ durations are shown as `Primary` or `Secondary`. Additional per-model limits
79
+ remain available in JSON for compatibility but are not shown in normal output.
80
+
81
+ ### Cache format
82
+
83
+ Schema version 2 stores aggregate windows by their backend role while retaining
84
+ their actual duration:
85
+
86
+ ```json
87
+ {
88
+ "schema_version": 2,
89
+ "primary": {
90
+ "pct": 39,
91
+ "resets_at": "2026-07-18T20:00:00+00:00",
92
+ "window_secs": 604800
93
+ }
94
+ }
95
+ ```
96
+
97
+ Legacy `5h` and `7d` aliases are written only when the corresponding aggregate
98
+ window actually classifies as 5-hour or weekly. Readers should prefer
99
+ `primary` and `secondary`.
100
+
101
+ ### Authentication
102
+
103
+ The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
104
+
105
+ Codex owns automatic token refresh and credential persistence. The compatibility
106
+ fallback reads `auth.json` but never modifies credentials.
107
+
108
+ ### Local files
109
+
110
+ | File | Written by | Contains |
111
+ |------|-----------|----------|
112
+ | `~/.codex/auth.json` | Codex CLI | OAuth tokens (access, refresh, id_token) |
113
+ | `~/.codex/usage-limits.json` | codex-cli-usage daemon | Cached API usage data (this tool) |
@@ -0,0 +1,6 @@
1
+ codex_cli_usage/__init__.py,sha256=FPHWaTVqb2-rVXovkA_iE-iqABvZIpApKDFk3sNxGog,22939
2
+ codex_cli_usage/__main__.py,sha256=V_ll4j4TO5TIww8Uh9mQZwAGMMvb66-9JSXuTkYbr3A,59
3
+ codex_cli_usage-0.1.8.dist-info/METADATA,sha256=fq7dHo5MWi4nftD7o5LALilfW9PoVWdJtf_AXNyAnxA,3455
4
+ codex_cli_usage-0.1.8.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ codex_cli_usage-0.1.8.dist-info/entry_points.txt,sha256=P4fiVVq76DXzfc_gXLICI7J4-xwdBqeZoQjlUxq2d4g,57
6
+ codex_cli_usage-0.1.8.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,86 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: codex-cli-usage
3
- Version: 0.1.6
4
- Summary: Codex CLI usage monitor — fetches rate limits from OpenAI's ChatGPT backend API
5
- Project-URL: Homepage, https://github.com/wakamex/codex-cli-usage
6
- Project-URL: Source, https://github.com/wakamex/codex-cli-usage
7
- Requires-Python: >=3.12
8
- Description-Content-Type: text/markdown
9
-
10
- # codex-cli-usage
11
-
12
- Codex CLI usage monitor. Fetches your rate limit data from OpenAI's ChatGPT backend API and displays it in the terminal.
13
-
14
- ## Example output
15
-
16
- `codex-cli-usage` command:
17
-
18
- ```
19
- Plan: plus
20
- Session (5h) 39% resets 1h26m
21
- Week (7d) 15% resets 143h26m
22
- ```
23
-
24
- Codex statusline (self-caching — refreshes from API when stale, no daemon needed):
25
-
26
- ```
27
- 5h:39% 7d:15% plus reset:1h26m
28
- ```
29
-
30
- ## Install
31
-
32
- ```bash
33
- uv tool install codex-cli-usage
34
- ```
35
-
36
- Then run:
37
-
38
- ```bash
39
- # Check usage once
40
- codex-cli-usage
41
-
42
- # Run the daemon (keeps usage-limits.json updated)
43
- codex-cli-usage daemon
44
- ```
45
-
46
- ## Commands
47
-
48
- | Command | Description |
49
- |---------|-------------|
50
- | `codex-cli-usage` | Show current usage (colored terminal output) |
51
- | `codex-cli-usage json` | Print raw JSON |
52
- | `codex-cli-usage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
53
- | `codex-cli-usage statusline` | Compact statusline (self-caching, no daemon needed) |
54
- | `codex-cli-usage install` | Print setup instructions |
55
-
56
- ## How Codex CLI rate limiting works
57
-
58
- Discovered by inspecting the Codex CLI and its authentication flow.
59
-
60
- ### Data sources
61
-
62
- Codex CLI gets rate limit data from:
63
-
64
- 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).
65
-
66
- ### Rate limit types
67
-
68
- | Type | Description |
69
- |------|-------------|
70
- | `primary_window` | Rolling 5-hour session window |
71
- | `secondary_window` | Rolling 7-day all-models window |
72
- | `additional_rate_limits` | Per-model limits (e.g. specific model caps) |
73
- | `code_review_rate_limit` | Code review usage limit |
74
-
75
- ### Authentication
76
-
77
- The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
78
-
79
- The access token expires roughly hourly. codex-cli-usage refreshes it automatically using the stored refresh token.
80
-
81
- ### Local files
82
-
83
- | File | Written by | Contains |
84
- |------|-----------|----------|
85
- | `~/.codex/auth.json` | Codex CLI | OAuth tokens (access, refresh, id_token) |
86
- | `~/.codex/usage-limits.json` | codex-cli-usage daemon | Cached API usage data (this tool) |
@@ -1,6 +0,0 @@
1
- codex_cli_usage/__init__.py,sha256=GuYkA8U3SiLiEeI_qgB3o-5TikuNVrcd4djLNy4uc6k,14364
2
- codex_cli_usage/__main__.py,sha256=X0mUh4runjs7ZbPfABekiq5SDaQXZM4aSIi0uDco12s,40
3
- codex_cli_usage-0.1.6.dist-info/METADATA,sha256=MRj8CzpUI5C6qRfskQbZCZvRlC5ldbb8P6WDJhpnuj4,2510
4
- codex_cli_usage-0.1.6.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
- codex_cli_usage-0.1.6.dist-info/entry_points.txt,sha256=P4fiVVq76DXzfc_gXLICI7J4-xwdBqeZoQjlUxq2d4g,57
6
- codex_cli_usage-0.1.6.dist-info/RECORD,,