codex-cli-usage 0.1.7__py3-none-any.whl → 0.1.9__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.
- codex_cli_usage/__init__.py +362 -112
- codex_cli_usage/__main__.py +2 -1
- codex_cli_usage-0.1.9.dist-info/METADATA +113 -0
- codex_cli_usage-0.1.9.dist-info/RECORD +6 -0
- {codex_cli_usage-0.1.7.dist-info → codex_cli_usage-0.1.9.dist-info}/WHEEL +1 -1
- codex_cli_usage-0.1.7.dist-info/METADATA +0 -86
- codex_cli_usage-0.1.7.dist-info/RECORD +0 -6
- {codex_cli_usage-0.1.7.dist-info → codex_cli_usage-0.1.9.dist-info}/entry_points.txt +0 -0
codex_cli_usage/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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,27 +332,66 @@ def fetch_usage() -> dict:
|
|
|
140
332
|
|
|
141
333
|
account_id = tokens.get("account_id", "")
|
|
142
334
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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:
|
|
@@ -169,47 +400,43 @@ def build_usage_json(api_data: dict) -> dict:
|
|
|
169
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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)
|
|
423
|
+
# Preserve additional limits in JSON for compatibility, but normal output
|
|
424
|
+
# intentionally shows aggregate Codex limits only.
|
|
194
425
|
additional = api_data.get("additional_rate_limits") or []
|
|
195
426
|
if additional:
|
|
196
427
|
result["additional"] = []
|
|
197
428
|
for item in additional:
|
|
198
|
-
|
|
429
|
+
entry = {"name": item.get("limit_name", "")}
|
|
199
430
|
sub_rl = item.get("rate_limit") or {}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
}
|
|
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
|
|
@@ -229,6 +456,40 @@ def build_usage_json(api_data: dict) -> dict:
|
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
379
|
-
seven_d = usage.get("7d", {})
|
|
625
|
+
windows = usage_windows(usage)
|
|
380
626
|
|
|
381
627
|
parts = []
|
|
382
628
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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(
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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())
|
codex_cli_usage/__main__.py
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-cli-usage
|
|
3
|
+
Version: 0.1.9
|
|
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.9.dist-info/METADATA,sha256=eO2PlghUxjqpG2IuP1khkCCQKH6EiKPDDrSoj7p8eZI,3455
|
|
4
|
+
codex_cli_usage-0.1.9.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
+
codex_cli_usage-0.1.9.dist-info/entry_points.txt,sha256=P4fiVVq76DXzfc_gXLICI7J4-xwdBqeZoQjlUxq2d4g,57
|
|
6
|
+
codex_cli_usage-0.1.9.dist-info/RECORD,,
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: codex-cli-usage
|
|
3
|
-
Version: 0.1.7
|
|
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=-JhcMN0W_SfxUjyb0_A0AdTyDKAv8vnpAEuGpae9ndI,14374
|
|
2
|
-
codex_cli_usage/__main__.py,sha256=X0mUh4runjs7ZbPfABekiq5SDaQXZM4aSIi0uDco12s,40
|
|
3
|
-
codex_cli_usage-0.1.7.dist-info/METADATA,sha256=yTWT_e47ws_m7SgDvUoqBl2qFOt6KdNqzIt_OmfQlhQ,2510
|
|
4
|
-
codex_cli_usage-0.1.7.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
-
codex_cli_usage-0.1.7.dist-info/entry_points.txt,sha256=P4fiVVq76DXzfc_gXLICI7J4-xwdBqeZoQjlUxq2d4g,57
|
|
6
|
-
codex_cli_usage-0.1.7.dist-info/RECORD,,
|
|
File without changes
|