codex-switch-cli 0.1.1__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.
Files changed (40) hide show
  1. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/PKG-INFO +2 -1
  2. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/README.md +1 -0
  3. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/pyproject.toml +1 -1
  4. codex_switch_cli-0.1.3/src/codex_switch/__init__.py +1 -0
  5. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/cli.py +67 -1
  6. codex_switch_cli-0.1.3/src/codex_switch/probe.py +241 -0
  7. codex_switch_cli-0.1.3/src/codex_switch/rate_limits.py +280 -0
  8. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/PKG-INFO +2 -1
  9. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/SOURCES.txt +2 -0
  10. codex_switch_cli-0.1.3/tests/test_probe.py +160 -0
  11. codex_switch_cli-0.1.3/tests/test_rate_limits.py +124 -0
  12. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_smoke.py +1 -1
  13. codex_switch_cli-0.1.1/src/codex_switch/__init__.py +0 -1
  14. codex_switch_cli-0.1.1/src/codex_switch/probe.py +0 -69
  15. codex_switch_cli-0.1.1/tests/test_probe.py +0 -85
  16. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/setup.cfg +0 -0
  17. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/auth.py +0 -0
  18. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/config.py +0 -0
  19. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/doctor.py +0 -0
  20. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/install.py +0 -0
  21. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/instances.py +0 -0
  22. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/models.py +0 -0
  23. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/paths.py +0 -0
  24. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/routing.py +0 -0
  25. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/runtime.py +0 -0
  26. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/wizard.py +0 -0
  27. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch/wrapper.py +0 -0
  28. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/dependency_links.txt +0 -0
  29. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/entry_points.txt +0 -0
  30. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/requires.txt +0 -0
  31. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/src/codex_switch_cli.egg-info/top_level.txt +0 -0
  32. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_auth.py +0 -0
  33. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_config.py +0 -0
  34. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_doctor.py +0 -0
  35. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_instances.py +0 -0
  36. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_integration_wrapper.py +0 -0
  37. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_routing.py +0 -0
  38. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_runtime.py +0 -0
  39. {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.3}/tests/test_wizard.py +0 -0
  40. {codex_switch_cli-0.1.1 → 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.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
@@ -52,6 +52,7 @@ Once setup is done, day-to-day usage stays the same:
52
52
  ```bash
53
53
  codex "review this branch"
54
54
  codex exec "make test"
55
+ codex-switch list
55
56
  ```
56
57
 
57
58
  ## How it works
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-switch-cli"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Transparent account-aware wrapper for the Codex CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
- typer.echo(f"{instance.name}\t{instance.home_dir}")
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,241 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pty
5
+ import re
6
+ import select
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from codex_switch.auth import CodexCommandError, login_status
12
+ from codex_switch.models import InstanceConfig, ProbeResult
13
+ from codex_switch.runtime import build_instance_env
14
+
15
+
16
+ QUOTA_PATTERNS = (
17
+ re.compile(r"remaining[^0-9]*(\d+)", re.IGNORECASE),
18
+ re.compile(r"(\d+)[^0-9]*remaining", re.IGNORECASE),
19
+ )
20
+ READY_PATTERNS = (
21
+ "OpenAI Codex",
22
+ "tab to queue message",
23
+ "context left",
24
+ "Codex ready",
25
+ "BOOTED",
26
+ )
27
+ ANSI_ESCAPE_RE = re.compile(
28
+ r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[@-_]"
29
+ )
30
+
31
+
32
+ def parse_remaining_quota(output: str) -> int:
33
+ for pattern in QUOTA_PATTERNS:
34
+ match = pattern.search(output)
35
+ if match:
36
+ return int(match.group(1))
37
+ raise ValueError("Unable to parse remaining quota from /status output")
38
+
39
+
40
+ def _failure(instance: InstanceConfig, reason: str) -> ProbeResult:
41
+ return ProbeResult(
42
+ instance_name=instance.name,
43
+ order=instance.order,
44
+ quota_remaining=None,
45
+ ok=False,
46
+ reason=reason,
47
+ )
48
+
49
+
50
+ def _sanitize_terminal_output(raw_output: bytes) -> str:
51
+ text = raw_output.decode("utf-8", errors="ignore").replace("\r", "\n")
52
+ text = ANSI_ESCAPE_RE.sub("", text)
53
+ text = text.replace("\b", "")
54
+ return text
55
+
56
+
57
+ def _trusted_project_override(path: Path) -> str:
58
+ escaped = str(path).replace("\\", "\\\\").replace('"', '\\"')
59
+ return f'projects."{escaped}".trust_level="trusted"'
60
+
61
+
62
+ def _looks_ready(output: str) -> bool:
63
+ return any(pattern in output for pattern in READY_PATTERNS)
64
+
65
+
66
+ def _fallback_logged_in_result(
67
+ real_codex_path: str,
68
+ instance: InstanceConfig,
69
+ reason: str,
70
+ ) -> ProbeResult | None:
71
+ try:
72
+ status = login_status(real_codex_path, instance)
73
+ except CodexCommandError:
74
+ return None
75
+ if not status.logged_in:
76
+ return None
77
+ return ProbeResult(
78
+ instance_name=instance.name,
79
+ order=instance.order,
80
+ quota_remaining=0,
81
+ ok=True,
82
+ reason=f"{reason}. Falling back to login-only availability.",
83
+ )
84
+
85
+
86
+ def _run_status_probe(
87
+ real_codex_path: str,
88
+ instance: InstanceConfig,
89
+ *,
90
+ timeout: int = 6,
91
+ ) -> tuple[int, str]:
92
+ instance_home = Path(instance.home_dir)
93
+ instance_home.mkdir(parents=True, exist_ok=True)
94
+ env = build_instance_env(instance.name, instance_home)
95
+ command = [
96
+ real_codex_path,
97
+ "-C",
98
+ str(instance_home),
99
+ "-c",
100
+ _trusted_project_override(instance_home),
101
+ "--no-alt-screen",
102
+ ]
103
+
104
+ master_fd, slave_fd = pty.openpty()
105
+ process: subprocess.Popen[bytes] | None = None
106
+ sent_status = False
107
+ sent_exit = False
108
+ sent_trust = False
109
+ status_sent_at: float | None = None
110
+ exit_sent_at: float | None = None
111
+ output = bytearray()
112
+ deadline = time.monotonic() + timeout
113
+ startup_deadline = time.monotonic() + min(3.0, timeout / 2)
114
+
115
+ try:
116
+ process = subprocess.Popen(
117
+ command,
118
+ stdin=slave_fd,
119
+ stdout=slave_fd,
120
+ stderr=slave_fd,
121
+ env=env,
122
+ cwd=instance_home,
123
+ close_fds=True,
124
+ )
125
+ finally:
126
+ os.close(slave_fd)
127
+
128
+ try:
129
+ while True:
130
+ now = time.monotonic()
131
+ if now >= deadline:
132
+ raise subprocess.TimeoutExpired(cmd=command, timeout=timeout)
133
+
134
+ wait_for = min(0.2, deadline - now)
135
+ ready, _, _ = select.select([master_fd], [], [], wait_for)
136
+ if ready:
137
+ try:
138
+ chunk = os.read(master_fd, 4096)
139
+ except OSError:
140
+ chunk = b""
141
+ if chunk:
142
+ output.extend(chunk)
143
+
144
+ cleaned_output = _sanitize_terminal_output(output)
145
+
146
+ if (
147
+ not sent_trust
148
+ and "Do you trust the contents of this directory?" in cleaned_output
149
+ ):
150
+ os.write(master_fd, b"1\n")
151
+ sent_trust = True
152
+
153
+ if not sent_status and (
154
+ _looks_ready(cleaned_output) or time.monotonic() >= startup_deadline
155
+ ):
156
+ os.write(master_fd, b"/status\n")
157
+ sent_status = True
158
+ status_sent_at = time.monotonic()
159
+
160
+ if sent_status and not sent_exit:
161
+ try:
162
+ parse_remaining_quota(cleaned_output)
163
+ except ValueError:
164
+ if status_sent_at is not None and time.monotonic() - status_sent_at > 2:
165
+ os.write(master_fd, b"/exit\n")
166
+ sent_exit = True
167
+ exit_sent_at = time.monotonic()
168
+ else:
169
+ os.write(master_fd, b"/exit\n")
170
+ sent_exit = True
171
+ exit_sent_at = time.monotonic()
172
+
173
+ if (
174
+ sent_exit
175
+ and exit_sent_at is not None
176
+ and time.monotonic() - exit_sent_at > 1
177
+ and process.poll() is None
178
+ ):
179
+ process.terminate()
180
+
181
+ poll_result = process.poll()
182
+ if poll_result is not None:
183
+ while True:
184
+ ready, _, _ = select.select([master_fd], [], [], 0)
185
+ if not ready:
186
+ break
187
+ try:
188
+ chunk = os.read(master_fd, 4096)
189
+ except OSError:
190
+ break
191
+ if not chunk:
192
+ break
193
+ output.extend(chunk)
194
+ return poll_result, _sanitize_terminal_output(output)
195
+ except subprocess.TimeoutExpired:
196
+ process.kill()
197
+ process.wait(timeout=1)
198
+ raise
199
+ finally:
200
+ os.close(master_fd)
201
+
202
+
203
+ def probe_instance(real_codex_path: str, instance: InstanceConfig) -> ProbeResult:
204
+ try:
205
+ returncode, output = _run_status_probe(real_codex_path, instance)
206
+ except FileNotFoundError as exc:
207
+ return _failure(instance, f"Probe could not launch the real Codex binary: {exc}")
208
+ except subprocess.TimeoutExpired:
209
+ fallback = _fallback_logged_in_result(
210
+ real_codex_path,
211
+ instance,
212
+ "Probe timed out",
213
+ )
214
+ if fallback is not None:
215
+ return fallback
216
+ return _failure(instance, "Probe timed out")
217
+
218
+ if returncode != 0:
219
+ fallback = _fallback_logged_in_result(
220
+ real_codex_path,
221
+ instance,
222
+ f"Probe exited with exit code {returncode}",
223
+ )
224
+ if fallback is not None:
225
+ return fallback
226
+ return _failure(instance, f"Probe exited with exit code {returncode}")
227
+
228
+ try:
229
+ remaining = parse_remaining_quota(output)
230
+ except ValueError as exc:
231
+ fallback = _fallback_logged_in_result(real_codex_path, instance, str(exc))
232
+ if fallback is not None:
233
+ return fallback
234
+ return _failure(instance, str(exc))
235
+
236
+ return ProbeResult(
237
+ instance_name=instance.name,
238
+ order=instance.order,
239
+ quota_remaining=remaining,
240
+ ok=True,
241
+ )
@@ -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.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,160 @@
1
+ import subprocess
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from codex_switch.models import InstanceConfig
7
+ from codex_switch.auth import LoginStatus
8
+ from codex_switch.probe import parse_remaining_quota
9
+ from codex_switch.probe import probe_instance
10
+
11
+
12
+ def test_parse_remaining_quota_from_status_output() -> None:
13
+ output = """
14
+ Account: acct-001
15
+ Requests remaining: 42
16
+ """
17
+
18
+ assert parse_remaining_quota(output) == 42
19
+
20
+
21
+ def test_probe_instance_returns_failure_for_malformed_output(
22
+ tmp_path, monkeypatch
23
+ ) -> None:
24
+ instance = InstanceConfig(
25
+ name="acct-001",
26
+ order=1,
27
+ home_dir=str(tmp_path / "home"),
28
+ )
29
+
30
+ monkeypatch.setattr(
31
+ "codex_switch.probe._run_status_probe",
32
+ lambda *args, **kwargs: (0, "Account: acct-001\n"),
33
+ )
34
+ monkeypatch.setattr(
35
+ "codex_switch.probe.login_status",
36
+ lambda *args, **kwargs: LoginStatus(logged_in=False, output="", returncode=1),
37
+ )
38
+
39
+ result = probe_instance("/usr/local/bin/codex", instance)
40
+
41
+ assert result.ok is False
42
+ assert result.quota_remaining is None
43
+ assert "Unable to parse remaining quota" in result.reason
44
+
45
+
46
+ def test_probe_instance_returns_failure_for_nonzero_exit(
47
+ tmp_path, monkeypatch
48
+ ) -> None:
49
+ instance = InstanceConfig(
50
+ name="acct-001",
51
+ order=1,
52
+ home_dir=str(tmp_path / "home"),
53
+ )
54
+
55
+ monkeypatch.setattr(
56
+ "codex_switch.probe._run_status_probe",
57
+ lambda *args, **kwargs: (1, "Requests remaining: 42\npermission denied"),
58
+ )
59
+
60
+ result = probe_instance("/usr/local/bin/codex", instance)
61
+
62
+ assert result.ok is False
63
+ assert result.quota_remaining is None
64
+ assert "exit code 1" in result.reason
65
+
66
+
67
+ def test_probe_instance_returns_failure_for_timeout(tmp_path, monkeypatch) -> None:
68
+ instance = InstanceConfig(
69
+ name="acct-001",
70
+ order=1,
71
+ home_dir=str(tmp_path / "home"),
72
+ )
73
+
74
+ def raise_timeout(*args, **kwargs):
75
+ raise subprocess.TimeoutExpired(cmd=["codex"], timeout=15)
76
+
77
+ monkeypatch.setattr("codex_switch.probe._run_status_probe", raise_timeout)
78
+
79
+ result = probe_instance("/usr/local/bin/codex", instance)
80
+
81
+ assert result.ok is False
82
+ assert result.quota_remaining is None
83
+ assert result.reason == "Probe timed out"
84
+
85
+
86
+ def test_probe_instance_handles_tty_only_codex_process(tmp_path) -> None:
87
+ home_dir = tmp_path / "acct-home"
88
+ home_dir.mkdir()
89
+ instance = InstanceConfig(
90
+ name="acct-001",
91
+ order=1,
92
+ home_dir=str(home_dir),
93
+ )
94
+ script_path = tmp_path / "fake_tty_codex.py"
95
+ launcher_path = tmp_path / "fake-codex"
96
+ script_path.write_text(
97
+ "import os\n"
98
+ "import sys\n"
99
+ "print('OpenAI Codex ready', flush=True)\n"
100
+ "if not sys.stdin.isatty():\n"
101
+ " print('stdin is not a terminal', file=sys.stderr, flush=True)\n"
102
+ " raise SystemExit(1)\n"
103
+ "expected_cwd = os.environ.get('EXPECTED_CWD')\n"
104
+ "if expected_cwd and os.getcwd() != expected_cwd:\n"
105
+ " print(f'bad cwd: {os.getcwd()}', file=sys.stderr, flush=True)\n"
106
+ " raise SystemExit(2)\n"
107
+ "for raw_line in sys.stdin:\n"
108
+ " line = raw_line.strip()\n"
109
+ " if line == '/status':\n"
110
+ " print('Requests remaining: 42', flush=True)\n"
111
+ " elif line == '/exit':\n"
112
+ " raise SystemExit(0)\n"
113
+ )
114
+ launcher_path.write_text(
115
+ "#!/bin/sh\n"
116
+ f'exec "{sys.executable}" "{script_path}" "$@"\n'
117
+ )
118
+ os.chmod(launcher_path, 0o755)
119
+
120
+ previous_expected_cwd = os.environ.get("EXPECTED_CWD")
121
+ os.environ["EXPECTED_CWD"] = str(home_dir)
122
+ try:
123
+ result = probe_instance(str(launcher_path), instance)
124
+ finally:
125
+ if previous_expected_cwd is None:
126
+ os.environ.pop("EXPECTED_CWD", None)
127
+ else:
128
+ os.environ["EXPECTED_CWD"] = previous_expected_cwd
129
+
130
+ assert result.ok is True
131
+ assert result.quota_remaining == 42
132
+
133
+
134
+ def test_probe_instance_falls_back_to_logged_in_account_when_quota_parse_fails(
135
+ tmp_path, monkeypatch
136
+ ) -> None:
137
+ instance = InstanceConfig(
138
+ name="acct-001",
139
+ order=1,
140
+ home_dir=str(tmp_path / "home"),
141
+ )
142
+
143
+ monkeypatch.setattr(
144
+ "codex_switch.probe._run_status_probe",
145
+ lambda *args, **kwargs: (0, "Account status unavailable\n"),
146
+ )
147
+ monkeypatch.setattr(
148
+ "codex_switch.probe.login_status",
149
+ lambda *args, **kwargs: LoginStatus(
150
+ logged_in=True,
151
+ output="Logged in using ChatGPT",
152
+ returncode=0,
153
+ ),
154
+ )
155
+
156
+ result = probe_instance("/usr/local/bin/codex", instance)
157
+
158
+ assert result.ok is True
159
+ assert result.quota_remaining == 0
160
+ assert "falling back" in (result.reason or "").lower()
@@ -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
+
@@ -5,7 +5,7 @@ from codex_switch.cli import app
5
5
 
6
6
 
7
7
  def test_package_exposes_version() -> None:
8
- assert __version__ == "0.1.1"
8
+ assert __version__ == "0.1.3"
9
9
 
10
10
 
11
11
  def test_cli_app_displays_help() -> None:
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
@@ -1,69 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- import subprocess
5
- from pathlib import Path
6
-
7
- from codex_switch.models import InstanceConfig, ProbeResult
8
- from codex_switch.runtime import build_instance_env
9
-
10
-
11
- QUOTA_PATTERNS = (
12
- re.compile(r"remaining[^0-9]*(\d+)", re.IGNORECASE),
13
- re.compile(r"(\d+)[^0-9]*remaining", re.IGNORECASE),
14
- )
15
-
16
-
17
- def parse_remaining_quota(output: str) -> int:
18
- for pattern in QUOTA_PATTERNS:
19
- match = pattern.search(output)
20
- if match:
21
- return int(match.group(1))
22
- raise ValueError("Unable to parse remaining quota from /status output")
23
-
24
-
25
- def _failure(instance: InstanceConfig, reason: str) -> ProbeResult:
26
- return ProbeResult(
27
- instance_name=instance.name,
28
- order=instance.order,
29
- quota_remaining=None,
30
- ok=False,
31
- reason=reason,
32
- )
33
-
34
-
35
- def probe_instance(real_codex_path: str, instance: InstanceConfig) -> ProbeResult:
36
- env = build_instance_env(instance.name, Path(instance.home_dir))
37
- try:
38
- completed = subprocess.run(
39
- [real_codex_path, "--no-alt-screen"],
40
- input="/status\n/exit\n",
41
- text=True,
42
- capture_output=True,
43
- env=env,
44
- check=False,
45
- timeout=15,
46
- )
47
- except FileNotFoundError as exc:
48
- return _failure(instance, f"Probe could not launch the real Codex binary: {exc}")
49
- except subprocess.TimeoutExpired:
50
- return _failure(instance, "Probe timed out")
51
-
52
- output = f"{completed.stdout}\n{completed.stderr}"
53
- if completed.returncode != 0:
54
- return _failure(
55
- instance,
56
- f"Probe exited with exit code {completed.returncode}",
57
- )
58
-
59
- try:
60
- remaining = parse_remaining_quota(output)
61
- except ValueError as exc:
62
- return _failure(instance, str(exc))
63
-
64
- return ProbeResult(
65
- instance_name=instance.name,
66
- order=instance.order,
67
- quota_remaining=remaining,
68
- ok=True,
69
- )
@@ -1,85 +0,0 @@
1
- import subprocess
2
-
3
- from codex_switch.models import InstanceConfig
4
- from codex_switch.probe import parse_remaining_quota
5
- from codex_switch.probe import probe_instance
6
-
7
-
8
- def test_parse_remaining_quota_from_status_output() -> None:
9
- output = """
10
- Account: acct-001
11
- Requests remaining: 42
12
- """
13
-
14
- assert parse_remaining_quota(output) == 42
15
-
16
-
17
- def test_probe_instance_returns_failure_for_malformed_output(
18
- tmp_path, monkeypatch
19
- ) -> None:
20
- instance = InstanceConfig(
21
- name="acct-001",
22
- order=1,
23
- home_dir=str(tmp_path / "home"),
24
- )
25
-
26
- monkeypatch.setattr(
27
- "codex_switch.probe.subprocess.run",
28
- lambda *args, **kwargs: subprocess.CompletedProcess(
29
- args=["codex"],
30
- returncode=0,
31
- stdout="Account: acct-001\n",
32
- stderr="",
33
- ),
34
- )
35
-
36
- result = probe_instance("/usr/local/bin/codex", instance)
37
-
38
- assert result.ok is False
39
- assert result.quota_remaining is None
40
- assert "Unable to parse remaining quota" in result.reason
41
-
42
-
43
- def test_probe_instance_returns_failure_for_nonzero_exit(
44
- tmp_path, monkeypatch
45
- ) -> None:
46
- instance = InstanceConfig(
47
- name="acct-001",
48
- order=1,
49
- home_dir=str(tmp_path / "home"),
50
- )
51
-
52
- monkeypatch.setattr(
53
- "codex_switch.probe.subprocess.run",
54
- lambda *args, **kwargs: subprocess.CompletedProcess(
55
- args=["codex"],
56
- returncode=1,
57
- stdout="Requests remaining: 42\n",
58
- stderr="permission denied",
59
- ),
60
- )
61
-
62
- result = probe_instance("/usr/local/bin/codex", instance)
63
-
64
- assert result.ok is False
65
- assert result.quota_remaining is None
66
- assert "exit code 1" in result.reason
67
-
68
-
69
- def test_probe_instance_returns_failure_for_timeout(tmp_path, monkeypatch) -> None:
70
- instance = InstanceConfig(
71
- name="acct-001",
72
- order=1,
73
- home_dir=str(tmp_path / "home"),
74
- )
75
-
76
- def raise_timeout(*args, **kwargs):
77
- raise subprocess.TimeoutExpired(cmd=["codex"], timeout=15)
78
-
79
- monkeypatch.setattr("codex_switch.probe.subprocess.run", raise_timeout)
80
-
81
- result = probe_instance("/usr/local/bin/codex", instance)
82
-
83
- assert result.ok is False
84
- assert result.quota_remaining is None
85
- assert result.reason == "Probe timed out"