codex-switch-cli 0.1.1__tar.gz → 0.1.2__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.1 → codex_switch_cli-0.1.2}/PKG-INFO +1 -1
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/pyproject.toml +1 -1
- codex_switch_cli-0.1.2/src/codex_switch/__init__.py +1 -0
- codex_switch_cli-0.1.2/src/codex_switch/probe.py +241 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/PKG-INFO +1 -1
- codex_switch_cli-0.1.2/tests/test_probe.py +160 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_smoke.py +1 -1
- codex_switch_cli-0.1.1/src/codex_switch/__init__.py +0 -1
- codex_switch_cli-0.1.1/src/codex_switch/probe.py +0 -69
- codex_switch_cli-0.1.1/tests/test_probe.py +0 -85
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/README.md +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/setup.cfg +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/auth.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/cli.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/config.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/doctor.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/install.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/instances.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/models.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/paths.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/routing.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/runtime.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/wizard.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch/wrapper.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/SOURCES.txt +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/dependency_links.txt +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/entry_points.txt +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/requires.txt +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/top_level.txt +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_auth.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_config.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_doctor.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_instances.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_integration_wrapper.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_routing.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_runtime.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/tests/test_wizard.py +0 -0
- {codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/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.2
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -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
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-switch-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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
|
|
@@ -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()
|
|
@@ -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"
|
|
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
|
|
File without changes
|
|
File without changes
|
{codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/src/codex_switch_cli.egg-info/requires.txt
RENAMED
|
File without changes
|
{codex_switch_cli-0.1.1 → codex_switch_cli-0.1.2}/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
|