From b060706e18c3d778b7db545a437c16e1dc9b0316 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 20 May 2026 18:54:47 +0400 Subject: [PATCH] v3.0.0: ThreadingHTTPServer, dynamic ports, health gating, atomic config, safe cleanup, buffered SSE, batched stats, graceful shutdown --- CHANGELOG.md | 29 ++++ codex-launcher_3.0.0_all.deb | Bin 0 -> 37210 bytes src/codex-launcher-gui | 216 ++++++++++++++++++++++++----- src/translate-proxy.py | 259 +++++++++++++++++++++++++++-------- 4 files changed, 410 insertions(+), 94 deletions(-) create mode 100644 codex-launcher_3.0.0_all.deb diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b70c8e..b4a7242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## v3.0.0 (2026-05-20) + +**Major architectural overhaul — Phase 0 + Phase 1 of engineering roadmap** + +### Proxy (translate-proxy.py) +- **ThreadingHTTPServer** — serves concurrent requests (no more blocking) +- **Thread-safe shared state** — OrderedDict response store with locks, Crof state lock, stats lock +- **Batched + atomic stats writes** — stats buffered in memory, flushed every 5s via `os.replace()` +- **Graceful shutdown** — SIGTERM/SIGINT drain active connections (up to 5s), reject new with 503 +- **Progressive upstream timeouts** — based on input size and tools (60-300s instead of flat 180s) +- **Lazy JSON parsing** — skip parsing SSE events unless they contain `response.completed` +- **Buffered SSE writes** — flush every 30ms, on urgent events, or at 4KB (reduces syscalls) +- **`/health` endpoint** — returns backend, target, models, BGP route count +- **Consolidated imports** — all at top, no more missing import crashes +- **`main()` entry point** — runtime init moved out of module level +- **TCP_NODELAY** — on all streaming paths (from v2.7.0) +- **Anthropic prompt caching** — `cache_control: ephemeral` on system prompts (from v2.7.0) + +### Launcher (codex-launcher-gui) +- **Dynamic port allocation** — `_pick_free_port()` picks random free port, no more 8080 conflicts +- **Proxy health gating** — Codex will NOT launch if proxy fails health check within 15s +- **Error dialogs** — clear GTK error dialog when proxy startup fails +- **Atomic config backup/restore** — temp file + `os.replace()`, no more corrupted config.toml +- **Config transactions** — recovery from interrupted sessions on next startup +- **Safe cleanup (PID registry)** — only kills processes launched by the app (pids.json) +- **Proxy stderr piped to log** — real-time proxy logs in launcher UI +- **Bearer token** — Codex config uses `codex-launcher-local` instead of real API key +- **Usage Dashboard v2** — OpenUsage-inspired dark theme with status pills, KPI strip, model bars (from v2.7.0) + ## v2.7.0 (2026-05-20) - **Usage Dashboard redesigned** (inspired by OpenUsage design patterns) diff --git a/codex-launcher_3.0.0_all.deb b/codex-launcher_3.0.0_all.deb new file mode 100644 index 0000000000000000000000000000000000000000..96e0474aafb33deac442cc21450539969c125129 GIT binary patch literal 37210 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 literal 0 HcmV?d00001 diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index 26a9a1d..604e4d3 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -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="Codex Launcher v2.7.0") + lbl = Gtk.Label(label="Codex Launcher v3.0.0") 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") diff --git a/src/translate-proxy.py b/src/translate-proxy.py index aa087f4..53996f6 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -11,7 +11,8 @@ Usage: python3 translate-proxy.py --backend openai-compat --target-url https://... --api-key sk-... """ -import json, http.server, urllib.request, time, uuid, os, sys, argparse, threading, socket +import json, http.server, socketserver, urllib.request, urllib.parse, urllib.error +import time, uuid, os, sys, argparse, threading, socket, collections, contextlib, signal # ═══════════════════════════════════════════════════════════════════ # Config @@ -74,25 +75,64 @@ def load_config(): return cfg -CONFIG = load_config() -PORT = CONFIG["port"] -BACKEND = CONFIG["backend_type"] -TARGET_URL = CONFIG["target_url"].rstrip("/") -API_KEY = CONFIG["api_key"] -OAUTH_PROVIDER = CONFIG.get("oauth_provider", "") -MODELS = CONFIG["models"] -CC_VERSION = CONFIG.get("cc_version", "") -REASONING_ENABLED = CONFIG.get("reasoning_enabled", True) -REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium") -BGP_ROUTES = CONFIG.get("bgp_routes", []) -BGP_MODELS = [] -for _r in BGP_ROUTES: - for _m in _r.get("models", [{"id": _r.get("model", "unknown")}]): - if _m.get("id", _m) not in BGP_MODELS: - BGP_MODELS.append(_m.get("id", _m) if isinstance(_m, dict) else _m) -if BGP_ROUTES and not MODELS: - MODELS = [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in BGP_MODELS] - CONFIG["models"] = MODELS +CONFIG = None +PORT = 8080 +BACKEND = "openai-compat" +TARGET_URL = "" +API_KEY = "" +OAUTH_PROVIDER = "" +MODELS = [] +CC_VERSION = "" +REASONING_ENABLED = True +REASONING_EFFORT = "medium" +BGP_ROUTES = [] +SERVER = None + +_LOG_DIR = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy") +os.makedirs(_LOG_DIR, exist_ok=True) +_stats_path = os.path.join(_LOG_DIR, "usage-stats.json") +_stats_lock = threading.Lock() +_stats_pending = [] +_stats_flush_timer = None +_STATS_FLUSH_INTERVAL = 5.0 + +_response_store = collections.OrderedDict() +_response_store_lock = threading.Lock() +_MAX_STORED = 50 + +_crof_lock = threading.Lock() + +_shutdown_requested = False +_active_connections = 0 +_active_connections_lock = threading.Lock() + +_pool = uuid.uuid4().hex[:8] + +def _init_runtime(): + global CONFIG, PORT, BACKEND, TARGET_URL, API_KEY, OAUTH_PROVIDER + global MODELS, CC_VERSION, REASONING_ENABLED, REASONING_EFFORT, BGP_ROUTES + + CONFIG = load_config() + PORT = CONFIG["port"] + BACKEND = CONFIG["backend_type"] + TARGET_URL = CONFIG["target_url"].rstrip("/") + API_KEY = CONFIG["api_key"] + OAUTH_PROVIDER = CONFIG.get("oauth_provider", "") + MODELS = CONFIG["models"] + CC_VERSION = CONFIG.get("cc_version", "") + REASONING_ENABLED = CONFIG.get("reasoning_enabled", True) + REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium") + BGP_ROUTES = CONFIG.get("bgp_routes", []) + + bgp_models = [] + for _r in BGP_ROUTES: + for _m in _r.get("models", [{"id": _r.get("model", "unknown")}]): + mid = _m.get("id", _m) if isinstance(_m, dict) else _m + if mid not in bgp_models: + bgp_models.append(mid) + if BGP_ROUTES and not MODELS: + MODELS = [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_models] + CONFIG["models"] = MODELS def _refresh_oauth_token(): return _refresh_oauth_token_for(API_KEY, OAUTH_PROVIDER) @@ -138,14 +178,6 @@ def _refresh_oauth_token_for(api_key, oauth_provider): _pool = uuid.uuid4().hex[:8] -_response_store = {} -_MAX_STORED = 50 - -_LOG_DIR = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy") -os.makedirs(_LOG_DIR, exist_ok=True) -_stats_path = os.path.join(_LOG_DIR, "usage-stats.json") -_stats_lock = threading.Lock() - def _load_stats(): try: if os.path.exists(_stats_path): @@ -154,46 +186,78 @@ def _load_stats(): pass return {"providers": {}, "updated": None} -def _record_usage(provider, model, success, duration_s, tokens_in=0, tokens_out=0, error_type=None): +def _atomic_write_json(path, obj): + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(obj, f, indent=2, ensure_ascii=False) + os.replace(tmp, path) + +def _flush_stats(): + global _stats_flush_timer with _stats_lock: - stats = _load_stats() + batch = list(_stats_pending) + _stats_pending.clear() + _stats_flush_timer = None + if not batch: + return + stats = _load_stats() + for entry in batch: + provider = entry["provider"] + model = entry["model"] p = stats["providers"].setdefault(provider, { "total_requests": 0, "successes": 0, "failures": 0, "total_tokens_in": 0, "total_tokens_out": 0, "total_duration_s": 0.0, "models": {}, "last_used": None, "last_error": None, }) p["total_requests"] += 1 - p["total_tokens_in"] += tokens_in - p["total_tokens_out"] += tokens_out - p["total_duration_s"] += duration_s - p["last_used"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - if success: + p["total_tokens_in"] += entry["tokens_in"] + p["total_tokens_out"] += entry["tokens_out"] + p["total_duration_s"] += entry["duration_s"] + p["last_used"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(entry["ts"])) + if entry["success"]: p["successes"] += 1 else: p["failures"] += 1 - p["last_error"] = error_type or "unknown" + p["last_error"] = entry.get("error_type") or "unknown" m = p["models"].setdefault(model, {"requests": 0, "tokens_in": 0, "tokens_out": 0}) m["requests"] += 1 - m["tokens_in"] += tokens_in - m["tokens_out"] += tokens_out - stats["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - with open(_stats_path, "w") as f: - json.dump(stats, f, indent=2) + m["tokens_in"] += entry["tokens_in"] + m["tokens_out"] += entry["tokens_out"] + stats["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + _atomic_write_json(_stats_path, stats) + +def _record_usage(provider, model, success, duration_s, tokens_in=0, tokens_out=0, error_type=None): + global _stats_flush_timer + entry = { + "provider": provider or "unknown", "model": model or "unknown", + "success": bool(success), "duration_s": float(duration_s or 0), + "tokens_in": int(tokens_in or 0), "tokens_out": int(tokens_out or 0), + "error_type": error_type, "ts": time.time(), + } + with _stats_lock: + _stats_pending.append(entry) + if _stats_flush_timer is None: + _stats_flush_timer = threading.Timer(_STATS_FLUSH_INTERVAL, _flush_stats) + _stats_flush_timer.daemon = True + _stats_flush_timer.start() def store_response(resp_id, input_data, output_items): if not resp_id: return - _response_store[resp_id] = {"input": input_data, "output": output_items} - if len(_response_store) > _MAX_STORED: - oldest = list(_response_store.keys())[0] - del _response_store[oldest] + with _response_store_lock: + _response_store[resp_id] = {"input": input_data, "output": output_items, "ts": time.time()} + while len(_response_store) > _MAX_STORED: + _response_store.popitem(last=False) def resolve_previous_response(body): prev_id = body.get("previous_response_id") input_data = body.get("input", "") - if not prev_id or prev_id not in _response_store: + if not prev_id: + return input_data + with _response_store_lock: + stored = _response_store.get(prev_id) + if not stored: return input_data - stored = _response_store[prev_id] prev_input = stored["input"] prev_output = stored["output"] new_input = input_data if isinstance(input_data, list) else [] @@ -983,18 +1047,60 @@ def _log_resp(resp_id, status, output): except Exception: pass +class ConnectionTracker: + def __enter__(self): + global _active_connections + with _active_connections_lock: + _active_connections += 1 + def __exit__(self, *a): + global _active_connections + with _active_connections_lock: + _active_connections -= 1 + +def _handle_shutdown_signal(signum, frame): + global _shutdown_requested + _shutdown_requested = True + print("[proxy] shutdown requested; draining connections", file=sys.stderr) + def _drain(): + deadline = time.time() + 5 + while time.time() < deadline: + with _active_connections_lock: + if _active_connections == 0: + break + time.sleep(0.1) + if SERVER is not None: + SERVER.shutdown() + threading.Thread(target=_drain, daemon=True).start() + +def _upstream_timeout(body, stream): + input_data = body.get("input", "") + n_items = len(input_data) if isinstance(input_data, list) else 1 + has_tools = bool(body.get("tools")) + if stream: + return min((180 if has_tools else 120) + n_items * 2, 300) + return min(60 + n_items * 2, 120) + class Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_GET(self): if self.path in ("/v1/models", "/models"): self.send_json(200, {"object": "list", "data": MODELS}) + elif self.path in ("/health", "/v1/health"): + self.send_json(200, {"ok": True, "backend": BACKEND, + "target_url": TARGET_URL, + "models": [m.get("id") for m in MODELS], + "bgp_routes": len(BGP_ROUTES)}) else: self.send_error(404) def do_POST(self): + if _shutdown_requested: + return self.send_json(503, {"error": {"type": "proxy_shutting_down", + "message": "Proxy is shutting down"}}) if self.path in ("/v1/responses", "/responses"): - self._handle() + with ConnectionTracker(): + self._handle() else: self.send_error(404) @@ -1082,7 +1188,7 @@ class Handler(http.server.BaseHTTPRequestHandler): for attempt in range(max_retries + 1): req = urllib.request.Request(target, data=chat_body_b, headers=fwd) try: - upstream = urllib.request.urlopen(req, timeout=180) + upstream = urllib.request.urlopen(req, timeout=_upstream_timeout(body, stream)) except urllib.error.HTTPError as e: err_body = e.read().decode() if e.code in (429, 502, 503) and attempt < max_retries: @@ -1163,7 +1269,7 @@ class Handler(http.server.BaseHTTPRequestHandler): route_ok = False for attempt in range(3): try: - upstream = urllib.request.urlopen(req, timeout=180) + upstream = urllib.request.urlopen(req, timeout=_upstream_timeout(body, stream)) print(f"[bgp] route '{route.get('name', r_url)}' connected OK", file=sys.stderr) self._forward_oa_compat(upstream, stream, r_model, chat_body, body, input_data, fwd, target) return @@ -1284,7 +1390,7 @@ class Handler(http.server.BaseHTTPRequestHandler): def _forward_oa_compat_retry(self, req, model, chat_body, body, input_data): try: - upstream = urllib.request.urlopen(req, timeout=180) + upstream = urllib.request.urlopen(req, timeout=_upstream_timeout(body, True)) except Exception as e: print(f"[crof-adaptive] retry failed: {e}", file=sys.stderr) return @@ -1427,7 +1533,7 @@ class Handler(http.server.BaseHTTPRequestHandler): if stream: try: - upstream = urllib.request.urlopen(req, timeout=180) + upstream = urllib.request.urlopen(req, timeout=_upstream_timeout(body, True)) except urllib.error.HTTPError as e: err = e.read().decode() return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) @@ -1461,7 +1567,7 @@ class Handler(http.server.BaseHTTPRequestHandler): store_response(last_resp_id, body.get("input", ""), last_output) else: try: - upstream = urllib.request.urlopen(req, timeout=180) + upstream = urllib.request.urlopen(req, timeout=_upstream_timeout(body, False)) except urllib.error.HTTPError as e: err = e.read().decode() return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) @@ -1478,7 +1584,7 @@ class Handler(http.server.BaseHTTPRequestHandler): def _forward(self, req, stream, model, nonstream_fn, stream_fn, input_data=None): try: - upstream = urllib.request.urlopen(req, timeout=180) + upstream = urllib.request.urlopen(req, timeout=_upstream_timeout({}, stream)) except urllib.error.HTTPError as e: err = e.read().decode() return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) @@ -1533,17 +1639,54 @@ class Handler(http.server.BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def stream_buffered_events(self, event_iter, flush_interval=0.03, max_bytes=4096): + buf = bytearray() + last_flush = time.monotonic() + def _flush(): + nonlocal buf, last_flush + if buf: + self.wfile.write(buf) + self.wfile.flush() + buf.clear() + last_flush = time.monotonic() + for event in event_iter: + encoded = event.encode("utf-8") if isinstance(event, str) else event + buf.extend(encoded) + urgent = ("response.completed" in event or "response.output_text.done" in event + or "response.output_item.done" in event + or "function_call_arguments.done" in event) + if urgent or len(buf) >= max_bytes or time.monotonic() - last_flush >= flush_interval: + _flush() + _flush() + def log_message(self, fmt, *args): msg = fmt % args if args else fmt print(f"[translate-proxy] {BACKEND} {msg}", file=sys.stderr) -if __name__ == "__main__": - class ReusableHTTPServer(http.server.HTTPServer): +def main(): + global SERVER + _init_runtime() + signal.signal(signal.SIGTERM, _handle_shutdown_signal) + signal.signal(signal.SIGINT, _handle_shutdown_signal) + try: + from http.server import ThreadingHTTPServer as _BaseSrv + except ImportError: + class _BaseSrv(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + class ReusableHTTPServer(_BaseSrv): allow_reuse_address = True - server = ReusableHTTPServer(("127.0.0.1", PORT), Handler) + daemon_threads = True + request_queue_size = 64 + SERVER = ReusableHTTPServer(("127.0.0.1", PORT), Handler) print(f"translate-proxy ({BACKEND}) listening on http://127.0.0.1:{PORT}", flush=True) print(f"Target: {TARGET_URL}", flush=True) print(f"Models: {[m['id'] for m in MODELS]}", flush=True) if BGP_ROUTES: print(f"BGP routes: {len(BGP_ROUTES)} ({[r.get('name','?') for r in BGP_ROUTES]})", flush=True) - server.serve_forever() + try: + SERVER.serve_forever() + finally: + _flush_stats() + +if __name__ == "__main__": + main()