v3.0.0: ThreadingHTTPServer, dynamic ports, health gating, atomic config, safe cleanup, buffered SSE, batched stats, graceful shutdown
This commit is contained in:
@@ -5,7 +5,7 @@ import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib
|
||||
import subprocess, os, signal, sys, threading, time, json, urllib.request, tempfile, shutil
|
||||
import hashlib
|
||||
import hashlib, socket, contextlib
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
@@ -435,11 +435,51 @@ def import_profile_bundle(path):
|
||||
|
||||
def backup_config():
|
||||
if CONFIG.exists():
|
||||
shutil.copy2(str(CONFIG), str(CONFIG_BAK))
|
||||
tmp = CONFIG_BAK.with_suffix(".tmp")
|
||||
shutil.copy2(str(CONFIG), str(tmp))
|
||||
os.replace(str(tmp), str(CONFIG_BAK))
|
||||
|
||||
def restore_config():
|
||||
if CONFIG_BAK.exists():
|
||||
CONFIG_BAK.rename(CONFIG)
|
||||
tmp = CONFIG.with_suffix(".tmp")
|
||||
shutil.copy2(str(CONFIG_BAK), str(tmp))
|
||||
os.replace(str(tmp), str(CONFIG))
|
||||
|
||||
def write_secure_text(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
os.chmod(str(tmp), 0o600)
|
||||
os.replace(str(tmp), str(path))
|
||||
|
||||
CONFIG_TXN = HOME / ".codex/config.toml.launcher-txn.json"
|
||||
|
||||
def begin_config_transaction(reason):
|
||||
txn = {"started_at": time.time(), "reason": reason,
|
||||
"config_existed": CONFIG.exists(), "backup_path": str(CONFIG_BAK)}
|
||||
if CONFIG.exists():
|
||||
backup_config()
|
||||
CONFIG_TXN.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_TXN.write_text(json.dumps(txn, indent=2))
|
||||
|
||||
def end_config_transaction():
|
||||
CONFIG_TXN.unlink(missing_ok=True)
|
||||
|
||||
def recover_config_if_needed(logfn=None):
|
||||
if not CONFIG_TXN.exists():
|
||||
return
|
||||
try:
|
||||
txn = json.loads(CONFIG_TXN.read_text())
|
||||
if txn.get("config_existed") and CONFIG_BAK.exists():
|
||||
restore_config()
|
||||
if logfn:
|
||||
logfn("Recovered Codex config from interrupted session.")
|
||||
elif CONFIG.exists():
|
||||
CONFIG.unlink()
|
||||
if logfn:
|
||||
logfn("Removed generated config from interrupted session.")
|
||||
finally:
|
||||
CONFIG_TXN.unlink(missing_ok=True)
|
||||
|
||||
def write_config_for_native(endpoint, selected_model):
|
||||
"""Write config for native OpenAI (no proxy needed)."""
|
||||
@@ -470,8 +510,7 @@ def _toml_safe(val):
|
||||
val = str(val).replace('"', '\\"')
|
||||
return val.split('\n', 1)[0].strip()
|
||||
|
||||
def write_config_for_translated(endpoint, selected_model):
|
||||
"""Write config pointing at local proxy."""
|
||||
def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
||||
backup_config()
|
||||
model_catalog = _gen_model_catalog(endpoint, selected_model)
|
||||
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
||||
@@ -484,8 +523,8 @@ def write_config_for_translated(endpoint, selected_model):
|
||||
f'model_catalog_json = "{mc_path}"\n',
|
||||
f'\n[model_providers."{endpoint["name"]}"]\n',
|
||||
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
||||
f'base_url = "http://127.0.0.1:8080"\n',
|
||||
f'experimental_bearer_token = "{_toml_safe(endpoint["api_key"])}"\n',
|
||||
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
||||
f'experimental_bearer_token = "codex-launcher-local"\n',
|
||||
f'\n[profiles."{endpoint["name"]}"]\n',
|
||||
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
||||
f'model = "{_toml_safe(selected_model)}"\n',
|
||||
@@ -493,7 +532,7 @@ def write_config_for_translated(endpoint, selected_model):
|
||||
f'service_tier = "fast"\n',
|
||||
f'approvals_reviewer = "user"\n',
|
||||
]
|
||||
CONFIG.write_text("".join(lines))
|
||||
write_secure_text(CONFIG, "".join(lines))
|
||||
|
||||
def _gen_model_catalog(endpoint, selected_model=None):
|
||||
default_model = selected_model or endpoint.get("default_model")
|
||||
@@ -533,13 +572,66 @@ def _gen_model_catalog(endpoint, selected_model=None):
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
_proxy_proc = None
|
||||
_proxy_port = None
|
||||
|
||||
PID_REGISTRY = HOME / ".cache" / "codex-launcher" / "pids.json"
|
||||
|
||||
def _pick_free_port():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
def _load_pid_registry():
|
||||
if PID_REGISTRY.exists():
|
||||
try:
|
||||
return json.loads(PID_REGISTRY.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_pid_registry(data):
|
||||
PID_REGISTRY.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = PID_REGISTRY.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2))
|
||||
os.replace(str(tmp), str(PID_REGISTRY))
|
||||
|
||||
def _register_pgid(kind, pid):
|
||||
data = _load_pid_registry()
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
data[kind] = {"pid": pid, "pgid": pgid, "ts": time.time()}
|
||||
_save_pid_registry(data)
|
||||
|
||||
def safe_cleanup_owned(logfn=None):
|
||||
data = _load_pid_registry()
|
||||
changed = False
|
||||
for kind, meta in list(data.items()):
|
||||
pgid = meta.get("pgid")
|
||||
if not pgid:
|
||||
continue
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
if logfn:
|
||||
logfn(f"Stopped {kind} (pgid {pgid})")
|
||||
changed = True
|
||||
except ProcessLookupError:
|
||||
changed = True
|
||||
except Exception as e:
|
||||
if logfn:
|
||||
logfn(f"Could not stop {kind}: {e}")
|
||||
if changed:
|
||||
_save_pid_registry({})
|
||||
|
||||
def _start_proxy_for(endpoint, logfn):
|
||||
global _proxy_proc
|
||||
global _proxy_proc, _proxy_port
|
||||
_stop_proxy()
|
||||
port = _pick_free_port()
|
||||
_proxy_port = port
|
||||
|
||||
pcfg = {
|
||||
"port": 8080,
|
||||
"port": port,
|
||||
"backend_type": endpoint["backend_type"],
|
||||
"target_url": normalize_base_url(endpoint["base_url"]),
|
||||
"api_key": endpoint["api_key"],
|
||||
@@ -550,26 +642,49 @@ def _start_proxy_for(endpoint, logfn):
|
||||
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
|
||||
for m in endpoint.get("models", [])],
|
||||
}
|
||||
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}.json"
|
||||
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}-{port}.json"
|
||||
pcfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pcfg_path.write_text(json.dumps(pcfg, indent=2))
|
||||
_start_proxy_with_config(pcfg_path, logfn)
|
||||
_start_proxy_with_config(pcfg_path, port, logfn)
|
||||
return port
|
||||
|
||||
def _start_proxy_with_config(pcfg_path, logfn):
|
||||
def _start_proxy_with_config(pcfg_path, port, logfn):
|
||||
global _proxy_proc
|
||||
_proxy_proc = subprocess.Popen(
|
||||
["python3", str(PROXY), "--config", str(pcfg_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
preexec_fn=os.setsid,
|
||||
text=True,
|
||||
)
|
||||
for _ in range(30):
|
||||
try:
|
||||
urllib.request.urlopen("http://127.0.0.1:8080/v1/models", timeout=2)
|
||||
logfn("Proxy ready on port 8080")
|
||||
_register_pgid("proxy", _proxy_proc.pid)
|
||||
|
||||
def _pipe_stderr():
|
||||
if not _proxy_proc.stderr:
|
||||
return
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
logfn("WARNING: proxy may not have started in time")
|
||||
for line in _proxy_proc.stderr:
|
||||
GLib.idle_add(logfn, f"[proxy] {line.rstrip()}")
|
||||
threading.Thread(target=_pipe_stderr, daemon=True).start()
|
||||
|
||||
deadline = time.time() + 15
|
||||
last_err = None
|
||||
while time.time() < deadline:
|
||||
if _proxy_proc.poll() is not None:
|
||||
raise RuntimeError(f"Proxy exited early with code {_proxy_proc.returncode}")
|
||||
try:
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=2)
|
||||
logfn(f"Proxy ready on port {port}")
|
||||
return
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(0.3)
|
||||
try:
|
||||
os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM)
|
||||
_proxy_proc.wait(timeout=3)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL)
|
||||
raise RuntimeError(f"Proxy failed health check on port {port}: {last_err}")
|
||||
|
||||
def _stop_proxy():
|
||||
global _proxy_proc
|
||||
@@ -583,8 +698,8 @@ def _stop_proxy():
|
||||
pass
|
||||
_proxy_proc = None
|
||||
|
||||
def _run_cleanup():
|
||||
subprocess.run(["bash", str(CLEANUP)], capture_output=True, timeout=30)
|
||||
def _run_cleanup(logfn=None):
|
||||
safe_cleanup_owned(logfn)
|
||||
|
||||
def _last_log_lines(n=15):
|
||||
try:
|
||||
@@ -640,6 +755,7 @@ class LauncherWin(Gtk.Window):
|
||||
self.set_position(Gtk.WindowPosition.CENTER)
|
||||
self._proc = None
|
||||
self._endpoints_data = load_endpoints()
|
||||
recover_config_if_needed()
|
||||
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
self.add(vbox)
|
||||
@@ -647,7 +763,7 @@ class LauncherWin(Gtk.Window):
|
||||
# header row
|
||||
hdr = Gtk.Box(spacing=8)
|
||||
vbox.pack_start(hdr, False, False, 0)
|
||||
lbl = Gtk.Label(label="<b>Codex Launcher v2.7.0</b>")
|
||||
lbl = Gtk.Label(label="<b>Codex Launcher v3.0.0</b>")
|
||||
lbl.set_use_markup(True)
|
||||
hdr.pack_start(lbl, False, False, 0)
|
||||
changelog_btn = Gtk.Button(label="Changelog")
|
||||
@@ -1207,17 +1323,24 @@ class LauncherWin(Gtk.Window):
|
||||
def _run(self, ep, model, target):
|
||||
try:
|
||||
self.log("Cleaning up stale processes…")
|
||||
_run_cleanup()
|
||||
_run_cleanup(self.log)
|
||||
recover_config_if_needed(self.log)
|
||||
|
||||
needs_proxy = ep["backend_type"] != "native"
|
||||
|
||||
if needs_proxy:
|
||||
self.log("Starting translation proxy…")
|
||||
_start_proxy_for(ep, self.log)
|
||||
self.log(f"Configuring Codex for {ep['name']} (proxied)…")
|
||||
write_config_for_translated(ep, model)
|
||||
try:
|
||||
proxy_port = _start_proxy_for(ep, self.log)
|
||||
except RuntimeError as e:
|
||||
GLib.idle_add(self._show_error_dialog, "Proxy startup failed", str(e))
|
||||
return
|
||||
self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})…")
|
||||
begin_config_transaction(f"launch:{ep['name']}")
|
||||
write_config_for_translated(ep, model, proxy_port)
|
||||
else:
|
||||
self.log(f"Configuring Codex for {ep['name']} (native)…")
|
||||
begin_config_transaction(f"launch:{ep['name']}")
|
||||
write_config_for_native(ep, model)
|
||||
|
||||
if target == "desktop":
|
||||
@@ -1230,15 +1353,18 @@ class LauncherWin(Gtk.Window):
|
||||
finally:
|
||||
_stop_proxy()
|
||||
restore_config()
|
||||
end_config_transaction()
|
||||
self._set_busy(False)
|
||||
self.log("Ready.")
|
||||
|
||||
def _run_bgp(self, pool, model, target):
|
||||
try:
|
||||
self.log("Cleaning up stale processes…")
|
||||
_run_cleanup()
|
||||
_run_cleanup(self.log)
|
||||
recover_config_if_needed(self.log)
|
||||
|
||||
self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes…")
|
||||
port = _pick_free_port()
|
||||
self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes on :{port}…")
|
||||
bgp_ep = {
|
||||
"name": pool["name"],
|
||||
"backend_type": "openai-compat",
|
||||
@@ -1248,19 +1374,24 @@ class LauncherWin(Gtk.Window):
|
||||
"models": list(dict.fromkeys(r.get("model", model) for r in pool.get("routes", []))),
|
||||
}
|
||||
pcfg = {
|
||||
"port": 8080,
|
||||
"port": port,
|
||||
"backend_type": "openai-compat",
|
||||
"target_url": "http://bgp.placeholder",
|
||||
"api_key": "",
|
||||
"bgp_routes": pool.get("routes", []),
|
||||
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]],
|
||||
}
|
||||
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}.json"
|
||||
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}-{port}.json"
|
||||
pcfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pcfg_path.write_text(json.dumps(pcfg, indent=2))
|
||||
_start_proxy_with_config(pcfg_path, self.log)
|
||||
try:
|
||||
_start_proxy_with_config(pcfg_path, port, self.log)
|
||||
except RuntimeError as e:
|
||||
GLib.idle_add(self._show_error_dialog, "BGP proxy startup failed", str(e))
|
||||
return
|
||||
|
||||
write_config_for_translated(bgp_ep, model)
|
||||
begin_config_transaction(f"launch:bgp:{pool['name']}")
|
||||
write_config_for_translated(bgp_ep, model, port)
|
||||
|
||||
if target == "desktop":
|
||||
self._launch_desktop(bgp_ep, model)
|
||||
@@ -1272,17 +1403,19 @@ class LauncherWin(Gtk.Window):
|
||||
finally:
|
||||
_stop_proxy()
|
||||
restore_config()
|
||||
end_config_transaction()
|
||||
self._set_busy(False)
|
||||
self.log("Ready.")
|
||||
|
||||
def _run_codex_default(self, target):
|
||||
try:
|
||||
self.log("Cleaning up stale processes…")
|
||||
_run_cleanup()
|
||||
_run_cleanup(self.log)
|
||||
_stop_proxy()
|
||||
recover_config_if_needed(self.log)
|
||||
|
||||
self.log("Resetting config to Codex defaults (OAuth)…")
|
||||
backup_config()
|
||||
begin_config_transaction("launch:default")
|
||||
if CONFIG.exists():
|
||||
CONFIG.unlink()
|
||||
|
||||
@@ -1294,9 +1427,19 @@ class LauncherWin(Gtk.Window):
|
||||
self.log(f"ERROR: {e}")
|
||||
finally:
|
||||
restore_config()
|
||||
end_config_transaction()
|
||||
self._set_busy(False)
|
||||
self.log("Ready.")
|
||||
|
||||
def _show_error_dialog(self, title, message):
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self, flags=0,
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.CLOSE, text=str(title))
|
||||
dialog.format_secondary_text(str(message))
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def _launch_desktop(self, ep, model):
|
||||
args = [str(START_SH)]
|
||||
if ep["backend_type"] != "native":
|
||||
@@ -1454,8 +1597,9 @@ class LauncherWin(Gtk.Window):
|
||||
pass
|
||||
self._proc = None
|
||||
_stop_proxy()
|
||||
_run_cleanup()
|
||||
_run_cleanup(self.log)
|
||||
restore_config()
|
||||
end_config_transaction()
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LAUNCH_LOG.unlink(missing_ok=True)
|
||||
self.log("Cleanup complete")
|
||||
|
||||
Reference in New Issue
Block a user