relay-codex 0.0.0-stage → 0.5.0
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.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +176 -0
- package/docs/publishing.md +72 -0
- package/docs/troubleshooting.md +49 -0
- package/package.json +47 -4
- package/python/Menu.swift +188 -0
- package/python/broker.py +297 -0
- package/python/commands.py +900 -0
- package/python/compatibility.py +227 -0
- package/python/desktop.py +171 -0
- package/python/execution.py +551 -0
- package/python/launch.py +417 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +586 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2288 -0
- package/python/runtime.py +97 -0
- package/python/service.py +737 -0
- package/python/settings.py +110 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +240 -0
- package/tests/test_audit.py +320 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +517 -0
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +479 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Validated local preferences; no automatic model fallback or extra billing."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
"retry_interval": 300,
|
|
11
|
+
"retry_attempts": 3,
|
|
12
|
+
"unknown_reset_interval": 300,
|
|
13
|
+
"watchdog_interval": 30,
|
|
14
|
+
"idle_warning_after": 120,
|
|
15
|
+
"routing": "balanced",
|
|
16
|
+
"notifications": False,
|
|
17
|
+
"fallback_models": [],
|
|
18
|
+
"preferred_accounts": [],
|
|
19
|
+
"max_sessions_per_account": 0,
|
|
20
|
+
"reservation_seconds": 120,
|
|
21
|
+
"history_days": 30,
|
|
22
|
+
"event_limit": 2000,
|
|
23
|
+
"offline_retry": 30,
|
|
24
|
+
"background_on_disconnect": False,
|
|
25
|
+
"broker_idle_seconds": 600,
|
|
26
|
+
"keep_awake": False,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate(values):
|
|
31
|
+
unknown = set(values) - set(DEFAULTS)
|
|
32
|
+
if unknown:
|
|
33
|
+
raise ValueError("Unknown settings: " + ", ".join(sorted(unknown)))
|
|
34
|
+
for key, value in values.items():
|
|
35
|
+
expected = DEFAULTS[key]
|
|
36
|
+
if isinstance(expected, bool):
|
|
37
|
+
if not isinstance(value, bool):
|
|
38
|
+
raise ValueError(f"{key} must be true or false")
|
|
39
|
+
elif isinstance(expected, int):
|
|
40
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
41
|
+
raise ValueError(f"{key} must be a nonnegative integer")
|
|
42
|
+
if key not in {"max_sessions_per_account", "retry_attempts"} and value < 1:
|
|
43
|
+
raise ValueError(f"{key} must be positive")
|
|
44
|
+
elif isinstance(expected, list):
|
|
45
|
+
if not isinstance(value, list) or not all(
|
|
46
|
+
isinstance(v, str) for v in value
|
|
47
|
+
):
|
|
48
|
+
raise ValueError(f"{key} must be a list of strings")
|
|
49
|
+
if values.get("routing", "balanced") not in {
|
|
50
|
+
"balanced",
|
|
51
|
+
"quota",
|
|
52
|
+
"priority",
|
|
53
|
+
"round-robin",
|
|
54
|
+
}:
|
|
55
|
+
raise ValueError("routing must be balanced, quota, priority, or round-robin")
|
|
56
|
+
if values.get("reservation_seconds", 120) < 30:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"reservation_seconds must be at least 30 to allow heartbeat renewal"
|
|
59
|
+
)
|
|
60
|
+
return values
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load(state, project=None, profile=None):
|
|
64
|
+
result = copy.deepcopy(DEFAULTS)
|
|
65
|
+
for path in [
|
|
66
|
+
state / "settings.json",
|
|
67
|
+
Path(project) / ".codex-accounts.toml" if project else None,
|
|
68
|
+
Path(project) / ".relay.toml" if project else None,
|
|
69
|
+
]:
|
|
70
|
+
if not path or not path.exists():
|
|
71
|
+
continue
|
|
72
|
+
data = (
|
|
73
|
+
json.loads(path.read_text())
|
|
74
|
+
if path.suffix == ".json"
|
|
75
|
+
else tomllib.loads(path.read_text())
|
|
76
|
+
)
|
|
77
|
+
result.update(validate(data.get("settings", {})))
|
|
78
|
+
if profile:
|
|
79
|
+
result.update(validate(data.get("profiles", {}).get(profile, {})))
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def edit(state, key, raw, profile=None):
|
|
84
|
+
try:
|
|
85
|
+
value = json.loads(raw)
|
|
86
|
+
except ValueError:
|
|
87
|
+
value = raw
|
|
88
|
+
validate({key: value})
|
|
89
|
+
path = state / "settings.json"
|
|
90
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
91
|
+
target = (
|
|
92
|
+
data.setdefault("profiles", {}).setdefault(profile, {})
|
|
93
|
+
if profile
|
|
94
|
+
else data.setdefault("settings", {})
|
|
95
|
+
)
|
|
96
|
+
target[key] = value
|
|
97
|
+
atomic_json(path, data)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def atomic_json(path, data):
|
|
101
|
+
path = Path(path)
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
103
|
+
temporary = path.with_name(path.name + f".{os.getpid()}.tmp")
|
|
104
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
105
|
+
with os.fdopen(fd, "w") as stream:
|
|
106
|
+
json.dump(data, stream, indent=2)
|
|
107
|
+
stream.write("\n")
|
|
108
|
+
stream.flush()
|
|
109
|
+
os.fsync(stream.fileno())
|
|
110
|
+
temporary.replace(path)
|
package/python/tools.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Local launch explanations, redacted diagnostics, saved workspaces, and cleanup."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from settings import atomic_json
|
|
11
|
+
from pool import Pool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def explain(arguments, cwd, binary=None):
|
|
15
|
+
from launch import (
|
|
16
|
+
translate,
|
|
17
|
+
working_directory,
|
|
18
|
+
native_mode,
|
|
19
|
+
additional_roots,
|
|
20
|
+
tokens,
|
|
21
|
+
arguments_only,
|
|
22
|
+
)
|
|
23
|
+
from router import VERSION
|
|
24
|
+
|
|
25
|
+
if binary is None:
|
|
26
|
+
from router import codex_path
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
binary = codex_path()
|
|
30
|
+
except Exception:
|
|
31
|
+
binary = None
|
|
32
|
+
native = native_mode(arguments, binary)
|
|
33
|
+
_, overrides = translate(arguments)
|
|
34
|
+
model = next(
|
|
35
|
+
(
|
|
36
|
+
value
|
|
37
|
+
for name, value, _ in tokens(arguments_only(arguments))
|
|
38
|
+
if name == "--model"
|
|
39
|
+
),
|
|
40
|
+
None,
|
|
41
|
+
)
|
|
42
|
+
return {
|
|
43
|
+
"relay_version": VERSION,
|
|
44
|
+
"mode": "native" if native else "managed",
|
|
45
|
+
"account_recovery": not native,
|
|
46
|
+
"cwd": working_directory(arguments, cwd),
|
|
47
|
+
"model": model or "Native Codex default",
|
|
48
|
+
"permissions": overrides or "Saved thread or native Codex defaults",
|
|
49
|
+
"additional_directories": additional_roots(
|
|
50
|
+
arguments, working_directory(arguments, cwd)
|
|
51
|
+
),
|
|
52
|
+
"codex": binary,
|
|
53
|
+
"note": "Native utilities and external/local-provider endpoints retain native behavior."
|
|
54
|
+
if native
|
|
55
|
+
else "Relay manages account resets, temporary failures, and the saved conversation.",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def diagnostics(state, output):
|
|
60
|
+
"""Allowlisted operational facts only; never read credentials or raw job arguments."""
|
|
61
|
+
from router import VERSION
|
|
62
|
+
import platform
|
|
63
|
+
|
|
64
|
+
pool = Pool(state)
|
|
65
|
+
try:
|
|
66
|
+
details = pool.detail()
|
|
67
|
+
with pool.db() as db:
|
|
68
|
+
health = db.execute("PRAGMA quick_check").fetchone()[0]
|
|
69
|
+
events = [
|
|
70
|
+
r[0]
|
|
71
|
+
for r in db.execute(
|
|
72
|
+
"SELECT kind FROM events ORDER BY id DESC LIMIT 100"
|
|
73
|
+
)
|
|
74
|
+
]
|
|
75
|
+
value = {
|
|
76
|
+
"relay_version": VERSION,
|
|
77
|
+
"python": platform.python_version(),
|
|
78
|
+
"platform": sys.platform,
|
|
79
|
+
"database": "ok" if health == "ok" else "failed",
|
|
80
|
+
"active_sessions": len(details),
|
|
81
|
+
"session_states": [r.get("state", "unknown") for r in details],
|
|
82
|
+
"recent_event_types": events,
|
|
83
|
+
}
|
|
84
|
+
destination = Path(output).expanduser()
|
|
85
|
+
with destination.open("x", encoding="utf-8") as stream:
|
|
86
|
+
os.chmod(destination, 0o600)
|
|
87
|
+
json.dump(value, stream, indent=2)
|
|
88
|
+
stream.write("\n")
|
|
89
|
+
return str(destination)
|
|
90
|
+
finally:
|
|
91
|
+
pool.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def workspace(state, action, name=None, sessions=None, binary=None):
|
|
95
|
+
import service
|
|
96
|
+
|
|
97
|
+
path = state / "workspaces.json"
|
|
98
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
99
|
+
pool = Pool(state)
|
|
100
|
+
try:
|
|
101
|
+
if action == "list":
|
|
102
|
+
return {key: len(value) for key, value in data.items()}
|
|
103
|
+
if action == "remove":
|
|
104
|
+
data.pop(name, None)
|
|
105
|
+
atomic_json(path, data)
|
|
106
|
+
return {"removed": name}
|
|
107
|
+
if action == "save":
|
|
108
|
+
live = pool.detail()
|
|
109
|
+
chosen = [
|
|
110
|
+
r
|
|
111
|
+
for r in live
|
|
112
|
+
if not sessions or any(r["id"].startswith(s) for s in sessions)
|
|
113
|
+
]
|
|
114
|
+
if not chosen or sessions and len(chosen) != len(sessions):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"Choose live session IDs from relay status, or omit IDs to save every live session"
|
|
117
|
+
)
|
|
118
|
+
jobs = []
|
|
119
|
+
for row in chosen:
|
|
120
|
+
job_path = service.folder(state) / (row["id"] + ".job.json")
|
|
121
|
+
if not job_path.exists():
|
|
122
|
+
continue
|
|
123
|
+
jobs.append(row["id"])
|
|
124
|
+
if not jobs:
|
|
125
|
+
raise ValueError("No supervised sessions were available to save")
|
|
126
|
+
data[name] = jobs
|
|
127
|
+
atomic_json(path, data)
|
|
128
|
+
return {"saved": name, "sessions": len(jobs)}
|
|
129
|
+
if name not in data:
|
|
130
|
+
raise ValueError("Unknown workspace")
|
|
131
|
+
alive = {r["id"] for r in pool.detail()}
|
|
132
|
+
result = []
|
|
133
|
+
for ident in data[name]:
|
|
134
|
+
if ident not in alive:
|
|
135
|
+
service.restart(state, ident)
|
|
136
|
+
result.append({"session": ident, "attach": "relay attach " + ident})
|
|
137
|
+
return result
|
|
138
|
+
finally:
|
|
139
|
+
pool.close()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cleanup(state, *, apply=False, days=30, jobs=False):
|
|
143
|
+
"""Default to a dry run. Preserve runtimes used by live or restartable jobs."""
|
|
144
|
+
import runtime
|
|
145
|
+
|
|
146
|
+
pool = Pool(state)
|
|
147
|
+
try:
|
|
148
|
+
live = {r["id"] for r in pool.detail()}
|
|
149
|
+
finally:
|
|
150
|
+
pool.close()
|
|
151
|
+
threshold = time.time() - days * 86400
|
|
152
|
+
protected = {str(Path(__file__).parent.resolve())}
|
|
153
|
+
workspaces_file = state / "workspaces.json"
|
|
154
|
+
saved_workspaces = (
|
|
155
|
+
json.loads(workspaces_file.read_text()) if workspaces_file.exists() else {}
|
|
156
|
+
)
|
|
157
|
+
workspace_jobs = {ident for group in saved_workspaces.values() for ident in group}
|
|
158
|
+
value = runtime.index(state)
|
|
159
|
+
native_protected = {
|
|
160
|
+
row["sha256"][:20] for row in value["runtimes"][-1:] if row.get("sha256")
|
|
161
|
+
}
|
|
162
|
+
if value.get("selected"):
|
|
163
|
+
native_protected.add(value["selected"][:20])
|
|
164
|
+
candidates = []
|
|
165
|
+
protect_all_python = False
|
|
166
|
+
for path in (state / "services").glob("*.job.json"):
|
|
167
|
+
try:
|
|
168
|
+
job = json.loads(path.read_text())
|
|
169
|
+
except (OSError, ValueError):
|
|
170
|
+
protect_all_python = True
|
|
171
|
+
continue
|
|
172
|
+
ident = path.name.removesuffix(".job.json")
|
|
173
|
+
old_stopped = (
|
|
174
|
+
ident not in live
|
|
175
|
+
and job.get("state") in {"stopped", "removed"}
|
|
176
|
+
and path.stat().st_mtime < threshold
|
|
177
|
+
)
|
|
178
|
+
if old_stopped and jobs and ident not in workspace_jobs:
|
|
179
|
+
candidates.extend(
|
|
180
|
+
p
|
|
181
|
+
for p in path.parent.glob(ident + ".*")
|
|
182
|
+
if p.is_file() and not p.is_symlink()
|
|
183
|
+
)
|
|
184
|
+
continue
|
|
185
|
+
if job.get("binary"):
|
|
186
|
+
protected.add(str(Path(job["binary"]).resolve()))
|
|
187
|
+
if job.get("relay_runtime"):
|
|
188
|
+
protected.add(str(Path(job["relay_runtime"]).resolve()))
|
|
189
|
+
elif ident in live:
|
|
190
|
+
protect_all_python = True
|
|
191
|
+
for folder in ["relay-runtimes", "native-runtimes"]:
|
|
192
|
+
root = state / folder
|
|
193
|
+
if not root.exists() or folder == "relay-runtimes" and protect_all_python:
|
|
194
|
+
continue
|
|
195
|
+
for path in root.iterdir():
|
|
196
|
+
resolved = str(path.resolve())
|
|
197
|
+
if (
|
|
198
|
+
path.is_symlink()
|
|
199
|
+
or not path.is_dir()
|
|
200
|
+
or path.stat().st_mtime >= threshold
|
|
201
|
+
):
|
|
202
|
+
continue
|
|
203
|
+
if folder == "native-runtimes" and path.name in native_protected:
|
|
204
|
+
continue
|
|
205
|
+
if any(p == resolved or p.startswith(resolved + os.sep) for p in protected):
|
|
206
|
+
continue
|
|
207
|
+
candidates.append(path)
|
|
208
|
+
# Recheck live references immediately before mutations. Never delete from another user's state.
|
|
209
|
+
if apply:
|
|
210
|
+
guard = Pool(state)
|
|
211
|
+
try:
|
|
212
|
+
if {r["id"] for r in guard.detail()} != live:
|
|
213
|
+
raise ValueError("Sessions changed during cleanup; rerun the dry run")
|
|
214
|
+
finally:
|
|
215
|
+
guard.close()
|
|
216
|
+
for path in candidates:
|
|
217
|
+
if path.is_symlink():
|
|
218
|
+
raise ValueError(
|
|
219
|
+
"Cleanup encountered a changed symlink; nothing further was removed"
|
|
220
|
+
)
|
|
221
|
+
if path.is_dir():
|
|
222
|
+
shutil.rmtree(path)
|
|
223
|
+
else:
|
|
224
|
+
path.unlink(missing_ok=True)
|
|
225
|
+
value["runtimes"] = [
|
|
226
|
+
r for r in value["runtimes"] if Path(r["binary"]).is_file()
|
|
227
|
+
]
|
|
228
|
+
atomic_json(state / "runtimes.json", value)
|
|
229
|
+
return {
|
|
230
|
+
"dry_run": not apply,
|
|
231
|
+
"paths": [str(p) for p in candidates],
|
|
232
|
+
"count": len(candidates),
|
|
233
|
+
}
|
package/python/watch.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Wait for local filesystem changes; never fetch allowance or run inference."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import select
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fingerprint(value):
|
|
11
|
+
"""Heartbeats and sample time do not represent a dashboard change."""
|
|
12
|
+
value = {**value, "at": None}
|
|
13
|
+
value["sessions"] = [
|
|
14
|
+
{k: v for k, v in item.items() if k != "seen"}
|
|
15
|
+
for item in value.get("sessions", [])
|
|
16
|
+
]
|
|
17
|
+
return json.dumps(value, sort_keys=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LocalChanges:
|
|
21
|
+
def __init__(self, state):
|
|
22
|
+
self.state = Path(state)
|
|
23
|
+
self.queue = select.kqueue() if hasattr(select, "kqueue") else None
|
|
24
|
+
self.files = []
|
|
25
|
+
self.signature = None
|
|
26
|
+
|
|
27
|
+
def close(self):
|
|
28
|
+
if self.queue:
|
|
29
|
+
self.queue.close()
|
|
30
|
+
for fd in self.files:
|
|
31
|
+
os.close(fd)
|
|
32
|
+
self.files.clear()
|
|
33
|
+
|
|
34
|
+
def wait(self, timeout=30):
|
|
35
|
+
paths = [
|
|
36
|
+
self.state,
|
|
37
|
+
self.state / "services",
|
|
38
|
+
self.state / "accounts",
|
|
39
|
+
self.state / "pool.sqlite",
|
|
40
|
+
self.state / "pool.sqlite-wal",
|
|
41
|
+
self.state / "settings.json",
|
|
42
|
+
]
|
|
43
|
+
if not self.queue:
|
|
44
|
+
import time
|
|
45
|
+
|
|
46
|
+
time.sleep(timeout)
|
|
47
|
+
return
|
|
48
|
+
for fd in self.files:
|
|
49
|
+
os.close(fd)
|
|
50
|
+
self.files = []
|
|
51
|
+
registrations = []
|
|
52
|
+
for path in paths:
|
|
53
|
+
try:
|
|
54
|
+
fd = os.open(path, os.O_RDONLY)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
continue
|
|
57
|
+
self.files.append(fd)
|
|
58
|
+
registrations.append(
|
|
59
|
+
select.kevent(
|
|
60
|
+
fd,
|
|
61
|
+
filter=select.KQ_FILTER_VNODE,
|
|
62
|
+
flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
|
|
63
|
+
fflags=select.KQ_NOTE_WRITE
|
|
64
|
+
| select.KQ_NOTE_RENAME
|
|
65
|
+
| select.KQ_NOTE_DELETE
|
|
66
|
+
| select.KQ_NOTE_ATTRIB,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
self.queue.control(registrations, max(1, len(registrations)), timeout)
|
|
70
|
+
|
|
71
|
+
async def changed(self, timeout=30):
|
|
72
|
+
await asyncio.to_thread(self.wait, timeout)
|
|
73
|
+
# Coalesce a burst of updates into one consistent snapshot.
|
|
74
|
+
await asyncio.sleep(0.15)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Offline test double for native JSONL transport. Never contacts OpenAI."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
import fcntl
|
|
12
|
+
|
|
13
|
+
home = Path(os.environ.get("CODEX_HOME", "."))
|
|
14
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
state_file = home / "fake-history.json"
|
|
16
|
+
guard = threading.RLock()
|
|
17
|
+
out = threading.Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def send(value):
|
|
21
|
+
with out:
|
|
22
|
+
print(json.dumps(value), flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def change(operation):
|
|
26
|
+
with guard:
|
|
27
|
+
with (home / "fake-history.lock").open("a") as lock:
|
|
28
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
29
|
+
data = (
|
|
30
|
+
json.loads(state_file.read_text())
|
|
31
|
+
if state_file.exists()
|
|
32
|
+
else {"threads": {}, "calls": []}
|
|
33
|
+
)
|
|
34
|
+
result = operation(data)
|
|
35
|
+
temporary = state_file.with_suffix(".tmp")
|
|
36
|
+
temporary.write_text(json.dumps(data))
|
|
37
|
+
temporary.replace(state_file)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def finish(thread, turn, text, mode):
|
|
42
|
+
status = "failed" if mode in {"quota", "capacity"} else "completed"
|
|
43
|
+
error = (
|
|
44
|
+
{"message": "synthetic limit", "codexErrorInfo": "usageLimitExceeded"}
|
|
45
|
+
if mode == "quota"
|
|
46
|
+
else None
|
|
47
|
+
)
|
|
48
|
+
if mode == "capacity":
|
|
49
|
+
error = {
|
|
50
|
+
"message": "Selected model is at capacity. Please try a different model.",
|
|
51
|
+
"codexErrorInfo": "policyCapacityUnavailable",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def apply(data):
|
|
55
|
+
value = data["threads"][thread]
|
|
56
|
+
value["status"] = {"type": "idle"}
|
|
57
|
+
value["turns"][-1].update(status=status, error=error)
|
|
58
|
+
return value["turns"][-1].copy()
|
|
59
|
+
|
|
60
|
+
value = change(apply)
|
|
61
|
+
if status == "completed":
|
|
62
|
+
send(
|
|
63
|
+
{
|
|
64
|
+
"method": "item/completed",
|
|
65
|
+
"params": {
|
|
66
|
+
"threadId": thread,
|
|
67
|
+
"item": {
|
|
68
|
+
"id": "reply-" + turn,
|
|
69
|
+
"type": "agentMessage",
|
|
70
|
+
"text": "FAKE_OK",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
if mode == "capacity":
|
|
76
|
+
send(
|
|
77
|
+
{
|
|
78
|
+
"method": "error",
|
|
79
|
+
"params": {
|
|
80
|
+
"threadId": thread,
|
|
81
|
+
"turnId": turn,
|
|
82
|
+
"error": error,
|
|
83
|
+
"willRetry": False,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
send({"method": "turn/completed", "params": {"threadId": thread, "turn": value}})
|
|
88
|
+
send(
|
|
89
|
+
{
|
|
90
|
+
"method": "thread/status/changed",
|
|
91
|
+
"params": {"threadId": thread, "status": {"type": "idle"}},
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if "--version" in sys.argv:
|
|
97
|
+
print("codex-cli 0.166.0")
|
|
98
|
+
raise SystemExit()
|
|
99
|
+
if "--help" in sys.argv:
|
|
100
|
+
print("--remote --remote-auth-token-env")
|
|
101
|
+
raise SystemExit()
|
|
102
|
+
|
|
103
|
+
for raw in sys.stdin:
|
|
104
|
+
try:
|
|
105
|
+
request = json.loads(raw)
|
|
106
|
+
method = request.get("method")
|
|
107
|
+
params = request.get("params") or {}
|
|
108
|
+
ident = request.get("id")
|
|
109
|
+
if ident is None:
|
|
110
|
+
continue
|
|
111
|
+
change(
|
|
112
|
+
lambda d: d["calls"].append(
|
|
113
|
+
{"method": method, "thread": params.get("threadId")}
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
result = {}
|
|
117
|
+
if method == "initialize":
|
|
118
|
+
result = {
|
|
119
|
+
"userAgent": "fake/1",
|
|
120
|
+
"codexHome": str(home),
|
|
121
|
+
"platformFamily": "unix",
|
|
122
|
+
"platformOs": "macos",
|
|
123
|
+
}
|
|
124
|
+
elif method == "account/read":
|
|
125
|
+
result = {"account": {"type": "chatgpt", "planType": "plus"}}
|
|
126
|
+
elif method == "account/login/start":
|
|
127
|
+
result = {"type": params["type"]}
|
|
128
|
+
elif method == "account/rateLimits/read":
|
|
129
|
+
result = {
|
|
130
|
+
"ordinaryUsageAllowed": True,
|
|
131
|
+
"rateLimits": {
|
|
132
|
+
"primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
elif method in ("thread/start", "thread/fork"):
|
|
136
|
+
thread = str(uuid.uuid4())
|
|
137
|
+
value = {
|
|
138
|
+
"id": thread,
|
|
139
|
+
"cwd": params.get("cwd", str(home)),
|
|
140
|
+
"model": params.get("model", "fake-model"),
|
|
141
|
+
"canAcceptDirectInput": True,
|
|
142
|
+
"status": {"type": "idle"},
|
|
143
|
+
"turns": [],
|
|
144
|
+
}
|
|
145
|
+
change(lambda d: d["threads"].update({thread: value}))
|
|
146
|
+
result = {
|
|
147
|
+
"thread": value,
|
|
148
|
+
"model": value["model"],
|
|
149
|
+
"cwd": value["cwd"],
|
|
150
|
+
"approvalPolicy": params.get("approvalPolicy", "never"),
|
|
151
|
+
"sandbox": params.get("sandbox", "read-only"),
|
|
152
|
+
}
|
|
153
|
+
send({"method": "thread/started", "params": {"thread": value}})
|
|
154
|
+
elif method == "thread/resume":
|
|
155
|
+
value = change(lambda d: d["threads"].get(params["threadId"]))
|
|
156
|
+
if not value:
|
|
157
|
+
raise ValueError("no thread")
|
|
158
|
+
if (
|
|
159
|
+
value.get("runtime") != os.getpid()
|
|
160
|
+
and value.get("status", {}).get("type") == "active"
|
|
161
|
+
):
|
|
162
|
+
value["status"] = {"type": "idle"}
|
|
163
|
+
if value["turns"]:
|
|
164
|
+
value["turns"][-1].update(status="interrupted", error=None)
|
|
165
|
+
value.update({k: v for k, v in params.items() if k in {"model", "cwd"}})
|
|
166
|
+
change(lambda d: d["threads"].update({value["id"]: value}))
|
|
167
|
+
result = {"thread": value, "model": value["model"], "cwd": value["cwd"]}
|
|
168
|
+
elif method == "thread/read":
|
|
169
|
+
result = {"thread": change(lambda d: d["threads"][params["threadId"]])}
|
|
170
|
+
elif method == "thread/turns/list":
|
|
171
|
+
result = {
|
|
172
|
+
"data": change(
|
|
173
|
+
lambda d: list(reversed(d["threads"][params["threadId"]]["turns"]))[
|
|
174
|
+
: params.get("limit", 1)
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
elif method == "thread/loaded/list":
|
|
179
|
+
result = {"data": change(lambda d: list(d["threads"]))}
|
|
180
|
+
elif method in {"turn/start", "review/start"}:
|
|
181
|
+
thread = params["threadId"]
|
|
182
|
+
turn = str(uuid.uuid4())
|
|
183
|
+
value = {"id": turn, "status": "inProgress", "items": [], "error": None}
|
|
184
|
+
|
|
185
|
+
def begin(data):
|
|
186
|
+
data["threads"][thread]["turns"].append(value)
|
|
187
|
+
data["threads"][thread]["status"] = {
|
|
188
|
+
"type": "active",
|
|
189
|
+
"activeFlags": [],
|
|
190
|
+
}
|
|
191
|
+
data["threads"][thread]["runtime"] = os.getpid()
|
|
192
|
+
return sum(
|
|
193
|
+
c["method"] in {"turn/start", "review/start"} for c in data["calls"]
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
count = change(begin)
|
|
197
|
+
fault = (
|
|
198
|
+
json.loads((home / "fault.json").read_text())
|
|
199
|
+
if (home / "fault.json").exists()
|
|
200
|
+
else {}
|
|
201
|
+
)
|
|
202
|
+
mode = "quota" if count <= fault.get("quota_turns", 0) else "ok"
|
|
203
|
+
if count <= fault.get("capacity_turns", 0):
|
|
204
|
+
mode = "capacity"
|
|
205
|
+
if not fault.get("drop_ack"):
|
|
206
|
+
send({"id": ident, "result": {"turn": value}})
|
|
207
|
+
send(
|
|
208
|
+
{
|
|
209
|
+
"method": "turn/started",
|
|
210
|
+
"params": {"threadId": thread, "turn": value},
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
if fault.get("external"):
|
|
214
|
+
send(
|
|
215
|
+
{
|
|
216
|
+
"method": "item/started",
|
|
217
|
+
"params": {
|
|
218
|
+
"threadId": thread,
|
|
219
|
+
"item": {"id": "external1", "type": "commandExecution"},
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
if fault.get("die_once") and count == 1:
|
|
224
|
+
os._exit(11)
|
|
225
|
+
timer = threading.Timer(
|
|
226
|
+
float(fault.get("delay", 0.02)), finish, args=(thread, turn, "", mode)
|
|
227
|
+
)
|
|
228
|
+
timer.daemon = True
|
|
229
|
+
timer.start()
|
|
230
|
+
continue
|
|
231
|
+
elif method == "turn/interrupt":
|
|
232
|
+
result = {}
|
|
233
|
+
send({"id": ident, "result": result})
|
|
234
|
+
except Exception:
|
|
235
|
+
send(
|
|
236
|
+
{
|
|
237
|
+
"id": request.get("id"),
|
|
238
|
+
"error": {"code": -32600, "message": "Synthetic request rejected"},
|
|
239
|
+
}
|
|
240
|
+
)
|