codex-switch-cli 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/PKG-INFO +2 -1
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/README.md +1 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/pyproject.toml +1 -1
- codex_switch_cli-0.1.3/src/codex_switch/__init__.py +1 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/cli.py +67 -1
- codex_switch_cli-0.1.3/src/codex_switch/rate_limits.py +280 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/PKG-INFO +2 -1
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/SOURCES.txt +2 -0
- codex_switch_cli-0.1.3/tests/test_rate_limits.py +124 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_smoke.py +1 -1
- codex_switch_cli-0.1.2/src/codex_switch/__init__.py +0 -1
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/setup.cfg +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/auth.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/config.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/doctor.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/install.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/instances.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/models.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/paths.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/probe.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/routing.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/runtime.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/wizard.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch/wrapper.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/dependency_links.txt +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/entry_points.txt +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/requires.txt +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/top_level.txt +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_auth.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_config.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_doctor.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_instances.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_integration_wrapper.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_probe.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_routing.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_runtime.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_wizard.py +0 -0
- {codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/tests/test_wrapper.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-switch-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Transparent account-aware wrapper for the Codex CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/ForeverHYX/codex-switch
|
|
6
6
|
Project-URL: Repository, https://github.com/ForeverHYX/codex-switch
|
|
@@ -67,6 +67,7 @@ Once setup is done, day-to-day usage stays the same:
|
|
|
67
67
|
```bash
|
|
68
68
|
codex "review this branch"
|
|
69
69
|
codex exec "make test"
|
|
70
|
+
codex-switch list
|
|
70
71
|
```
|
|
71
72
|
|
|
72
73
|
## How it works
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -21,6 +21,13 @@ from codex_switch.doctor import create_doctor_report
|
|
|
21
21
|
from codex_switch.install import install_shim, runtime_wrapper_dir, uninstall_shim
|
|
22
22
|
from codex_switch.models import AppConfig
|
|
23
23
|
from codex_switch.paths import config_path
|
|
24
|
+
from codex_switch.rate_limits import (
|
|
25
|
+
FIVE_HOUR_WINDOW_MINS,
|
|
26
|
+
SEVEN_DAY_WINDOW_MINS,
|
|
27
|
+
format_reset_timestamp,
|
|
28
|
+
read_instance_rate_limits,
|
|
29
|
+
select_window_for_duration,
|
|
30
|
+
)
|
|
24
31
|
from codex_switch.runtime import find_real_codex, resolve_real_codex
|
|
25
32
|
from codex_switch.wizard import bootstrap_from_prompt, clear_existing_state, initialize_app
|
|
26
33
|
|
|
@@ -73,6 +80,22 @@ def _resolve_instance(config: AppConfig, instance_name: str):
|
|
|
73
80
|
return instance
|
|
74
81
|
|
|
75
82
|
|
|
83
|
+
def _render_table(headers: list[str], rows: list[list[str]]) -> list[str]:
|
|
84
|
+
widths = [len(header) for header in headers]
|
|
85
|
+
for row in rows:
|
|
86
|
+
for index, cell in enumerate(row):
|
|
87
|
+
widths[index] = max(widths[index], len(cell))
|
|
88
|
+
|
|
89
|
+
rendered = [
|
|
90
|
+
" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
|
|
91
|
+
]
|
|
92
|
+
for row in rows:
|
|
93
|
+
rendered.append(
|
|
94
|
+
" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row))
|
|
95
|
+
)
|
|
96
|
+
return rendered
|
|
97
|
+
|
|
98
|
+
|
|
76
99
|
@app.command()
|
|
77
100
|
def init(
|
|
78
101
|
instance_count: int | None = typer.Option(None, min=1),
|
|
@@ -129,8 +152,51 @@ def init(
|
|
|
129
152
|
@app.command("list")
|
|
130
153
|
def list_instances() -> None:
|
|
131
154
|
config = _load_initialized_config()
|
|
155
|
+
real_codex_path = _resolve_real_codex_for_management(config)
|
|
156
|
+
|
|
157
|
+
rows: list[list[str]] = []
|
|
132
158
|
for instance in config.instances:
|
|
133
|
-
|
|
159
|
+
result = read_instance_rate_limits(real_codex_path, instance)
|
|
160
|
+
if not result.ok or result.snapshot is None:
|
|
161
|
+
rows.append(
|
|
162
|
+
[
|
|
163
|
+
instance.name,
|
|
164
|
+
"unavailable",
|
|
165
|
+
"-",
|
|
166
|
+
"unavailable",
|
|
167
|
+
"-",
|
|
168
|
+
result.reason or "Unavailable",
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
five_hour = select_window_for_duration(
|
|
174
|
+
result.snapshot,
|
|
175
|
+
FIVE_HOUR_WINDOW_MINS,
|
|
176
|
+
fallback="primary",
|
|
177
|
+
)
|
|
178
|
+
seven_day = select_window_for_duration(
|
|
179
|
+
result.snapshot,
|
|
180
|
+
SEVEN_DAY_WINDOW_MINS,
|
|
181
|
+
fallback="secondary",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
rows.append(
|
|
185
|
+
[
|
|
186
|
+
instance.name,
|
|
187
|
+
f"{five_hour.remaining_percent}%" if five_hour is not None else "-",
|
|
188
|
+
format_reset_timestamp(five_hour.resets_at) if five_hour is not None else "-",
|
|
189
|
+
f"{seven_day.remaining_percent}%" if seven_day is not None else "-",
|
|
190
|
+
format_reset_timestamp(seven_day.resets_at) if seven_day is not None else "-",
|
|
191
|
+
result.snapshot.plan_type or "ok",
|
|
192
|
+
]
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
for line in _render_table(
|
|
196
|
+
["INSTANCE", "5H REMAINING", "5H RESET", "7D REMAINING", "7D RESET", "STATUS"],
|
|
197
|
+
rows,
|
|
198
|
+
):
|
|
199
|
+
typer.echo(line)
|
|
134
200
|
|
|
135
201
|
|
|
136
202
|
@app.command()
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import select
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from codex_switch.auth import CodexCommandError
|
|
12
|
+
from codex_switch.models import InstanceConfig
|
|
13
|
+
from codex_switch.runtime import build_instance_env
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
INITIALIZE_REQUEST_ID = 1
|
|
17
|
+
RATE_LIMITS_REQUEST_ID = 2
|
|
18
|
+
FIVE_HOUR_WINDOW_MINS = 300
|
|
19
|
+
SEVEN_DAY_WINDOW_MINS = 10080
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class RateLimitWindow:
|
|
24
|
+
used_percent: int
|
|
25
|
+
window_duration_mins: int | None = None
|
|
26
|
+
resets_at: int | None = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def remaining_percent(self) -> int:
|
|
30
|
+
return max(0, 100 - self.used_percent)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class RateLimitSnapshot:
|
|
35
|
+
limit_id: str | None
|
|
36
|
+
limit_name: str | None
|
|
37
|
+
plan_type: str | None
|
|
38
|
+
primary: RateLimitWindow | None = None
|
|
39
|
+
secondary: RateLimitWindow | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class InstanceRateLimitResult:
|
|
44
|
+
instance_name: str
|
|
45
|
+
ok: bool
|
|
46
|
+
snapshot: RateLimitSnapshot | None = None
|
|
47
|
+
reason: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _start_app_server(
|
|
51
|
+
real_codex_path: str | Path,
|
|
52
|
+
instance: InstanceConfig,
|
|
53
|
+
) -> subprocess.Popen[str]:
|
|
54
|
+
instance_home = Path(instance.home_dir)
|
|
55
|
+
instance_home.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
try:
|
|
57
|
+
return subprocess.Popen(
|
|
58
|
+
[str(real_codex_path), "app-server"],
|
|
59
|
+
stdin=subprocess.PIPE,
|
|
60
|
+
stdout=subprocess.PIPE,
|
|
61
|
+
stderr=subprocess.PIPE,
|
|
62
|
+
text=True,
|
|
63
|
+
env=build_instance_env(instance.name, instance_home),
|
|
64
|
+
cwd=instance_home,
|
|
65
|
+
)
|
|
66
|
+
except FileNotFoundError as exc:
|
|
67
|
+
raise CodexCommandError(f"Unable to launch the real Codex binary: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_request(
|
|
71
|
+
process: subprocess.Popen[str],
|
|
72
|
+
request_id: int,
|
|
73
|
+
method: str,
|
|
74
|
+
params: object,
|
|
75
|
+
) -> None:
|
|
76
|
+
if process.stdin is None:
|
|
77
|
+
raise CodexCommandError("Codex app-server stdin is unavailable")
|
|
78
|
+
process.stdin.write(
|
|
79
|
+
json.dumps(
|
|
80
|
+
{
|
|
81
|
+
"jsonrpc": "2.0",
|
|
82
|
+
"id": request_id,
|
|
83
|
+
"method": method,
|
|
84
|
+
"params": params,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
+ "\n"
|
|
88
|
+
)
|
|
89
|
+
process.stdin.flush()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read_response(
|
|
93
|
+
process: subprocess.Popen[str],
|
|
94
|
+
request_id: int,
|
|
95
|
+
*,
|
|
96
|
+
timeout: float,
|
|
97
|
+
) -> dict[str, object]:
|
|
98
|
+
if process.stdout is None:
|
|
99
|
+
raise CodexCommandError("Codex app-server stdout is unavailable")
|
|
100
|
+
|
|
101
|
+
deadline = time.monotonic() + timeout
|
|
102
|
+
stdout_fd = process.stdout.fileno()
|
|
103
|
+
|
|
104
|
+
while True:
|
|
105
|
+
remaining = deadline - time.monotonic()
|
|
106
|
+
if remaining <= 0:
|
|
107
|
+
raise subprocess.TimeoutExpired(cmd=process.args, timeout=timeout)
|
|
108
|
+
|
|
109
|
+
ready, _, _ = select.select([stdout_fd], [], [], remaining)
|
|
110
|
+
if not ready:
|
|
111
|
+
raise subprocess.TimeoutExpired(cmd=process.args, timeout=timeout)
|
|
112
|
+
|
|
113
|
+
line = process.stdout.readline()
|
|
114
|
+
if not line:
|
|
115
|
+
stderr_output = ""
|
|
116
|
+
if process.stderr is not None:
|
|
117
|
+
stderr_output = process.stderr.read().strip()
|
|
118
|
+
message = "Codex app-server exited before returning a response"
|
|
119
|
+
if stderr_output:
|
|
120
|
+
message = f"{message}: {stderr_output}"
|
|
121
|
+
raise CodexCommandError(message)
|
|
122
|
+
|
|
123
|
+
payload = json.loads(line)
|
|
124
|
+
if payload.get("id") != request_id:
|
|
125
|
+
continue
|
|
126
|
+
return payload
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _extract_result(payload: dict[str, object], method: str) -> dict[str, object]:
|
|
130
|
+
error = payload.get("error")
|
|
131
|
+
if isinstance(error, dict):
|
|
132
|
+
message = error.get("message")
|
|
133
|
+
if isinstance(message, str) and message:
|
|
134
|
+
raise CodexCommandError(f"{method} failed: {message}")
|
|
135
|
+
raise CodexCommandError(f"{method} failed")
|
|
136
|
+
|
|
137
|
+
result = payload.get("result")
|
|
138
|
+
if not isinstance(result, dict):
|
|
139
|
+
raise CodexCommandError(f"{method} returned an invalid payload")
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_window(payload: object) -> RateLimitWindow | None:
|
|
144
|
+
if payload is None:
|
|
145
|
+
return None
|
|
146
|
+
if not isinstance(payload, dict):
|
|
147
|
+
raise CodexCommandError("Rate limit window payload is malformed")
|
|
148
|
+
|
|
149
|
+
used_percent = payload.get("usedPercent")
|
|
150
|
+
if not isinstance(used_percent, int) or isinstance(used_percent, bool):
|
|
151
|
+
raise CodexCommandError("Rate limit window is missing usedPercent")
|
|
152
|
+
|
|
153
|
+
window_duration_mins = payload.get("windowDurationMins")
|
|
154
|
+
if window_duration_mins is not None and (
|
|
155
|
+
not isinstance(window_duration_mins, int) or isinstance(window_duration_mins, bool)
|
|
156
|
+
):
|
|
157
|
+
raise CodexCommandError("Rate limit window has an invalid windowDurationMins")
|
|
158
|
+
|
|
159
|
+
resets_at = payload.get("resetsAt")
|
|
160
|
+
if resets_at is not None and (not isinstance(resets_at, int) or isinstance(resets_at, bool)):
|
|
161
|
+
raise CodexCommandError("Rate limit window has an invalid resetsAt")
|
|
162
|
+
|
|
163
|
+
return RateLimitWindow(
|
|
164
|
+
used_percent=used_percent,
|
|
165
|
+
window_duration_mins=window_duration_mins,
|
|
166
|
+
resets_at=resets_at,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_snapshot(payload: dict[str, object]) -> RateLimitSnapshot:
|
|
171
|
+
return RateLimitSnapshot(
|
|
172
|
+
limit_id=payload.get("limitId") if isinstance(payload.get("limitId"), str) else None,
|
|
173
|
+
limit_name=payload.get("limitName") if isinstance(payload.get("limitName"), str) else None,
|
|
174
|
+
plan_type=payload.get("planType") if isinstance(payload.get("planType"), str) else None,
|
|
175
|
+
primary=_parse_window(payload.get("primary")),
|
|
176
|
+
secondary=_parse_window(payload.get("secondary")),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _select_snapshot(result_payload: dict[str, object]) -> dict[str, object]:
|
|
181
|
+
by_limit = result_payload.get("rateLimitsByLimitId")
|
|
182
|
+
if isinstance(by_limit, dict):
|
|
183
|
+
codex_snapshot = by_limit.get("codex")
|
|
184
|
+
if isinstance(codex_snapshot, dict):
|
|
185
|
+
return codex_snapshot
|
|
186
|
+
|
|
187
|
+
rate_limits = result_payload.get("rateLimits")
|
|
188
|
+
if isinstance(rate_limits, dict):
|
|
189
|
+
return rate_limits
|
|
190
|
+
|
|
191
|
+
raise CodexCommandError("account/rateLimits/read returned no rate limit snapshot")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def read_rate_limits(
|
|
195
|
+
real_codex_path: str | Path,
|
|
196
|
+
instance: InstanceConfig,
|
|
197
|
+
*,
|
|
198
|
+
timeout: float = 8,
|
|
199
|
+
) -> RateLimitSnapshot:
|
|
200
|
+
process = _start_app_server(real_codex_path, instance)
|
|
201
|
+
try:
|
|
202
|
+
_write_request(
|
|
203
|
+
process,
|
|
204
|
+
INITIALIZE_REQUEST_ID,
|
|
205
|
+
"initialize",
|
|
206
|
+
{
|
|
207
|
+
"clientInfo": {
|
|
208
|
+
"name": "codex-switch",
|
|
209
|
+
"version": "0.1.3",
|
|
210
|
+
},
|
|
211
|
+
"capabilities": {
|
|
212
|
+
"experimentalApi": True,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
init_payload = _read_response(
|
|
217
|
+
process, INITIALIZE_REQUEST_ID, timeout=max(1.0, timeout / 2)
|
|
218
|
+
)
|
|
219
|
+
_extract_result(init_payload, "initialize")
|
|
220
|
+
|
|
221
|
+
_write_request(process, RATE_LIMITS_REQUEST_ID, "account/rateLimits/read", None)
|
|
222
|
+
rate_limit_payload = _read_response(
|
|
223
|
+
process, RATE_LIMITS_REQUEST_ID, timeout=max(1.0, timeout / 2)
|
|
224
|
+
)
|
|
225
|
+
result = _extract_result(rate_limit_payload, "account/rateLimits/read")
|
|
226
|
+
return _parse_snapshot(_select_snapshot(result))
|
|
227
|
+
except subprocess.TimeoutExpired as exc:
|
|
228
|
+
raise CodexCommandError("Timed out while reading account rate limits") from exc
|
|
229
|
+
finally:
|
|
230
|
+
if process.stdin is not None and not process.stdin.closed:
|
|
231
|
+
process.stdin.close()
|
|
232
|
+
try:
|
|
233
|
+
process.terminate()
|
|
234
|
+
process.wait(timeout=1)
|
|
235
|
+
except subprocess.TimeoutExpired:
|
|
236
|
+
process.kill()
|
|
237
|
+
process.wait(timeout=1)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def read_instance_rate_limits(
|
|
241
|
+
real_codex_path: str | Path,
|
|
242
|
+
instance: InstanceConfig,
|
|
243
|
+
) -> InstanceRateLimitResult:
|
|
244
|
+
try:
|
|
245
|
+
snapshot = read_rate_limits(real_codex_path, instance)
|
|
246
|
+
except CodexCommandError as exc:
|
|
247
|
+
return InstanceRateLimitResult(
|
|
248
|
+
instance_name=instance.name,
|
|
249
|
+
ok=False,
|
|
250
|
+
reason=str(exc),
|
|
251
|
+
)
|
|
252
|
+
return InstanceRateLimitResult(
|
|
253
|
+
instance_name=instance.name,
|
|
254
|
+
ok=True,
|
|
255
|
+
snapshot=snapshot,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def select_window_for_duration(
|
|
260
|
+
snapshot: RateLimitSnapshot,
|
|
261
|
+
duration_mins: int,
|
|
262
|
+
*,
|
|
263
|
+
fallback: str | None = None,
|
|
264
|
+
) -> RateLimitWindow | None:
|
|
265
|
+
candidates = [snapshot.primary, snapshot.secondary]
|
|
266
|
+
for window in candidates:
|
|
267
|
+
if window is not None and window.window_duration_mins == duration_mins:
|
|
268
|
+
return window
|
|
269
|
+
|
|
270
|
+
if fallback == "primary":
|
|
271
|
+
return snapshot.primary
|
|
272
|
+
if fallback == "secondary":
|
|
273
|
+
return snapshot.secondary
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def format_reset_timestamp(timestamp: int | None) -> str:
|
|
278
|
+
if timestamp is None:
|
|
279
|
+
return "-"
|
|
280
|
+
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-switch-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Transparent account-aware wrapper for the Codex CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/ForeverHYX/codex-switch
|
|
6
6
|
Project-URL: Repository, https://github.com/ForeverHYX/codex-switch
|
|
@@ -67,6 +67,7 @@ Once setup is done, day-to-day usage stays the same:
|
|
|
67
67
|
```bash
|
|
68
68
|
codex "review this branch"
|
|
69
69
|
codex exec "make test"
|
|
70
|
+
codex-switch list
|
|
70
71
|
```
|
|
71
72
|
|
|
72
73
|
## How it works
|
|
@@ -10,6 +10,7 @@ src/codex_switch/instances.py
|
|
|
10
10
|
src/codex_switch/models.py
|
|
11
11
|
src/codex_switch/paths.py
|
|
12
12
|
src/codex_switch/probe.py
|
|
13
|
+
src/codex_switch/rate_limits.py
|
|
13
14
|
src/codex_switch/routing.py
|
|
14
15
|
src/codex_switch/runtime.py
|
|
15
16
|
src/codex_switch/wizard.py
|
|
@@ -26,6 +27,7 @@ tests/test_doctor.py
|
|
|
26
27
|
tests/test_instances.py
|
|
27
28
|
tests/test_integration_wrapper.py
|
|
28
29
|
tests/test_probe.py
|
|
30
|
+
tests/test_rate_limits.py
|
|
29
31
|
tests/test_routing.py
|
|
30
32
|
tests/test_runtime.py
|
|
31
33
|
tests/test_smoke.py
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from codex_switch.cli import app
|
|
11
|
+
from codex_switch.models import AppConfig, InstanceConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_launcher(tmp_path: Path, fake_codex: Path) -> Path:
|
|
15
|
+
launcher = tmp_path / "codex"
|
|
16
|
+
launcher.write_text(
|
|
17
|
+
"#!/bin/sh\n"
|
|
18
|
+
f'exec "{sys.executable}" "{fake_codex}" "$@"\n'
|
|
19
|
+
)
|
|
20
|
+
os.chmod(launcher, 0o755)
|
|
21
|
+
return launcher
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_read_rate_limits_returns_structured_windows(tmp_path, fake_codex_path: Path) -> None:
|
|
25
|
+
from codex_switch.rate_limits import read_rate_limits
|
|
26
|
+
|
|
27
|
+
launcher = _make_launcher(tmp_path, fake_codex_path)
|
|
28
|
+
home = tmp_path / "acct-001"
|
|
29
|
+
home.mkdir()
|
|
30
|
+
(home / "rate-limits.json").write_text(
|
|
31
|
+
json.dumps(
|
|
32
|
+
{
|
|
33
|
+
"rateLimits": {
|
|
34
|
+
"limitId": "codex",
|
|
35
|
+
"limitName": None,
|
|
36
|
+
"planType": "plus",
|
|
37
|
+
"primary": {
|
|
38
|
+
"usedPercent": 64,
|
|
39
|
+
"windowDurationMins": 300,
|
|
40
|
+
"resetsAt": 1_775_461_397,
|
|
41
|
+
},
|
|
42
|
+
"secondary": {
|
|
43
|
+
"usedPercent": 29,
|
|
44
|
+
"windowDurationMins": 10080,
|
|
45
|
+
"resetsAt": 1_776_001_304,
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
instance = InstanceConfig(name="acct-001", order=1, home_dir=str(home))
|
|
52
|
+
|
|
53
|
+
snapshot = read_rate_limits(str(launcher), instance)
|
|
54
|
+
|
|
55
|
+
assert snapshot.plan_type == "plus"
|
|
56
|
+
assert snapshot.primary is not None
|
|
57
|
+
assert snapshot.primary.used_percent == 64
|
|
58
|
+
assert snapshot.primary.window_duration_mins == 300
|
|
59
|
+
assert snapshot.secondary is not None
|
|
60
|
+
assert snapshot.secondary.used_percent == 29
|
|
61
|
+
assert snapshot.secondary.window_duration_mins == 10080
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_list_command_displays_live_rate_limits(tmp_path, monkeypatch) -> None:
|
|
65
|
+
from codex_switch.rate_limits import (
|
|
66
|
+
InstanceRateLimitResult,
|
|
67
|
+
RateLimitSnapshot,
|
|
68
|
+
RateLimitWindow,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
config = AppConfig(
|
|
72
|
+
real_codex_path="/usr/local/bin/codex",
|
|
73
|
+
instances=[
|
|
74
|
+
InstanceConfig(name="acct-001", order=1, home_dir=str(tmp_path / "acct-001")),
|
|
75
|
+
InstanceConfig(name="acct-002", order=2, home_dir=str(tmp_path / "acct-002")),
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def fake_read(real_codex_path: str, instance: InstanceConfig) -> InstanceRateLimitResult:
|
|
80
|
+
if instance.name == "acct-001":
|
|
81
|
+
return InstanceRateLimitResult(
|
|
82
|
+
instance_name=instance.name,
|
|
83
|
+
ok=True,
|
|
84
|
+
snapshot=RateLimitSnapshot(
|
|
85
|
+
limit_id="codex",
|
|
86
|
+
limit_name=None,
|
|
87
|
+
plan_type="plus",
|
|
88
|
+
primary=RateLimitWindow(
|
|
89
|
+
used_percent=64,
|
|
90
|
+
window_duration_mins=300,
|
|
91
|
+
resets_at=1_775_461_397,
|
|
92
|
+
),
|
|
93
|
+
secondary=RateLimitWindow(
|
|
94
|
+
used_percent=29,
|
|
95
|
+
window_duration_mins=10080,
|
|
96
|
+
resets_at=1_776_001_304,
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
return InstanceRateLimitResult(
|
|
101
|
+
instance_name=instance.name,
|
|
102
|
+
ok=False,
|
|
103
|
+
reason="Not logged in",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr("codex_switch.cli._load_initialized_config", lambda: config)
|
|
107
|
+
monkeypatch.setattr(
|
|
108
|
+
"codex_switch.cli._resolve_real_codex_for_management",
|
|
109
|
+
lambda config: config.real_codex_path,
|
|
110
|
+
)
|
|
111
|
+
monkeypatch.setattr("codex_switch.cli.read_instance_rate_limits", fake_read)
|
|
112
|
+
|
|
113
|
+
result = CliRunner().invoke(app, ["list"])
|
|
114
|
+
|
|
115
|
+
assert result.exit_code == 0
|
|
116
|
+
assert "INSTANCE" in result.stdout
|
|
117
|
+
assert "5H REMAINING" in result.stdout
|
|
118
|
+
assert "7D REMAINING" in result.stdout
|
|
119
|
+
assert "acct-001" in result.stdout
|
|
120
|
+
assert "36%" in result.stdout
|
|
121
|
+
assert "71%" in result.stdout
|
|
122
|
+
assert "acct-002" in result.stdout
|
|
123
|
+
assert "unavailable" in result.stdout
|
|
124
|
+
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/requires.txt
RENAMED
|
File without changes
|
{codex_switch_cli-0.1.2 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|