Skip to content

Commit 7d1bb5f

Browse files
committed
Add limit helper and bump version to 0.6.1
1 parent a410a73 commit 7d1bb5f

5 files changed

Lines changed: 120 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "codexapi"
7-
version = "0.6.0"
7+
version = "0.6.1"
88
description = "Minimal Python API for running the Codex CLI."
99
readme = "README.md"
1010
requires-python = ">=3.8"

src/codexapi/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .agent import Agent, agent
44
from .foreach import ForeachResult, foreach
55
from .pushover import Pushover
6+
from .rate_limits import quota_line, rate_limits
67
from .ralph import Ralph
78
from .science import Science
89
from .task import Task, TaskFailed, TaskResult, task, task_result
@@ -11,6 +12,8 @@
1112
"Agent",
1213
"ForeachResult",
1314
"Pushover",
15+
"quota_line",
16+
"rate_limits",
1417
"Ralph",
1518
"Science",
1619
"Task",
@@ -21,4 +24,4 @@
2124
"task",
2225
"task_result",
2326
]
24-
__version__ = "0.6.0"
27+
__version__ = "0.6.1"

src/codexapi/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .science import Science
1919
from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task
2020
from .taskfile import TaskFile, load_task_file, task_def_uses_item
21+
from .rate_limits import quota_line
2122

2223
_SESSION_ID_RE = re.compile(
2324
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
@@ -1298,6 +1299,10 @@ def main(argv=None):
12981299
"top",
12991300
help="Show running Codex sessions.",
13001301
)
1302+
subparsers.add_parser(
1303+
"limit",
1304+
help="Show Codex rate limits.",
1305+
)
13011306

13021307
args = parser.parse_args(argv)
13031308
if args.command is None:
@@ -1318,6 +1323,9 @@ def main(argv=None):
13181323
if args.command == "top":
13191324
_run_top([])
13201325
return
1326+
if args.command == "limit":
1327+
print(quota_line())
1328+
return
13211329

13221330
if args.command == "foreach":
13231331
if args.n is not None and args.n < 1:

src/codexapi/pushover.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import urllib.parse
99
import urllib.request
1010

11+
from .rate_limits import quota_line
12+
1113
_PUSHOVER_PATH = "~/.pushover"
1214
_PUSHOVER_URL = "https://api.pushover.net/1/messages.json"
1315
_MAX_MESSAGE = 1024
@@ -58,6 +60,7 @@ def send(self, title, message):
5860
message_text = (message or "").strip()
5961
if not message_text:
6062
return False
63+
message_text = _append_quota_line(message_text)
6164
message_text = _truncate(message_text, _MAX_MESSAGE)
6265
payload = urllib.parse.urlencode(
6366
{
@@ -177,3 +180,10 @@ def _truncate(text, limit):
177180

178181
def _warn(message):
179182
print(message, file=sys.stderr)
183+
184+
185+
def _append_quota_line(message):
186+
line = quota_line()
187+
if not line:
188+
return message
189+
return f"{message}\n{line}"

src/codexapi/rate_limits.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Helpers for reading Codex rate limits from session logs."""
2+
3+
import json
4+
import os
5+
import time
6+
from pathlib import Path
7+
8+
_QUOTA_PREFIX = "Limits:"
9+
10+
11+
def rate_limits():
12+
"""Return the latest rate_limits dict from Codex session logs."""
13+
root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
14+
sessions = root / "sessions"
15+
if not sessions.exists():
16+
return None
17+
candidates = []
18+
for dirpath, _dirnames, filenames in os.walk(sessions):
19+
for name in filenames:
20+
if not name.endswith(".jsonl"):
21+
continue
22+
path = os.path.join(dirpath, name)
23+
try:
24+
mtime = os.path.getmtime(path)
25+
except OSError:
26+
continue
27+
candidates.append((mtime, path))
28+
if not candidates:
29+
return None
30+
for _mtime, path in sorted(candidates, reverse=True):
31+
found = _extract_rate_limits(path)
32+
if found is not None:
33+
return found
34+
return None
35+
36+
37+
def _extract_rate_limits(path):
38+
last = None
39+
try:
40+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
41+
for line in handle:
42+
if '"rate_limits"' not in line:
43+
continue
44+
try:
45+
event = json.loads(line)
46+
except json.JSONDecodeError:
47+
continue
48+
payload = event.get("payload") or {}
49+
rate_data = payload.get("rate_limits")
50+
if isinstance(rate_data, dict):
51+
last = rate_data
52+
except OSError:
53+
return None
54+
return last
55+
56+
57+
def quota_line():
58+
"""Return a human-readable quota line."""
59+
data = rate_limits()
60+
if not data:
61+
return f"{_QUOTA_PREFIX} unavailable"
62+
primary = data.get("primary") or {}
63+
secondary = data.get("secondary") or {}
64+
primary_left = _percent_left(primary.get("used_percent"))
65+
secondary_left = _percent_left(secondary.get("used_percent"))
66+
primary_reset = _format_reset(primary.get("resets_at"))
67+
secondary_reset = _format_reset(secondary.get("resets_at"))
68+
if primary_left is None or secondary_left is None:
69+
return f"{_QUOTA_PREFIX} unavailable"
70+
return (
71+
f"{_QUOTA_PREFIX} {primary_left}% / {secondary_left}% left "
72+
f"(reset in {primary_reset} / {secondary_reset})"
73+
)
74+
75+
76+
def _percent_left(used_percent):
77+
if not isinstance(used_percent, (int, float)):
78+
return None
79+
left = 100.0 - float(used_percent)
80+
if left < 0:
81+
left = 0.0
82+
if left > 100:
83+
left = 100.0
84+
return int(round(left))
85+
86+
87+
def _format_reset(resets_at):
88+
if not isinstance(resets_at, (int, float)):
89+
return "unknown"
90+
remaining = float(resets_at) - time.time()
91+
if remaining < 0:
92+
remaining = 0
93+
hours = remaining / 3600.0
94+
if hours > 24:
95+
days = hours / 24.0
96+
return f"{int(round(days))}d"
97+
return f"{int(round(hours))}h"

0 commit comments

Comments
 (0)