codex-quota 1.0.0__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_quota/__init__.py +5 -0
- codex_quota/__main__.py +3 -0
- codex_quota/app_server.py +152 -0
- codex_quota/cli.py +308 -0
- codex_quota/codex.py +103 -0
- codex_quota/config.py +102 -0
- codex_quota/errors.py +37 -0
- codex_quota/filesystem.py +45 -0
- codex_quota/models.py +89 -0
- codex_quota/paths.py +30 -0
- codex_quota/profiles.py +109 -0
- codex_quota/render.py +117 -0
- codex_quota/service.py +51 -0
- codex_quota/tui.py +204 -0
- codex_quota-1.0.0.dist-info/METADATA +389 -0
- codex_quota-1.0.0.dist-info/RECORD +19 -0
- codex_quota-1.0.0.dist-info/WHEEL +4 -0
- codex_quota-1.0.0.dist-info/entry_points.txt +2 -0
- codex_quota-1.0.0.dist-info/licenses/LICENSE +21 -0
codex_quota/__init__.py
ADDED
codex_quota/__main__.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .codex import codex_env, find_codex
|
|
10
|
+
from .errors import AppServerError
|
|
11
|
+
from .filesystem import ensure_private_dir
|
|
12
|
+
from .models import Profile, RateLimits, WindowLimit
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _window(data: object) -> WindowLimit | None:
|
|
16
|
+
if not isinstance(data, dict):
|
|
17
|
+
return None
|
|
18
|
+
return WindowLimit(
|
|
19
|
+
used_percent=int(data.get("usedPercent", 0)),
|
|
20
|
+
window_duration_mins=int(data.get("windowDurationMins", 0)),
|
|
21
|
+
resets_at=data.get("resetsAt"),
|
|
22
|
+
raw=data,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def decode_rate_limits(payload: dict[str, Any]) -> RateLimits:
|
|
27
|
+
rate_limits = payload.get("rateLimits") or payload.get("result", {}).get("rateLimits")
|
|
28
|
+
if not isinstance(rate_limits, dict):
|
|
29
|
+
raise AppServerError(f"Unexpected rate limit response: {payload}")
|
|
30
|
+
|
|
31
|
+
credits_data = rate_limits.get("credits")
|
|
32
|
+
return RateLimits(
|
|
33
|
+
primary=_window(rate_limits.get("primary")),
|
|
34
|
+
secondary=_window(rate_limits.get("secondary")),
|
|
35
|
+
plan_type=rate_limits.get("planType"),
|
|
36
|
+
rate_limit_reached_type=rate_limits.get("rateLimitReachedType"),
|
|
37
|
+
credits=credits_data if isinstance(credits_data, dict) else {},
|
|
38
|
+
raw=rate_limits,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AppServerClient:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
profile: Profile,
|
|
46
|
+
*,
|
|
47
|
+
codex_bin: str = "codex",
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.profile = profile
|
|
51
|
+
self.codex_bin = codex_bin
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
|
|
54
|
+
def read_rate_limits(self) -> RateLimits:
|
|
55
|
+
binary = find_codex(self.codex_bin)
|
|
56
|
+
ensure_private_dir(self.profile.codex_home, parents=True, exist_ok=True)
|
|
57
|
+
with subprocess.Popen(
|
|
58
|
+
[binary, "app-server", "--stdio"],
|
|
59
|
+
stdin=subprocess.PIPE,
|
|
60
|
+
stdout=subprocess.PIPE,
|
|
61
|
+
stderr=subprocess.PIPE,
|
|
62
|
+
text=True,
|
|
63
|
+
env=codex_env(self.profile),
|
|
64
|
+
) as proc:
|
|
65
|
+
stdin = proc.stdin
|
|
66
|
+
stdout = proc.stdout
|
|
67
|
+
stderr = proc.stderr
|
|
68
|
+
if stdin is None or stdout is None or stderr is None:
|
|
69
|
+
raise AppServerError("codex app-server stdio pipes were not available")
|
|
70
|
+
|
|
71
|
+
def send(obj: dict[str, Any]) -> None:
|
|
72
|
+
stdin.write(json.dumps(obj) + "\n")
|
|
73
|
+
stdin.flush()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
request_id = 1
|
|
77
|
+
send(
|
|
78
|
+
{
|
|
79
|
+
"jsonrpc": "2.0",
|
|
80
|
+
"id": request_id,
|
|
81
|
+
"method": "initialize",
|
|
82
|
+
"params": {
|
|
83
|
+
"clientInfo": {
|
|
84
|
+
"name": "codex-quota",
|
|
85
|
+
"version": __version__,
|
|
86
|
+
},
|
|
87
|
+
"capabilities": {"experimentalApi": True},
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
deadline = time.monotonic() + self.timeout
|
|
93
|
+
initialized = False
|
|
94
|
+
|
|
95
|
+
while time.monotonic() < deadline:
|
|
96
|
+
line = stdout.readline()
|
|
97
|
+
|
|
98
|
+
if not line:
|
|
99
|
+
if proc.poll() is not None:
|
|
100
|
+
stderr_output = stderr.read()
|
|
101
|
+
raise AppServerError(
|
|
102
|
+
stderr_output.strip() or "codex app-server exited early"
|
|
103
|
+
)
|
|
104
|
+
time.sleep(0.02)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
msg = json.loads(line)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if not isinstance(msg, dict):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if msg.get("error"):
|
|
116
|
+
raise AppServerError(json.dumps(msg["error"]))
|
|
117
|
+
|
|
118
|
+
if msg.get("id") == 1 and not initialized:
|
|
119
|
+
initialized = True
|
|
120
|
+
send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
|
121
|
+
request_id = 2
|
|
122
|
+
send(
|
|
123
|
+
{
|
|
124
|
+
"jsonrpc": "2.0",
|
|
125
|
+
"id": request_id,
|
|
126
|
+
"method": "account/rateLimits/read",
|
|
127
|
+
"params": {},
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if msg.get("id") == request_id:
|
|
133
|
+
result = msg.get("result")
|
|
134
|
+
if not isinstance(result, dict):
|
|
135
|
+
raise AppServerError(f"Malformed JSON-RPC response: {msg}")
|
|
136
|
+
return decode_rate_limits(result)
|
|
137
|
+
|
|
138
|
+
raise AppServerError("Timed out waiting for Codex app-server rate limits")
|
|
139
|
+
finally:
|
|
140
|
+
proc.terminate()
|
|
141
|
+
try:
|
|
142
|
+
proc.wait(timeout=2)
|
|
143
|
+
except subprocess.TimeoutExpired:
|
|
144
|
+
proc.kill()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_rate_limits(
|
|
148
|
+
profile: Profile,
|
|
149
|
+
codex_bin: str = "codex",
|
|
150
|
+
timeout: float = 30.0,
|
|
151
|
+
) -> RateLimits:
|
|
152
|
+
return AppServerClient(profile, codex_bin=codex_bin, timeout=timeout).read_rate_limits()
|
codex_quota/cli.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from .codex import codex_version, simple_exec
|
|
11
|
+
from .codex import login as codex_login
|
|
12
|
+
from .config import ensure_config, load_config
|
|
13
|
+
from .errors import CodexUsageError, ProfileNotFoundError
|
|
14
|
+
from .models import AppConfig, ProfileStatus
|
|
15
|
+
from .paths import default_paths
|
|
16
|
+
from .profiles import add_profile, discover_profiles, ensure_profiles_dir, get_profile, profile_path
|
|
17
|
+
from .profiles import remove_profile as remove_profile_dir
|
|
18
|
+
from .render import render_profile_table, render_status_table
|
|
19
|
+
from .service import check_failed, collect_statuses
|
|
20
|
+
from .tui import run_fullscreen_tui
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(no_args_is_help=True, help="Manage Codex CLI profiles and quota usage.")
|
|
23
|
+
profile_app = typer.Typer(no_args_is_help=True, help="Manage isolated Codex profiles.")
|
|
24
|
+
app.add_typer(profile_app, name="profile")
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _config() -> AppConfig:
|
|
29
|
+
return load_config()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _print_error(exc: Exception) -> None:
|
|
33
|
+
console.print(f"[red]{exc}[/red]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _window_payload(window: Any) -> dict[str, Any] | None:
|
|
37
|
+
if window is None:
|
|
38
|
+
return None
|
|
39
|
+
return {
|
|
40
|
+
"used_percent": window.used_percent,
|
|
41
|
+
"remaining_percent": window.remaining_percent,
|
|
42
|
+
"window_duration_mins": window.window_duration_mins,
|
|
43
|
+
"resets_at": window.resets_at,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rate_limits_payload(
|
|
48
|
+
status: ProfileStatus,
|
|
49
|
+
*,
|
|
50
|
+
include_raw: bool = False,
|
|
51
|
+
) -> dict[str, Any] | None:
|
|
52
|
+
limits = status.rate_limits
|
|
53
|
+
if limits is None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
payload: dict[str, Any] = {
|
|
57
|
+
"blocked": limits.is_blocked,
|
|
58
|
+
"rate_limit_reached_type": limits.rate_limit_reached_type,
|
|
59
|
+
"primary": _window_payload(limits.primary),
|
|
60
|
+
"secondary": _window_payload(limits.secondary),
|
|
61
|
+
}
|
|
62
|
+
if include_raw:
|
|
63
|
+
payload["plan_type"] = limits.plan_type
|
|
64
|
+
payload["credits"] = limits.credits
|
|
65
|
+
payload["raw"] = limits.raw
|
|
66
|
+
return payload
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _status_payload(
|
|
70
|
+
statuses: list[ProfileStatus],
|
|
71
|
+
*,
|
|
72
|
+
include_path: bool = False,
|
|
73
|
+
include_raw: bool = False,
|
|
74
|
+
) -> list[dict[str, Any]]:
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
"profile": item.profile.name,
|
|
78
|
+
"auth_ok": item.auth_ok,
|
|
79
|
+
"codex_ok": item.codex_ok,
|
|
80
|
+
"ok": item.ok,
|
|
81
|
+
"error": item.error,
|
|
82
|
+
**({"path": str(item.profile.codex_home)} if include_path else {}),
|
|
83
|
+
"rate_limits": _rate_limits_payload(item, include_raw=include_raw),
|
|
84
|
+
}
|
|
85
|
+
for item in statuses
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.callback()
|
|
90
|
+
def main() -> None:
|
|
91
|
+
"""Manage isolated Codex profiles and inspect quota usage."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def init(
|
|
96
|
+
overwrite_config: Annotated[bool, typer.Option("--overwrite-config")] = False,
|
|
97
|
+
verify: Annotated[bool, typer.Option("--verify", "--check")] = False,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Bootstrap directories and validate the local Codex environment."""
|
|
100
|
+
try:
|
|
101
|
+
config = ensure_config(overwrite=overwrite_config)
|
|
102
|
+
ensure_profiles_dir(config)
|
|
103
|
+
profiles = discover_profiles(config)
|
|
104
|
+
|
|
105
|
+
paths = default_paths()
|
|
106
|
+
console.print(f"Config file: {paths.config_file}")
|
|
107
|
+
console.print(f"Profiles dir: {config.profiles_dir}")
|
|
108
|
+
console.print(render_profile_table(profiles))
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
console.print(f"Codex: {codex_version(config.codex_bin)}")
|
|
112
|
+
except CodexUsageError as exc:
|
|
113
|
+
console.print(f"[yellow]{exc}[/yellow]")
|
|
114
|
+
if verify:
|
|
115
|
+
raise typer.Exit(2) from exc
|
|
116
|
+
|
|
117
|
+
if verify:
|
|
118
|
+
statuses = collect_statuses(config)
|
|
119
|
+
console.print(render_status_table(statuses))
|
|
120
|
+
raise typer.Exit(2 if check_failed(statuses) else 0)
|
|
121
|
+
except CodexUsageError as exc:
|
|
122
|
+
_print_error(exc)
|
|
123
|
+
raise typer.Exit(2) from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@profile_app.command("list")
|
|
127
|
+
def profile_list() -> None:
|
|
128
|
+
"""List profiles discovered under profiles_dir."""
|
|
129
|
+
try:
|
|
130
|
+
console.print(render_profile_table(discover_profiles(_config())))
|
|
131
|
+
except CodexUsageError as exc:
|
|
132
|
+
_print_error(exc)
|
|
133
|
+
raise typer.Exit(2) from exc
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@profile_app.command("add")
|
|
137
|
+
def profile_add(
|
|
138
|
+
name: Annotated[str, typer.Argument(help="Profile name")],
|
|
139
|
+
exist_ok: Annotated[bool, typer.Option("--exist-ok", "--force")] = False,
|
|
140
|
+
run_login: Annotated[bool, typer.Option("--login")] = False,
|
|
141
|
+
device_auth: Annotated[
|
|
142
|
+
bool,
|
|
143
|
+
typer.Option("--device-auth", help="Pass --device-auth to codex login."),
|
|
144
|
+
] = False,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Create a new isolated profile directory."""
|
|
147
|
+
try:
|
|
148
|
+
if device_auth and not run_login:
|
|
149
|
+
raise CodexUsageError("--device-auth requires --login")
|
|
150
|
+
config = _config()
|
|
151
|
+
profile = add_profile(config, name, exist_ok=exist_ok)
|
|
152
|
+
console.print(f"Created profile: {profile.name}")
|
|
153
|
+
console.print(str(profile.codex_home))
|
|
154
|
+
|
|
155
|
+
if run_login:
|
|
156
|
+
raise typer.Exit(codex_login(profile, config.codex_bin, device_auth=device_auth))
|
|
157
|
+
except CodexUsageError as exc:
|
|
158
|
+
_print_error(exc)
|
|
159
|
+
raise typer.Exit(2) from exc
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@profile_app.command("remove")
|
|
163
|
+
def profile_remove(
|
|
164
|
+
name: Annotated[str, typer.Argument(help="Profile name")],
|
|
165
|
+
yes: Annotated[bool, typer.Option("--yes", "-y")] = False,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Remove a profile directory after safety checks."""
|
|
168
|
+
try:
|
|
169
|
+
config = _config()
|
|
170
|
+
profile = get_profile(config, name)
|
|
171
|
+
|
|
172
|
+
if profile.has_auth:
|
|
173
|
+
console.print("[yellow]Warning: auth.json exists in this profile.[/yellow]")
|
|
174
|
+
|
|
175
|
+
if not yes:
|
|
176
|
+
confirmed = typer.confirm(f"Delete profile '{name}' at {profile.codex_home}?")
|
|
177
|
+
if not confirmed:
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
removed = remove_profile_dir(config, name)
|
|
181
|
+
console.print(f"Removed profile: {removed.name}")
|
|
182
|
+
except CodexUsageError as exc:
|
|
183
|
+
_print_error(exc)
|
|
184
|
+
raise typer.Exit(2) from exc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@profile_app.command("path")
|
|
188
|
+
def profile_path_cmd(name: Annotated[str, typer.Argument(help="Profile name")]) -> None:
|
|
189
|
+
"""Print the CODEX_HOME path for scripting."""
|
|
190
|
+
try:
|
|
191
|
+
typer.echo(str(profile_path(_config(), name)))
|
|
192
|
+
except CodexUsageError as exc:
|
|
193
|
+
_print_error(exc)
|
|
194
|
+
raise typer.Exit(2) from exc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def login(
|
|
199
|
+
profile_name: Annotated[str, typer.Argument(help="Profile name")],
|
|
200
|
+
create: Annotated[bool, typer.Option("--create")] = False,
|
|
201
|
+
device_auth: Annotated[
|
|
202
|
+
bool,
|
|
203
|
+
typer.Option("--device-auth", help="Pass --device-auth to codex login."),
|
|
204
|
+
] = False,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Run the official Codex login flow for an existing profile."""
|
|
207
|
+
try:
|
|
208
|
+
config = _config()
|
|
209
|
+
profile = (
|
|
210
|
+
add_profile(config, profile_name, exist_ok=True)
|
|
211
|
+
if create
|
|
212
|
+
else get_profile(config, profile_name)
|
|
213
|
+
)
|
|
214
|
+
raise typer.Exit(codex_login(profile, config.codex_bin, device_auth=device_auth))
|
|
215
|
+
except CodexUsageError as exc:
|
|
216
|
+
_print_error(exc)
|
|
217
|
+
raise typer.Exit(2) from exc
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command()
|
|
221
|
+
def status(
|
|
222
|
+
profile_name: Annotated[str | None, typer.Argument(help="Optional profile name")] = None,
|
|
223
|
+
json_out: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")] = False,
|
|
224
|
+
json_paths: Annotated[
|
|
225
|
+
bool,
|
|
226
|
+
typer.Option("--json-paths", help="Include absolute profile paths in JSON output."),
|
|
227
|
+
] = False,
|
|
228
|
+
json_raw: Annotated[
|
|
229
|
+
bool,
|
|
230
|
+
typer.Option("--json-raw", help="Include raw rate-limit payload in JSON output."),
|
|
231
|
+
] = False,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Read quota status for one or all discovered profiles."""
|
|
234
|
+
try:
|
|
235
|
+
statuses = collect_statuses(_config(), profile_name)
|
|
236
|
+
if json_out:
|
|
237
|
+
console.print_json(
|
|
238
|
+
json.dumps(_status_payload(statuses, include_path=json_paths, include_raw=json_raw))
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
console.print(render_status_table(statuses))
|
|
242
|
+
except ProfileNotFoundError as exc:
|
|
243
|
+
_print_error(exc)
|
|
244
|
+
raise typer.Exit(2) from exc
|
|
245
|
+
except CodexUsageError as exc:
|
|
246
|
+
_print_error(exc)
|
|
247
|
+
raise typer.Exit(2) from exc
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command()
|
|
251
|
+
def check(
|
|
252
|
+
profile_name: Annotated[str | None, typer.Argument(help="Optional profile name")] = None,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Exit non-zero if any selected profile has auth, quota, or Codex problems."""
|
|
255
|
+
try:
|
|
256
|
+
statuses = collect_statuses(_config(), profile_name)
|
|
257
|
+
console.print(render_status_table(statuses))
|
|
258
|
+
raise typer.Exit(2 if check_failed(statuses) else 0)
|
|
259
|
+
except CodexUsageError as exc:
|
|
260
|
+
_print_error(exc)
|
|
261
|
+
raise typer.Exit(2) from exc
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.command("exec")
|
|
265
|
+
def exec_prompt(
|
|
266
|
+
profile_name: Annotated[str, typer.Argument(help="Profile name")],
|
|
267
|
+
prompt: Annotated[str, typer.Argument(help="Prompt to send")],
|
|
268
|
+
raw: Annotated[bool, typer.Option("--raw", help="Do not force Codex JSON output.")] = False,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Run official codex exec under a selected CODEX_HOME."""
|
|
271
|
+
try:
|
|
272
|
+
config = _config()
|
|
273
|
+
proc = simple_exec(get_profile(config, profile_name), prompt, config, json_output=not raw)
|
|
274
|
+
|
|
275
|
+
if proc.stdout:
|
|
276
|
+
console.print(proc.stdout.rstrip())
|
|
277
|
+
if proc.stderr:
|
|
278
|
+
console.print(proc.stderr.rstrip(), style="yellow")
|
|
279
|
+
|
|
280
|
+
raise typer.Exit(proc.returncode)
|
|
281
|
+
except CodexUsageError as exc:
|
|
282
|
+
_print_error(exc)
|
|
283
|
+
raise typer.Exit(2) from exc
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.command()
|
|
287
|
+
def tui(refresh: Annotated[float | None, typer.Option("--refresh", "-r")] = None) -> None:
|
|
288
|
+
"""Open a Textual live quota view."""
|
|
289
|
+
try:
|
|
290
|
+
config = _config()
|
|
291
|
+
refresh_seconds = refresh or config.refresh_seconds
|
|
292
|
+
|
|
293
|
+
if refresh_seconds <= 0:
|
|
294
|
+
raise typer.BadParameter("--refresh must be greater than zero")
|
|
295
|
+
|
|
296
|
+
if not sys.stdin.isatty() or not console.is_terminal:
|
|
297
|
+
statuses = collect_statuses(config)
|
|
298
|
+
console.print(render_status_table(statuses))
|
|
299
|
+
raise typer.Exit(2 if check_failed(statuses) else 0)
|
|
300
|
+
|
|
301
|
+
run_fullscreen_tui(config, refresh_seconds)
|
|
302
|
+
except CodexUsageError as exc:
|
|
303
|
+
_print_error(exc)
|
|
304
|
+
raise typer.Exit(2) from exc
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
app()
|
codex_quota/codex.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from .errors import CodexCommandError, CodexNotFoundError
|
|
8
|
+
from .filesystem import ensure_private_dir
|
|
9
|
+
from .models import AppConfig, Profile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_codex(codex_bin: str = "codex") -> str:
|
|
13
|
+
found = shutil.which(codex_bin)
|
|
14
|
+
if not found:
|
|
15
|
+
raise CodexNotFoundError(
|
|
16
|
+
"Codex CLI was not found. Install the official Codex CLI and ensure "
|
|
17
|
+
"it is available on PATH, or set codex_bin in config.json."
|
|
18
|
+
)
|
|
19
|
+
return found
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def codex_env(profile: Profile) -> dict[str, str]:
|
|
23
|
+
return {**os.environ, "CODEX_HOME": str(profile.codex_home)}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_codex(
|
|
27
|
+
args: list[str],
|
|
28
|
+
*,
|
|
29
|
+
profile: Profile | None = None,
|
|
30
|
+
codex_bin: str = "codex",
|
|
31
|
+
timeout: float = 60.0,
|
|
32
|
+
check: bool = False,
|
|
33
|
+
) -> subprocess.CompletedProcess[str]:
|
|
34
|
+
binary = find_codex(codex_bin)
|
|
35
|
+
env = codex_env(profile) if profile else os.environ.copy()
|
|
36
|
+
try:
|
|
37
|
+
proc = subprocess.run(
|
|
38
|
+
[binary, *args],
|
|
39
|
+
text=True,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
env=env,
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
except subprocess.TimeoutExpired as exc:
|
|
46
|
+
raise CodexCommandError(f"codex {' '.join(args)} timed out") from exc
|
|
47
|
+
if check and proc.returncode != 0:
|
|
48
|
+
raise CodexCommandError(
|
|
49
|
+
proc.stderr.strip() or proc.stdout.strip() or "codex command failed"
|
|
50
|
+
)
|
|
51
|
+
return proc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def codex_version(codex_bin: str = "codex") -> str:
|
|
55
|
+
proc = run_codex(["--version"], codex_bin=codex_bin, timeout=10)
|
|
56
|
+
if proc.returncode != 0:
|
|
57
|
+
raise CodexCommandError(proc.stderr.strip() or "Failed to read Codex CLI version")
|
|
58
|
+
return proc.stdout.strip() or proc.stderr.strip() or "unknown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def login_status(profile: Profile, codex_bin: str = "codex") -> bool:
|
|
62
|
+
if not profile.auth_file.is_file():
|
|
63
|
+
return False
|
|
64
|
+
proc = run_codex(["login", "status"], profile=profile, codex_bin=codex_bin, timeout=20)
|
|
65
|
+
output = f"{proc.stdout}\n{proc.stderr}".lower()
|
|
66
|
+
return proc.returncode == 0 and ("logged in" in output or "authenticated" in output)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def login(
|
|
70
|
+
profile: Profile,
|
|
71
|
+
codex_bin: str = "codex",
|
|
72
|
+
*,
|
|
73
|
+
device_auth: bool = False,
|
|
74
|
+
) -> int:
|
|
75
|
+
binary = find_codex(codex_bin)
|
|
76
|
+
ensure_private_dir(profile.codex_home, parents=True, exist_ok=True)
|
|
77
|
+
args = [binary, "login"]
|
|
78
|
+
if device_auth:
|
|
79
|
+
args.append("--device-auth")
|
|
80
|
+
return subprocess.call(args, env=codex_env(profile))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def simple_exec(
|
|
84
|
+
profile: Profile,
|
|
85
|
+
prompt: str,
|
|
86
|
+
config: AppConfig,
|
|
87
|
+
*,
|
|
88
|
+
json_output: bool = True,
|
|
89
|
+
timeout: float = 180.0,
|
|
90
|
+
) -> subprocess.CompletedProcess[str]:
|
|
91
|
+
args = [
|
|
92
|
+
"exec",
|
|
93
|
+
"--skip-git-repo-check",
|
|
94
|
+
"--ephemeral",
|
|
95
|
+
"-m",
|
|
96
|
+
config.default_model,
|
|
97
|
+
"-c",
|
|
98
|
+
f'model_reasoning_effort="{config.reasoning_effort}"',
|
|
99
|
+
]
|
|
100
|
+
if json_output:
|
|
101
|
+
args.append("--json")
|
|
102
|
+
args.append(prompt)
|
|
103
|
+
return run_codex(args, profile=profile, codex_bin=config.codex_bin, timeout=timeout)
|
codex_quota/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from json import JSONDecodeError
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import ConfigError
|
|
9
|
+
from .filesystem import ensure_private_dir
|
|
10
|
+
from .models import AppConfig
|
|
11
|
+
from .paths import default_paths
|
|
12
|
+
|
|
13
|
+
CONFIG_KEYS = {"profiles_dir", "codex_bin", "default_model", "reasoning_effort", "refresh_seconds"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_default_config() -> AppConfig:
|
|
17
|
+
paths = default_paths()
|
|
18
|
+
return AppConfig(profiles_dir=paths.profiles_dir)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _as_path(value: object, fallback: Path) -> Path:
|
|
22
|
+
if not isinstance(value, str) or not value.strip():
|
|
23
|
+
return fallback
|
|
24
|
+
return Path(value).expanduser()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
28
|
+
paths = default_paths()
|
|
29
|
+
config_file = path or paths.config_file
|
|
30
|
+
if not config_file.exists():
|
|
31
|
+
return create_default_config()
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(config_file.read_text(encoding="utf-8"))
|
|
35
|
+
except JSONDecodeError as exc:
|
|
36
|
+
raise ConfigError(f"Malformed config JSON: {config_file}: {exc}") from exc
|
|
37
|
+
except OSError as exc:
|
|
38
|
+
raise ConfigError(f"Could not read config: {config_file}: {exc}") from exc
|
|
39
|
+
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
raise ConfigError(f"Config must be a JSON object: {config_file}")
|
|
42
|
+
|
|
43
|
+
refresh = data.get("refresh_seconds", 30.0)
|
|
44
|
+
try:
|
|
45
|
+
refresh_seconds = float(refresh)
|
|
46
|
+
except (TypeError, ValueError) as exc:
|
|
47
|
+
raise ConfigError("config refresh_seconds must be a number") from exc
|
|
48
|
+
|
|
49
|
+
extra = {key: value for key, value in data.items() if key not in CONFIG_KEYS}
|
|
50
|
+
return AppConfig(
|
|
51
|
+
profiles_dir=_as_path(data.get("profiles_dir"), paths.profiles_dir),
|
|
52
|
+
codex_bin=str(data.get("codex_bin", "codex")),
|
|
53
|
+
default_model=str(data.get("default_model", "gpt-5.4-mini")),
|
|
54
|
+
reasoning_effort=str(data.get("reasoning_effort", "low")),
|
|
55
|
+
refresh_seconds=refresh_seconds,
|
|
56
|
+
extra=extra,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def config_to_json(config: AppConfig, *, preserve_extra: bool = True) -> dict[str, Any]:
|
|
61
|
+
data: dict[str, Any] = dict(config.extra) if preserve_extra else {}
|
|
62
|
+
data.update(
|
|
63
|
+
{
|
|
64
|
+
"profiles_dir": str(config.profiles_dir),
|
|
65
|
+
"codex_bin": config.codex_bin,
|
|
66
|
+
"default_model": config.default_model,
|
|
67
|
+
"reasoning_effort": config.reasoning_effort,
|
|
68
|
+
"refresh_seconds": config.refresh_seconds,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_config(
|
|
75
|
+
config: AppConfig,
|
|
76
|
+
path: Path | None = None,
|
|
77
|
+
*,
|
|
78
|
+
preserve_extra: bool = True,
|
|
79
|
+
) -> Path:
|
|
80
|
+
paths = default_paths()
|
|
81
|
+
config_file = path or paths.config_file
|
|
82
|
+
ensure_private_dir(config_file.parent, parents=True, exist_ok=True)
|
|
83
|
+
data = config_to_json(config, preserve_extra=preserve_extra)
|
|
84
|
+
try:
|
|
85
|
+
config_file.write_text(
|
|
86
|
+
json.dumps(data, indent=2, sort_keys=True) + "\n",
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
)
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise ConfigError(f"Could not write config: {config_file}: {exc}") from exc
|
|
91
|
+
return config_file
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ensure_config(path: Path | None = None, *, overwrite: bool = False) -> AppConfig:
|
|
95
|
+
paths = default_paths()
|
|
96
|
+
config_file = path or paths.config_file
|
|
97
|
+
ensure_private_dir(config_file.parent, parents=True, exist_ok=True)
|
|
98
|
+
if config_file.exists() and not overwrite:
|
|
99
|
+
return load_config(config_file)
|
|
100
|
+
config = create_default_config()
|
|
101
|
+
save_config(config, config_file, preserve_extra=False)
|
|
102
|
+
return config
|