diff --git a/src/cleanup-codex-stale.py b/src/cleanup-codex-stale.py new file mode 100644 index 0000000..0157190 --- /dev/null +++ b/src/cleanup-codex-stale.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Cleanup stale Codex Launcher processes and artifacts — cross-platform. + +Kills registered process groups and removes stale PID/socket files left +by previous Codex Launcher sessions. + +Windows: uses taskkill /F /T /PID +Linux: uses kill -TERM -- -PGID +""" + +import json, os, sys, subprocess, time +from pathlib import Path + +IS_WINDOWS = sys.platform == "win32" + +if IS_WINDOWS: + _local = os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")) + PID_REGISTRY = Path(_local) / "codex-launcher" / "pids.json" + CODEX_DIR = Path.home() / ".codex" + _local_share = Path(_local) + _cache = Path(_local) +else: + PID_REGISTRY = Path.home() / ".cache" / "codex-launcher" / "pids.json" + CODEX_DIR = Path.home() / ".codex" + _local_share = Path.home() / ".local" / "share" + _cache = Path.home() / ".cache" + + +def kill_group(pid): + if IS_WINDOWS: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, timeout=10) + else: + import signal + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGTERM) + time.sleep(0.5) + try: + os.killpg(pgid, signal.SIGKILL) + except OSError: + pass + except OSError: + pass + + +def main(): + print("[cleanup] Cleaning up stale Codex Launcher processes...", file=sys.stderr) + + if PID_REGISTRY.exists(): + try: + with open(PID_REGISTRY) as f: + registry = json.load(f) + except Exception as e: + print(f"[cleanup] Failed to read PID registry: {e}", file=sys.stderr) + registry = {} + + for kind, info in registry.items(): + pid = info.get("pid") if isinstance(info, dict) else info + if pid and isinstance(pid, int): + print(f"[cleanup] Killing {kind} (PID {pid})", file=sys.stderr) + kill_group(pid) + + try: + PID_REGISTRY.unlink() + except OSError: + pass + else: + print("[cleanup] No PID registry found — nothing to stop", file=sys.stderr) + + stale_files = [] + if IS_WINDOWS: + stale_files = [ + _cache / "codex-desktop" / ".codex-desktop-pid", + _cache / "codex-desktop" / ".webview-pid", + ] + else: + stale_files = [ + CODEX_DIR / ".launch-action-socket", + CODEX_DIR / ".codex-desktop-launch-action", + CODEX_DIR / ".codex-desktop-pid", + CODEX_DIR / ".webview-pid", + _local_share / "codex-desktop" / ".codex-desktop-pid", + _local_share / "codex-desktop" / ".webview-pid", + _cache / "codex-desktop" / ".codex-desktop-pid", + _cache / "codex-desktop" / ".webview-pid", + ] + + for fp in stale_files: + try: + if fp.exists(): + fp.unlink() + print(f"[cleanup] Removed {fp}", file=sys.stderr) + except OSError: + pass + + print("[cleanup] Done", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/codex-launcher-gui.py b/src/codex-launcher-gui.py new file mode 100644 index 0000000..3d53629 --- /dev/null +++ b/src/codex-launcher-gui.py @@ -0,0 +1,2700 @@ +#!/usr/bin/env python3 +"""Codex Launcher GUI (tkinter) — manage endpoints, launch Desktop or CLI with any provider. + +Windows-native tkinter GUI mirroring all features of the GTK version. +Imports process management, config engine, proxy lifecycle from codex_launcher_lib. +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext +import json +import os +import shutil +import socket +import ssl +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import base64 +import hashlib +import secrets +import http.server +import collections +from pathlib import Path + +from codex_launcher_lib import ( + IS_WINDOWS, HOME, CONFIG, CONFIG_BAK, CONFIG_TXN, + ENDPOINTS_FILE, BGP_POOLS_FILE, LAUNCH_LOG, LOG_DIR, + PROXY_CONFIG_DIR, BIN_DIR, PROXY, CLEANUP, PID_REGISTRY, + PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, + safe_name, label_for_backend, normalize_model_id, normalize_base_url, + parse_model_list, now_utc_iso, apply_provider_preset, + load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools, + get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle, + backup_config, restore_config, begin_config_transaction, end_config_transaction, + recover_config_if_needed, write_config_for_native, write_config_for_translated, + endpoint_models_url, endpoint_model_headers, fetch_models_for_endpoint, + refresh_endpoint_models, run_endpoint_doctor, + detect_codex_cli, detect_codex_desktop, check_codex_auth, + last_log_lines, kill_existing_desktop, safe_cleanup_owned, + start_proxy_for, stop_proxy, start_bgp_proxy, get_proxy_state, set_proxy_state, + detect_terminal, open_url, open_file, write_secure_text, + ensure_dirs, create_default_endpoints, + load_monitoring_config, save_monitoring_config, + load_incident_store, save_incident_store, load_usage_stats, + monitoring_log, + IncidentStore, AIDiagnosticAgent, HealthWatcher, + _usage_theme, UA, +) + + +# ═══════════════════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════════════════ + +def _fmt_tok(n): + if n >= 1_000_000: + return f"{n/1_000_000:.1f}M" + if n >= 1_000: + return f"{n/1_000:.1f}K" + return str(n) + + +def _fmt_dur(s): + if s >= 3600: + return f"{s/3600:.1f}h" + if s >= 60: + return f"{s/60:.1f}m" + return f"{s:.1f}s" + + +def _status_pill(success_rate, fail_pct): + U = _usage_theme() + if fail_pct > 0.15: + return ("ERR", U["red"]) + if fail_pct > 0.05: + return ("WARN", U["yellow"]) + return ("OK", U["green"]) + + +def _show_doctor_results_tk(parent, ep_name, checks): + dlg = tk.Toplevel(parent) + dlg.title(f"Doctor: {ep_name}") + dlg.geometry("520x420") + dlg.transient(parent) + dlg.grab_set() + + passed = sum(1 for _, ok, _ in checks if ok is True) + failed = sum(1 for _, ok, _ in checks if ok is False) + warned = sum(1 for _, ok, _ in checks if ok is None) + + hdr = tk.Label(dlg, text=f"{ep_name} {passed} passed {failed} failed {warned} warnings", + font=("Segoe UI", 10, "bold")) + hdr.pack(padx=12, pady=(12, 4), anchor="w") + + ttk.Separator(dlg).pack(fill="x", padx=12) + + canvas = tk.Canvas(dlg) + scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview) + inner = tk.Frame(canvas) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + for name, ok, detail in checks: + row = tk.Frame(inner) + row.pack(fill="x", padx=12, pady=1) + if ok is True: + color, sym = "#27ae60", "✓" + elif ok is False: + color, sym = "#e74c3c", "✗" + else: + color, sym = "#f39c12", "○" + tk.Label(row, text=sym, fg=color, font=("Segoe UI", 11, "bold")).pack(side="left") + tk.Label(row, text=name, font=("Segoe UI", 9, "bold")).pack(side="left", padx=(4, 0)) + if detail: + tk.Label(row, text=detail, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="right") + + canvas.pack(side="left", fill="both", expand=True, padx=(12, 0), pady=6) + scrollbar.pack(side="right", fill="y", pady=6) + + btn_frame = tk.Frame(dlg) + btn_frame.pack(pady=(0, 10)) + ttk.Button(btn_frame, text="Close", command=dlg.destroy).pack() + + +# ═══════════════════════════════════════════════════════════════════════ +# EditEndpointDialog +# ═══════════════════════════════════════════════════════════════════════ + +class EditEndpointDialog: + def __init__(self, parent, existing_name=None): + self.result = False + self._existing_name = existing_name + self._parent_mgr = parent + + if existing_name: + self._data = get_endpoint(existing_name) or {} + else: + self._data = { + "name": "", "backend_type": "openai-compat", + "base_url": "", "api_key": "", "default_model": "", + "models": [], "provider_preset": "Custom", + } + + self._dlg = tk.Toplevel(parent) + title = "Edit Endpoint" if existing_name else "Add Endpoint" + self._dlg.title(title) + self._dlg.geometry("520x600") + self._dlg.transient(parent) + self._dlg.grab_set() + + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) + + grid = ttk.Frame(main) + grid.pack(fill="x") + + row_idx = [0] + + def add_field(label, widget_factory): + ttk.Label(grid, text=label).grid(row=row_idx[0], column=0, sticky="e", padx=(0, 6), pady=2) + w = widget_factory() + w.grid(row=row_idx[0], column=1, sticky="ew", pady=2) + row_idx[0] += 1 + return w + + self._entry_name = add_field("Name:", lambda: ttk.Entry(grid)) + self._entry_name.insert(0, self._data.get("name", "")) + + self._combo_preset = ttk.Combobox(grid, values=list(PROVIDER_PRESETS.keys()), state="readonly") + preset = self._data.get("provider_preset", "Custom") + self._combo_preset.set(preset) + add_field("Preset:", lambda: self._combo_preset) + self._combo_preset.bind("<>", lambda e: self._apply_selected_preset(initial=False)) + + backend_types = [ + ("openai-compat", "OpenAI-compatible (needs proxy)"), + ("anthropic", "Anthropic (needs proxy)"), + ("command-code", "Command Code (needs proxy)"), + ("freebuff", "Freebuff - Free DeepSeek/Kimi (needs proxy)"), + ("gemini-oauth-cli", "Gemini CLI OAuth (needs proxy)"), + ("gemini-oauth-antigravity", "Antigravity OAuth (needs proxy)"), + ("native", "Native OpenAI (no proxy)"), + ] + self._combo_type = ttk.Combobox(grid, values=[f"{v} - {l}" for v, l in backend_types], state="readonly") + bt = self._data.get("backend_type", "openai-compat") + bt_display = next((f"{v} - {l}" for v, l in backend_types if v == bt), backend_types[0][0] + " - " + backend_types[0][1]) + self._combo_type.set(bt_display) + add_field("Type:", lambda: self._combo_type) + self._bt_map = {f"{v} - {l}": v for v, l in backend_types} + + self._entry_url = add_field("Base URL:", lambda: ttk.Entry(grid)) + self._entry_url.insert(0, self._data.get("base_url", "")) + + key_frame = ttk.Frame(grid) + self._entry_key = ttk.Entry(key_frame, show="*") + self._entry_key.pack(side="left", fill="x", expand=True) + self._entry_key.insert(0, self._data.get("api_key", "")) + self._reveal_var = tk.BooleanVar(value=False) + ttk.Checkbutton(key_frame, text="Show", variable=self._reveal_var, + command=lambda: self._entry_key.configure(show="" if self._reveal_var.get() else "*")).pack(side="left", padx=(4, 0)) + self._oauth_btn = ttk.Button(key_frame, text="OAuth Login", command=self._do_oauth_login) + self._oauth_btn.pack(side="left", padx=(4, 0)) + add_field("API Key:", lambda: key_frame) + + self._entry_cc_ver = add_field("CC Version:", lambda: ttk.Entry(grid)) + self._entry_cc_ver.insert(0, self._data.get("cc_version", "")) + + reason_frame = ttk.Frame(grid) + self._reason_var = tk.BooleanVar(value=self._data.get("reasoning_enabled", True)) + self._reason_cb = ttk.Checkbutton(reason_frame, text="Reasoning ON", variable=self._reason_var, + command=self._on_reasoning_toggled) + self._reason_cb.pack(side="left") + self._combo_effort = ttk.Combobox(reason_frame, values=["none", "minimal", "low", "medium", "high", "max"], + state="readonly", width=10) + self._combo_effort.set(self._data.get("reasoning_effort", "medium")) + self._combo_effort.pack(side="left", padx=(8, 0)) + ttk.Label(reason_frame, text="Effort").pack(side="left", padx=(4, 0)) + add_field("Reasoning:", lambda: reason_frame) + self._on_reasoning_toggled() + + grid.columnconfigure(1, weight=1) + + ttk.Label(main, text="Models:").pack(anchor="w", pady=(8, 2)) + + model_input_frame = ttk.Frame(main) + model_input_frame.pack(fill="x") + self._entry_model = ttk.Entry(model_input_frame) + self._entry_model.pack(side="left", fill="x", expand=True) + ttk.Button(model_input_frame, text="Add", command=self._add_model).pack(side="left", padx=(4, 0)) + ttk.Button(model_input_frame, text="Bulk Add", command=self._add_models_from_text).pack(side="left", padx=(4, 0)) + ttk.Button(model_input_frame, text="Fetch from API", command=self._fetch_models).pack(side="left", padx=(4, 0)) + ttk.Button(model_input_frame, text="Test Endpoint", command=self._diagnose_endpoint).pack(side="left", padx=(4, 0)) + + ttk.Label(main, text="Bulk add (one per line or comma-separated):").pack(anchor="w", pady=(4, 0)) + self._bulk_text = tk.Text(main, height=3, wrap="word") + self._bulk_text.pack(fill="x", pady=(2, 4)) + + list_frame = ttk.Frame(main) + list_frame.pack(fill="both", expand=True) + self._model_listbox = tk.Listbox(list_frame, height=6) + sb = ttk.Scrollbar(list_frame, orient="vertical", command=self._model_listbox.yview) + self._model_listbox.configure(yscrollcommand=sb.set) + self._model_listbox.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") + self._model_listbox.bind("", lambda e: self._remove_selected_model()) + for m in self._data.get("models", []): + self._model_listbox.insert("end", m) + + default_frame = ttk.Frame(main) + default_frame.pack(fill="x", pady=(4, 0)) + ttk.Label(default_frame, text="Default Model:").pack(side="left") + self._combo_default = ttk.Combobox(default_frame, state="readonly") + self._combo_default.pack(side="left", fill="x", expand=True, padx=(6, 0)) + self._refresh_default_combo() + dm = self._data.get("default_model", "") + if dm: + self._combo_default.set(dm) + + self._apply_selected_preset(initial=True) + + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(btn_frame, text="Cancel", command=self._cancel).pack(side="right") + ttk.Button(btn_frame, text="Save", command=self._save).pack(side="right", padx=(8, 0)) + + def _on_reasoning_toggled(self): + state = "readonly" if self._reason_var.get() else "disabled" + self._combo_effort.configure(state=state) + + def _apply_selected_preset(self, initial=False): + preset_name = self._combo_preset.get() or "Custom" + preset = PROVIDER_PRESETS.get(preset_name, {}) + is_oauth = bool(preset.get("oauth_provider")) + self._oauth_btn.configure(state="normal" if is_oauth else "disabled") + + if not initial or self._existing_name is None: + bt = preset.get("backend_type", "openai-compat") + bt_display = next((k for k, v in self._bt_map.items() if v == bt), list(self._bt_map.keys())[0]) + self._combo_type.set(bt_display) + self._entry_url.delete(0, "end") + self._entry_url.insert(0, preset.get("base_url", "")) + cc_ver = preset.get("cc_version", "") + if cc_ver and not self._entry_cc_ver.get().strip(): + self._entry_cc_ver.delete(0, "end") + self._entry_cc_ver.insert(0, cc_ver) + if preset.get("models") and self._model_listbox.size() == 0: + self._model_listbox.delete(0, "end") + for mid in preset["models"]: + self._model_listbox.insert("end", mid) + self._refresh_default_combo() + if preset["models"]: + self._combo_default.set(preset["models"][0]) + + def _add_model(self): + m = normalize_model_id(self._entry_model.get()) + if m: + self._model_listbox.insert("end", m) + self._refresh_default_combo() + self._entry_model.delete(0, "end") + + def _add_models_from_text(self): + text = self._bulk_text.get("1.0", "end") + models = parse_model_list(text) + existing = set(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + for mid in models: + if mid not in existing: + self._model_listbox.insert("end", mid) + self._bulk_text.delete("1.0", "end") + self._refresh_default_combo() + + def _remove_selected_model(self): + sel = self._model_listbox.curselection() + if sel: + self._model_listbox.delete(sel[0]) + self._refresh_default_combo() + + def _refresh_default_combo(self): + models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + current = self._combo_default.get() + self._combo_default["values"] = models + if current in models: + self._combo_default.set(current) + elif models: + self._combo_default.set(models[0]) + else: + self._combo_default.set("") + + def _fetch_models(self): + ep = self._make_endpoint_snapshot() + ids, err = fetch_models_for_endpoint(ep) + if ids: + existing = set(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + for mid in ids: + if mid not in existing: + self._model_listbox.insert("end", mid) + self._refresh_default_combo() + else: + messagebox.showerror("Fetch Models", f"Failed:\n{err}", parent=self._dlg) + + def _diagnose_endpoint(self): + ep = self._make_endpoint_snapshot() + wait = tk.Toplevel(self._dlg) + wait.title("Running Doctor...") + wait.geometry("280x80") + wait.transient(self._dlg) + wait.grab_set() + tk.Label(wait, text="Running endpoint diagnostics...").pack(expand=True) + + def _run(): + checks = run_endpoint_doctor(ep) + self._dlg.after(0, lambda: (wait.destroy(), _show_doctor_results_tk(self._dlg, ep.get("default_model", "endpoint"), checks))) + + threading.Thread(target=_run, daemon=True).start() + + def _make_endpoint_snapshot(self): + bt_display = self._combo_type.get() + bt = self._bt_map.get(bt_display, "openai-compat") + return { + "base_url": self._entry_url.get().strip(), + "api_key": self._entry_key.get().strip(), + "backend_type": bt, + "default_model": self._combo_default.get() or "", + } + + def _do_oauth_login(self): + preset_name = self._combo_preset.get() or "Custom" + preset = PROVIDER_PRESETS.get(preset_name, {}) + provider = preset.get("oauth_provider", "") + if (provider or "").startswith("google"): + self._google_oauth_flow(provider) + + def _google_oauth_flow(self, oauth_provider="google-cli"): + is_antigravity = oauth_provider == "google-antigravity" + token_path = str(PROXY_CONFIG_DIR / ("google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json")) + + if is_antigravity: + CLIENT_ID = "" + CLIENT_SECRET = "" + SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + ] + port = 51121 + redirect_uri = f"http://localhost:{port}/oauth-callback" + callback_path = "/oauth-callback" + provider_kind = "antigravity" + else: + CLIENT_ID = "" + CLIENT_SECRET = "" + SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ] + port = 0 + redirect_uri = None + callback_path = "/oauth2callback" + provider_kind = "cli" + + state = secrets.token_hex(32) + verifier = secrets.token_urlsafe(64) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + + if port == 0: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + redirect_uri = f"http://127.0.0.1:{port}/oauth2callback" + + scope_str = " ".join(SCOPES) + auth_url = ( + f"https://accounts.google.com/o/oauth2/v2/auth?" + f"client_id={CLIENT_ID}" + f"&redirect_uri={urllib.parse.quote(redirect_uri)}" + f"&response_type=code" + f"&scope={urllib.parse.quote(scope_str)}" + f"&access_type=offline" + f"&prompt=select_account%20consent" + f"&state={state}" + f"&code_challenge={challenge}" + f"&code_challenge_method=S256" + ) + + oauth_dlg = tk.Toplevel(self._dlg) + oauth_dlg.title("Google OAuth (Gemini Mode)") + oauth_dlg.geometry("520x280") + oauth_dlg.transient(self._dlg) + oauth_dlg.grab_set() + + tk.Label(oauth_dlg, text="Sign in with Google", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w") + tk.Label(oauth_dlg, text="Emulating Gemini CLI OAuth -- no client_secret.json needed.").pack(padx=16, anchor="w") + + link_lbl = tk.Label(oauth_dlg, text="Click here to open Google authorization", fg="blue", cursor="hand2") + link_lbl.pack(padx=16, pady=(8, 0), anchor="w") + link_lbl.bind("", lambda e: open_url(auth_url)) + + self._oauth_status_var = tk.StringVar(value="Opening browser...") + tk.Label(oauth_dlg, textvariable=self._oauth_status_var).pack(padx=16, pady=(8, 0), anchor="w") + + code_holder = [None] + error_holder = [None] + received_state = [None] + + class OAuthHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self2): + qs = urllib.parse.urlparse(self2.path).query + params = urllib.parse.parse_qs(qs) + received_state[0] = params.get("state", [None])[0] + if self2.path.find(callback_path) == -1: + self2.send_response(302) + self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini") + self2.end_headers() + error_holder[0] = "unexpected request" + return + if "code" in params: + if received_state[0] != state: + self2.send_response(400) + self2.send_header("Content-Type", "text/html") + self2.end_headers() + self2.wfile.write(b"

CSRF state mismatch.

") + error_holder[0] = "CSRF state mismatch" + return + code_holder[0] = params["code"][0] + self2.send_response(302) + self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini") + self2.end_headers() + else: + error_holder[0] = params.get("error", ["unknown"])[0] + self2.send_response(302) + self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini") + self2.end_headers() + def log_message(self2, fmt, *args): + pass + + try: + bind_host = "localhost" if is_antigravity else "127.0.0.1" + server = http.server.HTTPServer((bind_host, port), OAuthHandler) + except OSError: + self._oauth_status_var.set(f"Port {port} already in use -- close other apps and retry.") + return + + def wait_for_code(): + deadline = time.time() + 120 + while code_holder[0] is None and error_holder[0] is None and time.time() < deadline: + server.handle_request() + server.server_close() + if code_holder[0]: + try: + token_data = urllib.parse.urlencode({ + "code": code_holder[0], "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, "redirect_uri": redirect_uri, + "grant_type": "authorization_code", "code_verifier": verifier, + }).encode() + req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + resp = urllib.request.urlopen(req, timeout=30) + tokens = json.loads(resp.read()) + tokens["client_id"] = CLIENT_ID + tokens["client_secret"] = CLIENT_SECRET + tokens["provider_kind"] = provider_kind + tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600) + os.makedirs(os.path.dirname(token_path), exist_ok=True) + with open(token_path, "w") as f: + json.dump(tokens, f, indent=2) + + project_id = "" + try: + lr = urllib.request.Request( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + data=json.dumps({}).encode(), + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {tokens['access_token']}", + "User-Agent": "google-api-nodejs-client/9.15.1"}) + lresp = urllib.request.urlopen(lr, timeout=15) + ldata = json.loads(lresp.read()) + p = ldata.get("cloudaicompanionProject", "") + if isinstance(p, dict): + project_id = p.get("id", "") + elif isinstance(p, str): + project_id = p + if project_id: + tokens["project_id"] = project_id + with open(token_path, "w") as f2: + json.dump(tokens, f2, indent=2) + except Exception: + pass + + found_models = [] + if is_antigravity: + found_models = [ + "gemini-2.5-flash", "gemini-2.5-pro", + "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview", + "antigravity-gemini-3-flash", "antigravity-gemini-3-pro", + "antigravity-claude-sonnet-4-6", "antigravity-claude-opus-4-6-thinking", + ] + else: + found_models = ["gemini-2.5-flash", "gemini-2.5-pro"] + if found_models: + tokens["available_models"] = found_models + with open(token_path, "w") as f3: + json.dump(tokens, f3, indent=2) + + self._dlg.after(0, lambda: self._oauth_success(oauth_dlg, tokens.get("access_token", ""))) + except Exception as e: + self._dlg.after(0, lambda: self._oauth_failed(oauth_dlg, str(e))) + else: + self._dlg.after(0, lambda: self._oauth_failed(oauth_dlg, error_holder[0] or "No authorization code received.")) + + threading.Thread(target=wait_for_code, daemon=True).start() + open_url(auth_url) + + def _oauth_success(self, dlg, access_token): + self._entry_key.delete(0, "end") + self._entry_key.insert(0, access_token) + self._oauth_status_var.set("Authorization successful! Token saved.") + self._dlg.after(1500, dlg.destroy) + + def _oauth_failed(self, dlg, msg): + self._oauth_status_var.set(f"Failed: {msg}") + self._dlg.after(3000, dlg.destroy) + + def _cancel(self): + self._dlg.destroy() + + def _save(self): + name = self._entry_name.get().strip() + if not name: + messagebox.showerror("Error", "Name is required", parent=self._dlg) + return + bt_display = self._combo_type.get() + bt = self._bt_map.get(bt_display, "openai-compat") + url = self._entry_url.get().strip() + key = self._entry_key.get().strip() + models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + + if not models: + ep_snap = self._make_endpoint_snapshot() + ids, err = fetch_models_for_endpoint(ep_snap) + if ids: + for mid in ids: + self._model_listbox.insert("end", mid) + self._refresh_default_combo() + models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + else: + r = messagebox.askyesno("No Models", f"Auto-fetch failed ({err}).\n\nAdd models manually now?", parent=self._dlg) + if r: + self._entry_model.focus_set() + return + self._dlg.destroy() + return + + if not models: + messagebox.showerror("Error", "At least one model is required", parent=self._dlg) + return + + default = self._combo_default.get() or models[0] + data = load_endpoints() + + if self._existing_name and self._existing_name != name: + data["endpoints"] = [e for e in data["endpoints"] if e["name"] != self._existing_name] + + existing = [e for e in data["endpoints"] if e["name"] == name] + if existing: + messagebox.showerror("Error", f'Endpoint "{name}" already exists', parent=self._dlg) + return + + new_ep = { + "name": name, "backend_type": bt, "base_url": normalize_base_url(url), + "api_key": key, "default_model": default, "models": models, + "provider_preset": self._combo_preset.get() or "Custom", + "reasoning_enabled": self._reason_var.get(), + "reasoning_effort": self._combo_effort.get() or "medium", + } + cc_ver = self._entry_cc_ver.get().strip() + if cc_ver: + new_ep["cc_version"] = cc_ver + preset_name = self._combo_preset.get() or "Custom" + preset = PROVIDER_PRESETS.get(preset_name, {}) + if preset.get("oauth_provider"): + new_ep["oauth_provider"] = preset["oauth_provider"] + + found = False + for i, e in enumerate(data["endpoints"]): + if e["name"] == name: + data["endpoints"][i] = new_ep + found = True + break + if not found: + data["endpoints"].append(new_ep) + if data.get("default") is None: + data["default"] = name + + save_endpoints(data) + self.result = True + self._dlg.destroy() + + +# ═══════════════════════════════════════════════════════════════════════ +# EndpointMgr +# ═══════════════════════════════════════════════════════════════════════ + +class EndpointMgr: + def __init__(self, parent, on_update=None): + self._parent = parent + self._on_update = on_update + + self._dlg = tk.Toplevel(parent) + self._dlg.title("Manage Endpoints") + self._dlg.geometry("600x400") + self._dlg.transient(parent) + + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) + + ttk.Label(main, text="Endpoints", font=("Segoe UI", 11, "bold")).pack(anchor="w") + + tree_frame = ttk.Frame(main) + tree_frame.pack(fill="both", expand=True, pady=(4, 0)) + cols = ("name", "provider", "backend", "default_model") + self._tree = ttk.Treeview(tree_frame, columns=cols, show="headings", selectmode="browse") + for col, heading, width in [("name", "Name", 140), ("provider", "Provider", 160), + ("backend", "Type", 140), ("default_model", "Default Model", 140)]: + self._tree.heading(col, text=heading) + self._tree.column(col, width=width, minwidth=80) + sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview) + self._tree.configure(yscrollcommand=sb.set) + self._tree.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") + + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(btn_frame, text="Add", command=self._add).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Edit", command=self._edit).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Delete", command=self._delete).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Set Default", command=self._set_default).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Doctor", command=self._doctor_selected).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Doctor All", command=self._doctor_all).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right") + + self._rebuild() + + def _rebuild(self): + for item in self._tree.get_children(): + self._tree.delete(item) + data = load_endpoints() + for ep in data["endpoints"]: + provider = ep.get("provider_preset", "Custom") + bt = label_for_backend(ep["backend_type"]) + self._tree.insert("", "end", values=(ep["name"], provider, bt, ep.get("default_model", ""))) + + def _selected_name(self): + sel = self._tree.selection() + if not sel: + return None + return self._tree.item(sel[0])["values"][0] + + def _add(self): + d = EditEndpointDialog(self._dlg, None) + self._dlg.wait_window(d._dlg) + if d.result: + self._rebuild() + if self._on_update: + self._on_update() + + def _edit(self): + name = self._selected_name() + if not name: + return + d = EditEndpointDialog(self._dlg, name) + self._dlg.wait_window(d._dlg) + if d.result: + self._rebuild() + if self._on_update: + self._on_update() + + def _delete(self): + name = self._selected_name() + if not name: + return + if not messagebox.askyesno("Delete", f'Delete endpoint "{name}"?', parent=self._dlg): + return + data = load_endpoints() + data["endpoints"] = [e for e in data["endpoints"] if e["name"] != name] + if data.get("default") == name: + data["default"] = data["endpoints"][0]["name"] if data["endpoints"] else None + save_endpoints(data) + self._rebuild() + if self._on_update: + self._on_update() + + def _set_default(self): + name = self._selected_name() + if not name: + return + data = load_endpoints() + data["default"] = name + save_endpoints(data) + self._rebuild() + if self._on_update: + self._on_update() + + def _doctor_selected(self): + name = self._selected_name() + if not name: + return + ep = get_endpoint(name) + if not ep: + return + wait = tk.Toplevel(self._dlg) + wait.title(f"Doctor: {name}...") + wait.geometry("280x80") + wait.transient(self._dlg) + wait.grab_set() + tk.Label(wait, text=f"Running diagnostics for {name}...").pack(expand=True) + + def _run(): + checks = run_endpoint_doctor(ep) + self._dlg.after(0, lambda: (wait.destroy(), _show_doctor_results_tk(self._dlg, name, checks))) + + threading.Thread(target=_run, daemon=True).start() + + def _doctor_all(self): + data = load_endpoints() + endpoints = data.get("endpoints", []) + if not endpoints: + messagebox.showinfo("Doctor All", "No endpoints configured.", parent=self._dlg) + return + + wait = tk.Toplevel(self._dlg) + wait.title("Doctor All...") + wait.geometry("320x80") + wait.transient(self._dlg) + wait.grab_set() + tk.Label(wait, text=f"Testing {len(endpoints)} endpoints...").pack(expand=True) + + all_results = {} + + def _run(): + for ep in endpoints: + try: + all_results[ep["name"]] = run_endpoint_doctor(ep) + except Exception as e: + all_results[ep["name"]] = [("Doctor run", False, str(e)[:100])] + + def _show(): + wait.destroy() + dlg = tk.Toplevel(self._dlg) + dlg.title("Doctor All Results") + dlg.geometry("580x480") + dlg.transient(self._dlg) + + canvas = tk.Canvas(dlg) + scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview) + inner = tk.Frame(canvas) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + for ep_name, checks in all_results.items(): + passed = sum(1 for _, ok, _ in checks if ok is True) + failed = sum(1 for _, ok, _ in checks if ok is False) + color = "#e74c3c" if failed else "#27ae60" + status = f"{failed} failed" if failed else f"{passed} passed" + tk.Label(inner, text=f"{ep_name} {status}", fg=color, + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=12, pady=(8, 2)) + for name, ok, detail in checks: + if ok is True: + sym, sc = "✓", "#27ae60" + elif ok is False: + sym, sc = "✗", "#e74c3c" + else: + sym, sc = "○", "#f39c12" + row = tk.Frame(inner) + row.pack(anchor="w", padx=24, pady=0) + tk.Label(row, text=sym, fg=sc, font=("Segoe UI", 9, "bold")).pack(side="left") + txt = name + if detail: + txt += f" {detail}" + tk.Label(row, text=txt, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="left") + ttk.Separator(inner).pack(fill="x", padx=12, pady=4) + + canvas.pack(side="left", fill="both", expand=True, padx=(12, 0)) + scrollbar.pack(side="right", fill="y") + ttk.Button(dlg, text="Close", command=dlg.destroy).pack(pady=8) + + self._dlg.after(0, _show) + + threading.Thread(target=_run, daemon=True).start() + + +# ═══════════════════════════════════════════════════════════════════════ +# BGP Pool Manager +# ═══════════════════════════════════════════════════════════════════════ + +class BGPRouteDialog: + def __init__(self, parent, endpoints, existing=None): + self.result = None + self._dlg = tk.Toplevel(parent) + self._dlg.title("BGP Route") + self._dlg.geometry("440x300") + self._dlg.transient(parent) + self._dlg.grab_set() + + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) + + ttk.Label(main, text="Route Name:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2) + self._entry_name = ttk.Entry(main) + self._entry_name.grid(row=0, column=1, sticky="ew", pady=2) + if existing: + self._entry_name.insert(0, existing.get("name", "")) + + ttk.Label(main, text="Endpoint:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2) + ep_names = [e["name"] for e in endpoints] + self._combo_ep = ttk.Combobox(main, values=ep_names, state="readonly") + self._combo_ep.grid(row=1, column=1, sticky="ew", pady=2) + if existing and existing.get("endpoint_name") in ep_names: + self._combo_ep.set(existing["endpoint_name"]) + elif ep_names: + self._combo_ep.set(ep_names[0]) + + ttk.Label(main, text="URL:").grid(row=2, column=0, sticky="e", padx=(0, 6), pady=2) + self._entry_url = ttk.Entry(main) + self._entry_url.grid(row=2, column=1, sticky="ew", pady=2) + + ttk.Label(main, text="API Key:").grid(row=3, column=0, sticky="e", padx=(0, 6), pady=2) + self._entry_key = ttk.Entry(main, show="*") + self._entry_key.grid(row=3, column=1, sticky="ew", pady=2) + + ttk.Label(main, text="Model:").grid(row=4, column=0, sticky="e", padx=(0, 6), pady=2) + self._combo_model = ttk.Combobox(main, state="readonly") + self._combo_model.grid(row=4, column=1, sticky="ew", pady=2) + + main.columnconfigure(1, weight=1) + + self._endpoints = endpoints + self._combo_ep.bind("<>", lambda e: self._on_ep_changed()) + self._on_ep_changed() + + if existing: + self._entry_url.delete(0, "end") + self._entry_url.insert(0, existing.get("target_url", "")) + self._entry_key.delete(0, "end") + self._entry_key.insert(0, existing.get("api_key", "")) + if existing.get("model"): + self._combo_model.set(existing["model"]) + + btn_frame = ttk.Frame(main) + btn_frame.grid(row=5, column=0, columnspan=2, pady=(12, 0)) + ttk.Button(btn_frame, text="Cancel", command=self._dlg.destroy).pack(side="right") + ttk.Button(btn_frame, text="OK", command=self._ok).pack(side="right", padx=(8, 0)) + + self._dlg.wait_window() + + def _on_ep_changed(self): + ep_name = self._combo_ep.get() + ep = None + for e in self._endpoints: + if e["name"] == ep_name: + ep = e + break + if ep: + self._entry_url.delete(0, "end") + self._entry_url.insert(0, normalize_base_url(ep.get("base_url", ""))) + self._entry_key.delete(0, "end") + self._entry_key.insert(0, ep.get("api_key", "")) + models = ep.get("models", []) + self._combo_model["values"] = models + if ep.get("default_model") and ep["default_model"] in models: + self._combo_model.set(ep["default_model"]) + elif models: + self._combo_model.set(models[0]) + + def _ok(self): + ep_name = self._combo_ep.get() + ep = None + for e in self._endpoints: + if e["name"] == ep_name: + ep = e + break + self.result = { + "name": self._entry_name.get().strip() or ep_name, + "endpoint_name": ep_name, + "target_url": self._entry_url.get().strip(), + "api_key": self._entry_key.get().strip(), + "model": self._combo_model.get() or "", + "priority": 99, + } + if ep: + self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True) + self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium") + self.result["oauth_provider"] = ep.get("oauth_provider", "") + self._dlg.destroy() + + +class BGPPoolEditDialog: + def __init__(self, parent, existing_name=None): + self.result = False + self._existing_name = existing_name + self._parent_mgr = parent + + self._dlg = tk.Toplevel(parent._dlg if hasattr(parent, "_dlg") else parent) + title = "Edit BGP Pool" if existing_name else "Create BGP Pool" + self._dlg.title(title) + self._dlg.geometry("620x500") + self._dlg.transient(parent._dlg if hasattr(parent, "_dlg") else parent) + self._dlg.grab_set() + + data = load_bgp_pools() + pool = None + if existing_name: + for p in data.get("pools", []): + if p["name"] == existing_name: + pool = p + break + if not pool: + pool = {"name": "", "strategy": "failover", "routes": []} + + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) + + grid = ttk.Frame(main) + grid.pack(fill="x") + ttk.Label(grid, text="Pool Name:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2) + self._entry_name = ttk.Entry(grid) + self._entry_name.grid(row=0, column=1, sticky="ew", pady=2) + self._entry_name.insert(0, pool["name"]) + + ttk.Label(grid, text="Strategy:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2) + self._combo_strategy = ttk.Combobox(grid, values=["failover", "race"], state="readonly") + self._combo_strategy.grid(row=1, column=1, sticky="ew", pady=2) + self._combo_strategy.set(pool.get("strategy", "failover")) + grid.columnconfigure(1, weight=1) + + ttk.Label(main, text="Routes (double-click to remove):", font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(8, 2)) + + tree_frame = ttk.Frame(main) + tree_frame.pack(fill="both", expand=True) + cols = ("name", "endpoint", "url", "model", "priority") + self._route_tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=8) + for col, heading, w in [("name", "Route Name", 100), ("endpoint", "Endpoint", 120), + ("url", "URL", 160), ("model", "Model", 120), ("priority", "Priority", 60)]: + self._route_tree.heading(col, text=heading) + self._route_tree.column(col, width=w, minwidth=50) + rsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._route_tree.yview) + self._route_tree.configure(yscrollcommand=rsb.set) + self._route_tree.pack(side="left", fill="both", expand=True) + rsb.pack(side="right", fill="y") + + self._routes = [] + for r in pool.get("routes", []): + self._routes.append(dict(r)) + self._route_tree.insert("", "end", values=( + r.get("name", ""), r.get("endpoint_name", ""), + r.get("target_url", ""), r.get("model", ""), r.get("priority", 99))) + + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(6, 0)) + ttk.Button(btn_frame, text="Add Route", command=self._add_route).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Edit Route", command=self._edit_route).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Remove Route", command=self._remove_route).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Up", command=lambda: self._move_route(-1)).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Down", command=lambda: self._move_route(1)).pack(side="left", padx=(0, 4)) + + save_frame = ttk.Frame(main) + save_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(save_frame, text="Cancel", command=self._dlg.destroy).pack(side="right") + ttk.Button(save_frame, text="Save", command=self._save).pack(side="right", padx=(8, 0)) + + def _add_route(self): + endpoints = load_endpoints().get("endpoints", []) + if not endpoints: + messagebox.showinfo("Info", "No endpoints configured. Add endpoints first.", parent=self._dlg) + return + d = BGPRouteDialog(self._dlg, endpoints, None) + if d.result: + r = d.result + self._routes.append(r) + self._route_tree.insert("", "end", values=( + r.get("name", ""), r.get("endpoint_name", ""), + r.get("target_url", ""), r.get("model", ""), r.get("priority", 99))) + + def _edit_route(self): + sel = self._route_tree.selection() + if not sel: + return + idx = self._route_tree.index(sel[0]) + endpoints = load_endpoints().get("endpoints", []) + d = BGPRouteDialog(self._dlg, endpoints, self._routes[idx]) + if d.result: + r = d.result + self._routes[idx] = r + self._route_tree.item(sel[0], values=( + r.get("name", ""), r.get("endpoint_name", ""), + r.get("target_url", ""), r.get("model", ""), r.get("priority", 99))) + + def _remove_route(self): + sel = self._route_tree.selection() + if not sel: + return + idx = self._route_tree.index(sel[0]) + self._route_tree.delete(sel[0]) + del self._routes[idx] + + def _move_route(self, direction): + sel = self._route_tree.selection() + if not sel: + return + idx = self._route_tree.index(sel[0]) + new_idx = idx + direction + if new_idx < 0 or new_idx >= len(self._routes): + return + route = self._routes.pop(idx) + self._routes.insert(new_idx, route) + self._rebuild_routes_tree(new_idx) + + def _rebuild_routes_tree(self, select_idx=None): + for item in self._route_tree.get_children(): + self._route_tree.delete(item) + for r in self._routes: + self._route_tree.insert("", "end", values=( + r.get("name", ""), r.get("endpoint_name", ""), + r.get("target_url", ""), r.get("model", ""), r.get("priority", 99))) + if select_idx is not None: + children = self._route_tree.get_children() + if select_idx < len(children): + self._route_tree.selection_set(children[select_idx]) + + def _save(self): + name = self._entry_name.get().strip() + if not name: + return + strategy = self._combo_strategy.get() or "failover" + routes = [] + for i, r in enumerate(self._routes): + if not r.get("target_url"): + continue + routes.append({ + "name": r.get("name") or f"Route {i+1}", + "endpoint_name": r.get("endpoint_name", ""), + "target_url": r.get("target_url", ""), + "api_key": r.get("api_key", ""), + "model": r.get("model", ""), + "priority": i + 1, + "reasoning_enabled": True, + "reasoning_effort": "medium", + }) + data = load_bgp_pools() + if self._existing_name: + data["pools"] = [p for p in data["pools"] if p["name"] != self._existing_name] + data["pools"].append({"name": name, "strategy": strategy, "routes": routes}) + save_bgp_pools(data) + self.result = True + self._dlg.destroy() + + +class BGPPoolMgr: + def __init__(self, parent, on_update=None): + self._parent = parent + self._on_update = on_update + + self._dlg = tk.Toplevel(parent) + self._dlg.title("AI BGP -- Pool Manager") + self._dlg.geometry("660x440") + self._dlg.transient(parent) + + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) + + ttk.Label(main, text="AI BGP Pools -- multi-provider routing with automatic failover", + font=("Segoe UI", 10, "bold")).pack(anchor="w") + + tree_frame = ttk.Frame(main) + tree_frame.pack(fill="both", expand=True, pady=(8, 0)) + cols = ("name", "routes", "strategy") + self._tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=10) + for col, heading, w in [("name", "Pool Name", 180), ("routes", "Routes", 280), ("strategy", "Strategy", 100)]: + self._tree.heading(col, text=heading) + self._tree.column(col, width=w, minwidth=60) + sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview) + self._tree.configure(yscrollcommand=sb.set) + self._tree.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") + + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(btn_frame, text="Create Pool", command=self._add_pool).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Edit Pool", command=self._edit_pool).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Delete Pool", command=self._del_pool).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right") + + self._rebuild() + + def _rebuild(self): + for item in self._tree.get_children(): + self._tree.delete(item) + for pool in load_bgp_pools().get("pools", []): + routes_str = " -> ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", [])) + self._tree.insert("", "end", values=(pool["name"], routes_str, pool.get("strategy", "failover"))) + + def _selected_name(self): + sel = self._tree.selection() + if not sel: + return None + return self._tree.item(sel[0])["values"][0] + + def _add_pool(self): + d = BGPPoolEditDialog(self, None) + self._dlg.wait_window(d._dlg) + if d.result: + self._rebuild() + if self._on_update: + self._on_update() + + def _edit_pool(self): + name = self._selected_name() + if not name: + return + d = BGPPoolEditDialog(self, name) + self._dlg.wait_window(d._dlg) + if d.result: + self._rebuild() + if self._on_update: + self._on_update() + + def _del_pool(self): + name = self._selected_name() + if not name: + return + if not messagebox.askyesno("Delete", f'Delete BGP pool "{name}"?', parent=self._dlg): + return + data = load_bgp_pools() + data["pools"] = [p for p in data["pools"] if p["name"] != name] + save_bgp_pools(data) + self._rebuild() + if self._on_update: + self._on_update() + + +# ═══════════════════════════════════════════════════════════════════════ +# AI Monitoring Window +# ═══════════════════════════════════════════════════════════════════════ + +class AIMonitoringWindow: + def __init__(self, parent): + self._dlg = tk.Toplevel(parent) + self._dlg.title("AI Monitoring") + self._dlg.geometry("580x520") + self._dlg.transient(parent) + + self._cfg = load_monitoring_config() + self._store = load_incident_store() + + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) + + hdr = ttk.Frame(main) + hdr.pack(fill="x") + ttk.Label(hdr, text="AI Monitoring", font=("Segoe UI", 11, "bold")).pack(side="left") + self._toggle_var = tk.BooleanVar(value=self._cfg.get("enabled", False)) + ttk.Checkbutton(hdr, text="Enabled", variable=self._toggle_var, + command=self._on_toggle).pack(side="right") + + frame = ttk.LabelFrame(main, text="Diagnostic Agent", padding=8) + frame.pack(fill="x", pady=(8, 0)) + + grid = ttk.Frame(frame) + grid.pack(fill="x") + grid.columnconfigure(1, weight=1) + + ttk.Label(grid, text="Provider URL:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2) + self._url_entry = ttk.Entry(grid) + self._url_entry.grid(row=0, column=1, sticky="ew", pady=2) + self._url_entry.insert(0, self._cfg.get("provider_url", "")) + + ttk.Label(grid, text="Model:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2) + self._model_entry = ttk.Entry(grid) + self._model_entry.grid(row=1, column=1, sticky="ew", pady=2) + self._model_entry.insert(0, self._cfg.get("model", "")) + + ttk.Label(grid, text="API Key:").grid(row=2, column=0, sticky="e", padx=(0, 6), pady=2) + key_frame = ttk.Frame(grid) + key_frame.grid(row=2, column=1, sticky="ew", pady=2) + self._key_entry = ttk.Entry(key_frame, show="*") + self._key_entry.pack(side="left", fill="x", expand=True) + self._key_entry.insert(0, self._cfg.get("api_key", "")) + self._reveal_key = tk.BooleanVar(value=False) + ttk.Checkbutton(key_frame, text="Show", variable=self._reveal_key, + command=lambda: self._key_entry.configure(show="" if self._reveal_key.get() else "*")).pack(side="left", padx=(4, 0)) + + ttk.Label(grid, text="Health Check:").grid(row=3, column=0, sticky="e", padx=(0, 6), pady=2) + spin_frame = ttk.Frame(grid) + spin_frame.grid(row=3, column=1, sticky="w", pady=2) + self._interval_spin = ttk.Spinbox(spin_frame, from_=2, to=30, width=5) + self._interval_spin.set(self._cfg.get("health_check_interval_s", 5)) + self._interval_spin.pack(side="left") + ttk.Label(spin_frame, text="seconds").pack(side="left", padx=(4, 0)) + + opts_frame = ttk.Frame(frame) + opts_frame.pack(fill="x", pady=(4, 0)) + self._auto_restart_var = tk.BooleanVar(value=self._cfg.get("auto_restart_proxy", True)) + ttk.Checkbutton(opts_frame, text="Auto-restart proxy on crash", + variable=self._auto_restart_var).pack(side="left") + self._auto_switch_var = tk.BooleanVar(value=self._cfg.get("auto_switch_provider", False)) + ttk.Checkbutton(opts_frame, text="Auto-switch provider on repeated failure", + variable=self._auto_switch_var).pack(side="left", padx=(12, 0)) + + ttk.Button(frame, text="Save Configuration", command=self._on_save).pack(pady=(8, 0)) + + stats = self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}) + stats_text = (f"AI diagnostic calls: {stats.get('ai_calls', 0)} | " + f"Tokens used: {stats.get('tokens_used', 0):,} | " + f"Known patterns: {len(self._store.get('incidents', {}))}") + ttk.Label(main, text=stats_text, font=("Segoe UI", 8)).pack(anchor="w", pady=(8, 0)) + + inc_frame = ttk.LabelFrame(main, text="Recent Incidents", padding=4) + inc_frame.pack(fill="both", expand=True, pady=(4, 0)) + self._inc_text = tk.Text(inc_frame, height=8, wrap="word", state="disabled") + inc_sb = ttk.Scrollbar(inc_frame, orient="vertical", command=self._inc_text.yview) + self._inc_text.configure(yscrollcommand=inc_sb.set) + self._inc_text.pack(side="left", fill="both", expand=True) + inc_sb.pack(side="right", fill="y") + self._refresh_incidents() + + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(btn_frame, text="View Monitoring Log", + command=lambda: open_file(str(PROXY_CONFIG_DIR / "monitoring.log"))).pack(side="left") + ttk.Button(btn_frame, text="Clear Incident Store", command=self._on_clear_store).pack(side="left", padx=(8, 0)) + ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right") + + def _on_toggle(self): + self._cfg["enabled"] = self._toggle_var.get() + save_monitoring_config(self._cfg) + + def _on_save(self): + self._cfg["provider_url"] = self._url_entry.get().strip() + self._cfg["model"] = self._model_entry.get().strip() + self._cfg["api_key"] = self._key_entry.get().strip() + try: + self._cfg["health_check_interval_s"] = int(self._interval_spin.get()) + except ValueError: + pass + self._cfg["auto_restart_proxy"] = self._auto_restart_var.get() + self._cfg["auto_switch_provider"] = self._auto_switch_var.get() + save_monitoring_config(self._cfg) + self._inc_text.configure(state="normal") + self._inc_text.delete("1.0", "end") + self._inc_text.insert("end", "Configuration saved.\n") + self._inc_text.configure(state="disabled") + + def _on_clear_store(self): + save_incident_store({"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}) + self._store = {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}} + self._refresh_incidents() + + def _refresh_incidents(self): + lines = [] + for pattern, inc in sorted(self._store.get("incidents", {}).items(), + key=lambda x: x[1].get("last_seen", ""), reverse=True): + sc = inc.get("success_count", 0) + fc = inc.get("fail_count", 0) + rate = sc / max(sc + fc, 1) + lines.append( + f"[{inc.get('last_seen', '?')[:16]}] {pattern}\n" + f" fix={inc.get('fix', '?')} success_rate={rate:.0%} seen={inc.get('occurrences', 0)}x\n" + ) + if not lines: + lines.append("No incidents recorded yet.\n\nEnable AI Monitoring and use Codex to populate the store.\n") + self._inc_text.configure(state="normal") + self._inc_text.delete("1.0", "end") + self._inc_text.insert("end", "\n".join(lines)) + self._inc_text.configure(state="disabled") + + +# ═══════════════════════════════════════════════════════════════════════ +# Usage Dashboard +# ═══════════════════════════════════════════════════════════════════════ + +class UsageWindow: + def __init__(self, parent): + self._U = _usage_theme() + self._dlg = tk.Toplevel(parent) + self._dlg.title("Usage Dashboard") + self._dlg.geometry("720x640") + self._dlg.transient(parent) + self._dlg.configure(bg=self._U["base"]) + + self._build_header() + self._build_summary_strip() + ttk.Separator(self._dlg).pack(fill="x", padx=16) + + self._cards_frame = tk.Frame(self._dlg, bg=self._U["base"]) + canvas = tk.Canvas(self._cards_frame, bg=self._U["base"], highlightthickness=0) + scrollbar = ttk.Scrollbar(self._cards_frame, orient="vertical", command=canvas.yview) + self._cards_inner = tk.Frame(canvas, bg=self._U["base"]) + self._cards_inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=self._cards_inner, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side="left", fill="both", expand=True, padx=(16, 0)) + scrollbar.pack(side="right", fill="y") + self._cards_frame.pack(fill="both", expand=True, pady=(8, 0)) + + self._refresh() + + def _build_header(self): + U = self._U + hdr = tk.Frame(self._dlg, bg=U["base"]) + hdr.pack(fill="x", padx=16, pady=(12, 6)) + tk.Label(hdr, text="⚡", fg=U["accent"], bg=U["base"], font=("Segoe UI", 14)).pack(side="left") + tk.Label(hdr, text="Usage Dashboard", fg=U["text"], bg=U["base"], + font=("Segoe UI", 14, "bold")).pack(side="left", padx=(4, 0)) + self._status_dots = tk.Label(hdr, text="", fg=U["text"], bg=U["base"], font=("Segoe UI", 9)) + self._status_dots.pack(side="left", padx=(8, 0)) + self._updated_lbl = tk.Label(hdr, text="Never", fg=U["dim"], bg=U["base"], font=("Segoe UI", 8)) + self._updated_lbl.pack(side="right") + refresh_btn = tk.Button(hdr, text="Refresh", fg=U["text"], bg=U["surface0"], + activebackground=U["surface1"], relief="flat", bd=0, + command=self._refresh, padx=12, pady=2) + refresh_btn.pack(side="right", padx=(8, 0)) + + def _build_summary_strip(self): + U = self._U + strip = tk.Frame(self._dlg, bg=U["surface0"], padx=12, pady=8) + strip.pack(fill="x", padx=16, pady=(0, 6)) + self._kpi_labels = {} + for key, label, icon in [("providers", "Providers", "\U0001F4CA"), + ("requests", "Requests", "⚡"), + ("tokens", "Tokens", "\U0001F9E0"), + ("latency", "Avg Latency", "⏱")]: + box = tk.Frame(strip, bg=U["surface0"]) + box.pack(side="left", padx=(0, 20)) + tk.Label(box, text=f"{icon} {label}", fg=U["dim"], bg=U["surface0"], + font=("Segoe UI", 8), anchor="w").pack(anchor="w") + val = tk.Label(box, text="-", fg=U["text"], bg=U["surface0"], + font=("Segoe UI", 9, "bold"), anchor="w") + val.pack(anchor="w") + self._kpi_labels[key] = val + + def _refresh(self): + for w in self._cards_inner.winfo_children(): + w.destroy() + stats = load_usage_stats() + updated = stats.get("updated") + if updated: + self._updated_lbl.configure(text=updated) + providers = stats.get("providers", {}) + if not providers: + tk.Label(self._cards_inner, text="No usage data yet.\nLaunch a session to start tracking.", + fg=self._U["dim"], bg=self._U["base"], font=("Segoe UI", 11)).pack(pady=60) + return + + total_req = total_tok_in = total_tok_out = 0 + total_dur = 0.0 + n_ok = n_warn = n_err = 0 + + sorted_providers = sorted(providers.items(), key=lambda x: x[1].get("total_requests", 0), reverse=True) + for prov_name, prov_data in sorted_providers: + t = prov_data.get("total_requests", 0) + total_req += t + total_tok_in += prov_data.get("total_tokens_in", 0) + total_tok_out += prov_data.get("total_tokens_out", 0) + total_dur += prov_data.get("total_duration_s", 0.0) + fail = prov_data.get("failures", 0) + fail_pct = fail / t if t > 0 else 0 + if fail_pct > 0.15: + n_err += 1 + elif fail_pct > 0.05: + n_warn += 1 + else: + n_ok += 1 + + self._kpi_labels["providers"].configure(text=str(len(providers))) + self._kpi_labels["requests"].configure(text=f"{total_req:,}") + tok_sum = total_tok_in + total_tok_out + tok_str = f"{_fmt_tok(tok_sum)} in:{_fmt_tok(total_tok_in)} out:{_fmt_tok(total_tok_out)}" if tok_sum else "N/A" + self._kpi_labels["tokens"].configure(text=tok_str) + avg_lat = total_dur / total_req if total_req > 0 else 0 + self._kpi_labels["latency"].configure(text=_fmt_dur(avg_lat)) + + dots = "" + if n_ok: + dots += f"●{n_ok} " + if n_warn: + dots += f"◐{n_warn} " + if n_err: + dots += f"✗{n_err}" + self._status_dots.configure(text=dots) + + for prov_name, prov_data in sorted_providers: + self._build_card(prov_name, prov_data) + + def _build_card(self, name, data): + U = self._U + total = data.get("total_requests", 0) + ok = data.get("successes", 0) + fail = data.get("failures", 0) + success_rate = ok / total if total > 0 else 1.0 + fail_pct = fail / total if total > 0 else 0 + status_text, status_color = _status_pill(success_rate, fail_pct) + + card = tk.Frame(self._cards_inner, bg=U["surface0"], padx=14, pady=10, + highlightbackground=status_color, highlightthickness=1) + card.pack(fill="x", pady=(0, 6)) + + top = tk.Frame(card, bg=U["surface0"]) + top.pack(fill="x") + tk.Label(top, text="●", fg=status_color, bg=U["surface0"], font=("Segoe UI", 10)).pack(side="left") + short = name.replace("https://", "").replace("http://", "").split("/")[0] + tk.Label(top, text=short, fg=U["text"], bg=U["surface0"], + font=("Segoe UI", 10, "bold")).pack(side="left", padx=(4, 0)) + tk.Label(top, text=f" {status_text} ", fg=U["base"], bg=status_color, + font=("Segoe UI", 8, "bold")).pack(side="left", padx=(4, 0)) + tk.Label(top, text=f"{total} req", fg=U["subtext"], bg=U["surface0"], + font=("Segoe UI", 8)).pack(side="left", padx=(6, 0)) + last_used = data.get("last_used", "") + if last_used: + tk.Label(top, text=last_used, fg=U["dim"], bg=U["surface0"], + font=("Segoe UI", 7)).pack(side="right") + + gauge = tk.Frame(card, bg=U["surface0"]) + gauge.pack(fill="x", pady=(4, 0)) + bar_frame = tk.Frame(gauge, bg=U["surface1"], height=12) + bar_frame.pack(fill="x", side="left", expand=True) + bar_frame.pack_propagate(False) + fill_pct = int(success_rate * 100) + fill_frame = tk.Frame(bar_frame, bg=status_color, height=12) + fill_frame.place(relwidth=success_rate, relheight=1.0) + tk.Label(gauge, text=f"{fill_pct}%", fg=U["subtext"], bg=U["surface0"], + font=("Segoe UI", 8)).pack(side="left", padx=(4, 0)) + if fail > 0: + tk.Label(gauge, text=f"{fail} fail", fg=U["red"], bg=U["surface0"], + font=("Segoe UI", 8)).pack(side="right") + + metrics = tk.Frame(card, bg=U["surface0"]) + metrics.pack(fill="x", pady=(4, 0)) + t_in = data.get("total_tokens_in", 0) + t_out = data.get("total_tokens_out", 0) + dur = data.get("total_duration_s", 0.0) + avg_dur = dur / total if total > 0 else 0 + for label, value, color in [("Tokens In", _fmt_tok(t_in), U["sapphire"]), + ("Tokens Out", _fmt_tok(t_out), U["peach"]), + ("Avg Latency", _fmt_dur(avg_dur), U["sky"]), + ("Duration", _fmt_dur(dur), U["lavender"])]: + box = tk.Frame(metrics, bg=U["surface0"]) + box.pack(side="left", padx=(0, 16)) + tk.Label(box, text=label, fg=U["dim"], bg=U["surface0"], font=("Segoe UI", 7)).pack(anchor="w") + tk.Label(box, text=value, fg=color, bg=U["surface0"], + font=("Segoe UI", 9, "bold")).pack(anchor="w") + + models = data.get("models", {}) + if models: + models_frame = tk.Frame(card, bg=U["surface0"]) + models_frame.pack(fill="x", pady=(4, 0)) + tk.Label(models_frame, text="Models:", fg=U["lavender"], bg=U["surface0"], + font=("Segoe UI", 8, "bold")).pack(anchor="w") + sorted_models = sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True) + for i, (mname, mdata) in enumerate(sorted_models[:6]): + m_req = mdata.get("requests", 0) + pct = m_req / total * 100 if total > 0 else 0 + color = U["model_palette"][i % len(U["model_palette"])] + row = tk.Frame(models_frame, bg=U["surface0"]) + row.pack(fill="x") + tk.Label(row, text=f"● {mname}", fg=color, bg=U["surface0"], + font=("Segoe UI", 7)).pack(side="left") + tk.Label(row, text=f"{pct:.0f}% ({m_req})", fg=U["dim"], bg=U["surface0"], + font=("Segoe UI", 7)).pack(side="left", padx=(8, 0)) + + last_err = data.get("last_error") + if last_err: + err_frame = tk.Frame(card, bg=U["surface0"]) + err_frame.pack(fill="x", pady=(4, 0)) + tk.Label(err_frame, text=f"⚠ {last_err}", fg=U["red"], bg=U["surface0"], + font=("Segoe UI", 7)).pack(anchor="w") + + +# ═══════════════════════════════════════════════════════════════════════ +# Request History Window +# ═══════════════════════════════════════════════════════════════════════ + +class RequestHistoryWindow: + def __init__(self, parent): + self._snap_dir = PROXY_CONFIG_DIR / "requests" + self._dlg = tk.Toplevel(parent) + self._dlg.title("Request History") + self._dlg.geometry("720x500") + self._dlg.transient(parent) + + main = ttk.Frame(self._dlg, padding=10) + main.pack(fill="both", expand=True) + + hdr = ttk.Frame(main) + hdr.pack(fill="x") + ttk.Label(hdr, text="Request History", font=("Segoe UI", 11, "bold")).pack(side="left") + ttk.Button(hdr, text="Clear All", command=self._clear_all).pack(side="right") + ttk.Button(hdr, text="Refresh", command=self._load).pack(side="right", padx=(0, 4)) + + paned = ttk.PanedWindow(main, orient="vertical") + paned.pack(fill="both", expand=True, pady=(6, 0)) + + top_frame = ttk.Frame(paned) + cols = ("time", "model", "status", "duration", "id", "error") + self._tree = ttk.Treeview(top_frame, columns=cols, show="headings", height=10) + for col, heading, w in [("time", "Time", 140), ("model", "Model", 140), ("status", "Status", 80), + ("duration", "Duration", 70), ("id", "ID", 180), ("error", "Error", 120)]: + self._tree.heading(col, text=heading) + self._tree.column(col, width=w, minwidth=50) + tree_sb = ttk.Scrollbar(top_frame, orient="vertical", command=self._tree.yview) + self._tree.configure(yscrollcommand=tree_sb.set) + self._tree.pack(side="left", fill="both", expand=True) + tree_sb.pack(side="right", fill="y") + paned.add(top_frame, weight=1) + + bottom_frame = ttk.Frame(paned) + self._detail = tk.Text(bottom_frame, height=10, wrap="word", font=("Consolas", 9)) + detail_sb = ttk.Scrollbar(bottom_frame, orient="vertical", command=self._detail.yview) + self._detail.configure(yscrollcommand=detail_sb.set) + self._detail.pack(side="left", fill="both", expand=True) + detail_sb.pack(side="right", fill="y") + paned.add(bottom_frame, weight=1) + + self._tree.bind("<>", self._on_select) + + self._snapshots = [] + self._load() + + def _load(self): + for item in self._tree.get_children(): + self._tree.delete(item) + self._snapshots = [] + if not self._snap_dir.exists(): + return + files = sorted(self._snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + for f in files[:200]: + try: + data = json.loads(f.read_text()) + meta = data.get("_meta", {}) + self._snapshots.append(data) + ts = meta.get("ts_iso", "")[:19].replace("T", " ") + model = meta.get("model", "?") + status = meta.get("status", "unknown") + dur = f"{meta['duration_s']:.1f}s" if meta.get("duration_s") is not None else "-" + rid = meta.get("request_id", "")[:28] + err = (meta.get("error") or "")[:60] + self._tree.insert("", "end", values=(ts, model, status, dur, rid, err)) + except Exception: + pass + + def _on_select(self, event): + sel = self._tree.selection() + if not sel: + return + idx = self._tree.index(sel[0]) + if idx < len(self._snapshots): + data = self._snapshots[idx] + self._detail.delete("1.0", "end") + self._detail.insert("end", json.dumps(data, indent=2, ensure_ascii=False)[:50000]) + + def _clear_all(self): + if not messagebox.askyesno("Clear All", "Delete all request snapshots?", parent=self._dlg): + return + if self._snap_dir.exists(): + for f in self._snap_dir.glob("*.json"): + try: + f.unlink() + except Exception: + pass + for item in self._tree.get_children(): + self._tree.delete(item) + self._snapshots = [] + self._detail.delete("1.0", "end") + + +# ═══════════════════════════════════════════════════════════════════════ +# Benchmark Window +# ═══════════════════════════════════════════════════════════════════════ + +class BenchmarkWindow: + _BENCH_PROMPT = "In exactly 3 bullet points, explain why the sky is blue." + _BENCH_TOOLS = [{"type": "function", "function": {"name": "get_weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}}}] + + def __init__(self, parent): + self._dlg = tk.Toplevel(parent) + self._dlg.title("Model Benchmark") + self._dlg.geometry("820x560") + self._dlg.transient(parent) + self._running = False + self._ep_data = load_endpoints() + + main = ttk.Frame(self._dlg, padding=10) + main.pack(fill="both", expand=True) + + hdr = ttk.Frame(main) + hdr.pack(fill="x") + ttk.Label(hdr, text="Multi-Provider Benchmark", font=("Segoe UI", 11, "bold")).pack(side="left") + self._run_btn = ttk.Button(hdr, text="Run Benchmark", command=self._run) + self._run_btn.pack(side="right") + + lanes_frame = ttk.Frame(main) + lanes_frame.pack(fill="x", pady=(8, 0)) + + self._lanes = [] + self._c_var = tk.BooleanVar(value=False) + for i, lane_label in enumerate(["A", "B", "C"]): + if i == 2: + lf = ttk.LabelFrame(lanes_frame, text="Lane C (optional)") + cb = ttk.Checkbutton(lanes_frame, text="Enable Lane C", variable=self._c_var, + command=lambda: lf.configure() if not self._c_var.get() else None) + else: + lf = ttk.LabelFrame(lanes_frame, text=f"Lane {lane_label}") + lf.pack(side="left", fill="both", expand=True, padx=(0, 4 if i < 2 else 0)) + + ep_frame = ttk.Frame(lf, padding=4) + ep_frame.pack(fill="x") + ttk.Label(ep_frame, text="Endpoint:").pack(side="left") + ep_combo = ttk.Combobox(ep_frame, values=[e["name"] for e in self._ep_data.get("endpoints", [])], state="readonly") + ep_combo.pack(side="left", fill="x", expand=True, padx=(4, 0)) + + m_frame = ttk.Frame(lf, padding=4) + m_frame.pack(fill="x") + ttk.Label(m_frame, text="Model:").pack(side="left") + m_combo = ttk.Combobox(m_frame, state="readonly") + m_combo.pack(side="left", fill="x", expand=True, padx=(4, 0)) + + ep_combo.bind("<>", lambda e, mc=m_combo: self._update_lane_models(ep_combo, mc)) + self._lanes.append({"ep": ep_combo, "model": m_combo}) + + default_name = self._ep_data.get("default") + eps = self._ep_data.get("endpoints", []) + if default_name: + self._lanes[0]["ep"].set(default_name) + if len(eps) > 1: + self._lanes[1]["ep"].set(eps[1]["name"]) + elif eps: + self._lanes[1]["ep"].set(eps[0]["name"]) + if len(eps) > 2: + self._lanes[2]["ep"].set(eps[2]["name"]) + elif len(eps) > 1: + self._lanes[2]["ep"].set(eps[1]["name"]) + + tests_frame = ttk.Frame(main) + tests_frame.pack(fill="x", pady=(8, 0)) + self._test_ttft = tk.BooleanVar(value=True) + self._test_total = tk.BooleanVar(value=True) + self._test_tools = tk.BooleanVar(value=True) + self._test_tps = tk.BooleanVar(value=True) + ttk.Checkbutton(tests_frame, text="Time to First Token", variable=self._test_ttft).pack(side="left") + ttk.Checkbutton(tests_frame, text="Total Latency", variable=self._test_total).pack(side="left", padx=(8, 0)) + ttk.Checkbutton(tests_frame, text="Tool Call", variable=self._test_tools).pack(side="left", padx=(8, 0)) + ttk.Checkbutton(tests_frame, text="Tokens/sec", variable=self._test_tps).pack(side="left", padx=(8, 0)) + + results_frame = ttk.Frame(main) + results_frame.pack(fill="both", expand=True, pady=(8, 0)) + cols = ("test", "a", "b", "c", "winner") + self._results_tree = ttk.Treeview(results_frame, columns=cols, show="headings", height=6) + for col, heading in [("test", "Test"), ("a", "Lane A"), ("b", "Lane B"), ("c", "Lane C"), ("winner", "Winner")]: + self._results_tree.heading(col, text=heading) + self._results_tree.column(col, width=150, minwidth=80) + rsb = ttk.Scrollbar(results_frame, orient="vertical", command=self._results_tree.yview) + self._results_tree.configure(yscrollcommand=rsb.set) + self._results_tree.pack(side="left", fill="both", expand=True) + rsb.pack(side="right", fill="y") + + self._status_var = tk.StringVar(value="Select endpoints and models per lane, then Run Benchmark.") + ttk.Label(main, textvariable=self._status_var).pack(anchor="w", pady=(4, 0)) + + def _update_lane_models(self, ep_combo, model_combo): + name = ep_combo.get() + if not name: + return + ep = get_endpoint(name) + models = (ep or {}).get("models", []) + model_combo["values"] = models + if models: + model_combo.set(models[0]) + + def _collect_lanes(self): + active = [] + for i, lane in enumerate(self._lanes): + if i == 2 and not self._c_var.get(): + continue + ep_name = lane["ep"].get() + model = lane["model"].get() + if not ep_name or not model: + continue + ep = get_endpoint(ep_name) + if not ep: + continue + active.append({"ep": ep, "model": model, "label": f"{ep_name}/{model}"}) + return active + + def _bench_single(self, ep, model, stream, with_tools=False): + url = normalize_base_url(ep.get("base_url", "")) + key = (ep.get("api_key") or "").strip() + bt = ep.get("backend_type", "openai-compat") + if bt == "anthropic": + test_url = f"{url}/v1/messages" + headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} + body = {"model": model, "max_tokens": 100, "stream": stream, + "messages": [{"role": "user", "content": self._BENCH_PROMPT}]} + if with_tools: + body["tools"] = self._BENCH_TOOLS + body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}] + data = json.dumps(body).encode() + else: + test_url = f"{url}/chat/completions" + headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"} + body = {"model": model, "max_tokens": 100, "stream": stream, + "messages": [{"role": "user", "content": self._BENCH_PROMPT}]} + if with_tools: + body["tools"] = self._BENCH_TOOLS + body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}] + data = json.dumps(body).encode() + + req = urllib.request.Request(test_url, data=data, headers=headers, method="POST") + t0 = time.time() + ttft = None + try: + resp = urllib.request.urlopen(req, timeout=60) + if stream: + first_chunk_time = None + chunks = [] + while True: + chunk = resp.read(4096) + if not chunk: + break + if first_chunk_time is None: + first_chunk_time = time.time() + ttft = first_chunk_time - t0 + chunks.append(chunk) + total = time.time() - t0 + result_text = b"".join(chunks).decode(errors="replace")[:300] + else: + raw = resp.read() + total = time.time() - t0 + result_text = raw.decode(errors="replace")[:300] + payload = json.loads(raw) + choices = payload.get("choices", []) + if choices: + msg = choices[0].get("message", {}) + if with_tools: + tcs = msg.get("tool_calls", []) + has_tools = len(tcs) > 0 + return {"ttft": ttft or total, "total": total, + "detail": f"tools={has_tools}, tok={payload.get('usage', {}).get('total_tokens', '?')}"} + content = msg.get("content", "")[:50] + return {"ttft": ttft or total, "total": total, + "detail": f"{content[:40]}... tok={payload.get('usage', {}).get('total_tokens', '?')}"} + return {"ttft": ttft or total, "total": total, "detail": result_text[:60]} + except Exception as e: + total = time.time() - t0 + return {"ttft": ttft or total, "total": total, "detail": f"Error: {str(e)[:40]}"} + + def _bench_tps(self, ep, model): + url = normalize_base_url(ep.get("base_url", "")) + key = (ep.get("api_key") or "").strip() + bt = ep.get("backend_type", "openai-compat") + prompt = "Write a detailed paragraph about artificial intelligence in at least 150 words." + max_tok = 512 + if bt == "anthropic": + test_url = f"{url}/v1/messages" + headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} + else: + test_url = f"{url}/chat/completions" + headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"} + body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True, + "messages": [{"role": "user", "content": prompt}]}).encode() + req = urllib.request.Request(test_url, data=body, headers=headers, method="POST") + t0 = time.time() + first_token_t = None + token_count = 0 + try: + resp = urllib.request.urlopen(req, timeout=90) + buf = b"" + while True: + chunk = resp.read(4096) + if not chunk: + break + if first_token_t is None: + first_token_t = time.time() + buf += chunk + total = time.time() - t0 + text = buf.decode(errors="replace") + for line in text.split("\n"): + if line.startswith("data: ") and line != "data: [DONE]": + try: + d = json.loads(line[6:]) + content = d.get("choices", [{}])[0].get("delta", {}).get("content", "") + if content: + token_count += max(1, len(content) / 4) + except Exception: + pass + if token_count == 0: + token_count = max(1, len(text) / 4) + gen_time = (time.time() - first_token_t) if first_token_t else total + tps = token_count / gen_time if gen_time > 0 else 0 + return {"tps": tps, "tokens": int(token_count), "gen_time": gen_time, "total": total, + "detail": f"{int(token_count)} tok / {gen_time:.1f}s"} + except Exception as e: + total = time.time() - t0 + return {"tps": 0, "tokens": 0, "gen_time": total, "total": total, "detail": f"Error: {str(e)[:40]}"} + + def _run(self): + if self._running: + return + lanes = self._collect_lanes() + if len(lanes) < 2: + self._status_var.set("Need at least 2 lanes with endpoint + model selected.") + return + self._running = True + self._run_btn.configure(state="disabled") + for item in self._results_tree.get_children(): + self._results_tree.delete(item) + self._status_var.set("Running benchmark...") + threading.Thread(target=self._run_bench, args=(lanes,), daemon=True).start() + + def _run_bench(self, lanes): + results = [] + tests = [] + if self._test_ttft.get(): + tests.append(("TTFT (stream)", True, False)) + if self._test_total.get(): + tests.append(("Total latency", False, False)) + if self._test_tools.get(): + tests.append(("Tool call", False, True)) + run_tps = self._test_tps.get() + + for test_name, stream, tools in tests: + lane_results = [] + for lane in lanes: + label = lane["label"] + self._dlg.after(0, lambda l=label: self._status_var.set(f"Running {test_name}: {l}...")) + r = self._bench_single(lane["ep"], lane["model"], stream, tools) + lane_results.append((label, r)) + + metric = "ttft" if stream else "total" + values = [(lr[0], lr[1][metric]) for lr in lane_results] + sorted_v = sorted(values, key=lambda x: x[1]) + best_val = sorted_v[0][1] + second_val = sorted_v[1][1] if len(sorted_v) > 1 else best_val + 1 + if best_val < second_val * 0.85: + winner = sorted_v[0][0] + else: + winner = "Tie" + + cols = [] + for lr in lane_results: + v = lr[1][metric] + cols.append(f"{v:.2f}s ({lr[1]['detail'][:30]})") + while len(cols) < 3: + cols.append("--") + cols.append(winner) + results.append(tuple([test_name] + cols)) + + if run_tps: + lane_tps = [] + for lane in lanes: + label = lane["label"] + self._dlg.after(0, lambda l=label: self._status_var.set(f"Tokens/sec: {l}...")) + r = self._bench_tps(lane["ep"], lane["model"]) + lane_tps.append((label, r)) + + tps_vals = [(lt[0], lt[1]["tps"]) for lt in lane_tps] + sorted_tps = sorted(tps_vals, key=lambda x: x[1], reverse=True) + best_tps = sorted_tps[0][1] + second_tps = sorted_tps[1][1] if len(sorted_tps) > 1 else 0 + if best_tps > 0 and second_tps > 0 and best_tps > second_tps * 1.15: + winner_tps = sorted_tps[0][0] + else: + winner_tps = "Tie" + + cols_tps = [] + for lt in lane_tps: + tps = lt[1]["tps"] + cols_tps.append(f"{tps:.1f} t/s ({lt[1]['detail'][:25]})") + while len(cols_tps) < 3: + cols_tps.append("--") + cols_tps.append(winner_tps) + results.append(tuple(["Tokens/sec"] + cols_tps)) + + def _show(): + for row in results: + self._results_tree.insert("", "end", values=row) + self._status_var.set("Benchmark complete.") + self._running = False + self._run_btn.configure(state="normal") + + self._dlg.after(0, _show) + + +# ═══════════════════════════════════════════════════════════════════════ +# Main Launcher Window +# ═══════════════════════════════════════════════════════════════════════ + +class LauncherWin: + def __init__(self, root): + self._root = root + self._root.title("Codex Launcher") + self._root.geometry("580x520") + self._root.minsize(480, 400) + self._proc = None + self._endpoints_data = load_endpoints() + self._refresh_running = False + recover_config_if_needed() + + main = ttk.Frame(root, padding=12) + main.pack(fill="both", expand=True) + + # Title + hdr = ttk.Frame(main) + hdr.pack(fill="x") + ttk.Label(hdr, text="Codex Launcher v3.8.1", font=("Segoe UI", 13, "bold")).pack(side="left") + + # Toolbar — two rows to fit all buttons + tb1 = ttk.Frame(main) + tb1.pack(fill="x", pady=(6, 0)) + ttk.Button(tb1, text="Endpoints...", command=self._open_mgr).pack(side="left") + ttk.Button(tb1, text="AI Monitor", command=self._open_monitoring).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="AI BGP", command=self._open_bgp).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="Usage", command=self._open_usage).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="Benchmark", command=self._open_benchmark).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="History", command=self._open_history).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="Changelog", command=self._show_changelog).pack(side="right") + + # Detection status — one row per item so long paths don't truncate + self._cli_info = detect_codex_cli() + self._desktop_info = detect_codex_desktop() + + cli_row = ttk.Frame(main) + cli_row.pack(fill="x", pady=(4, 0)) + if self._cli_info: + cli_path, cli_ver = self._cli_info + ttk.Label(cli_row, text=f"✓ Codex CLI {cli_ver}", foreground="#2ea043").pack(side="left") + ttk.Label(cli_row, text=f" ({cli_path})", foreground="gray").pack(side="left") + else: + ttk.Label(cli_row, text="✗ Codex CLI -- not found", foreground="#d29922").pack(side="left") + ttk.Button(cli_row, text="Install", command=lambda: self._show_install_guide("cli")).pack(side="left", padx=(6, 0)) + + desk_row = ttk.Frame(main) + desk_row.pack(fill="x", pady=(2, 0)) + if self._desktop_info: + ttk.Label(desk_row, text="✓ Codex Desktop", foreground="#2ea043").pack(side="left") + ttk.Label(desk_row, text=f" ({self._desktop_info})", foreground="gray").pack(side="left") + else: + ttk.Label(desk_row, text="✗ Codex Desktop -- not found", foreground="#d29922").pack(side="left") + ttk.Button(desk_row, text="Install", command=lambda: self._show_install_guide("desktop")).pack(side="left", padx=(6, 0)) + + self._missing = [] + if not self._cli_info: + self._missing.append("cli") + if not self._desktop_info: + self._missing.append("desktop") + + # Auth status + auth_frame = ttk.Frame(main) + auth_frame.pack(fill="x", pady=(6, 0)) + self._auth_label = ttk.Label(auth_frame, text="Checking auth...") + self._auth_label.pack(side="left") + self._relogin_btn = ttk.Button(auth_frame, text="Re-login", command=self._codex_relogin, state="disabled") + self._relogin_btn.pack(side="right") + threading.Thread(target=self._check_auth_async, daemon=True).start() + + # Ops bar + ops_frame = ttk.Frame(main) + ops_frame.pack(fill="x", pady=(6, 0)) + self._refresh_all_btn = ttk.Button(ops_frame, text="Refresh Models", command=self._refresh_all_models) + self._refresh_all_btn.pack(side="left") + ttk.Button(ops_frame, text="Backup Profile", command=self._backup_profile).pack(side="left", padx=(8, 0)) + ttk.Button(ops_frame, text="Import Profile", command=self._import_profile).pack(side="left", padx=(8, 0)) + + # Endpoint + Model selectors + sel_frame = ttk.Frame(main) + sel_frame.pack(fill="x", pady=(6, 0)) + ttk.Label(sel_frame, text="Endpoint:").pack(side="left") + self._combo_ep = ttk.Combobox(sel_frame, state="readonly", width=24) + self._combo_ep.pack(side="left", padx=(4, 0)) + self._combo_ep.bind("<>", lambda e: self._on_endpoint_changed()) + ttk.Label(sel_frame, text="Model:").pack(side="left", padx=(12, 0)) + self._combo_model = ttk.Combobox(sel_frame, state="readonly", width=24) + self._combo_model.pack(side="left", padx=(4, 0)) + + # Launch buttons + btn_frame1 = ttk.Frame(main) + btn_frame1.pack(fill="x", pady=(8, 0)) + self._btn_desktop = ttk.Button(btn_frame1, text="Launch Desktop", command=lambda: self._launch("desktop")) + if "desktop" in self._missing: + self._btn_desktop.configure(state="disabled") + self._btn_desktop.pack(side="left", fill="x", expand=True, padx=(0, 4)) + self._btn_cli = ttk.Button(btn_frame1, text="Launch CLI", command=lambda: self._launch("cli")) + if "cli" in self._missing: + self._btn_cli.configure(state="disabled") + self._btn_cli.pack(side="left", fill="x", expand=True) + + btn_frame2 = ttk.Frame(main) + btn_frame2.pack(fill="x", pady=(4, 0)) + self._btn_codex_desktop = ttk.Button(btn_frame2, text="Codex Default (Desktop)", + command=lambda: self._launch_codex_default("desktop")) + if "desktop" in self._missing: + self._btn_codex_desktop.configure(state="disabled") + self._btn_codex_desktop.pack(side="left", fill="x", expand=True, padx=(0, 4)) + self._btn_codex_cli = ttk.Button(btn_frame2, text="Codex Default (CLI)", + command=lambda: self._launch_codex_default("cli")) + if "cli" in self._missing: + self._btn_codex_cli.configure(state="disabled") + self._btn_codex_cli.pack(side="left", fill="x", expand=True) + + # Log area + self._log_text = scrolledtext.ScrolledText(main, height=8, state="disabled", wrap="word", + font=("Consolas", 9)) + self._log_text.pack(fill="both", expand=True, pady=(8, 0)) + + # Bottom bar + bb = ttk.Frame(main) + bb.pack(fill="x", pady=(6, 0)) + ttk.Button(bb, text="AI Assistant", command=self._open_assistant).pack(side="left") + self._kill_btn = ttk.Button(bb, text="Kill && Cleanup", command=self._kill, state="disabled") + self._kill_btn.pack(side="left", fill="x", expand=True, padx=(8, 0)) + ttk.Button(bb, text="View Log", command=lambda: open_file(str(LAUNCH_LOG))).pack(side="left") + ttk.Button(bb, text="Close", command=self._do_close).pack(side="left", padx=(8, 0)) + + self._rebuild_combo() + self._log_dependency_status() + self._start_watcher() + + # ── Logging ────────────────────────────────────────────────────── + + def log(self, msg): + self._root.after(0, self._append_log, msg) + + def _append_log(self, msg): + self._log_text.configure(state="normal") + self._log_text.insert("end", msg + "\n") + self._log_text.see("end") + self._log_text.configure(state="disabled") + + def _log_dependency_status(self): + if self._cli_info: + _, ver = self._cli_info + self.log(f"✓ Codex CLI detected ({ver})") + else: + self.log("✗ Codex CLI NOT found -- CLI launch disabled.") + if self._desktop_info: + self.log(f"✓ Codex Desktop detected ({self._desktop_info})") + else: + self.log("✗ Codex Desktop NOT found -- Desktop launch disabled.") + if self._missing: + self.log("Install missing tools before using the launcher.") + else: + self.log("All dependencies OK.") + + # ── Auth ───────────────────────────────────────────────────────── + + def _check_auth_async(self): + status, msg = check_codex_auth() + self._root.after(0, lambda: self._update_auth_status(status, msg)) + + def _update_auth_status(self, status, msg): + if status == "logged_in": + self._auth_label.configure(text=f"✓ Auth: {msg}", foreground="#2ea043") + self._relogin_btn.configure(state="normal" if "cli" not in self._missing else "disabled") + elif status == "not_installed": + self._auth_label.configure(text="Auth: N/A (CLI not installed)", foreground="#888") + else: + self._auth_label.configure(text=f"⚠ Auth: {msg}", foreground="#d29922") + self._relogin_btn.configure(state="normal" if "cli" not in self._missing else "disabled") + + def _codex_relogin(self): + self.log("Opening codex login in terminal...") + term = detect_terminal() + if not term: + self.log("ERROR: no terminal emulator found for re-login") + return + term_name, term_args, term_path = term + cmd_parts = [term_name] + term_args + ["codex", "login"] + if IS_WINDOWS: + subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + subprocess.Popen(cmd_parts, preexec_fn=os.setsid) + self.log("Login flow started in terminal. Re-checking auth in 30s...") + self._auth_label.configure(text="Auth: waiting for login...") + threading.Thread(target=lambda: (time.sleep(30), self._check_auth_async()), daemon=True).start() + + # ── Combo management ───────────────────────────────────────────── + + def _rebuild_combo(self): + self._endpoints_data = load_endpoints() + ep_names = [e["name"] for e in self._endpoints_data["endpoints"]] + bgp_names = [f"\U0001F500 {p['name']}" for p in load_bgp_pools().get("pools", [])] + all_names = ep_names + bgp_names + self._combo_ep["values"] = all_names + if all_names: + default = self._endpoints_data.get("default") + if default and default in ep_names: + self._combo_ep.set(default) + else: + self._combo_ep.set(all_names[0]) + self._on_endpoint_changed() + + def _on_endpoint_changed(self): + name = self._combo_ep.get() + is_bgp = name.startswith("\U0001F500 ") + bgp_name = name[2:] if is_bgp else None + ep = get_endpoint(name) if name and not is_bgp else None + models = [] + if is_bgp: + for p in load_bgp_pools().get("pools", []): + if p["name"] == bgp_name: + seen = set() + for r in p.get("routes", []): + m = r.get("model", "") + if m and m not in seen: + models.append(m) + seen.add(m) + break + elif ep: + models = ep.get("models", []) + self._combo_model["values"] = models + if ep and ep.get("default_model") in models: + self._combo_model.set(ep["default_model"]) + elif models: + self._combo_model.set(models[0]) + else: + self._combo_model.set("") + + # ── Window openers ─────────────────────────────────────────────── + + def _on_endpoints_updated(self): + self._rebuild_combo() + + def _open_mgr(self): + EndpointMgr(self._root, on_update=self._on_endpoints_updated) + + def _open_bgp(self): + BGPPoolMgr(self._root, on_update=self._on_endpoints_updated) + + def _open_monitoring(self): + AIMonitoringWindow(self._root) + + def _open_usage(self): + UsageWindow(self._root) + + def _open_history(self): + RequestHistoryWindow(self._root) + + def _open_benchmark(self): + BenchmarkWindow(self._root) + + def _open_assistant(self): + assist_path = str(Path(__file__).resolve().parent / "flet-codex-assist.py") + if Path(assist_path).exists(): + subprocess.Popen([sys.executable, assist_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if IS_WINDOWS else 0) + + # ── Watcher ────────────────────────────────────────────────────── + + def _start_watcher(self): + cfg = load_monitoring_config() + if not cfg.get("enabled"): + return + self._watcher = HealthWatcher( + on_failure=lambda c: self.log(f"[AI Monitor] Proxy unresponsive (failures={c})"), + on_recovery=lambda: self.log("[AI Monitor] Proxy recovered"), + on_signal=lambda fid, cat, line: None, + on_action=self._on_watcher_action, + ) + self._watcher.start() + self.log("AI Monitoring: watchdog started") + + def _on_watcher_action(self, action, trigger): + cfg = load_monitoring_config() + if action == "restart_proxy" and cfg.get("auto_restart_proxy"): + self.log(f"[AI Monitor] Auto-restarting proxy (trigger: {trigger})") + self._root.after(0, self._restart_proxy_from_watcher) + elif action in ("clear_schema_cache", "delete_provider_caps"): + try: + cap_file = PROXY_CONFIG_DIR / "provider-caps.json" + if cap_file.exists(): + cap_file.unlink() + self.log("[AI Monitor] Cleared corrupt schema cache") + except Exception as e: + self.log(f"[AI Monitor] Failed to clear cache: {e}") + elif action == "kill_stale_restart": + self.log(f"[AI Monitor] Killing stale processes + restarting (trigger: {trigger})") + self._kill() + self._root.after(0, self._restart_proxy_from_watcher) + else: + self.log(f"[AI Monitor] Alert: {action} (trigger: {trigger})") + + def _restart_proxy_from_watcher(self): + try: + ep_name = load_endpoints().get("default") + if not ep_name: + return + for ep in load_endpoints().get("endpoints", []): + if ep.get("name") == ep_name: + start_proxy_for(ep, self.log) + break + except Exception as e: + self.log(f"[AI Monitor] Proxy restart failed: {e}") + + # ── Profile operations ─────────────────────────────────────────── + + def _backup_profile(self): + filename = filedialog.asksaveasfilename( + title="Backup Codex Profile", + defaultextension=".json", + initialfile=f"codex-profile-{time.strftime('%Y%m%d-%H%M%S')}.json", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not filename: + return + try: + save_profile_bundle(filename) + self.log(f"Profile backed up to {filename}") + except Exception as e: + messagebox.showerror("Backup Failed", str(e)) + + def _refresh_all_models(self): + if self._refresh_running: + return + self._refresh_running = True + self._refresh_all_btn.configure(state="disabled") + self.log("Refreshing models for all providers...") + threading.Thread(target=self._refresh_all_models_worker, daemon=True).start() + + def _refresh_all_models_worker(self): + try: + data = load_endpoints() + updated = 0 + failed = [] + for idx, ep in enumerate(list(data["endpoints"])): + refreshed, err = refresh_endpoint_models(ep) + if refreshed: + data["endpoints"][idx] = refreshed + updated += 1 + else: + failed.append(f"{ep['name']}: {err}") + if updated: + save_endpoints(data) + self._root.after(0, lambda: self._finish_refresh(updated, failed)) + except Exception as e: + self._root.after(0, lambda: self._finish_refresh_error(str(e))) + + def _finish_refresh(self, updated, failed): + if updated: + self._rebuild_combo() + self.log(f"Refreshed models for {updated} provider(s)") + if failed: + messagebox.showwarning("Refresh", "Some providers could not auto-fetch models.\n\n" + + "\n".join(failed)) + elif updated: + messagebox.showinfo("Refresh", f"Refreshed models for {updated} provider(s).") + else: + messagebox.showinfo("Refresh", "No providers were refreshed.") + self._refresh_running = False + self._refresh_all_btn.configure(state="normal") + + def _finish_refresh_error(self, err): + messagebox.showerror("Refresh Failed", err) + self._refresh_running = False + self._refresh_all_btn.configure(state="normal") + + def _import_profile(self): + if self._proc and self._proc.poll() is None: + messagebox.showwarning("Import", "Stop Codex before importing a profile.") + return + filename = filedialog.askopenfilename( + title="Import Codex Profile", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not filename: + return + if not messagebox.askyesno("Import", + "Importing will replace the current endpoints and Codex config. Continue?"): + return + try: + import_profile_bundle(filename) + self._rebuild_combo() + self.log(f"Profile imported from {filename}") + messagebox.showinfo("Import", "Profile imported successfully.") + except Exception as e: + messagebox.showerror("Import Failed", str(e)) + + # ── Dialogs ────────────────────────────────────────────────────── + + def _show_changelog(self): + dlg = tk.Toplevel(self._root) + dlg.title("Changelog") + dlg.geometry("540x480") + dlg.transient(self._root) + text = scrolledtext.ScrolledText(dlg, wrap="word", font=("Segoe UI", 9)) + text.pack(fill="both", expand=True, padx=12, pady=12) + for ver, date, items in CHANGELOG: + text.insert("end", f"v{ver} ({date})\n") + for item in items: + text.insert("end", f" • {item}\n") + text.insert("end", "\n") + text.configure(state="disabled") + ttk.Button(dlg, text="Close", command=dlg.destroy).pack(pady=(0, 10)) + + def _show_install_guide(self, which): + if which == "cli": + guide = ("Codex CLI is required to use CLI launch features.\n\n" + "Install with npm:\n npm install -g @openai/codex\n\n" + "Or download from:\n https://github.com/openai/codex\n\n" + "After installing, restart the launcher.") + else: + guide = ("Codex Desktop is required to use Desktop launch features.\n\n" + "Download from:\n https://codex.desktop.openai.com\n\n" + "After installing, restart the launcher.") + messagebox.showinfo(f"Install Codex {which.title()}", guide) + + # ── Launch ─────────────────────────────────────────────────────── + + def _set_busy(self, busy): + has_cli = "cli" not in self._missing + has_desk = "desktop" not in self._missing + def _update(): + self._btn_desktop.configure(state="disabled" if busy or not has_desk else "normal") + self._btn_cli.configure(state="disabled" if busy or not has_cli else "normal") + self._btn_codex_desktop.configure(state="disabled" if busy or not has_desk else "normal") + self._btn_codex_cli.configure(state="disabled" if busy or not has_cli else "normal") + self._kill_btn.configure(state="normal" if busy else "disabled") + self._root.after(0, _update) + + def _launch(self, target): + name = self._combo_ep.get() + if not name: + self.log("ERROR: no endpoint selected") + return + model = self._combo_model.get() + if not model: + self.log("ERROR: no model selected") + return + + is_bgp = name.startswith("\U0001F500 ") + if is_bgp: + pool_name = name[2:] + pool = None + for p in load_bgp_pools().get("pools", []): + if p["name"] == pool_name: + pool = p + break + if not pool: + self.log(f"ERROR: BGP pool '{pool_name}' not found") + return + self._set_busy(True) + target_name = "Desktop" if target == "desktop" else "CLI" + self.log(f"=== BGP: {pool_name} / {model} -> {target_name} ===") + threading.Thread(target=self._run_bgp, args=(pool, model, target), daemon=True).start() + return + + ep = get_endpoint(name) + if not ep: + self.log("ERROR: endpoint not found") + return + self._set_busy(True) + target_name = "Desktop" if target == "desktop" else "CLI" + self.log(f"=== {ep['name']} / {model} -> {target_name} ===") + threading.Thread(target=self._run, args=(ep, model, target), daemon=True).start() + + def _launch_codex_default(self, target): + if "cli" not in self._missing: + status, msg = check_codex_auth() + if status != "logged_in": + if not messagebox.askyesno("Auth Warning", + f"Codex auth check: {msg}\n\n" + "Launch may fail without valid authentication.\nContinue anyway?"): + self._set_busy(False) + return + self._set_busy(True) + target_name = "Desktop" if target == "desktop" else "CLI" + self.log(f"=== Codex Default (OAuth) -> {target_name} ===") + threading.Thread(target=self._run_codex_default, args=(target,), daemon=True).start() + + def _run(self, ep, model, target): + keep_session_alive = False + try: + self.log("Cleaning up stale processes...") + safe_cleanup_owned(self.log) + recover_config_if_needed(self.log) + + needs_proxy = ep["backend_type"] != "native" + if needs_proxy: + self.log("Starting translation proxy...") + try: + proxy_port = start_proxy_for(ep, self.log) + except RuntimeError as e: + self._root.after(0, lambda: messagebox.showerror("Proxy 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": + if needs_proxy: + kill_existing_desktop(self.log) + keep_session_alive = self._launch_desktop(ep, model) + else: + self._launch_cli(ep, model) + except Exception as e: + self.log(f"ERROR: {e}") + finally: + if keep_session_alive: + self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.") + self._set_busy(False) + self.log("Ready. Use Kill && Cleanup when finished.") + else: + stop_proxy() + restore_config() + end_config_transaction() + self._set_busy(False) + self.log("Ready.") + + def _run_bgp(self, pool, model, target): + keep_session_alive = False + try: + self.log("Cleaning up stale processes...") + safe_cleanup_owned(self.log) + recover_config_if_needed(self.log) + + self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes...") + port, bgp_ep = start_bgp_proxy(pool, model, self.log) + + begin_config_transaction(f"launch:bgp:{pool['name']}") + write_config_for_translated(bgp_ep, model, port) + + if target == "desktop": + kill_existing_desktop(self.log) + keep_session_alive = self._launch_desktop(bgp_ep, model) + else: + self._launch_cli(bgp_ep, model) + except Exception as e: + self.log(f"ERROR: {e}") + finally: + if keep_session_alive: + self.log("Warm-start handoff detected; keeping proxy/config active.") + self._set_busy(False) + self.log("Ready. Use Kill && Cleanup when finished.") + else: + 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...") + safe_cleanup_owned(self.log) + stop_proxy() + recover_config_if_needed(self.log) + self.log("Resetting config to Codex defaults (OAuth)...") + begin_config_transaction("launch:default") + if CONFIG.exists(): + CONFIG.unlink() + if target == "desktop": + self._launch_desktop_direct() + else: + self._launch_cli_default() + except Exception as e: + self.log(f"ERROR: {e}") + finally: + restore_config() + end_config_transaction() + self._set_busy(False) + self.log("Ready.") + + def _launch_desktop(self, ep, model): + desktop_path = self._desktop_info + if not desktop_path: + self.log("ERROR: Codex Desktop not found") + return False + + if IS_WINDOWS: + self._proc = subprocess.Popen( + [desktop_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + self._proc = subprocess.Popen( + [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=os.setsid) + + pid = self._proc.pid + self.log(f"Desktop started (PID {pid})") + self.log(f"Log: {LAUNCH_LOG}") + + t0 = time.time() + stall_warned = False + while self._proc and self._proc.poll() is None: + time.sleep(1.5) + el = time.time() - t0 + if el > 20 and not stall_warned: + self.log("Still starting after 20s -- possible stall. Click Kill if window doesn't appear.") + self.log(f"--- last log lines ---\n{last_log_lines()}") + stall_warned = True + + if self._proc: + rc = self._proc.poll() + el = time.time() - t0 + self.log(f"Desktop exited (code {rc}) after {el:.0f}s") + if el < 12: + self.log("TIP: Quick exit -- may be warm-start handoff (normal) or crash.") + last_lines = last_log_lines() + self.log(f"--- last log lines ---\n{last_lines}") + if rc == 0 and "warm-start" in last_lines.lower(): + self._proc = None + return True + self._proc = None + return False + + def _launch_cli(self, ep, model): + self.log(f"Launching Codex CLI with {ep['name']}...") + term = detect_terminal() + if not term: + self.log("ERROR: no terminal found") + return + + term_name, term_args, _ = term + cmd_parts = [term_name] + term_args + if ep["backend_type"] == "native": + cmd_parts.extend(["codex", "-c", f"model={model}"]) + else: + cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"]) + + self.log(f"Running: {' '.join(cmd_parts)}") + if IS_WINDOWS: + self._proc = subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) + pid = self._proc.pid + self.log(f"CLI started in terminal (PID {pid})") + + while self._proc and self._proc.poll() is None: + time.sleep(1.5) + if self._proc: + rc = self._proc.poll() + self.log(f"CLI exited (code {rc})") + self._proc = None + + def _launch_desktop_direct(self): + self.log("Launching Codex Desktop (default OAuth)...") + desktop_path = self._desktop_info + if not desktop_path: + self.log("ERROR: Codex Desktop not found") + return + if IS_WINDOWS: + self._proc = subprocess.Popen( + [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + self._proc = subprocess.Popen( + [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=os.setsid) + pid = self._proc.pid + self.log(f"Desktop started (PID {pid})") + + t0 = time.time() + stall_warned = False + while self._proc and self._proc.poll() is None: + time.sleep(1.5) + el = time.time() - t0 + if el > 20 and not stall_warned: + self.log("Still starting after 20s -- possible stall.") + self.log(f"--- last log lines ---\n{last_log_lines()}") + stall_warned = True + if self._proc: + rc = self._proc.poll() + el = time.time() - t0 + self.log(f"Desktop exited (code {rc}) after {el:.0f}s") + self._proc = None + + def _launch_cli_default(self): + self.log("Launching Codex CLI (default OAuth)...") + term = detect_terminal() + if not term: + self.log("ERROR: no terminal found") + return + term_name, term_args, _ = term + cmd_parts = [term_name] + term_args + ["codex"] + self.log(f"Running: {' '.join(cmd_parts)}") + if IS_WINDOWS: + self._proc = subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) + pid = self._proc.pid + self.log(f"CLI started in terminal (PID {pid})") + while self._proc and self._proc.poll() is None: + time.sleep(1.5) + if self._proc: + rc = self._proc.poll() + self.log(f"CLI exited (code {rc})") + self._proc = None + + # ── Kill ───────────────────────────────────────────────────────── + + def _kill(self): + self.log("=== Killing ===") + if self._proc and self._proc.poll() is None: + try: + if IS_WINDOWS: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(self._proc.pid)], + capture_output=True, timeout=10) + else: + import signal as sig + pgid = os.getpgid(self._proc.pid) + os.killpg(pgid, sig.SIGTERM) + time.sleep(1) + if self._proc.poll() is None: + os.killpg(pgid, sig.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + self._proc = None + stop_proxy() + safe_cleanup_owned(self.log) + restore_config() + end_config_transaction() + LOG_DIR.mkdir(parents=True, exist_ok=True) + if LAUNCH_LOG.exists(): + try: + LAUNCH_LOG.unlink() + except Exception: + pass + self.log("Cleanup complete") + self._set_busy(False) + self.log("Ready.") + + def _do_close(self): + if self._proc and self._proc.poll() is None: + if not messagebox.askyesno("Confirm", "Codex is still running. Kill it?"): + return + self._kill() + stop_proxy() + self._root.destroy() + + +# ═══════════════════════════════════════════════════════════════════════ +# Entry point +# ═══════════════════════════════════════════════════════════════════════ + +if __name__ == "__main__": + ensure_dirs() + create_default_endpoints() + + root = tk.Tk() + app = LauncherWin(root) + root.mainloop() diff --git a/src/codex_launcher_lib.py b/src/codex_launcher_lib.py new file mode 100644 index 0000000..e38b118 --- /dev/null +++ b/src/codex_launcher_lib.py @@ -0,0 +1,1972 @@ +#!/usr/bin/env python3 +"""Codex Launcher shared library — pure Python stdlib, zero GUI dependencies. + +Provides cross-platform utility functions for both the GTK GUI (Linux) and +the tkinter GUI (Windows). No pip dependencies. No GTK/PyGObject imports. +""" + +import base64 +import collections +import contextlib +import hashlib +import json +import os +import re +import secrets +import shutil +import signal +import socket +import ssl +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +UA = "codex-launcher/1.0" + +# ═══════════════════════════════════════════════════════════════════════ +# Platform detection +# ═══════════════════════════════════════════════════════════════════════ + +IS_WINDOWS = sys.platform == "win32" +HOME = Path.home() + +if IS_WINDOWS: + _LOCAL_APPDATA = Path(os.environ.get("LOCALAPPDATA", HOME / "AppData/Local")) + PROXY_CONFIG_DIR = _LOCAL_APPDATA / "codex-proxy" + CONFIG_DIR = HOME / ".codex" + BIN_DIR = _LOCAL_APPDATA / "Programs" / "Codex-Launcher" + LOG_DIR = _LOCAL_APPDATA / "codex-desktop" + PID_REGISTRY = _LOCAL_APPDATA / "codex-launcher" / "pids.json" + _USAGE_STATS_FILE = _LOCAL_APPDATA / "codex-proxy" / "usage-stats.json" + MONITORING_FILE = _LOCAL_APPDATA / "codex-proxy" / "monitoring-config.json" + INCIDENT_STORE_FILE = _LOCAL_APPDATA / "codex-proxy" / "incident-store.json" + MONITORING_LOG = _LOCAL_APPDATA / "codex-proxy" / "monitoring.log" + REQUEST_SNAP_DIR = _LOCAL_APPDATA / "codex-proxy" / "requests" +else: + PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy" + CONFIG_DIR = HOME / ".codex" + BIN_DIR = HOME / ".local/bin" + LOG_DIR = HOME / ".cache/codex-desktop" + PID_REGISTRY = HOME / ".cache" / "codex-launcher" / "pids.json" + _USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json" + MONITORING_FILE = HOME / ".cache/codex-proxy/monitoring-config.json" + INCIDENT_STORE_FILE = HOME / ".cache/codex-proxy/incident-store.json" + MONITORING_LOG = HOME / ".cache/codex-proxy/monitoring.log" + REQUEST_SNAP_DIR = HOME / ".cache/codex-proxy/requests" + +CONFIG = CONFIG_DIR / "config.toml" +CONFIG_BAK = CONFIG_DIR / "config.toml.launcher-bak" +CONFIG_TXN = CONFIG_DIR / "config.toml.launcher-txn.json" +ENDPOINTS_FILE = CONFIG_DIR / "endpoints.json" +BGP_POOLS_FILE = CONFIG_DIR / "bgp-pools.json" +LAUNCH_LOG = LOG_DIR / "launcher.log" + +if IS_WINDOWS: + PROXY = BIN_DIR / "translate-proxy.py" + CLEANUP = BIN_DIR / "cleanup-codex-stale.py" + START_SH = None +else: + PROXY = BIN_DIR / "translate-proxy.py" + CLEANUP = BIN_DIR / "cleanup-codex-stale.sh" + START_SH = Path("/opt/codex-desktop/start.sh") + +DEFAULT_CONFIG = """model = "" +model_provider = "" +model_catalog_json = "" +""" + +CHANGELOG = [ + ("3.8.1", "2026-05-24", [ + "Freebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7", + "Freebuff backend: auto agent-run lifecycle, credential detection, model routing", + "Restored all provider presets (Command Code, Crof, OpenAdapter, OpenRouter, etc.)", + "AI Monitoring — self-healing watchdog with 3-tier response system", + "HealthWatcher: monitors proxy health every 5s, auto-restarts on crash", + "LogAnalyzer: tails debug logs for 18 failure signal patterns", + "Tier 1: 14 rule-based auto-recovery rules (< 1 s response)", + "Tier 2: Incident pattern store with success rate tracking", + "Tier 3: AI diagnostic agent — configurable provider/model for novel failures", + "30 fault types catalogued across 5 categories (A-E)", + "GUI: AI Monitor panel with ON/OFF, provider selector, incident log", + "Enhanced /health endpoint with memory and uptime metrics", + ]), + ("3.7.0", "2026-05-22", [ + "Intelligence Routing — self-healing parser system for Command Code", + "Layer 1: Deep URL extraction from nested JSON in explore_agent blocks", + "Layer 2: Auto-proceed on require_escalation / request_escalation_permission blocks", + "Layer 3: Intent-based command synthesis when all parsers fail (5 heuristics)", + "Module-level _build_explore_cmd() — reuses URL extraction across parser + stream", + "54 self-test patterns covering all three Intelligence Routing layers", + ]), + ("3.6.0", "2026-05-22", [ + "Connection pooling — persistent HTTPS connections per host", + "Stream idle timeout (300s) — kills silent streams instead of hanging", + "Retry-After header support on all retry paths", + "Bounded stream buffers (8MB) — prevents OOM", + "Dual logging to proxy.log + stderr", + ]), + ("3.5.0", "2026-05-22", [ + "Command Code adapter overhaul — 17 patches for multi-format tool-call parsing", + "DSML, XML, explore_agent, bash blocks, raw JSON parser chain", + "Self-revive watchdog — auto-restarts proxy on crash", + "Debug-to-file logging in cc-debug.log", + "Inline self-test (19 patterns)", + ]), + ("3.3.0", "2026-05-20", [ + "Antigravity + Gemini CLI OAuth — full Codex agent loop working", + "Auto-continue on MAX_TOKENS for Gemini/Antigravity", + "BGP++ route scoring and provider policy layer", + ]), + ("3.0.0", "2026-05-20", [ + "Major overhaul — ThreadingHTTPServer, thread-safe state, graceful shutdown", + "Dynamic port allocation, proxy health gating, atomic config", + "Usage Dashboard v2 with dark theme", + ]), + ("2.7.0", "2026-05-20", [ + "Usage Dashboard redesigned (OpenUsage-inspired dark theme)", + "TCP_NODELAY streaming, Anthropic prompt caching", + ]), + ("2.6.1", "2026-05-20", [ + "Google OAuth rebuilt to emulate Gemini CLI — no client_secret.json needed", + "Uses Google's public OAuth client_id (same as gemini-cli)", + "PKCE + CSRF state protection for secure auth", + "Just click OAuth Login — browser opens — authorize — done", + "Includes cloud-platform scope for Gemini Code Assist compatibility", + ]), + ("2.6.0", "2026-05-20", [ + "Usage Dashboard — per-provider request/token/latency tracking", + "Visual cards with success rate bars, model breakdown, error tracking", + "Google OAuth: browse for client_secret.json instead of fixed path", + ]), + ("2.5.1", "2026-05-20", [ + "Adaptive retry for 429/502/503 errors with exponential backoff", + "BGP routes also retry transient errors before failing over", + "Proxy socket reuse — no more 'Address already in use' crashes", + "BGP route count shown at proxy startup", + ]), + ("2.5.0", "2026-05-20", [ + "AI BGP — multi-provider routing with automatic failover", + "Create BGP pools with ordered routes from any configured endpoint", + "Each route uses its own endpoint URL, API key, and model", + "Failover strategy: tries primary, falls back on error/timeout", + "BGP pools appear in endpoint dropdown with shuffle icon", + "Up/down reordering for route priority in pool editor", + "Fixed TOML config breakage from multi-line paste in fields", + ]), + ("2.4.0", "2026-05-20", [ + "Added OpenAdapter provider preset (api.openadapter.in)", + "One API key access to 40+ models — GLM, DeepSeek, Kimi, Qwen, Claude, GPT, Gemini", + "Fixed Add/Edit dialog crash (missing _on_reasoning_toggled method)", + "Redesigned Google OAuth flow with live status dialog", + ]), + ("2.3.2", "2026-05-20", [ + "Added Google Gemini provider with OAuth support", + "Two presets: 'Google Gemini (API Key)' and 'Google Gemini (OAuth)'", + "OAuth Login button in endpoint editor — full Google OAuth2 flow with auto-refresh", + "Auto-refreshes OAuth access tokens when expired (no manual re-login needed)", + "Supports gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, and more", + "Uses Gemini's OpenAI-compatible endpoint — works with existing proxy", + ]), + ("2.3.0", "2026-05-20", [ + "Adaptive Crof self-healing system — auto-adjusts to Crof model limits", + "Tracks per-model success/failure history, learns item count limits dynamically", + "Proactively compacts input when above learned limit before sending to Crof", + "Auto-retries on finish_reason=length — aggressively compacts and resends", + "Prevents 'stream disconnected' and 'incomplete' errors on long conversations", + ]), + ("2.2.1", "2026-05-20", [ + "Fixed compaction orphaning function_call_output items — root cause of Crof incomplete responses", + "Compaction now respects function_call/function_call_output pairs — no more dangling tool results", + "Fixed reasoning control: reasoning_effort=none now always sends enable_thinking=false too", + ]), + ("2.2.0", "2026-05-20", [ + "Added per-provider Reasoning On/Off toggle in endpoint editor", + "Added Reasoning Effort level per provider: None, Minimal, Low, Medium, High, Max", + "When reasoning is OFF: sends enable_thinking=false + reasoning_effort=none to upstream API", + "When reasoning is ON: sends user-selected effort level (default: Medium)", + "Fixes Crof mimo-v2.5-pro and similar reasoning models exhausting output tokens", + "Strip reasoning_content from proxy output — Codex doesn't use it", + "Force max_tokens=64000 minimum for openai-compat providers", + ]), + ("2.1.3", "2026-05-19", [ + "Fixed Crof mimo-v2.5-pro stopping: reasoning_content exhausted all output tokens", + "Strip reasoning_content from proxy output — Codex doesn't use it, avoids token waste", + "Force max_tokens=64000 minimum for openai-compat providers", + ]), + ("2.1.2", "2026-05-19", [ + "Fixed Crof.ai and providers stopping after first tool call (root cause: None tool IDs)", + "Codex sends function_call items with id=None — proxy now matches tool results to calls by position", + "Fixed orphan message output item when response has only tool calls (no text)", + "Auto-trims long conversations (>30 items) to prevent context overflow on providers like Crof", + "Added request/response logging to ~/.cache/codex-proxy/requests.log", + ]), + ("2.1.1", "2026-05-19", [ + "Fixed proxy: map 'developer' role to 'system' for Chat Completions providers", + "Fixed proxy: map 'developer' role to 'user' for Anthropic providers", + "Forward 'instructions' field from Responses API as system message/param", + "Fixes DeepSeek and other providers rejecting unknown 'developer' role", + ]), + ("2.1.0", "2026-05-19", [ + "Added Codex auth status detection (codex login status)", + "Added Re-login button to re-authenticate via codex login", + "Auto-checks auth before launching Codex Default mode", + "Warns if OAuth token expired or missing before launch", + ]), + ("2.0.1", "2026-05-19", [ + "Added Codex CLI/Desktop installation verifier to main page", + "Disables Desktop/CLI launch buttons when corresponding tool is missing", + "Shows install instructions in status area on startup", + ]), + ("2.0.0", "2026-05-19", [ + "Initial release: multi-provider Codex Launcher", + "Translation proxy: Responses API to Chat Completions + Anthropic Messages", + "GTK endpoint manager with 10+ provider presets", + "Codex Default mode (built-in OAuth, zero config)", + "Browser UA injection for Cloudflare-protected providers (OpenCode)", + "Streaming SSE, tool calls, reasoning content support", + "Profile backup/import, model auto-fetch, bulk import", + "Refresh Models in background thread", + "URL normalization to prevent double-path bugs", + "Config backup/restore around sessions", + ".deb installer package", + ]), +] + +# ═══════════════════════════════════════════════════════════════════════ +# Provider presets (17 providers) +# ═══════════════════════════════════════════════════════════════════════ + +PROVIDER_PRESETS = { + "Custom": { + "backend_type": "openai-compat", + "base_url": "", + "models": [], + }, + "OpenAI": { + "backend_type": "native", + "base_url": "https://api.openai.com/v1", + "models": ["gpt-4o", "gpt-4o-mini"], + }, + "Anthropic": { + "backend_type": "anthropic", + "base_url": "https://api.anthropic.com/v1", + "models": ["claude-sonnet-4-5", "claude-3-5-haiku-latest"], + }, + "OpenCode Zen (OpenAI-compatible)": { + "backend_type": "openai-compat", + "base_url": "https://opencode.ai/zen/v1", + "models": [ + "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6", + "minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free", + "deepseek-v4-flash-free", "nemotron-3-super-free", + "qwen3.6-plus", "qwen3.5-plus", "big-pickle", + ], + }, + "OpenCode Zen (Anthropic)": { + "backend_type": "anthropic", + "base_url": "https://opencode.ai/zen/v1", + "models": [ + "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", + "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", + "claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku", + ], + }, + "OpenCode Go (OpenAI-compatible)": { + "backend_type": "openai-compat", + "base_url": "https://opencode.ai/zen/go/v1", + "models": [ + "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6", + "mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m2.5", + "qwen3.6-plus", "qwen3.5-plus", "deepseek-v4-pro", "deepseek-v4-flash", + ], + }, + "OpenCode Go (Anthropic)": { + "backend_type": "anthropic", + "base_url": "https://opencode.ai/zen/go/v1", + "models": ["minimax-m2.7", "minimax-m2.5"], + }, + "Crof.ai": { + "backend_type": "openai-compat", + "base_url": "https://crof.ai/v1", + "models": [], + }, + "NVIDIA NIM": { + "backend_type": "openai-compat", + "base_url": "https://integrate.api.nvidia.com/v1", + "models": [], + }, + "Kilo.ai Gateway": { + "backend_type": "openai-compat", + "base_url": "https://api.kilo.ai/api/gateway", + "models": [], + }, + "Command Code": { + "backend_type": "command-code", + "base_url": "https://api.commandcode.ai", + "cc_version": "0.26.8", + "models": [ + "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", + "anthropic:claude-sonnet-4-6", "anthropic:claude-haiku-4-5-20251001", + "anthropic:claude-opus-4-7", "anthropic:claude-opus-4-6", + "openai:gpt-5.5", "openai:gpt-5.4", "openai:gpt-5.4-mini", "openai:gpt-5.3-codex", + "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5", + "zai-org/GLM-5.1", "zai-org/GLM-5", + "MiniMaxAI/MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.5", + "Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus", + "stepfun/Step-3.5-Flash", "google/gemini-3.1-flash-lite", + ], + }, + "OpenRouter": { + "backend_type": "openai-compat", + "base_url": "https://openrouter.ai/api/v1", + "models": [], + }, + "Google Gemini (API Key)": { + "backend_type": "openai-compat", + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "models": [ + "gemini-2.5-flash", "gemini-2.5-pro", + "gemini-2.0-flash", "gemini-2.0-flash-lite", + "gemini-2.5-flash-preview-native-audio-dialog", + ], + }, + "Google Gemini (OAuth)": { + "backend_type": "gemini-oauth-cli", + "base_url": "https://cloudcode-pa.googleapis.com", + "oauth_provider": "google-cli", + "models": [ + "gemini-2.5-flash", "gemini-2.5-pro", + ], + }, + "Google Antigravity (OAuth)": { + "backend_type": "gemini-oauth-antigravity", + "base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com", + "oauth_provider": "google-antigravity", + "models": [ + "antigravity-gemini-3-flash", + "antigravity-gemini-3-pro", + "antigravity-gemini-3.1-pro", + "antigravity-claude-sonnet-4-6", + "antigravity-claude-opus-4-6-thinking", + "gemini-2.5-flash", "gemini-2.5-pro", + "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview", + ], + }, + "OpenAdapter": { + "backend_type": "openai-compat", + "base_url": "https://api.openadapter.in/v1", + "models": [ + "0G-DeepSeek-V3", + "0G-DeepSeek-v4-Pro", + "0G-GLM-5", + "0G-GLM-5.1", + "0G-Qwen3.6", + "0G-Qwen-VL", + ], + }, + "Z.ai Coding": { + "backend_type": "openai-compat", + "base_url": "https://api.z.ai/api/coding/paas/v4", + "models": [ + "glm-5.1", "glm-4.7", "GLM-4-Plus", "GLM-4-Long", + "GLM-4-Flash", "GLM-4-FlashX", "GLM-Z1-Flash", + ], + }, + "Freebuff (Free DeepSeek/Kimi)": { + "backend_type": "freebuff", + "base_url": "https://freebuff.com", + "models": [ + "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", + "moonshotai/kimi-k2.6", "minimax/minimax-m2.7", + ], + }, + "Ollama (local)": { + "backend_type": "openai-compat", + "base_url": "http://localhost:11434/v1", + "models": [], + }, +} + +# ═══════════════════════════════════════════════════════════════════════ +# Cross-platform process management +# ═══════════════════════════════════════════════════════════════════════ + +def _subprocess_new_group_flag(): + if IS_WINDOWS: + return subprocess.CREATE_NEW_PROCESS_GROUP + return None + + +def _subprocess_preexec_fn(): + if IS_WINDOWS: + return None + return os.setsid + + +def _kill_process_group(pid): + if IS_WINDOWS: + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, timeout=10, + ) + except Exception: + pass + else: + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGTERM) + time.sleep(0.5) + try: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + except (ProcessLookupError, PermissionError): + pass + + +def _kill_process_group_soft(pid): + if IS_WINDOWS: + try: + subprocess.run( + ["taskkill", "/T", "/PID", str(pid)], + capture_output=True, timeout=10, + ) + except Exception: + pass + else: + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + pass + + +def _register_pgid_entry(kind, pid): + data = _load_pid_registry() + if IS_WINDOWS: + data[kind] = {"pid": pid, "pgid": pid, "ts": time.time()} + else: + try: + pgid = os.getpgid(pid) + except ProcessLookupError: + return + data[kind] = {"pid": pid, "pgid": pgid, "ts": time.time()} + _save_pid_registry(data) + +# ═══════════════════════════════════════════════════════════════════════ +# Cross-platform terminal detection +# ═══════════════════════════════════════════════════════════════════════ + +def detect_terminal(): + if IS_WINDOWS: + for term in ["wt.exe", "cmd.exe", "powershell.exe"]: + path = shutil.which(term) + if path: + return (term, [], path) + return None + terms = [ + ("x-terminal-emulator", ["-e"]), + ("kgx", ["--"]), + ("gnome-terminal", ["--"]), + ("konsole", ["-e"]), + ("xterm", ["-e"]), + ] + for t in terms: + if shutil.which(t[0]): + return (t[0], t[1], shutil.which(t[0])) + return None + +# ═══════════════════════════════════════════════════════════════════════ +# Cross-platform URL/file opening +# ═══════════════════════════════════════════════════════════════════════ + +def open_url(url): + if IS_WINDOWS: + os.startfile(url) + elif shutil.which("xdg-open"): + subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif sys.platform == "darwin": + subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def open_file(path): + open_url(str(path)) + +# ═══════════════════════════════════════════════════════════════════════ +# String / utility helpers +# ═══════════════════════════════════════════════════════════════════════ + +def safe_name(name): + base = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name).strip("._-") or "endpoint" + digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] + return f"{base}-{digest}" + + +def label_for_backend(backend_type): + return { + "openai-compat": "OpenAI-compatible", + "anthropic": "Anthropic", + "command-code": "Command Code", + "freebuff": "Freebuff (Free AI)", + "native": "Native", + }.get(backend_type, backend_type) + + +def normalize_model_id(text): + value = text.strip().lower() + if not value: + return "" + value = value.replace("/", "-") + value = value.replace("+", "plus") + value = "".join(ch if ch.isalnum() or ch in ".-" else "-" for ch in value) + while "--" in value: + value = value.replace("--", "-") + return value.strip("-.") + + +def normalize_base_url(url): + base = (url or "").strip().rstrip("/") + for suffix in ("/chat/completions", "/responses", "/messages"): + if base.endswith(suffix): + base = base[: -len(suffix)] + break + return base.rstrip("/") + + +def parse_model_list(text): + out = [] + seen = set() + for raw in text.replace(",", "\n").splitlines(): + mid = normalize_model_id(raw) + if mid and mid not in seen: + seen.add(mid) + out.append(mid) + return out + + +def now_utc_iso(): + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def _fmt_tok(n): + if n >= 1_000_000: + return f"{n/1_000_000:.1f}M" + if n >= 1_000: + return f"{n/1_000:.1f}K" + return str(n) + + +def _fmt_dur(s): + if s >= 3600: + return f"{s/3600:.1f}h" + if s >= 60: + return f"{s/60:.1f}m" + return f"{s:.1f}s" + + +def _status_pill(success_rate, fail_pct): + _U = _usage_theme() + if fail_pct > 0.15: + return ("ERR", _U["red"]) + if fail_pct > 0.05: + return ("WARN", _U["yellow"]) + return ("OK", _U["green"]) + + +def _usage_theme(): + return { + "base": "#0C0E16", "surface0": "#161928", "surface1": "#1E2235", + "surface2": "#2A2F47", "text": "#E4E6F0", "subtext": "#B0B4C8", + "dim": "#5C6180", "accent": "#7EB8F7", "blue": "#5DA4E8", + "sapphire": "#4EC5C1", "green": "#59D4A0", "yellow": "#F0C75E", + "red": "#F06A77", "peach": "#F09860", "teal": "#4EC5C1", + "lavender": "#A899F0", "sky": "#70C8E8", "maroon": "#C44B5C", + "flamingo": "#E878B0", "rosewater": "#F0D0C0", + "model_palette": ["#F09860", "#4EC5C1", "#5DA4E8", "#59D4A0", + "#F0C75E", "#A899F0", "#70C8E8", "#E878B0", + "#C44B5C", "#F0D0C0", "#7EB8F7", "#F06A77"], + } + +# ═══════════════════════════════════════════════════════════════════════ +# Provider preset helpers +# ═══════════════════════════════════════════════════════════════════════ + +def apply_provider_preset(endpoint, preset_name): + preset = PROVIDER_PRESETS.get(preset_name) + if not preset: + return endpoint + updated = dict(endpoint) + updated["provider_preset"] = preset_name + updated["backend_type"] = preset["backend_type"] + updated["base_url"] = normalize_base_url(preset["base_url"]) + if preset.get("cc_version") and not updated.get("cc_version"): + updated["cc_version"] = preset["cc_version"] + if not updated.get("models") or (preset.get("backend_type") or "").startswith("gemini-oauth"): + updated["models"] = list(preset.get("models", [])) + if preset.get("oauth_provider"): + updated["oauth_provider"] = preset["oauth_provider"] + if not updated.get("default_model") and updated.get("models"): + updated["default_model"] = updated["models"][0] + return updated + +# ═══════════════════════════════════════════════════════════════════════ +# Endpoint CRUD +# ═══════════════════════════════════════════════════════════════════════ + +def load_endpoints(): + if ENDPOINTS_FILE.exists(): + try: + return json.loads(ENDPOINTS_FILE.read_text()) + except Exception: + pass + return {"default": None, "endpoints": []} + + +def save_endpoints(data): + ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True) + ENDPOINTS_FILE.write_text(json.dumps(data, indent=2)) + + +def load_bgp_pools(): + if BGP_POOLS_FILE.exists(): + try: + return json.loads(BGP_POOLS_FILE.read_text()) + except Exception: + pass + return {"pools": []} + + +def save_bgp_pools(data): + BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True) + BGP_POOLS_FILE.write_text(json.dumps(data, indent=2)) + + +def get_endpoint(name): + for e in load_endpoints()["endpoints"]: + if e["name"] == name: + return e + return None + +# ═══════════════════════════════════════════════════════════════════════ +# Profile bundle import/export +# ═══════════════════════════════════════════════════════════════════════ + +def build_profile_bundle(): + return { + "version": 1, + "exported_at": now_utc_iso(), + "endpoints": load_endpoints(), + "codex_config_toml": CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "", + } + + +def save_profile_bundle(path): + bundle = build_profile_bundle() + Path(path).write_text(json.dumps(bundle, indent=2), encoding="utf-8") + + +def import_profile_bundle(path): + data = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("Invalid profile bundle") + + endpoints = data.get("endpoints") + if not isinstance(endpoints, dict) or "endpoints" not in endpoints: + raise ValueError("Profile bundle missing endpoints") + + if CONFIG.exists(): + shutil.copy2(str(CONFIG), str(CONFIG_BAK)) + if ENDPOINTS_FILE.exists(): + shutil.copy2(str(ENDPOINTS_FILE), str(ENDPOINTS_FILE.with_suffix(".json.import-bak"))) + + save_endpoints(endpoints) + + cfg = data.get("codex_config_toml", "") + if isinstance(cfg, str) and cfg.strip(): + CONFIG.parent.mkdir(parents=True, exist_ok=True) + CONFIG.write_text(cfg, encoding="utf-8") + return endpoints + +# ═══════════════════════════════════════════════════════════════════════ +# Secure file write +# ═══════════════════════════════════════════════════════════════════════ + +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") + if not IS_WINDOWS: + os.chmod(str(tmp), 0o600) + os.replace(str(tmp), str(path)) + +# ═══════════════════════════════════════════════════════════════════════ +# Config management +# ═══════════════════════════════════════════════════════════════════════ + +def backup_config(): + if CONFIG.exists(): + 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(): + tmp = CONFIG.with_suffix(".tmp") + shutil.copy2(str(CONFIG_BAK), str(tmp)) + os.replace(str(tmp), str(CONFIG)) + + +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 _toml_safe(val): + val = str(val).replace("\\", "/").replace('"', '\\"') + return val.split('\n', 1)[0].strip() + + +def _resolve_secret(value): + value = (value or "").strip() + m = re.fullmatch(r"\$\{ENV:([A-Z0-9_]+)\}", value) + if m: + return os.environ.get(m.group(1), "") + return value + + +def _merge_toml(existing_text, new_sections_text): + """Merge launcher-generated TOML sections into an existing config.toml. + + Preserves all existing sections/keys that are not overwritten by the + launcher. This is a simple line-based merge — good enough for the flat + TOML structure Codex uses. + """ + if not existing_text: + return new_sections_text + + new_lines = new_sections_text.rstrip().splitlines() + + root_keys = [] + new_section_blocks = {} + current_section = None + current_block_lines = [] + + for line in new_lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("[") and not stripped.startswith("[["): + if current_section is not None: + new_section_blocks[current_section] = current_block_lines + current_section = stripped + current_block_lines = [] + elif current_section is None: + root_keys.append(line) + else: + current_block_lines.append(line) + if current_section is not None: + new_section_blocks[current_section] = current_block_lines + + existing_lines = existing_text.splitlines() + existing_sections = {} + existing_root_lines = [] + existing_section_order = [] + cur_sec = None + + for line in existing_lines: + stripped = line.strip() + if stripped.startswith("[") and not stripped.startswith("[["): + if cur_sec is not None: + pass + cur_sec = stripped + existing_section_order.append(cur_sec) + existing_sections[cur_sec] = [line] + elif cur_sec is not None: + existing_sections[cur_sec].append(line) + else: + existing_root_lines.append(line) + + merged_root = [] + root_key_names = set() + for rk in root_keys: + key_name = rk.strip().split("=")[0].strip() if "=" in rk else "" + if key_name: + root_key_names.add(key_name) + + for line in existing_root_lines: + stripped = line.strip() + if stripped.startswith("#") or not stripped: + merged_root.append(line) + continue + if "=" in stripped: + key_name = stripped.split("=")[0].strip() + if key_name in root_key_names: + continue + merged_root.append(line) + + merged_root.extend(root_keys) + + all_sections = list(existing_section_order) + for sec in new_section_blocks: + if sec not in all_sections: + all_sections.append(sec) + + merged = list(merged_root) + if merged and merged[-1] != "": + merged.append("") + for sec in all_sections: + if sec in new_section_blocks: + merged.append(sec) + merged.extend(new_section_blocks[sec]) + else: + merged.extend(existing_sections.get(sec, [])) + merged.append("") + + return "\n".join(merged).strip() + "\n" + + +def _gen_model_catalog(endpoint, selected_model=None): + default_model = selected_model or endpoint.get("default_model") + models = [] + for mid in endpoint.get("models", []): + models.append({ + "slug": mid, "model": mid, "display_name": mid, + "description": f"{endpoint['name']} {mid}", + "hidden": False, "isDefault": mid == default_model, + "shell_type": "shell_command", "visibility": "list", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + {"effort": "low", "description": "Fast"}, + {"effort": "medium", "description": "Balanced"}, + {"effort": "high", "description": "Deep"}, + {"effort": "xhigh", "description": "Extra deep"}, + ], + "supportedReasoningEfforts": [ + {"reasoningEffort": "low", "description": "Fast"}, + {"reasoningEffort": "medium", "description": "Balanced"}, + {"reasoningEffort": "high", "description": "Deep"}, + {"reasoningEffort": "xhigh", "description": "Extra deep"}, + ], + "priority": 30, "context_size": 128000, + "additional_speed_tiers": [], "service_tiers": [], + "supports_reasoning_summaries": True, "support_verbosity": True, + "reasoning": True, "tool_call": True, + "supports_parallel_tool_calls": True, + "experimental_supported_tools": [], "supported_in_api": True, + "truncation_policy": {"mode": "tokens", "limit": 128000}, + "base_instructions": "You are Codex, a coding agent.", + }) + return {"models": models} + + +def write_config_for_native(endpoint, selected_model): + backup_config() + model_catalog = _gen_model_catalog(endpoint, selected_model) + mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" + mc_path.parent.mkdir(parents=True, exist_ok=True) + mc_path.write_text(json.dumps(model_catalog, indent=2)) + + mc_str = str(mc_path).replace("\\", "/") + new_config = [ + f'profile = "{_toml_safe(endpoint["name"])}"\n', + f'model = "{_toml_safe(selected_model)}"\n', + f'model_provider = "{_toml_safe(endpoint["name"])}"\n', + f'model_catalog_json = "{mc_str}"\n', + f'\n[model_providers."{endpoint["name"]}"]\n', + f'name = "{_toml_safe(endpoint["name"])}"\n', + f'base_url = "{_toml_safe(endpoint["base_url"])}"\n', + f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n', + f'\n[profiles."{endpoint["name"]}"]\n', + f'model_provider = "{_toml_safe(endpoint["name"])}"\n', + f'model = "{_toml_safe(selected_model)}"\n', + f'model_catalog_json = "{mc_str}"\n', + f'service_tier = "default"\n', + f'approvals_reviewer = "user"\n', + ] + existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "" + merged = _merge_toml(existing, "".join(new_config)) + write_secure_text(CONFIG, merged) + + +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" + mc_path.parent.mkdir(parents=True, exist_ok=True) + mc_path.write_text(json.dumps(model_catalog, indent=2)) + + mc_str = str(mc_path).replace("\\", "/") + new_config = [ + f'profile = "{_toml_safe(endpoint["name"])}"\n', + f'model = "{_toml_safe(selected_model)}"\n', + f'model_provider = "{_toml_safe(endpoint["name"])}"\n', + f'model_catalog_json = "{mc_str}"\n', + f'\n[model_providers."{endpoint["name"]}"]\n', + f'name = "{_toml_safe(endpoint["name"])}"\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', + f'model_catalog_json = "{mc_str}"\n', + f'service_tier = "fast"\n', + f'approvals_reviewer = "user"\n', + ] + existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "" + merged = _merge_toml(existing, "".join(new_config)) + write_secure_text(CONFIG, merged) + +# ═══════════════════════════════════════════════════════════════════════ +# Model fetching +# ═══════════════════════════════════════════════════════════════════════ + +def endpoint_models_url(endpoint): + base = normalize_base_url(endpoint.get("base_url") or "") + if not base: + return "" + return f"{base}/models" + + +def endpoint_model_headers(endpoint): + key = (endpoint.get("api_key") or "").strip() + backend = endpoint.get("backend_type", "openai-compat") + headers = {"User-Agent": UA} + if backend == "anthropic": + if key: + headers["x-api-key"] = key + headers["anthropic-version"] = "2023-06-01" + elif key: + headers["Authorization"] = f"Bearer {key}" + return headers + + +def fetch_models_for_endpoint(endpoint, timeout=10): + url = endpoint_models_url(endpoint) + if not url: + return None, "Base URL is empty" + try: + req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint)) + raw = urllib.request.urlopen(req, timeout=timeout).read() + payload = json.loads(raw) + items = payload.get("data") or payload.get("models") or [] + ids = [] + seen = set() + for item in items: + mid = item.get("id") if isinstance(item, dict) else None + if mid and mid not in seen: + seen.add(mid) + ids.append(mid) + if not ids: + return None, "No models returned" + return ids, None + except Exception as e: + return None, str(e) + + +def refresh_endpoint_models(endpoint): + ids, err = fetch_models_for_endpoint(endpoint) + if not ids: + return None, err + updated = dict(endpoint) + updated["models"] = ids + if updated.get("default_model") not in ids: + updated["default_model"] = ids[0] + return updated, None + +# ═══════════════════════════════════════════════════════════════════════ +# Doctor checks +# ═══════════════════════════════════════════════════════════════════════ + +def _doctor_check_streaming(base_url, key, bt, model, add): + if bt == "anthropic": + test_url = f"{base_url}/v1/messages" + headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} + body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1, "stream": True, + "messages": [{"role": "user", "content": "hi"}]}).encode() + else: + test_url = f"{base_url}/chat/completions" + headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"} + body = json.dumps({"model": model, "max_tokens": 1, "stream": True, + "messages": [{"role": "user", "content": "hi"}]}).encode() + try: + req = urllib.request.Request(test_url, data=body, headers=headers, method="POST") + t0 = time.time() + resp = urllib.request.urlopen(req, timeout=20) + content_type = resp.headers.get("content-type", "") + first_chunk = resp.read(512) + lat = (time.time() - t0) * 1000 + is_sse = "text/event-stream" in content_type or first_chunk.startswith(b"data:") + if is_sse: + add("Streaming support", True, f"SSE OK in {lat:.0f}ms") + else: + add("Streaming support", False, f"Expected SSE, got {content_type[:60]}") + except urllib.error.HTTPError as e: + body_text = "" + try: + body_text = e.read(200).decode(errors="replace") + except Exception: + pass + if e.code == 429: + add("Streaming support", None, "Rate limited (skipped)") + elif e.code in (400, 404, 422): + add("Streaming support", False, f"HTTP {e.code}: {body_text[:80]}") + else: + add("Streaming support", False, f"HTTP {e.code}") + except Exception as e: + add("Streaming support", False, str(e)[:100]) + + +def _doctor_check_toolcall(base_url, key, bt, model, add): + tool = {"type": "function", "function": {"name": "test_tool", "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}}} + if bt == "anthropic": + test_url = f"{base_url}/v1/messages" + headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} + body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 50, "stream": False, + "tools": [tool], "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode() + else: + test_url = f"{base_url}/chat/completions" + headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"} + body = json.dumps({"model": model, "max_tokens": 50, "stream": False, "tools": [tool], + "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode() + try: + req = urllib.request.Request(test_url, data=body, headers=headers, method="POST") + t0 = time.time() + resp = urllib.request.urlopen(req, timeout=30) + raw = resp.read() + lat = (time.time() - t0) * 1000 + payload = json.loads(raw) + has_tools = False + if bt == "anthropic": + for block in (payload.get("content") or []): + if block.get("type") == "tool_use": + has_tools = True + break + else: + choices = payload.get("choices") or [] + for ch in choices: + if (ch.get("message", {}).get("tool_calls")): + has_tools = True + break + if has_tools: + add("Tool-call support", True, f"Tool call received in {lat:.0f}ms") + else: + add("Tool-call support", None, f"Responded but no tool_call ({lat:.0f}ms)") + except urllib.error.HTTPError as e: + if e.code == 429: + add("Tool-call support", None, "Rate limited (skipped)") + elif e.code in (400, 404, 422): + err_body = "" + try: + err_body = e.read(200).decode(errors="replace") + except Exception: + pass + add("Tool-call support", False, f"HTTP {e.code}: {err_body[:80]}") + else: + add("Tool-call support", False, f"HTTP {e.code}") + except Exception as e: + add("Tool-call support", False, str(e)[:100]) + + +def run_endpoint_doctor(endpoint): + """Comprehensive health checks for an endpoint. Returns [(name, ok, detail), ...]. + ok: True=pass, False=fail, None=warn/skip.""" + checks = [] + def add(name, ok, detail=""): + checks.append((name, ok, detail)) + + url = normalize_base_url(endpoint.get("base_url") or "") + key = (endpoint.get("api_key") or "").strip() + bt = endpoint.get("backend_type", "openai-compat") + model = endpoint.get("default_model") or (endpoint.get("models", [""])[0] if endpoint.get("models") else "") + + parsed = urllib.parse.urlparse(url) + has_url = bool(parsed.scheme and parsed.netloc) + add("URL format", has_url, url if has_url else "Missing scheme or host") + if not has_url: + return checks + + host = parsed.hostname + port = parsed.port or (443 if parsed.scheme == "https" else 80) + + try: + t0 = time.time() + addrs = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM) + dns_ms = (time.time() - t0) * 1000 + add("DNS resolution", True, f"{addrs[0][4][0]} ({dns_ms:.0f}ms)") + except socket.gaierror as e: + add("DNS resolution", False, str(e)) + return checks + + try: + t0 = time.time() + sock = socket.create_connection((host, port), timeout=10) + tcp_ms = (time.time() - t0) * 1000 + if parsed.scheme == "https": + ctx = ssl.create_default_context() + try: + ssock = ctx.wrap_socket(sock, server_hostname=host) + tls_ms = (time.time() - t0) * 1000 + add("TLS connection", True, f"TCP {tcp_ms:.0f}ms + handshake {tls_ms:.0f}ms") + ssock.close() + except ssl.SSLError as e: + add("TLS certificate", False, str(e)[:120]) + sock.close() + return checks + else: + add("TCP connection", True, f"{tcp_ms:.0f}ms") + sock.close() + except (socket.timeout, ConnectionRefusedError, OSError) as e: + add("TCP connection", False, str(e)[:100]) + return checks + + if bt == "anthropic": + add("/models endpoint", None, "Anthropic has no /models endpoint — testing via /messages") + try: + t0 = time.time() + msg_url = f"{url}/v1/messages" + body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1, + "messages": [{"role": "user", "content": "hi"}]}).encode() + req = urllib.request.Request(msg_url, data=body, headers={ + "User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json", + }, method="POST") + urllib.request.urlopen(req, timeout=15) + lat = (time.time() - t0) * 1000 + add("Auth valid", True, f"Responded in {lat:.0f}ms") + except urllib.error.HTTPError as e: + if e.code in (401, 403): + add("Auth valid", False, f"HTTP {e.code} — check API key") + elif e.code == 400: + add("Auth valid", True, "Authenticated (model or param error)") + else: + add("Auth valid", False, f"HTTP {e.code}") + except Exception as e: + add("Auth valid", False, str(e)[:100]) + elif bt.startswith("gemini-oauth"): + token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json" + token_path = PROXY_CONFIG_DIR / token_name + if token_path.exists(): + try: + td = json.loads(token_path.read_text()) + exp = td.get("expires_at", 0) + if exp > time.time(): + remaining = exp - time.time() + add("OAuth token", True, f"Valid ({remaining / 60:.0f} min remaining)") + else: + add("OAuth token", False, "Token expired — re-login required") + except Exception as e: + add("OAuth token", False, str(e)[:80]) + else: + add("OAuth token", False, f"No token file ({token_name})") + try: + t0 = time.time() + ids, err = fetch_models_for_endpoint(endpoint) + lat = (time.time() - t0) * 1000 + if ids: + add("Network reachable", True, f"{lat:.0f}ms") + add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)") + if model: + add("Selected model exists", model in ids, + model if model in ids else f"'{model}' not in {ids[:5]}...") + elif err and ("401" in str(err) or "403" in str(err)): + add("Network reachable", True, f"{lat:.0f}ms") + add("Auth valid", False, str(err)[:100]) + else: + add("Network reachable", False, str(err or "no response")[:100]) + except Exception as e: + add("Network", False, str(e)[:100]) + else: + try: + t0 = time.time() + ids, err = fetch_models_for_endpoint(endpoint) + lat = (time.time() - t0) * 1000 + if ids: + add("Network reachable", True, f"{lat:.0f}ms") + add("Auth valid", True) + add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)") + if model: + add("Selected model exists", model in ids, + model if model in ids else f"'{model}' not found in {len(ids)} models") + else: + add("Selected model", False, "No model selected") + elif err and ("401" in str(err) or "403" in str(err)): + add("Network reachable", True, f"{lat:.0f}ms") + add("Auth valid", False, "HTTP 401/403 — check API key") + elif err and "429" in str(err): + add("Network reachable", True, f"{lat:.0f}ms") + add("Auth valid", True, "Authenticated but rate-limited") + add("/models endpoint", None, "Rate limited — skipped") + else: + add("Network reachable", False, str(err or "no response")[:100]) + except Exception as e: + add("Network", False, str(e)[:100]) + + if bt not in ("native", "command-code"): + _doctor_check_streaming(url, key, bt, model, add) + + if bt not in ("native", "command-code"): + _doctor_check_toolcall(url, key, bt, model, add) + + return checks + +# ═══════════════════════════════════════════════════════════════════════ +# PID registry +# ═══════════════════════════════════════════════════════════════════════ + +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 safe_cleanup_owned(logfn=None): + data = _load_pid_registry() + changed = False + for kind, meta in list(data.items()): + pid = meta.get("pid") or meta.get("pgid") + if not pid: + continue + try: + _kill_process_group(pid) + if logfn: + logfn(f"Stopped {kind} (pid {pid})") + changed = True + except ProcessLookupError: + changed = True + except Exception as e: + if logfn: + logfn(f"Could not stop {kind}: {e}") + if changed: + _save_pid_registry({}) + +# ═══════════════════════════════════════════════════════════════════════ +# Proxy lifecycle +# ═══════════════════════════════════════════════════════════════════════ + +_proxy_proc = None +_proxy_port = None + + +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 get_proxy_state(): + return _proxy_proc, _proxy_port + + +def set_proxy_state(proc, port): + global _proxy_proc, _proxy_port + _proxy_proc = proc + _proxy_port = port + + +def stop_proxy(): + global _proxy_proc + if _proxy_proc and _proxy_proc.poll() is None: + _kill_process_group(_proxy_proc.pid) + _proxy_proc = None + + +def start_proxy_for(endpoint, logfn): + """Start the translation proxy for an endpoint. Returns the port. + logfn(msg) is used for status messages (may be called from any thread). + """ + global _proxy_proc, _proxy_port + stop_proxy() + port = _pick_free_port() + _proxy_port = port + + model_list = endpoint.get("models", []) + if (endpoint.get("backend_type") or "").startswith("gemini-oauth") and (endpoint.get("oauth_provider") or "").startswith("google"): + token_name = "google-antigravity-oauth-token.json" if endpoint.get("oauth_provider") == "google-antigravity" else "google-cli-oauth-token.json" + token_path = PROXY_CONFIG_DIR / token_name + try: + with open(token_path) as tf: + td = json.load(tf) + discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", []) + if discovered: + model_list = discovered + except Exception: + pass + + pcfg = { + "port": port, + "backend_type": endpoint["backend_type"], + "target_url": normalize_base_url(endpoint["base_url"]), + "api_key": endpoint["api_key"], + "cc_version": endpoint.get("cc_version", ""), + "oauth_provider": endpoint.get("oauth_provider", ""), + "reasoning_enabled": endpoint.get("reasoning_enabled", True), + "reasoning_effort": endpoint.get("reasoning_effort", "medium"), + "force_model": endpoint.get("default_model") or "", + "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]} + for m in model_list], + } + 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, port, logfn) + return port + + +def _start_proxy_with_config(pcfg_path, port, logfn): + global _proxy_proc + python_bin = sys.executable + proxy_script = str(PROXY) + + popen_kwargs = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.PIPE, + "text": True, + } + if IS_WINDOWS: + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["preexec_fn"] = os.setsid + + _proxy_proc = subprocess.Popen( + [python_bin, proxy_script, "--config", str(pcfg_path)], + **popen_kwargs, + ) + _register_pgid_entry("proxy", _proxy_proc.pid) + + def _pipe_stderr(): + if not _proxy_proc.stderr: + return + for line in _proxy_proc.stderr: + 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) + + _kill_process_group(_proxy_proc.pid) + raise RuntimeError(f"Proxy failed health check on port {port}: {last_err}") + + +def start_bgp_proxy(pool, model, logfn): + """Start a BGP proxy for a pool. Returns (port, bgp_endpoint, pcfg_path).""" + global _proxy_proc, _proxy_port + stop_proxy() + port = _pick_free_port() + _proxy_port = port + + bgp_ep = { + "name": pool["name"], + "backend_type": "openai-compat", + "base_url": "http://bgp.placeholder", + "api_key": "", + "default_model": model, + "models": list(dict.fromkeys(r.get("model", model) for r in pool.get("routes", []))), + } + pcfg = { + "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'])}-{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, port, logfn) + return port, bgp_ep + +# ═══════════════════════════════════════════════════════════════════════ +# Codex detection +# ═══════════════════════════════════════════════════════════════════════ + +def detect_codex_cli(): + try: + path = shutil.which("codex") + if not path: + return None + out = subprocess.run(["codex", "--version"], capture_output=True, text=True, timeout=5) + ver = (out.stdout or "").strip() or (out.stderr or "").strip() or "unknown" + return (path, ver) + except Exception: + return None + + +def detect_codex_desktop(): + if IS_WINDOWS: + la = os.environ.get("LOCALAPPDATA", "") + pf = os.environ.get("PROGRAMFILES", "") + pf86 = os.environ.get("PROGRAMFILES(X86)", "") + desktop_paths = [ + Path(la) / "Programs" / "Codex Desktop" / "Codex Desktop.exe", + Path(pf) / "Codex Desktop" / "Codex Desktop.exe", + Path(pf86) / "Codex Desktop" / "Codex Desktop.exe", + Path(la) / "OpenAI" / "Codex Desktop" / "Codex Desktop.exe", + ] + for p in desktop_paths: + if p.exists(): + return str(p) + # MSIX / Microsoft Store install: locate via Get-AppxPackage + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-Command", + "(Get-AppxPackage *OpenAI.Codex*).InstallLocation"], + capture_output=True, text=True, timeout=10, + ) + loc = r.stdout.strip() if r.returncode == 0 else "" + if loc: + msix_exe = Path(loc) / "app" / "Codex.exe" + if msix_exe.exists(): + return str(msix_exe) + except Exception: + pass + return None + if START_SH and START_SH.exists(): + return str(START_SH) + return None + + +def check_codex_auth(): + try: + out = subprocess.run( + ["codex", "login", "status"], + capture_output=True, text=True, timeout=10, + ) + text = (out.stdout or "").strip() + if not text: + text = (out.stderr or "").strip() + if out.returncode == 0 and text: + return ("logged_in", text) + if text: + return ("error", text) + return ("unknown", "No output from codex login status") + except FileNotFoundError: + return ("not_installed", "codex not found") + except Exception as e: + return ("error", str(e)) + +# ═══════════════════════════════════════════════════════════════════════ +# Log helpers +# ═══════════════════════════════════════════════════════════════════════ + +def last_log_lines(n=15): + try: + t = LAUNCH_LOG.read_text() + return "\n".join(t.splitlines()[-n:]) + except Exception: + return "(no log file)" + +# ═══════════════════════════════════════════════════════════════════════ +# Process helpers (desktop kill etc.) +# ═══════════════════════════════════════════════════════════════════════ + +def kill_existing_desktop(logfn=None): + if IS_WINDOWS: + try: + out = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq Codex Desktop.exe", "/FO", "CSV", "/NH"], + capture_output=True, text=True, timeout=5, + ) + for line in out.stdout.strip().splitlines(): + parts = line.split(",") + if len(parts) >= 2: + pid_str = parts[1].strip('"') + if pid_str.isdigit(): + pid = int(pid_str) + _kill_process_group(pid) + if logfn: + logfn(f"Killed existing Codex Desktop (pid {pid})") + time.sleep(2) + except Exception as e: + if logfn: + logfn(f"Note: could not kill existing Desktop: {e}") + else: + try: + out = subprocess.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5) + pids = [p for p in out.stdout.strip().splitlines() if p.strip().isdigit()] + if not pids: + return + main_pid = int(pids[0]) + pgid = os.getpgid(main_pid) + if pgid > 0: + os.killpg(pgid, signal.SIGTERM) + if logfn: + logfn(f"Killed existing Codex Desktop (pid {main_pid}, pgid {pgid})") + time.sleep(2) + try: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + except Exception as e: + if logfn: + logfn(f"Note: could not kill existing Desktop: {e}") + +# ═══════════════════════════════════════════════════════════════════════ +# AI Monitoring — Self-Healing Watchdog +# ═══════════════════════════════════════════════════════════════════════ + +_TIER1_RULES = [ + ("proxy_health_fail", "restart_proxy", 30), + ("proxy_port_conflict", "kill_stale_restart", 60), + ("upstream_429", "wait_retry", 0), + ("upstream_502_503", "retry_backoff", 30), + ("upstream_500_repeat", "switch_provider", 60), + ("upstream_timeout", "retry_increase_timeout",30), + ("upstream_401_403", "alert_bad_key", 0), + ("stream_broken_pipe", "restart_proxy", 30), + ("stream_reset", "restart_proxy", 30), + ("parsed_tool_calls_0_x3", "clear_schema_cache", 300), + ("sanitizer_suspicious_5x","alert_model_issue", 0), + ("stuck_recovery_x5", "suggest_switch_model", 0), + ("codex_process_dead", "alert_restart", 0), + ("schema_corrupt", "delete_provider_caps", 0), +] + +_FAILURE_SIGNALS = { + "parsed_tool_calls=0": ("C1", "parser_empty"), + "[STUCK-RECOVERY]": ("C3", "stuck_recovery"), + "suspicious cmd": ("C4", "sanitizer_flag"), + "empty cmd recovered": ("C6", "empty_cmd"), + "HTTP 429": ("B1", "rate_limited"), + "HTTP 500": ("B2", "server_error"), + "HTTP 502": ("B2", "server_error"), + "HTTP 503": ("B2", "server_error"), + "HTTP 401": ("B3", "auth_failure"), + "HTTP 403": ("B4", "forbidden"), + "Connection refused": ("A1", "proxy_dead"), + "Address already in use": ("A2", "port_conflict"), + "Broken pipe": ("B7", "broken_pipe"), + "Connection reset": ("B6", "connection_reset"), + "timed out": ("B5", "timeout"), + "SELF-REVIVE CRASH": ("A5", "proxy_crash"), + "stream error": ("B6", "stream_error"), + "content_type.*array": ("E1", "schema_corrupt"), +} + +_DIAGNOSTIC_SYSTEM_PROMPT = ( + 'You are a diagnostic agent for "Codex Launcher" — a desktop app that runs a local ' + 'translation proxy between OpenAI Codex CLI/Desktop and AI providers.\n\n' + 'Analyze the incident and respond with ONLY a JSON object:\n' + '{"action": "...", "reason": "...", "confidence": 0.0-1.0}\n\n' + 'Available actions: restart_proxy, kill_stale_processes, clear_schema_cache, ' + 'switch_provider, increase_timeout, regenerate_config, cleanup_stale, ' + 'alert_user, ignore, retry_now\n\n' + 'Rules:\n' + '- upstream 401/403 with auth error -> alert_user\n' + '- proxy dead -> restart_proxy\n' + '- same error 5+ times -> switch_provider or alert_user\n' + '- schema/content_type error -> clear_schema_cache\n' + '- "Address already in use" -> kill_stale_processes then restart_proxy\n' + '- timeout on slow upstream -> increase_timeout\n' + '- single transient 429/502/503 -> ignore\n' + '- "stream disconnected" + proxy healthy -> ignore\n' + '- no extra text, no markdown, just the JSON object' +) + + +def load_monitoring_config(): + if MONITORING_FILE.exists(): + try: + return json.loads(MONITORING_FILE.read_text()) + except Exception: + pass + return { + "enabled": False, + "provider_url": "", + "model": "", + "api_key": "", + "health_check_interval_s": 5, + "auto_restart_proxy": True, + "auto_switch_provider": False, + } + + +def save_monitoring_config(cfg): + MONITORING_FILE.parent.mkdir(parents=True, exist_ok=True) + MONITORING_FILE.write_text(json.dumps(cfg, indent=2)) + + +def load_incident_store(): + if INCIDENT_STORE_FILE.exists(): + try: + return json.loads(INCIDENT_STORE_FILE.read_text()) + except Exception: + pass + return {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}} + + +def save_incident_store(store): + INCIDENT_STORE_FILE.parent.mkdir(parents=True, exist_ok=True) + INCIDENT_STORE_FILE.write_text(json.dumps(store, indent=2)) + + +def monitoring_log(msg): + try: + with open(str(MONITORING_LOG), "a") as f: + f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n") + except Exception: + pass + + +class IncidentStore: + def __init__(self): + self._store = load_incident_store() + self._dirty = False + + def lookup(self, pattern): + inc = self._store.get("incidents", {}).get(pattern) + if inc and inc.get("success_count", 0) > 0: + rate = inc["success_count"] / max(inc["success_count"] + inc.get("fail_count", 0), 1) + if rate > 0.5: + return inc + return None + + def record(self, pattern, fix, success=True): + incs = self._store.setdefault("incidents", {}) + inc = incs.setdefault(pattern, { + "fix": fix, "success_count": 0, "fail_count": 0, + "last_seen": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "occurrences": 0, + }) + inc["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + inc["occurrences"] = inc.get("occurrences", 0) + 1 + if success: + inc["success_count"] = inc.get("success_count", 0) + 1 + else: + inc["fail_count"] = inc.get("fail_count", 0) + 1 + self._dirty = True + + def record_ai_call(self, tokens=0): + stats = self._store.setdefault("stats", {"ai_calls": 0, "tokens_used": 0}) + stats["ai_calls"] = stats.get("ai_calls", 0) + 1 + stats["tokens_used"] = stats.get("tokens_used", 0) + tokens + self._dirty = True + + def flush(self): + if self._dirty: + save_incident_store(self._store) + self._dirty = False + + @property + def stats(self): + return self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}) + + +class AIDiagnosticAgent: + def __init__(self, provider_url, model, api_key): + self.provider_url = provider_url + self.model = model + self.api_key = api_key + self.incident_store = IncidentStore() + + def diagnose(self, context): + pattern = self._extract_pattern(context) + known = self.incident_store.lookup(pattern) + if known: + monitoring_log(f"Tier 2 HIT: pattern={pattern} fix={known['fix']}") + return {"action": known["fix"], "reason": "known_pattern", "confidence": 0.9, "tier": 2} + action = self._call_model(context) + if action: + self.incident_store.record(pattern, action.get("action", "unknown")) + self.incident_store.flush() + return action + + def _extract_pattern(self, context): + parts = [] + for k in sorted(context.get("signals", [])): + parts.append(k) + if context.get("http_code"): + parts.append(f"http_{context['http_code']}") + return "+".join(parts[:3]) or "unknown" + + def _call_model(self, context): + prompt = ( + f"INCIDENT REPORT:\n" + f"Time: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n" + f"Proxy health: {context.get('proxy_alive', 'unknown')}\n" + f"Upstream: {context.get('upstream_url', 'unknown')}\n" + f"Model: {context.get('model', 'unknown')}\n" + f"Last HTTP code: {context.get('http_code', 'n/a')}\n" + f"Recent signals: {context.get('signals', [])}\n" + f"Recent log tail:\n{context.get('log_tail', '')[:1500]}\n" + ) + body = { + "model": self.model, + "messages": [ + {"role": "system", "content": _DIAGNOSTIC_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 200, + "temperature": 0.1, + } + try: + req = urllib.request.Request( + self.provider_url, + data=json.dumps(body).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + }, + ) + resp = urllib.request.urlopen(req, timeout=15) + result = json.loads(resp.read()) + text = result["choices"][0]["message"]["content"].strip() + self.incident_store.record_ai_call(tokens=800) + action = json.loads(text) + action["tier"] = 3 + monitoring_log(f"Tier 3 AI: action={action.get('action')} reason={action.get('reason')}") + return action + except Exception as e: + monitoring_log(f"Tier 3 AI FAILED: {e}") + return {"action": "alert_user", "reason": f"ai_diag_failed: {e}", "confidence": 0.0, "tier": 3} + + +class HealthWatcher(threading.Thread): + def __init__(self, on_failure, on_recovery, on_signal, on_action): + super().__init__(daemon=True) + self.cfg = load_monitoring_config() + self.on_failure = on_failure + self.on_recovery = on_recovery + self.on_signal = on_signal + self.on_action = on_action + self.failures = 0 + self.running = False + self._signal_counts = collections.defaultdict(int) + self._last_actions = {} + self._restart_count = 0 + self._last_restart_time = 0 + + def run(self): + self.running = True + self.incident_store = IncidentStore() + self._log_analyzer = _LogAnalyzerThread(self._on_log_signal) + self._log_analyzer.start() + while self.running: + self.cfg = load_monitoring_config() + if not self.cfg.get("enabled"): + time.sleep(5) + continue + port = self._get_proxy_port() + if port: + healthy = self._check_health(port) + if healthy: + if self.failures > 0: + self.failures = 0 + self.on_recovery() + else: + self.failures += 1 + if self.failures >= 3: + self._handle_failure("proxy_health_fail") + self.incident_store.flush() + interval = self.cfg.get("health_check_interval_s", 5) + time.sleep(interval) + + def stop(self): + self.running = False + if hasattr(self, '_log_analyzer'): + self._log_analyzer.running = False + + def _get_proxy_port(self): + try: + cfg_path = PROXY_CONFIG_DIR / "proxy-config.json" + if cfg_path.exists(): + d = json.loads(cfg_path.read_text()) + return d.get("port") + except Exception: + pass + return None + + def _check_health(self, port): + try: + req = urllib.request.Request(f"http://localhost:{port}/health") + resp = urllib.request.urlopen(req, timeout=5) + return resp.status == 200 + except Exception: + return False + + def _on_log_signal(self, fault_id, category, line): + self._signal_counts[category] += 1 + self.on_signal(fault_id, category, line[:200]) + count = self._signal_counts[category] + if category in ("proxy_dead", "port_conflict") and count >= 2: + self._handle_failure(category) + elif category in ("server_error", "timeout") and count >= 3: + self._handle_failure(category + "_repeat") + elif category in ("sanitizer_flag",) and count >= 5: + self._handle_failure("sanitizer_suspicious_5x") + elif category in ("stuck_recovery",) and count >= 5: + self._handle_failure("stuck_recovery_x5") + elif category in ("parser_empty",) and count >= 3: + self._handle_failure("parsed_tool_calls_0_x3") + elif category in ("schema_corrupt",): + self._handle_failure("schema_corrupt") + + def _handle_failure(self, trigger): + now = time.time() + for rule_trigger, action, cooldown in _TIER1_RULES: + if rule_trigger == trigger: + last_t = self._last_actions.get(action, 0) + if now - last_t < cooldown: + return + self._last_actions[action] = now + monitoring_log(f"Tier 1: trigger={trigger} action={action}") + self.on_action(action, trigger) + self.incident_store.record(trigger, action, success=True) + return + self._try_tier2_3(trigger) + + def _try_tier2_3(self, trigger): + cfg = self.cfg + if not cfg.get("provider_url") or not cfg.get("model") or not cfg.get("api_key"): + monitoring_log(f"No AI configured for Tier 2/3 — alerting user for trigger={trigger}") + self.on_action("alert_user", trigger) + return + agent = AIDiagnosticAgent(cfg["provider_url"], cfg["model"], cfg["api_key"]) + context = { + "signals": [trigger], + "proxy_alive": self.failures == 0, + "log_tail": self._get_recent_log(), + } + result = agent.diagnose(context) + if result: + action = result.get("action", "alert_user") + monitoring_log(f"Tier {result.get('tier', '?')}: action={action}") + self.on_action(action, trigger) + + def _get_recent_log(self): + lines = [] + for log_name in ["cc-debug.log", "proxy.log"]: + log_path = PROXY_CONFIG_DIR / log_name + try: + text = log_path.read_text() + lines.extend(text.splitlines()[-20:]) + except Exception: + pass + return "\n".join(lines[-30:]) + + +class _LogAnalyzerThread(threading.Thread): + def __init__(self, on_signal): + super().__init__(daemon=True) + self.on_signal = on_signal + self.running = False + + def run(self): + self.running = True + log_paths = [ + str(PROXY_CONFIG_DIR / "cc-debug.log"), + str(PROXY_CONFIG_DIR / "proxy.log"), + ] + fhs = {} + for p in log_paths: + try: + f = open(p, "r") + f.seek(0, 2) + fhs[p] = f + except Exception: + pass + while self.running: + activity = False + for p, fh in list(fhs.items()): + try: + line = fh.readline() + if line: + activity = True + for pattern, (fault_id, category) in _FAILURE_SIGNALS.items(): + if re.search(pattern, line): + self.on_signal(fault_id, category, line.strip()) + break + except Exception: + pass + if not activity: + time.sleep(0.5) + +# ═══════════════════════════════════════════════════════════════════════ +# Usage stats +# ═══════════════════════════════════════════════════════════════════════ + +def load_usage_stats(): + try: + if _USAGE_STATS_FILE.exists(): + return json.loads(_USAGE_STATS_FILE.read_text()) + except Exception: + pass + return {"providers": {}, "updated": None} + +# ═══════════════════════════════════════════════════════════════════════ +# Default endpoints creation +# ═══════════════════════════════════════════════════════════════════════ + +def create_default_endpoints(): + if not ENDPOINTS_FILE.exists(): + save_endpoints({ + "default": "OpenAI", + "endpoints": [ + {"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1", + "api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"], + "provider_preset": "OpenAI"}, + {"name": "Z.AI", "backend_type": "openai-compat", + "base_url": "https://api.z.ai/api/coding/paas/v4", + "api_key": "", "default_model": "glm-5.1", + "models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"], + "provider_preset": "Custom"}, + ], + }) + + +def ensure_dirs(): + for d in [LOG_DIR, PROXY_CONFIG_DIR]: + d.mkdir(parents=True, exist_ok=True) diff --git a/src/translate-proxy.py b/src/translate-proxy.py index 76d18a9..9f3f200 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -160,6 +160,9 @@ import time, uuid, os, sys, argparse, threading, socket, collections, contextlib import dataclasses import http.client import selectors +import tempfile + +_IS_WINDOWS = sys.platform == "win32" # ═══════════════════════════════════════════════════════════════════ # Config @@ -241,13 +244,28 @@ MODELS = [] CC_VERSION = "" REASONING_ENABLED = True REASONING_EFFORT = "medium" +FORCE_MODEL = "" BGP_ROUTES = [] SERVER = None -_LOG_DIR = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy") +if _IS_WINDOWS: + _LOG_DIR = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "codex-proxy") +else: + _LOG_DIR = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy") os.makedirs(_LOG_DIR, exist_ok=True) _REQUESTS_DIR = os.path.join(_LOG_DIR, "requests") os.makedirs(_REQUESTS_DIR, exist_ok=True) +try: + for _f in os.listdir(_REQUESTS_DIR): + if _f.endswith(".tmp"): + os.remove(os.path.join(_REQUESTS_DIR, _f)) + for _f in os.listdir(_LOG_DIR): + if _f.startswith("proxy-") and _f.endswith(".json"): + os.remove(os.path.join(_LOG_DIR, _f)) + if _f.startswith("models-") and _f.endswith(".json"): + os.remove(os.path.join(_LOG_DIR, _f)) +except Exception: + pass _stats_path = os.path.join(_LOG_DIR, "usage-stats.json") _provider_caps_path = os.path.join(_LOG_DIR, "provider-caps.json") _stats_lock = threading.Lock() @@ -257,7 +275,7 @@ _STATS_FLUSH_INTERVAL = 5.0 _STATS = {} try: - _LOG_FILE = open(os.path.join(_LOG_DIR, "proxy.log"), "a") + _LOG_FILE = open(os.path.join(_LOG_DIR, "proxy.log"), "a", encoding="utf-8") except Exception: _LOG_FILE = None @@ -273,6 +291,9 @@ _deepseek_reasoning_store = {} _deepseek_reasoning_lock = threading.Lock() _MAX_DS_STORED = 100 +_last_reasoning_store = {} +_last_reasoning_lock = threading.Lock() + _crof_lock = threading.Lock() _provider_caps_lock = threading.Lock() _provider_caps = None @@ -302,7 +323,10 @@ _CODEBUFF_AGENT_MAP = { "moonshotai/kimi-k2.6": "base2-free-kimi", "minimax/minimax-m2.7": "base2-free", } -_CODEBUFF_CREDS_PATH = os.path.join(os.path.expanduser("~"), ".config", "manicode", "credentials.json") +if _IS_WINDOWS: + _CODEBUFF_CREDS_PATH = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "manicode", "credentials.json") +else: + _CODEBUFF_CREDS_PATH = os.path.join(os.path.expanduser("~"), ".config", "manicode", "credentials.json") _codebuff_token_cache = {"token": None, "checked": 0} _codebuff_session_cache = {"instance_id": None, "expires": 0, "model": None} _codebuff_token_lock = threading.Lock() @@ -634,7 +658,7 @@ def _refresh_google_token(token_data, token_path): new_tokens = json.loads(resp.read()) token_data["access_token"] = new_tokens.get("access_token", token_data.get("access_token")) token_data["expires_at"] = time.time() + new_tokens.get("expires_in", 3600) - with open(token_path, "w") as f: + with open(token_path, "w", encoding="utf-8") as f: json.dump(token_data, f, indent=2) print("[oauth] token refreshed OK", file=sys.stderr) return token_data["access_token"] @@ -727,7 +751,7 @@ def _fetch_antigravity_version(): version = m.group(0) try: os.makedirs(os.path.dirname(cache_path), exist_ok=True) - with open(cache_path, "w") as f: + with open(cache_path, "w", encoding="utf-8") as f: json.dump({"version": version, "checked_at": time.time()}, f) except Exception: pass @@ -762,6 +786,7 @@ def _init_runtime(): CC_VERSION = CONFIG.get("cc_version", "") REASONING_ENABLED = CONFIG.get("reasoning_enabled", True) REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium") + FORCE_MODEL = (CONFIG.get("force_model") or "").strip() BGP_ROUTES = CONFIG.get("bgp_routes", []) _api_key_pool = None if API_KEY and "," in API_KEY and not OAUTH_PROVIDER.startswith("google") and BACKEND not in ("codebuff", "freebuff"): @@ -903,7 +928,7 @@ def _load_provider_caps(): def _save_provider_caps(): try: os.makedirs(os.path.dirname(_provider_caps_path), exist_ok=True) - with open(_provider_caps_path, "w") as f: + with open(_provider_caps_path, "w", encoding="utf-8") as f: json.dump(_provider_caps or {}, f, indent=2) except Exception as e: print(f"[provider-sensor] failed to save caps: {e}", file=sys.stderr) @@ -959,7 +984,7 @@ def _refresh_oauth_token_for(api_key, oauth_provider): new_tokens = json.loads(resp.read()) tokens["access_token"] = new_tokens.get("access_token", tokens.get("access_token")) tokens["expires_at"] = time.time() + new_tokens.get("expires_in", 3600) - with open(token_path, "w") as f: + with open(token_path, "w", encoding="utf-8") as f: json.dump(tokens, f, indent=2) print("[oauth] token refreshed OK", file=sys.stderr) return tokens["access_token"] @@ -983,7 +1008,7 @@ def _load_stats(): def _atomic_write_json(path, obj): tmp = path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(obj, f, indent=2, ensure_ascii=False) os.replace(tmp, path) @@ -1297,7 +1322,7 @@ def _load_bgp_stats(): def _save_bgp_stats(stats): tmp = _BGP_STATS_PATH + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(stats, f, indent=2) os.replace(tmp, _BGP_STATS_PATH) @@ -1790,7 +1815,7 @@ def save_request_snapshot(request_id, body): } path = os.path.join(_REQUESTS_DIR, f"{request_id}.json") tmp = path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(snapshot, f, ensure_ascii=False, indent=2) os.replace(tmp, path) _rotate_snapshots() @@ -1813,7 +1838,7 @@ def update_snapshot_response(request_id, status, duration_s=None, error=None): meta["error"] = str(error)[:200] snapshot["_meta"] = meta tmp = path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(snapshot, f, ensure_ascii=False, indent=2) os.replace(tmp, path) except Exception: @@ -1865,6 +1890,27 @@ def _bucket_for_route(route): # OpenAI-compat backend # ═══════════════════════════════════════════════════════════════════ +def _inject_stored_reasoning(messages): + with _last_reasoning_lock: + snapshot = dict(_last_reasoning_store) + if not snapshot: + return messages + expired = [k for k, v in snapshot.items() if time.time() - v["ts"] > _RESPONSE_TTL] + for k in expired: + with _last_reasoning_lock: + _last_reasoning_store.pop(k, None) + snapshot.pop(k, None) + if not snapshot: + return messages + latest = max(snapshot.values(), key=lambda v: v["ts"]) + reasoning = latest.get("reasoning", "") + if not reasoning: + return messages + for msg in messages: + if msg.get("role") == "assistant" and "reasoning_content" not in msg and msg.get("tool_calls"): + msg["reasoning_content"] = reasoning + return messages + def oa_input_to_messages(input_data): msgs = [] tool_name_by_id = {} @@ -2384,10 +2430,10 @@ def an_stream_to_sse(stream, model, req_id): "status": status, "created": int(time.time()), "output": completed}}) _DEFAULT_CC_CONFIG = { - "workingDir": "/tmp", + "workingDir": tempfile.gettempdir(), "date": "", - "environment": "linux", - "shell": "bash", + "environment": "windows" if _IS_WINDOWS else "linux", + "shell": "powershell" if _IS_WINDOWS else "bash", "files": [], "structure": [], "isGitRepo": False, @@ -2462,13 +2508,24 @@ def _build_explore_cmd(text_for_url): api_base = repo_url.replace("/admin/", "/api/v1/repos/") else: api_base = repo_url - cmd = ( - f"cd /tmp && " - f"curl -sL --max-time 15 '{api_base}/contents/README.md' 2>/dev/null | " - f"python3 -c \"import sys,json,base64; d=json.load(sys.stdin); print(base64.b64decode(d['content']).decode())\" 2>/dev/null | head -600 && " - f"curl -sL --max-time 15 '{api_base}/contents' 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print('\\n'.join(f'{{x.get(\'path\')}} {{x.get(\'type\')}}' for x in d[:50]))\" 2>/dev/null && " - f"curl -sL --max-time 15 '{api_base}/releases' 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print(json.dumps(d[:3], indent=2)[:2000])\" 2>/dev/null" - ) + if _IS_WINDOWS: + cmd = ( + f"cd $env:TEMP; " + f"$r = Invoke-WebRequest -Uri '{api_base}/contents/README.md' -UseBasicParsing -TimeoutSec 15 2>$null; " + f"if ($r) {{ $j = $r.Content | ConvertFrom-Json; [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($j.content)) | Select-Object -First 600 }}; " + f"$r2 = Invoke-WebRequest -Uri '{api_base}/contents' -UseBasicParsing -TimeoutSec 15 2>$null; " + f"if ($r2) {{ $j2 = $r2.Content | ConvertFrom-Json; $j2 | Select-Object -First 50 | ForEach-Object {{ $_.path + ' ' + $_.type }} }}; " + f"$r3 = Invoke-WebRequest -Uri '{api_base}/releases' -UseBasicParsing -TimeoutSec 15 2>$null; " + f"if ($r3) {{ ($r3.Content | ConvertFrom-Json | Select-Object -First 3 | ConvertTo-Json).Substring(0, [Math]::Min(2000, ($r3.Content | ConvertFrom-Json | Select-Object -First 3 | ConvertTo-Json).Length)) }}" + ) + else: + cmd = ( + f"cd /tmp && " + f"curl -sL --max-time 15 '{api_base}/contents/README.md' 2>/dev/null | " + f"python3 -c \"import sys,json,base64; d=json.load(sys.stdin); print(base64.b64decode(d['content']).decode())\" 2>/dev/null | head -600 && " + f"curl -sL --max-time 15 '{api_base}/contents' 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print('\\n'.join(f'{{x.get(\'path\')}} {{x.get(\'type\')}}' for x in d[:50]))\" 2>/dev/null && " + f"curl -sL --max-time 15 '{api_base}/releases' 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print(json.dumps(d[:3], indent=2)[:2000])\" 2>/dev/null" + ) return cmd, "Explore repository to understand the app and gather README, root contents, and releases for the landing page." def _parse_commandcode_text_tool_calls(text): @@ -3322,7 +3379,10 @@ def cc_stream_to_sse(cc_stream, model, req_id): _url_in_text = re.search(r"https?://[^\s\]'\\>\",]+", text_buf) if _url_in_text: _synth_url = _url_in_text.group(0).rstrip(")].,;'\\\"") - _synth_cmd = f"curl -sL --max-time 15 '{_synth_url}' 2>/dev/null | head -200" + if _IS_WINDOWS: + _synth_cmd = f"Invoke-WebRequest -Uri '{_synth_url}' -UseBasicParsing -TimeoutSec 15 | Select-Object -ExpandProperty Content | Select-Object -First 200" + else: + _synth_cmd = f"curl -sL --max-time 15 '{_synth_url}' 2>/dev/null | head -200" _synth_just = "Auto-synthesized: URL detected in text, fetching" # Heuristic 2: File path references → list or read @@ -3330,7 +3390,10 @@ def cc_stream_to_sse(cc_stream, model, req_id): _file_m = re.search(r"(?:read|open|view|check|examine|cat|show)\s+(?:the\s+)?(?:file\s+)?[`'\"]?(/[^\s'\"]+\.\w+)", _tl) if _file_m: _fpath = _file_m.group(1) - _synth_cmd = f"cat '{_fpath}' 2>/dev/null | head -200 || ls -la '{_fpath}'" + if _IS_WINDOWS: + _synth_cmd = f"Get-Content '{_fpath}' -ErrorAction SilentlyContinue | Select-Object -First 200; if (-not $?) {{ Get-Item '{_fpath}' | Select-Object Name,Length,LastWriteTime }}" + else: + _synth_cmd = f"cat '{_fpath}' 2>/dev/null | head -200 || ls -la '{_fpath}'" _synth_just = f"Auto-synthesized: file reference detected ({_fpath})" # Heuristic 3: Shell command mentioned in backticks or quotes @@ -3358,7 +3421,10 @@ def cc_stream_to_sse(cc_stream, model, req_id): if _intent_m: _intent_text = _intent_m.group(1).strip() if len(_intent_text) > 10 and len(_intent_text) < 200: - _synth_cmd = f"echo 'Stuck recovery: model intent was: {_intent_text[:100]}'" + if _IS_WINDOWS: + _synth_cmd = f"Write-Output 'Stuck recovery: model intent was: {_intent_text[:100]}'" + else: + _synth_cmd = f"echo 'Stuck recovery: model intent was: {_intent_text[:100]}'" _synth_just = f"Auto-synthesized from intent text: {_intent_text[:80]}" if _synth_cmd: @@ -3891,11 +3957,13 @@ def _extract_text(content): # HTTP Server # ═══════════════════════════════════════════════════════════════════ +_MAX_REQLOG_LINES = 2000 + def _log_resp(resp_id, status, output): try: import datetime as _dt _lp = os.path.join(_LOG_DIR, "requests.log") - with open(_lp, "a") as _f: + with open(_lp, "a", encoding="utf-8") as _f: _f.write(f" RESPONSE id={resp_id} status={status}\n") if output: for o in output: @@ -3908,6 +3976,11 @@ def _log_resp(resp_id, status, output): _f.write(f" -> {ot}\n") _f.write(f"{'='*60}\n") _f.flush() + _f.seek(0) + lines = _f.readlines() + if len(lines) > _MAX_REQLOG_LINES: + with open(_lp, "w", encoding="utf-8") as _f2: + _f2.writelines(lines[-_MAX_REQLOG_LINES:]) except Exception: pass @@ -4064,10 +4137,26 @@ class Handler(http.server.BaseHTTPRequestHandler): info["total"] = 0 self.send_json(200, info) elif self.path in ("/health", "/v1/health"): - import resource as _res _mem_mb = 0 try: - _mem_mb = _res.getrusage(_res.RUSAGE_SELF).ru_maxrss / 1024 + if _IS_WINDOWS: + import ctypes + class _PMI(ctypes.Structure): + _fields_ = [("cb", ctypes.c_ulong), ("PageFaultCount", ctypes.c_ulong), + ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)] + _pmi = _PMI() + _pmi.cb = ctypes.sizeof(_PMI) + ctypes.windll.psapi.GetProcessMemoryInfo.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_ulong] + ctypes.windll.psapi.GetProcessMemoryInfo.restype = ctypes.c_int + ctypes.windll.psapi.GetProcessMemoryInfo( + ctypes.windll.kernel32.GetCurrentProcess(), ctypes.byref(_pmi), _pmi.cb) + _mem_mb = _pmi.PeakWorkingSetSize / (1024 * 1024) + else: + import resource as _res + _mem_mb = _res.getrusage(_res.RUSAGE_SELF).ru_maxrss / 1024 except Exception: pass _uptime = time.time() - _START_TIME if '_START_TIME' in dir() else 0 @@ -4122,12 +4211,12 @@ class Handler(http.server.BaseHTTPRequestHandler): resolved_types = [i.get("type") for i in input_data] if isinstance(input_data, list) else "str" print(f"[{_sid}] prev_id={prev_id} raw={raw_types} resolved={resolved_types}", file=sys.stderr) - with open(_log_path, "a") as _lf: + with open(_log_path, "a", encoding="utf-8") as _lf: _lf.write(f"\n{'='*60}\n{_ts} [session={_sid}] REQUEST {self.path}\n") _lf.write(f" prev_id={prev_id}\n") _lf.write(f" raw_input_types={raw_types}\n") _lf.write(f" resolved_input_types={resolved_types}\n") - _lf.write(f" stream={body.get('stream')} model={body.get('model')}\n") + _lf.write(f" stream={body.get('stream')} model={body.get('model')} force_model={FORCE_MODEL}\n") _lf.write(f" store_keys={list(_response_store.keys())}\n") if isinstance(input_data, list): for i, item in enumerate(input_data): @@ -4143,6 +4232,9 @@ class Handler(http.server.BaseHTTPRequestHandler): _lf.flush() model = body.get("model", MODELS[0]["id"] if MODELS else "unknown") + if FORCE_MODEL: + model = FORCE_MODEL + body["model"] = FORCE_MODEL stream = body.get("stream", False) _desktop_forced_models = {"gpt-5.4-mini", "gpt-5.4", "gpt-5.5", "gpt-5-codex", "gpt-5.3-codex"} _launcher_model = os.environ.get("CODEX_LAUNCHER_MODEL", "") @@ -4211,6 +4303,7 @@ class Handler(http.server.BaseHTTPRequestHandler): body["input"] = input_data messages = oa_input_to_messages(input_data) + messages = _inject_stored_reasoning(messages) instructions = body.get("instructions", "").strip() if instructions: messages.insert(0, {"role": "system", "content": instructions}) @@ -4612,7 +4705,7 @@ class Handler(http.server.BaseHTTPRequestHandler): if n_contents > 10: debug_path = os.path.join(_LOG_DIR, f"gemini-long-ctx-{self._session_id}.json") try: - with open(debug_path, "w") as dbg: + with open(debug_path, "w", encoding="utf-8") as dbg: json.dump({"contents_count": n_contents, "contents_roles": [c.get("role") for c in contents], "has_tools": has_tools, "model": model, "wrapped_size": len(body_b)}, dbg, indent=2) except Exception: pass @@ -4628,7 +4721,7 @@ class Handler(http.server.BaseHTTPRequestHandler): if e.code == 400 and OAUTH_PROVIDER.startswith("google"): try: debug_path = os.path.join(_LOG_DIR, "gemini-last-400-request.json") - with open(debug_path, "w") as dbg: + with open(debug_path, "w", encoding="utf-8") as dbg: json.dump({"endpoint": ep, "model": model, "wrapped": wrapped, "error": err_body}, dbg, indent=2) print(f"[{self._session_id}] saved 400 debug request to {debug_path}", file=sys.stderr) except Exception: @@ -4940,7 +5033,8 @@ class Handler(http.server.BaseHTTPRequestHandler): pass try: - for event in oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id")): + reasoning_out = {} + for event in oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id"), _reasoning_out=reasoning_out): if tracker and tracker.cancelled.is_set(): print("[translate-proxy] stream cancelled", file=sys.stderr) break @@ -4958,6 +5052,16 @@ class Handler(http.server.BaseHTTPRequestHandler): _log_resp(last_resp_id, last_status, last_output) if last_resp_id and input_data is not None: store_response(last_resp_id, input_data, last_output) + if reasoning_out.get("text"): + with _last_reasoning_lock: + _last_reasoning_store[last_resp_id or ""] = { + "reasoning": reasoning_out["text"], + "tool_calls": reasoning_out.get("tool_calls", []), + "ts": time.time(), + } + while len(_last_reasoning_store) > _MAX_STORED: + oldest = next(iter(_last_reasoning_store)) + del _last_reasoning_store[oldest] _record_usage(provider, model, success, time.time() - t0, error_type="length" if not success else None) # Auto-learn provider quirks before flushing the bad response to Codex. @@ -5925,8 +6029,14 @@ def main(): global SERVER, _START_TIME _START_TIME = time.time() _init_runtime() - signal.signal(signal.SIGTERM, _handle_shutdown_signal) signal.signal(signal.SIGINT, _handle_shutdown_signal) + if _IS_WINDOWS: + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _handle_shutdown_signal) + import atexit + atexit.register(lambda: setattr(sys.modules[__name__], '_SHUTDOWN_REQUESTED', True)) + else: + signal.signal(signal.SIGTERM, _handle_shutdown_signal) try: from http.server import ThreadingHTTPServer as _BaseSrv except ImportError: @@ -6133,7 +6243,7 @@ Postamble text.""" _check("FIX23 explore nested JSON: parsed", len(_calls_m) == 1, f"got {len(_calls_m)} calls") if _calls_m: _args_m = json.loads(_calls_m[0].get("arguments", "{}")) - _check("FIX23 explore nested JSON: cmd has curl", "curl" in _args_m.get("cmd", ""), f"got {_args_m.get('cmd')!r}") + _check("FIX23 explore nested JSON: cmd has fetch cmd", "curl" in _args_m.get("cmd", "") or "Invoke-WebRequest" in _args_m.get("cmd", ""), f"got {_args_m.get('cmd')!r}") _check("FIX23 explore nested JSON: URL in cmd", "github.rommark.dev" in _args_m.get("cmd", ""), f"missing URL in cmd") # Pattern N: require_escalation block (FIX 24) @@ -6143,7 +6253,7 @@ Postamble text.""" if _calls_n: _args_n = json.loads(_calls_n[0].get("arguments", "{}")) _check("FIX24 require_escalation: name is exec_command", _calls_n[0].get("name") == "exec_command", f"got {_calls_n[0].get('name')}") - _check("FIX24 require_escalation: cmd has curl or echo", "curl" in _args_n.get("cmd", "") or "echo" in _args_n.get("cmd", ""), f"got {_args_n.get('cmd')!r}") + _check("FIX24 require_escalation: cmd has fetch or echo", "curl" in _args_n.get("cmd", "") or "echo" in _args_n.get("cmd", "") or "Invoke-WebRequest" in _args_n.get("cmd", "") or "Write-Output" in _args_n.get("cmd", ""), f"got {_args_n.get('cmd')!r}") # Pattern N2: bare request_escalation_permission tag (FIX 24b) _esc_bare = 'I want to proceed.\n\nPlease let me continue.' @@ -6155,13 +6265,13 @@ Postamble text.""" # Pattern O: _build_explore_cmd module-level function (FIX 23/25) _cmd_o, _just_o = _build_explore_cmd("https://github.rommark.dev/admin/Z.AI-Chat-for-Android") _check("FIX23/25 _build_explore_cmd: returns cmd", _cmd_o is not None, "returned None") - _check("FIX23/25 _build_explore_cmd: has curl", _cmd_o and "curl" in _cmd_o, f"no curl in {_cmd_o!r}") + _check("FIX23/25 _build_explore_cmd: has fetch cmd", _cmd_o and ("curl" in _cmd_o or "Invoke-WebRequest" in _cmd_o), f"no fetch cmd in {_cmd_o!r}") _check("FIX23/25 _build_explore_cmd: has api path", _cmd_o and "/api/v1/repos/" in _cmd_o, f"no api path in {_cmd_o!r}") # Pattern O2: _build_explore_cmd with JSON array containing URL _cmd_o2, _ = _build_explore_cmd('[{"content": "https://github.rommark.dev/admin/Z.AI-Chat-for-Android"}]') _check("FIX23/25 _build_explore_cmd from JSON array: returns cmd", _cmd_o2 is not None, "returned None") - _check("FIX23/25 _build_explore_cmd from JSON array: has curl", _cmd_o2 and "curl" in _cmd_o2, f"no curl in {_cmd_o2!r}") + _check("FIX23/25 _build_explore_cmd from JSON array: has fetch cmd", _cmd_o2 and ("curl" in _cmd_o2 or "Invoke-WebRequest" in _cmd_o2), f"no fetch cmd in {_cmd_o2!r}") print(f"[CC-SELF-TEST] Results: {_counts[0]} passed, {_counts[1]} failed", file=sys.stderr) diff --git a/translate-proxy.py b/translate-proxy.py index 891a3c0..7959e76 100755 --- a/translate-proxy.py +++ b/translate-proxy.py @@ -70,9 +70,9 @@ FIX 6: Double-wrapped arguments (nested {"cmd": "{\"cmd\": \"curl...\"}"}") FIX 7: _extract_field can't read values starting with \" Symptom: sandbox_permissions="allow_all" passes through unnormalized because - _extract_field sees val_start=\ (backslash) which != " or { → returns None + _extract_field sees val_start=\\ (backslash) which != \" or { → returns None Fix: Skip leading backslash before checking for " or { value type. - Location: _extract_field() leading-\ skip + Location: _extract_field() leading-backslash skip FIX 8: Adaptive probing caused format mismatch (REVERTED) Symptom: Probe system discovered OpenAI tool_calls+role=tool format but CC API couldn't @@ -160,6 +160,9 @@ import time, uuid, os, sys, argparse, threading, socket, collections, contextlib import dataclasses import http.client import selectors +import tempfile + +_IS_WINDOWS = sys.platform == "win32" # ═══════════════════════════════════════════════════════════════════ # Config @@ -172,11 +175,11 @@ DEFAULT_MODELS = { "anthropic": [ {"id": "claude-sonnet-4-20250514", "object": "model", "created": 1700000000, "owned_by": "anthropic"}, ], - "freebuff": [ - {"id": "deepseek/deepseek-v4-pro", "object": "model", "created": 1700000000, "owned_by": "freebuff"}, - {"id": "deepseek/deepseek-v4-flash", "object": "model", "created": 1700000000, "owned_by": "freebuff"}, - {"id": "moonshotai/kimi-k2.6", "object": "model", "created": 1700000000, "owned_by": "freebuff"}, - {"id": "minimax/minimax-m2.7", "object": "model", "created": 1700000000, "owned_by": "freebuff"}, + "codebuff": [ + {"id": "deepseek/deepseek-v4-pro", "object": "model", "created": 1700000000, "owned_by": "codebuff"}, + {"id": "deepseek/deepseek-v4-flash", "object": "model", "created": 1700000000, "owned_by": "codebuff"}, + {"id": "moonshotai/kimi-k2.6", "object": "model", "created": 1700000000, "owned_by": "codebuff"}, + {"id": "minimax/minimax-m2.7", "object": "model", "created": 1700000000, "owned_by": "codebuff"}, ], "auto": [ {"id": "default-model", "object": "model", "created": 1700000000, "owned_by": "auto"}, @@ -187,7 +190,7 @@ def load_config(): p = argparse.ArgumentParser(description="Responses API translation proxy") p.add_argument("--config", help="JSON config file path") p.add_argument("--port", type=int, default=None) - p.add_argument("--backend", default=None, choices=["openai-compat", "anthropic", "command-code", "freebuff", "auto"]) + p.add_argument("--backend", default=None, choices=["openai-compat", "anthropic", "command-code", "codebuff", "freebuff", "auto"]) p.add_argument("--target-url", default=None) p.add_argument("--api-key", default=None) p.add_argument("--models-file", default=None, help="JSON file with model list array") @@ -241,13 +244,23 @@ MODELS = [] CC_VERSION = "" REASONING_ENABLED = True REASONING_EFFORT = "medium" +FORCE_MODEL = "" BGP_ROUTES = [] SERVER = None -_LOG_DIR = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy") +if _IS_WINDOWS: + _LOG_DIR = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "codex-proxy") +else: + _LOG_DIR = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy") os.makedirs(_LOG_DIR, exist_ok=True) _REQUESTS_DIR = os.path.join(_LOG_DIR, "requests") os.makedirs(_REQUESTS_DIR, exist_ok=True) +try: + for _f in os.listdir(_REQUESTS_DIR): + if _f.endswith(".tmp"): + os.remove(os.path.join(_REQUESTS_DIR, _f)) +except Exception: + pass _stats_path = os.path.join(_LOG_DIR, "usage-stats.json") _provider_caps_path = os.path.join(_LOG_DIR, "provider-caps.json") _stats_lock = threading.Lock() @@ -257,7 +270,7 @@ _STATS_FLUSH_INTERVAL = 5.0 _STATS = {} try: - _LOG_FILE = open(os.path.join(_LOG_DIR, "proxy.log"), "a") + _LOG_FILE = open(os.path.join(_LOG_DIR, "proxy.log"), "a", encoding="utf-8") except Exception: _LOG_FILE = None @@ -273,6 +286,9 @@ _deepseek_reasoning_store = {} _deepseek_reasoning_lock = threading.Lock() _MAX_DS_STORED = 100 +_last_reasoning_store = {} +_last_reasoning_lock = threading.Lock() + _crof_lock = threading.Lock() _provider_caps_lock = threading.Lock() _provider_caps = None @@ -294,104 +310,146 @@ _conn_pool = {} _STREAM_IDLE_TIMEOUT = 300 -_FREEBUFF_AUTH_URL = "https://freebuff.com" -_FREEBUFF_API_URL = "https://www.codebuff.com" -_FREEBUFF_AGENT_MAP = { +_CODEBUFF_AUTH_URL = "https://codebuff.com" +_CODEBUFF_API_URL = "https://www.codebuff.com" +_CODEBUFF_AGENT_MAP = { "deepseek/deepseek-v4-pro": "base2-free-deepseek", "deepseek/deepseek-v4-flash": "base2-free-deepseek-flash", "moonshotai/kimi-k2.6": "base2-free-kimi", "minimax/minimax-m2.7": "base2-free", } -_FREEBUFF_CREDS_PATH = os.path.join(os.path.expanduser("~"), ".config", "manicode", "credentials.json") -_freebuff_token_cache = {"token": None, "checked": 0} -_freebuff_session_cache = {"instance_id": None, "expires": 0, "model": None} -_freebuff_token_lock = threading.Lock() +if _IS_WINDOWS: + _CODEBUFF_CREDS_PATH = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "manicode", "credentials.json") +else: + _CODEBUFF_CREDS_PATH = os.path.join(os.path.expanduser("~"), ".config", "manicode", "credentials.json") +_codebuff_token_cache = {"token": None, "checked": 0} +_codebuff_session_cache = {"instance_id": None, "expires": 0, "model": None} +_codebuff_token_lock = threading.Lock() -def _get_freebuff_token(): - with _freebuff_token_lock: - if _freebuff_token_cache["token"] and _freebuff_token_cache["checked"] > time.time() - 300: - return _freebuff_token_cache["token"] +def _get_codebuff_token(): + with _codebuff_token_lock: + if _codebuff_token_cache["token"] and _codebuff_token_cache["checked"] > time.time() - 300: + return _codebuff_token_cache["token"] try: - with open(_FREEBUFF_CREDS_PATH) as f: + with open(_CODEBUFF_CREDS_PATH) as f: creds = json.load(f) default_account = creds.get("default", {}) token = default_account.get("authToken") or creds.get("apiKey") or "" - with _freebuff_token_lock: - _freebuff_token_cache["token"] = token - _freebuff_token_cache["checked"] = time.time() + with _codebuff_token_lock: + _codebuff_token_cache["token"] = token + _codebuff_token_cache["checked"] = time.time() return token except Exception as e: - print(f"[freebuff] no credentials at {_FREEBUFF_CREDS_PATH}: {e}", file=sys.stderr) + print(f"[codebuff] no credentials at {_CODEBUFF_CREDS_PATH}: {e}", file=sys.stderr) return "" -def _freebuff_get_session(token, model): - with _freebuff_token_lock: - sc = _freebuff_session_cache +def _codebuff_get_session(token, model): + with _codebuff_token_lock: + sc = _codebuff_session_cache if sc["instance_id"] and sc["expires"] > time.time() + 60 and sc["model"] == model: return sc["instance_id"] try: - url = f"{_FREEBUFF_API_URL}/api/v1/freebuff/session" + url = f"{_CODEBUFF_API_URL}/api/v1/freebuff/session" body = json.dumps({"model": model}).encode() req = urllib.request.Request(url, data=body, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", - "User-Agent": "codex-launcher/3.9.0", - "x-freebuff-model": model, + "User-Agent": "codex-launcher/3.10.4", + "x-codebuff-model": model, }) - resp = urllib.request.urlopen(req, timeout=15) + try: + resp = urllib.request.urlopen(req, timeout=15) + except urllib.error.HTTPError as e: + err_body = e.read().decode()[:1000] + if e.code == 429: + retry_s = 120 + user_msg = "" + try: + err_data = json.loads(err_body) + retry_ms = err_data.get("retryAfterMs", 0) + if retry_ms: + retry_s = retry_ms / 1000 + user_msg = err_data.get("message", err_data.get("error", "")) + if isinstance(user_msg, dict): + user_msg = user_msg.get("message", "") + except Exception: + pass + if not user_msg: + user_msg = _sanitize_err_body(err_body) + raise RateLimitError(retry_s, user_msg) + print(f"[codebuff] session HTTP {e.code}: {err_body[:200]}", file=sys.stderr) + return None data = json.loads(resp.read()) instance_id = data.get("instanceId", data.get("data", {}).get("instance_id", "")) expires_at = data.get("remainingMs", 0) if instance_id: - with _freebuff_token_lock: - _freebuff_session_cache["instance_id"] = instance_id - _freebuff_session_cache["expires"] = time.time() + min(expires_at / 1000, 3600) - _freebuff_session_cache["model"] = model - print(f"[freebuff] session active, instance={instance_id[:8]}...", file=sys.stderr) + with _codebuff_token_lock: + _codebuff_session_cache["instance_id"] = instance_id + _codebuff_session_cache["expires"] = time.time() + min(expires_at / 1000, 3600) + _codebuff_session_cache["model"] = model + print(f"[codebuff] session active, instance={instance_id[:8]}...", file=sys.stderr) return instance_id return None + except RateLimitError: + raise except Exception as e: - print(f"[freebuff] session failed: {e}", file=sys.stderr) + print(f"[codebuff] session failed: {e}", file=sys.stderr) return None -def _freebuff_start_run(token, agent_id): - url = f"{_FREEBUFF_API_URL}/api/v1/agent-runs" +def _codebuff_start_run(token, agent_id): + url = f"{_CODEBUFF_API_URL}/api/v1/agent-runs" body = json.dumps({"action": "START", "agentId": agent_id, "ancestorRunIds": []}).encode() req = urllib.request.Request(url, data=body, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", - "User-Agent": "codex-launcher/3.9.0", + "User-Agent": "codex-launcher/3.10.4", }) try: resp = urllib.request.urlopen(req, timeout=15) data = json.loads(resp.read()) run_id = data.get("runId") - print(f"[freebuff] started run {run_id} for agent {agent_id}", file=sys.stderr) - return run_id + print(f"[codebuff] started run {run_id} for agent {agent_id}", file=sys.stderr) + return run_id, None except urllib.error.HTTPError as e: - err = e.read().decode()[:300] - print(f"[freebuff] start run failed: HTTP {e.code}: {err}", file=sys.stderr) - return None + err = e.read().decode()[:500] + print(f"[codebuff] start run failed: HTTP {e.code}: {err}", file=sys.stderr) + if e.code == 429: + retry_s = 120 + try: + err_data = json.loads(err) + retry_ms = err_data.get("retryAfterMs", 0) + if retry_ms: + retry_s = retry_ms / 1000 + except Exception: + pass + return None, ("rate_limit_error", 429, retry_s, _sanitize_err_body(err)) + return None, ("upstream_error", e.code, 0, _sanitize_err_body(err)) except Exception as e: - print(f"[freebuff] start run error: {e}", file=sys.stderr) - return None + print(f"[codebuff] start run error: {e}", file=sys.stderr) + return None, ("proxy_error", 502, 0, str(e)) -def _freebuff_finish_run(token, run_id, status="completed"): - url = f"{_FREEBUFF_API_URL}/api/v1/agent-runs" +def _codebuff_finish_run(token, run_id, status="completed"): + url = f"{_CODEBUFF_API_URL}/api/v1/agent-runs" body = json.dumps({"action": "FINISH", "runId": run_id, "status": status, "totalSteps": 1, "directCredits": 0, "totalCredits": 0}).encode() req = urllib.request.Request(url, data=body, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", - "User-Agent": "codex-launcher/3.9.0", + "User-Agent": "codex-launcher/3.10.4", }) try: urllib.request.urlopen(req, timeout=10) except Exception as e: - print(f"[freebuff] finish run {run_id} error: {e}", file=sys.stderr) + print(f"[codebuff] finish run {run_id} error: {e}", file=sys.stderr) # ═══════════════════════════════════════════════════════════════════ # Multi-account rotation system +class RateLimitError(Exception): + def __init__(self, retry_seconds, message=""): + self.retry_seconds = retry_seconds + self.message = message + super().__init__(f"rate-limited for {retry_seconds:.0f}s: {message}") + # ═══════════════════════════════════════════════════════════════════ class AccountPool: @@ -475,12 +533,12 @@ class AccountPool: result.append(info) return result -class FreebuffAccountPool(AccountPool): +class CodebuffAccountPool(AccountPool): def _do_load(self): - if not os.path.exists(_FREEBUFF_CREDS_PATH): + if not os.path.exists(_CODEBUFF_CREDS_PATH): return None try: - with open(_FREEBUFF_CREDS_PATH) as f: + with open(_CODEBUFF_CREDS_PATH) as f: creds = json.load(f) except Exception: return None @@ -549,14 +607,14 @@ class APIKeyPool(AccountPool): def load_accounts(self, force=False): return len(self._accounts) -_fb_pool = FreebuffAccountPool("freebuff") +_cb_pool = CodebuffAccountPool("codebuff") _google_antigravity_pool = GoogleAccountPool("antigravity") _google_cli_pool = GoogleAccountPool("cli") -def _get_freebuff_account(): - """Return (token, account_dict) for best available freebuff account.""" - _fb_pool.load_accounts() - acct = _fb_pool.get() +def _get_codebuff_account(): + """Return (token, account_dict) for best available codebuff account.""" + _cb_pool.load_accounts() + acct = _cb_pool.get() if not acct: return "", None return acct["token"], acct @@ -595,7 +653,7 @@ def _refresh_google_token(token_data, token_path): new_tokens = json.loads(resp.read()) token_data["access_token"] = new_tokens.get("access_token", token_data.get("access_token")) token_data["expires_at"] = time.time() + new_tokens.get("expires_in", 3600) - with open(token_path, "w") as f: + with open(token_path, "w", encoding="utf-8") as f: json.dump(token_data, f, indent=2) print("[oauth] token refreshed OK", file=sys.stderr) return token_data["access_token"] @@ -688,7 +746,7 @@ def _fetch_antigravity_version(): version = m.group(0) try: os.makedirs(os.path.dirname(cache_path), exist_ok=True) - with open(cache_path, "w") as f: + with open(cache_path, "w", encoding="utf-8") as f: json.dump({"version": version, "checked_at": time.time()}, f) except Exception: pass @@ -723,9 +781,10 @@ def _init_runtime(): CC_VERSION = CONFIG.get("cc_version", "") REASONING_ENABLED = CONFIG.get("reasoning_enabled", True) REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium") + FORCE_MODEL = (CONFIG.get("force_model") or "").strip() BGP_ROUTES = CONFIG.get("bgp_routes", []) _api_key_pool = None - if API_KEY and "," in API_KEY and not OAUTH_PROVIDER.startswith("google") and BACKEND not in ("freebuff",): + if API_KEY and "," in API_KEY and not OAUTH_PROVIDER.startswith("google") and BACKEND not in ("codebuff", "freebuff"): _api_key_pool = APIKeyPool(BACKEND, API_KEY) print(f"[multi-account] API key pool: {len(_api_key_pool._accounts)} keys for {BACKEND}", file=sys.stderr) if OAUTH_PROVIDER == "google-antigravity": @@ -864,7 +923,7 @@ def _load_provider_caps(): def _save_provider_caps(): try: os.makedirs(os.path.dirname(_provider_caps_path), exist_ok=True) - with open(_provider_caps_path, "w") as f: + with open(_provider_caps_path, "w", encoding="utf-8") as f: json.dump(_provider_caps or {}, f, indent=2) except Exception as e: print(f"[provider-sensor] failed to save caps: {e}", file=sys.stderr) @@ -920,7 +979,7 @@ def _refresh_oauth_token_for(api_key, oauth_provider): new_tokens = json.loads(resp.read()) tokens["access_token"] = new_tokens.get("access_token", tokens.get("access_token")) tokens["expires_at"] = time.time() + new_tokens.get("expires_in", 3600) - with open(token_path, "w") as f: + with open(token_path, "w", encoding="utf-8") as f: json.dump(tokens, f, indent=2) print("[oauth] token refreshed OK", file=sys.stderr) return tokens["access_token"] @@ -944,7 +1003,7 @@ def _load_stats(): def _atomic_write_json(path, obj): tmp = path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(obj, f, indent=2, ensure_ascii=False) os.replace(tmp, path) @@ -1048,9 +1107,9 @@ def _fb_get_any_reasoning(): return _fb_reasoning_store[k]["reasoning"] return "" -def _freebuff_hard_disable_reasoning(messages): +def _codebuff_hard_disable_reasoning(messages): """Strip all reasoning/thinking fields from every message. - FreeBuff rejects mixed reasoning_content histories. + Codebuff rejects mixed reasoning_content histories. The final chat body must be clean before POST.""" for msg in messages: if not isinstance(msg, dict): @@ -1111,7 +1170,7 @@ def _ds_rebuild_tool_history(messages): rebuilt.append(msg) return rebuilt -def _fb_input_to_messages(input_data, instructions=""): +def _cb_input_to_messages(input_data, instructions=""): msgs = [] tool_name_by_id = {} pending_tool_calls = [] @@ -1258,7 +1317,7 @@ def _load_bgp_stats(): def _save_bgp_stats(stats): tmp = _BGP_STATS_PATH + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(stats, f, indent=2) os.replace(tmp, _BGP_STATS_PATH) @@ -1549,6 +1608,12 @@ _MODEL_CONTEXT = { "claude-sonnet": 200000, "claude-haiku": 200000, "glm-5.1": 128000, "glm-5": 128000, "glm-4": 128000, "deepseek": 64000, "gemini-2.5-flash": 1000000, "gemini-2.5-pro": 2000000, + "gemini-3.5-flash": 1000000, "gemini-3.1-pro": 2000000, + "Gemini 3.5 Flash": 1000000, "Gemini 3.1 Pro": 2000000, + "Claude Sonnet 4.6": 200000, "Claude Opus 4.6": 200000, + "GPT-OSS 120B": 128000, + "claude-sonnet-4.6-thinking": 200000, "claude-opus-4.6-thinking": 200000, + "gpt-oss-120b": 128000, "mimo": 32768, "minimax": 32768, "kimi": 128000, "_default": 32768, } @@ -1739,7 +1804,7 @@ def save_request_snapshot(request_id, body): } path = os.path.join(_REQUESTS_DIR, f"{request_id}.json") tmp = path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(snapshot, f, ensure_ascii=False, indent=2) os.replace(tmp, path) _rotate_snapshots() @@ -1762,7 +1827,7 @@ def update_snapshot_response(request_id, status, duration_s=None, error=None): meta["error"] = str(error)[:200] snapshot["_meta"] = meta tmp = path + ".tmp" - with open(tmp, "w") as f: + with open(tmp, "w", encoding="utf-8") as f: json.dump(snapshot, f, ensure_ascii=False, indent=2) os.replace(tmp, path) except Exception: @@ -1814,6 +1879,27 @@ def _bucket_for_route(route): # OpenAI-compat backend # ═══════════════════════════════════════════════════════════════════ +def _inject_stored_reasoning(messages): + with _last_reasoning_lock: + snapshot = dict(_last_reasoning_store) + if not snapshot: + return messages + expired = [k for k, v in snapshot.items() if time.time() - v["ts"] > _RESPONSE_TTL] + for k in expired: + with _last_reasoning_lock: + _last_reasoning_store.pop(k, None) + snapshot.pop(k, None) + if not snapshot: + return messages + latest = max(snapshot.values(), key=lambda v: v["ts"]) + reasoning = latest.get("reasoning", "") + if not reasoning: + return messages + for msg in messages: + if msg.get("role") == "assistant" and "reasoning_content" not in msg and msg.get("tool_calls"): + msg["reasoning_content"] = reasoning + return messages + def oa_input_to_messages(input_data): msgs = [] tool_name_by_id = {} @@ -2333,10 +2419,10 @@ def an_stream_to_sse(stream, model, req_id): "status": status, "created": int(time.time()), "output": completed}}) _DEFAULT_CC_CONFIG = { - "workingDir": "/tmp", + "workingDir": tempfile.gettempdir(), "date": "", - "environment": "linux", - "shell": "bash", + "environment": "windows" if _IS_WINDOWS else "linux", + "shell": "powershell" if _IS_WINDOWS else "bash", "files": [], "structure": [], "isGitRepo": False, @@ -2411,13 +2497,24 @@ def _build_explore_cmd(text_for_url): api_base = repo_url.replace("/admin/", "/api/v1/repos/") else: api_base = repo_url - cmd = ( - f"cd /tmp && " - f"curl -sL --max-time 15 '{api_base}/contents/README.md' 2>/dev/null | " - f"python3 -c \"import sys,json,base64; d=json.load(sys.stdin); print(base64.b64decode(d['content']).decode())\" 2>/dev/null | head -600 && " - f"curl -sL --max-time 15 '{api_base}/contents' 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print('\\n'.join(f'{{x.get(\'path\')}} {{x.get(\'type\')}}' for x in d[:50]))\" 2>/dev/null && " - f"curl -sL --max-time 15 '{api_base}/releases' 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print(json.dumps(d[:3], indent=2)[:2000])\" 2>/dev/null" - ) + if _IS_WINDOWS: + cmd = ( + f"cd $env:TEMP; " + f"$r = Invoke-WebRequest -Uri '{api_base}/contents/README.md' -UseBasicParsing -TimeoutSec 15 2>$null; " + f"if ($r) {{ $j = $r.Content | ConvertFrom-Json; [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($j.content)) | Select-Object -First 600 }}; " + f"$r2 = Invoke-WebRequest -Uri '{api_base}/contents' -UseBasicParsing -TimeoutSec 15 2>$null; " + f"if ($r2) {{ $j2 = $r2.Content | ConvertFrom-Json; $j2 | Select-Object -First 50 | ForEach-Object {{ $_.path + ' ' + $_.type }} }}; " + f"$r3 = Invoke-WebRequest -Uri '{api_base}/releases' -UseBasicParsing -TimeoutSec 15 2>$null; " + f"if ($r3) {{ ($r3.Content | ConvertFrom-Json | Select-Object -First 3 | ConvertTo-Json).Substring(0, [Math]::Min(2000, ($r3.Content | ConvertFrom-Json | Select-Object -First 3 | ConvertTo-Json).Length)) }}" + ) + else: + cmd = ( + f"cd /tmp && " + f"curl -sL --max-time 15 '{api_base}/contents/README.md' 2>/dev/null | " + f"python3 -c \"import sys,json,base64; d=json.load(sys.stdin); print(base64.b64decode(d['content']).decode())\" 2>/dev/null | head -600 && " + f"curl -sL --max-time 15 '{api_base}/contents' 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print('\\n'.join(f'{{x.get(\'path\')}} {{x.get(\'type\')}}' for x in d[:50]))\" 2>/dev/null && " + f"curl -sL --max-time 15 '{api_base}/releases' 2>/dev/null | python3 -c \"import sys,json; d=json.load(sys.stdin); print(json.dumps(d[:3], indent=2)[:2000])\" 2>/dev/null" + ) return cmd, "Explore repository to understand the app and gather README, root contents, and releases for the landing page." def _parse_commandcode_text_tool_calls(text): @@ -2804,7 +2901,7 @@ def _parse_commandcode_text_tool_calls(text): Delegates to _extract_args() for the arguments field (handles unescaped + escaped JSON). Delegates to _extract_field() for name/id/sandbox_permissions/justification - (with FIX 7 for leading-\ handling). + (with FIX 7 for leading-backslash handling). Normalizes sandbox_permissions to valid values (use_default|require_escalated|with_user_approval) [FIX 6] Prevents double-wrapped args: {"cmd": "{\"cmd\": \"curl...\"}"} @@ -3271,7 +3368,10 @@ def cc_stream_to_sse(cc_stream, model, req_id): _url_in_text = re.search(r"https?://[^\s\]'\\>\",]+", text_buf) if _url_in_text: _synth_url = _url_in_text.group(0).rstrip(")].,;'\\\"") - _synth_cmd = f"curl -sL --max-time 15 '{_synth_url}' 2>/dev/null | head -200" + if _IS_WINDOWS: + _synth_cmd = f"Invoke-WebRequest -Uri '{_synth_url}' -UseBasicParsing -TimeoutSec 15 | Select-Object -ExpandProperty Content | Select-Object -First 200" + else: + _synth_cmd = f"curl -sL --max-time 15 '{_synth_url}' 2>/dev/null | head -200" _synth_just = "Auto-synthesized: URL detected in text, fetching" # Heuristic 2: File path references → list or read @@ -3279,7 +3379,10 @@ def cc_stream_to_sse(cc_stream, model, req_id): _file_m = re.search(r"(?:read|open|view|check|examine|cat|show)\s+(?:the\s+)?(?:file\s+)?[`'\"]?(/[^\s'\"]+\.\w+)", _tl) if _file_m: _fpath = _file_m.group(1) - _synth_cmd = f"cat '{_fpath}' 2>/dev/null | head -200 || ls -la '{_fpath}'" + if _IS_WINDOWS: + _synth_cmd = f"Get-Content '{_fpath}' -ErrorAction SilentlyContinue | Select-Object -First 200; if (-not $?) {{ Get-Item '{_fpath}' | Select-Object Name,Length,LastWriteTime }}" + else: + _synth_cmd = f"cat '{_fpath}' 2>/dev/null | head -200 || ls -la '{_fpath}'" _synth_just = f"Auto-synthesized: file reference detected ({_fpath})" # Heuristic 3: Shell command mentioned in backticks or quotes @@ -3307,7 +3410,10 @@ def cc_stream_to_sse(cc_stream, model, req_id): if _intent_m: _intent_text = _intent_m.group(1).strip() if len(_intent_text) > 10 and len(_intent_text) < 200: - _synth_cmd = f"echo 'Stuck recovery: model intent was: {_intent_text[:100]}'" + if _IS_WINDOWS: + _synth_cmd = f"Write-Output 'Stuck recovery: model intent was: {_intent_text[:100]}'" + else: + _synth_cmd = f"echo 'Stuck recovery: model intent was: {_intent_text[:100]}'" _synth_just = f"Auto-synthesized from intent text: {_intent_text[:80]}" if _synth_cmd: @@ -3840,11 +3946,13 @@ def _extract_text(content): # HTTP Server # ═══════════════════════════════════════════════════════════════════ +_MAX_REQLOG_LINES = 2000 + def _log_resp(resp_id, status, output): try: import datetime as _dt _lp = os.path.join(_LOG_DIR, "requests.log") - with open(_lp, "a") as _f: + with open(_lp, "a", encoding="utf-8") as _f: _f.write(f" RESPONSE id={resp_id} status={status}\n") if output: for o in output: @@ -3857,6 +3965,11 @@ def _log_resp(resp_id, status, output): _f.write(f" -> {ot}\n") _f.write(f"{'='*60}\n") _f.flush() + _f.seek(0) + lines = _f.readlines() + if len(lines) > _MAX_REQLOG_LINES: + with open(_lp, "w", encoding="utf-8") as _f2: + _f2.writelines(lines[-_MAX_REQLOG_LINES:]) except Exception: pass @@ -3998,9 +4111,9 @@ class Handler(http.server.BaseHTTPRequestHandler): self.send_json(200, {"object": "list", "data": MODELS}) elif self.path in ("/v1/accounts", "/accounts"): info = {"provider": BACKEND, "oauth_provider": OAUTH_PROVIDER} - if BACKEND == "freebuff": - info["accounts"] = _fb_pool.status() - info["total"] = len(_fb_pool._accounts) + if BACKEND in ("codebuff", "freebuff"): + info["accounts"] = _cb_pool.status() + info["total"] = len(_cb_pool._accounts) elif OAUTH_PROVIDER and OAUTH_PROVIDER.startswith("google"): pool = _google_antigravity_pool if OAUTH_PROVIDER == "google-antigravity" else _google_cli_pool info["accounts"] = pool.status() @@ -4013,10 +4126,26 @@ class Handler(http.server.BaseHTTPRequestHandler): info["total"] = 0 self.send_json(200, info) elif self.path in ("/health", "/v1/health"): - import resource as _res _mem_mb = 0 try: - _mem_mb = _res.getrusage(_res.RUSAGE_SELF).ru_maxrss / 1024 + if _IS_WINDOWS: + import ctypes + class _PMI(ctypes.Structure): + _fields_ = [("cb", ctypes.c_ulong), ("PageFaultCount", ctypes.c_ulong), + ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)] + _pmi = _PMI() + _pmi.cb = ctypes.sizeof(_PMI) + ctypes.windll.psapi.GetProcessMemoryInfo.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_ulong] + ctypes.windll.psapi.GetProcessMemoryInfo.restype = ctypes.c_int + ctypes.windll.psapi.GetProcessMemoryInfo( + ctypes.windll.kernel32.GetCurrentProcess(), ctypes.byref(_pmi), _pmi.cb) + _mem_mb = _pmi.PeakWorkingSetSize / (1024 * 1024) + else: + import resource as _res + _mem_mb = _res.getrusage(_res.RUSAGE_SELF).ru_maxrss / 1024 except Exception: pass _uptime = time.time() - _START_TIME if '_START_TIME' in dir() else 0 @@ -4071,12 +4200,12 @@ class Handler(http.server.BaseHTTPRequestHandler): resolved_types = [i.get("type") for i in input_data] if isinstance(input_data, list) else "str" print(f"[{_sid}] prev_id={prev_id} raw={raw_types} resolved={resolved_types}", file=sys.stderr) - with open(_log_path, "a") as _lf: + with open(_log_path, "a", encoding="utf-8") as _lf: _lf.write(f"\n{'='*60}\n{_ts} [session={_sid}] REQUEST {self.path}\n") _lf.write(f" prev_id={prev_id}\n") _lf.write(f" raw_input_types={raw_types}\n") _lf.write(f" resolved_input_types={resolved_types}\n") - _lf.write(f" stream={body.get('stream')} model={body.get('model')}\n") + _lf.write(f" stream={body.get('stream')} model={body.get('model')} force_model={FORCE_MODEL}\n") _lf.write(f" store_keys={list(_response_store.keys())}\n") if isinstance(input_data, list): for i, item in enumerate(input_data): @@ -4092,7 +4221,16 @@ class Handler(http.server.BaseHTTPRequestHandler): _lf.flush() model = body.get("model", MODELS[0]["id"] if MODELS else "unknown") + if FORCE_MODEL: + model = FORCE_MODEL + body["model"] = FORCE_MODEL stream = body.get("stream", False) + _desktop_forced_models = {"gpt-5.4-mini", "gpt-5.4", "gpt-5.5", "gpt-5-codex", "gpt-5.3-codex"} + _launcher_model = os.environ.get("CODEX_LAUNCHER_MODEL", "") + if _launcher_model and model in _desktop_forced_models: + print(f"[{_sid}] remap desktop model {model} -> {_launcher_model}", file=sys.stderr) + model = _launcher_model + body["model"] = model request_id = body.get("request_id") or body.get("id") or uid("req") if isinstance(input_data, list): for item in input_data: @@ -4110,8 +4248,8 @@ class Handler(http.server.BaseHTTPRequestHandler): self._handle_anthropic(body, model, stream, tracker) elif BACKEND == "command-code": self._handle_command_code(body, model, stream, tracker) - elif BACKEND == "freebuff": - self._handle_freebuff(body, model, stream, tracker) + elif BACKEND in ("codebuff", "freebuff"): + self._handle_codebuff(body, model, stream, tracker) elif (BACKEND or "").startswith("gemini-oauth"): self._handle_gemini_oauth(body, model, stream, tracker) else: @@ -4154,6 +4292,7 @@ class Handler(http.server.BaseHTTPRequestHandler): body["input"] = input_data messages = oa_input_to_messages(input_data) + messages = _inject_stored_reasoning(messages) instructions = body.get("instructions", "").strip() if instructions: messages.insert(0, {"role": "system", "content": instructions}) @@ -4264,16 +4403,41 @@ class Handler(http.server.BaseHTTPRequestHandler): if OAUTH_PROVIDER == "google-antigravity": alias_map = { - "antigravity-gemini-3-flash": "gemini-3-flash", - "antigravity-gemini-3-pro": "gemini-3-pro-low", - "antigravity-gemini-3.1-pro": "gemini-3.1-pro-low", + "Gemini 3.5 Flash (High)": "gemini-3-flash", + "Gemini 3.5 Flash (Medium)": "gemini-3-flash", + "Gemini 3.5 Flash (Low)": "gemini-3.5-flash-low", + "gemini-3.5-flash-high": "gemini-3-flash", + "gemini-3.5-flash-medium": "gemini-3-flash", + "gemini-3.5-flash-low": "gemini-3.5-flash-low", "gemini-3-flash-preview": "gemini-3-flash", - "gemini-3-pro-preview": "gemini-3-pro-low", + "gemini-3-flash": "gemini-3-flash", + "antigravity-gemini-3-flash": "gemini-3-flash", + "Gemini 3.1 Pro (High)": "gemini-3.1-pro-low", + "Gemini 3.1 Pro (Low)": "gemini-3.1-pro-low", + "gemini-3.1-pro-high": "gemini-3.1-pro-low", + "gemini-3.1-pro-low": "gemini-3.1-pro-low", "gemini-3.1-pro-preview": "gemini-3.1-pro-low", - "gemini-3-pro": "gemini-3-pro-low", "gemini-3.1-pro": "gemini-3.1-pro-low", + "gemini-3-pro-preview": "gemini-3.1-pro-low", + "gemini-3-pro": "gemini-3.1-pro-low", + "gemini-3-pro-low": "gemini-3.1-pro-low", + "gemini-3-pro-high": "gemini-3.1-pro-low", + "antigravity-gemini-3-pro": "gemini-3.1-pro-low", + "antigravity-gemini-3.1-pro": "gemini-3.1-pro-low", + "Claude Sonnet 4.6 (Thinking)": "claude-sonnet-4-6", + "Claude Sonnet 4.6 Thinking": "claude-sonnet-4-6", + "claude-sonnet-4.6-thinking": "claude-sonnet-4-6", "antigravity-claude-sonnet-4-6": "claude-sonnet-4-6", + "Claude Opus 4.6 (Thinking)": "claude-opus-4-6-thinking", + "Claude Opus 4.6 Thinking": "claude-opus-4-6-thinking", + "claude-opus-4.6-thinking": "claude-opus-4-6-thinking", "antigravity-claude-opus-4-6-thinking": "claude-opus-4-6-thinking", + "GPT-OSS 120B (Medium)": "gpt-oss-120b-medium", + "GPT-OSS 120B Medium": "gpt-oss-120b-medium", + "gpt-oss-120b": "gpt-oss-120b-medium", + "gemini-2.5-flash": "gemini-2.5-flash", + "gemini-2.5-pro": "gemini-2.5-pro", + "gemini-2.5-flash-lite": "gemini-2.5-flash-lite", } model = alias_map.get(model, model) if model != original_model: @@ -4530,7 +4694,7 @@ class Handler(http.server.BaseHTTPRequestHandler): if n_contents > 10: debug_path = os.path.join(_LOG_DIR, f"gemini-long-ctx-{self._session_id}.json") try: - with open(debug_path, "w") as dbg: + with open(debug_path, "w", encoding="utf-8") as dbg: json.dump({"contents_count": n_contents, "contents_roles": [c.get("role") for c in contents], "has_tools": has_tools, "model": model, "wrapped_size": len(body_b)}, dbg, indent=2) except Exception: pass @@ -4546,7 +4710,7 @@ class Handler(http.server.BaseHTTPRequestHandler): if e.code == 400 and OAUTH_PROVIDER.startswith("google"): try: debug_path = os.path.join(_LOG_DIR, "gemini-last-400-request.json") - with open(debug_path, "w") as dbg: + with open(debug_path, "w", encoding="utf-8") as dbg: json.dump({"endpoint": ep, "model": model, "wrapped": wrapped, "error": err_body}, dbg, indent=2) print(f"[{self._session_id}] saved 400 debug request to {debug_path}", file=sys.stderr) except Exception: @@ -4858,7 +5022,8 @@ class Handler(http.server.BaseHTTPRequestHandler): pass try: - for event in oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id")): + reasoning_out = {} + for event in oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id"), _reasoning_out=reasoning_out): if tracker and tracker.cancelled.is_set(): print("[translate-proxy] stream cancelled", file=sys.stderr) break @@ -4876,6 +5041,16 @@ class Handler(http.server.BaseHTTPRequestHandler): _log_resp(last_resp_id, last_status, last_output) if last_resp_id and input_data is not None: store_response(last_resp_id, input_data, last_output) + if reasoning_out.get("text"): + with _last_reasoning_lock: + _last_reasoning_store[last_resp_id or ""] = { + "reasoning": reasoning_out["text"], + "tool_calls": reasoning_out.get("tool_calls", []), + "ts": time.time(), + } + while len(_last_reasoning_store) > _MAX_STORED: + oldest = next(iter(_last_reasoning_store)) + del _last_reasoning_store[oldest] _record_usage(provider, model, success, time.time() - t0, error_type="length" if not success else None) # Auto-learn provider quirks before flushing the bad response to Codex. @@ -5174,52 +5349,69 @@ class Handler(http.server.BaseHTTPRequestHandler): if rid: store_response(rid, body.get("input", ""), result.get("output", [])) - def _handle_freebuff(self, body, model, stream, tracker=None): - agent_id = _FREEBUFF_AGENT_MAP.get(model) + def _handle_codebuff(self, body, model, stream, tracker=None): + agent_id = _CODEBUFF_AGENT_MAP.get(model) if not agent_id: matched = None - for m in _FREEBUFF_AGENT_MAP: + for m in _CODEBUFF_AGENT_MAP: if model.lower().replace("/", "").replace("-", "") in m.lower().replace("/", "").replace("-", ""): matched = m break if matched: - agent_id = _FREEBUFF_AGENT_MAP[matched] + agent_id = _CODEBUFF_AGENT_MAP[matched] model = matched else: fallback_model = "deepseek/deepseek-v4-flash" - agent_id = _FREEBUFF_AGENT_MAP.get(fallback_model, "base2-free-deepseek-flash") - print(f"[freebuff] unknown model '{model}', falling back to {fallback_model}", file=sys.stderr) + agent_id = _CODEBUFF_AGENT_MAP.get(fallback_model, "base2-free-deepseek-flash") + print(f"[codebuff] unknown model '{model}', falling back to {fallback_model}", file=sys.stderr) model = fallback_model - _fb_pool.load_accounts() - pool_status = _fb_pool.status() + _cb_pool.load_accounts() + pool_status = _cb_pool.status() n_accounts = len(pool_status) if n_accounts == 0: return self.send_json(401, {"error": {"type": "auth_error", - "message": "No freebuff credentials found. Add accounts to ~/.config/manicode/credentials.json"}}) + "message": "No codebuff credentials found. Add accounts to ~/.config/manicode/credentials.json"}}) last_err = None for attempt in range(n_accounts): - token, acct = _get_freebuff_account() + token, acct = _get_codebuff_account() if not token: return self.send_json(401, {"error": {"type": "auth_error", - "message": "No freebuff credentials found. All accounts exhausted."}}) + "message": "No codebuff credentials found. All accounts exhausted."}}) acct_id = acct.get("id", "?") if acct else "?" if attempt > 0: - print(f"[freebuff] rotation attempt {attempt+1}/{n_accounts}, trying account {acct_id}", file=sys.stderr) + print(f"[codebuff] rotation attempt {attempt+1}/{n_accounts}, trying account {acct_id}", file=sys.stderr) - run_id = _freebuff_start_run(token, agent_id) + run_id, run_err = _codebuff_start_run(token, agent_id) if not run_id: - _fb_pool.mark_rate_limited(acct, 60) - last_err = ("upstream_error", 502, "Failed to start freebuff agent run. Check credentials and network.") + if run_err and run_err[0] == "rate_limit_error": + retry_s = run_err[2] + _cb_pool.mark_rate_limited(acct, retry_s) + last_err = ("rate_limit_error", run_err[1], f"Account {acct_id} rate-limited by Codebuff: {run_err[3]}") + else: + _cb_pool.mark_rate_limited(acct, 60) + last_err = ("upstream_error", run_err[1] if run_err else 502, + f"Failed to start agent run for {acct_id}: {run_err[3] if run_err else 'unknown error'}") continue - instance_id = _freebuff_get_session(token, model) + try: + instance_id = _codebuff_get_session(token, model) + except RateLimitError as rle: + retry_s = rle.retry_seconds + fb_msg = rle.message + mins = int(retry_s // 60) + user_msg = fb_msg if fb_msg else f"Daily session limit reached. Resets in {mins}m." + print(f"[codebuff] session 429 for {acct_id}, retry after {retry_s:.0f}s", file=sys.stderr) + _cb_pool.mark_rate_limited(acct, retry_s) + _codebuff_finish_run(token, run_id, "completed") + last_err = ("rate_limit_error", 429, user_msg) + continue input_data = body.get("input", "") instructions = body.get("instructions", "").strip() - messages = _fb_input_to_messages(input_data, instructions) + messages = _cb_input_to_messages(input_data, instructions) messages = _ds_rebuild_tool_history(messages) metadata = { @@ -5227,7 +5419,7 @@ class Handler(http.server.BaseHTTPRequestHandler): "cost_mode": "free", } if instance_id: - metadata["freebuff_instance_id"] = instance_id + metadata["codebuff_instance_id"] = instance_id chat_body = { "model": model, @@ -5245,17 +5437,17 @@ class Handler(http.server.BaseHTTPRequestHandler): if body.get("tool_choice"): chat_body["tool_choice"] = body["tool_choice"] - target = f"{_FREEBUFF_API_URL}/api/v1/chat/completions" + target = f"{_CODEBUFF_API_URL}/api/v1/chat/completions" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {token}", - "User-Agent": "codex-launcher/3.9.0", - "x-freebuff-model": model, + "User-Agent": "codex-launcher/3.10.4", + "x-codebuff-model": model, } if instance_id: - headers["x-freebuff-instance-id"] = instance_id + headers["x-codebuff-instance-id"] = instance_id - print(f"[{self._session_id}] [freebuff] POST {target} model={model} stream={stream} run={run_id} acct={acct_id}", file=sys.stderr) + print(f"[{self._session_id}] [codebuff] POST {target} model={model} stream={stream} run={run_id} acct={acct_id}", file=sys.stderr) chat_body_b = json.dumps(chat_body).encode() try: @@ -5263,27 +5455,35 @@ class Handler(http.server.BaseHTTPRequestHandler): upstream = urllib.request.urlopen(req, timeout=_upstream_timeout(body, stream)) except urllib.error.HTTPError as e: err_body = e.read().decode()[:1000] - _freebuff_finish_run(token, run_id, "failed") + _codebuff_finish_run(token, run_id, "failed") if e.code in (429, 426): reset_ms = 0 + fb_msg = "" try: err_json = json.loads(err_body) reset_ms = err_json.get("retryAfterMs", 0) + fb_msg = err_json.get("message", err_json.get("error", "")) + if isinstance(fb_msg, dict): + fb_msg = fb_msg.get("message", "") except Exception: pass duration = max(reset_ms / 1000, 120) if reset_ms else 120 - _fb_pool.mark_rate_limited(acct, duration) - last_err = ("upstream_error", e.code, _sanitize_err_body(err_body)) - print(f"[freebuff] account {acct_id} got HTTP {e.code}, rotating", file=sys.stderr) + mins = int(duration // 60) + if not fb_msg: + fb_msg = _sanitize_err_body(err_body) + user_msg = f"{fb_msg} (resets in {mins}m)" if fb_msg else f"Rate limited. Resets in {mins}m." + _cb_pool.mark_rate_limited(acct, duration) + last_err = ("rate_limit_error", e.code, user_msg) + print(f"[codebuff] account {acct_id} got HTTP {e.code}, rotating", file=sys.stderr) continue if _is_reasoning_content_error(err_body): - print(f"[freebuff] reasoning_content error, retrying with thinking disabled", file=sys.stderr) - result = self._fb_retry_thinking_disabled(body, model, token, agent_id, stream, tracker, input_data, instructions, err_body, acct) + print(f"[codebuff] reasoning_content error, retrying with thinking disabled", file=sys.stderr) + result = self._cb_retry_thinking_disabled(body, model, token, agent_id, stream, tracker, input_data, instructions, err_body, acct) return result - print(f"[freebuff] HTTP {e.code}: {err_body[:300]}", file=sys.stderr) + print(f"[codebuff] HTTP {e.code}: {err_body[:300]}", file=sys.stderr) return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}}) except Exception as e: - _freebuff_finish_run(token, run_id, "failed") + _codebuff_finish_run(token, run_id, "failed") return self.send_json(502, {"error": {"type": "proxy_error", "message": str(e)}}) t0 = time.time() @@ -5328,11 +5528,11 @@ class Handler(http.server.BaseHTTPRequestHandler): _reasoning_out=reasoning_out), on_event=_on_fb_event) except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError): - print(f"[{self._session_id}] [freebuff] client disconnected", file=sys.stderr) + print(f"[{self._session_id}] [codebuff] client disconnected", file=sys.stderr) return success = finish_reason[0] != "length" - _record_usage("freebuff", model, success, time.time() - t0) + _record_usage("codebuff", model, success, time.time() - t0) if last_resp_id[0] and input_data is not None: store_response(last_resp_id[0], input_data, last_output[0]) if last_resp_id[0] and reasoning_out.get("text") or reasoning_out.get("tool_calls"): @@ -5342,7 +5542,7 @@ class Handler(http.server.BaseHTTPRequestHandler): if reasoning_out.get("text"): asm["reasoning_content"] = reasoning_out["text"] _ds_store_assistant(last_resp_id[0], asm) - print(f"[{self._session_id}] [freebuff] stream done status={last_status[0]} in {time.time()-t0:.1f}s acct={acct_id}", file=sys.stderr) + print(f"[{self._session_id}] [codebuff] stream done status={last_status[0]} in {time.time()-t0:.1f}s acct={acct_id}", file=sys.stderr) else: raw = upstream.read().decode() chat_resp = json.loads(raw) @@ -5351,25 +5551,47 @@ class Handler(http.server.BaseHTTPRequestHandler): rid = result.get("id") if rid: store_response(rid, input_data, result.get("output", [])) - print(f"[{self._session_id}] [freebuff] non-stream done in {time.time()-t0:.1f}s acct={acct_id}", file=sys.stderr) + print(f"[{self._session_id}] [codebuff] non-stream done in {time.time()-t0:.1f}s acct={acct_id}", file=sys.stderr) finally: - _freebuff_finish_run(token, run_id, "completed") + _codebuff_finish_run(token, run_id, "completed") return if last_err: - return self.send_json(last_err[1], {"error": {"type": last_err[0], "message": f"All {n_accounts} accounts exhausted. {last_err[2]}"}}) + msg = last_err[2] + resp_id = f"resp_{uuid.uuid4().hex[:24]}" + result = { + "id": resp_id, + "object": "response", + "created_at": int(time.time()), + "model": model, + "status": "completed", + "output": [{ + "id": f"msg_{uuid.uuid4().hex[:24]}", + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": msg, + "annotations": [], + }], + "status": "completed", + }], + "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + } + return self.send_json(200, result) - def _fb_retry_thinking_disabled(self, body, model, token, agent_id, stream, tracker, input_data, instructions, original_error, acct=None): - run_id = _freebuff_start_run(token, agent_id) + def _cb_retry_thinking_disabled(self, body, model, token, agent_id, stream, tracker, input_data, instructions, original_error, acct=None): + run_id, run_err = _codebuff_start_run(token, agent_id) if not run_id: - return self.send_json(502, {"error": {"type": "upstream_error", - "message": "Failed to start freebuff agent run for retry."}}) - instance_id = _freebuff_get_session(token, model) - messages = _fb_input_to_messages(input_data, instructions) - _freebuff_hard_disable_reasoning(messages) + msg = run_err[3] if run_err else "unknown error" + return self.send_json(run_err[1] if run_err else 502, {"error": {"type": run_err[0] if run_err else "upstream_error", + "message": f"Failed to start agent run for retry: {msg}"}}) + instance_id = _codebuff_get_session(token, model) + messages = _cb_input_to_messages(input_data, instructions) + _codebuff_hard_disable_reasoning(messages) metadata = {"run_id": run_id, "cost_mode": "free"} if instance_id: - metadata["freebuff_instance_id"] = instance_id + metadata["codebuff_instance_id"] = instance_id chat_body = { "model": model, "messages": messages, "stream": stream, "max_tokens": max(body.get("max_output_tokens", 0), 64000), @@ -5384,22 +5606,22 @@ class Handler(http.server.BaseHTTPRequestHandler): chat_body["tools"] = tools if body.get("tool_choice"): chat_body["tool_choice"] = body["tool_choice"] - target = f"{_FREEBUFF_API_URL}/api/v1/chat/completions" - headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "codex-launcher/3.9.0", "x-freebuff-model": model} + target = f"{_CODEBUFF_API_URL}/api/v1/chat/completions" + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "codex-launcher/3.10.4", "x-codebuff-model": model} if instance_id: - headers["x-freebuff-instance-id"] = instance_id - print(f"[freebuff] retry POST {target} model={model} stream={stream} run={run_id} (thinking disabled via DeepSeek native)", file=sys.stderr) + headers["x-codebuff-instance-id"] = instance_id + print(f"[codebuff] retry POST {target} model={model} stream={stream} run={run_id} (thinking disabled via DeepSeek native)", file=sys.stderr) try: req = urllib.request.Request(target, data=json.dumps(chat_body).encode(), headers=headers) upstream = urllib.request.urlopen(req, timeout=_upstream_timeout(body, stream)) except urllib.error.HTTPError as e: err_body = e.read().decode()[:500] - _freebuff_finish_run(token, run_id, "failed") - print(f"[freebuff] thinking-disabled retry failed: HTTP {e.code}: {err_body[:300]}", file=sys.stderr) - return self.send_json(e.code, {"error": {"type": "freebuff_deepseek_thinking_error", - "message": "FreeBuff/DeepSeek V4 requires reasoning_content round-trip for tool-call sessions. Use Command Code provider for this model instead.", "upstream_error": _sanitize_err_body(err_body)}}) + _codebuff_finish_run(token, run_id, "failed") + print(f"[codebuff] thinking-disabled retry failed: HTTP {e.code}: {err_body[:300]}", file=sys.stderr) + return self.send_json(e.code, {"error": {"type": "codebuff_deepseek_thinking_error", + "message": "Codebuff/DeepSeek V4 requires reasoning_content round-trip for tool-call sessions. Use Command Code provider for this model instead.", "upstream_error": _sanitize_err_body(err_body)}}) except Exception as e: - _freebuff_finish_run(token, run_id, "failed") + _codebuff_finish_run(token, run_id, "failed") return self.send_json(502, {"error": {"type": "proxy_error", "message": str(e)}}) t0 = time.time() try: @@ -5442,7 +5664,7 @@ class Handler(http.server.BaseHTTPRequestHandler): except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError): return success = finish_reason[0] != "length" - _record_usage("freebuff", model, success, time.time() - t0) + _record_usage("codebuff", model, success, time.time() - t0) if last_resp_id[0] and input_data is not None: store_response(last_resp_id[0], input_data, last_output[0]) if last_resp_id[0] and reasoning_out.get("text") or reasoning_out.get("tool_calls"): @@ -5452,7 +5674,7 @@ class Handler(http.server.BaseHTTPRequestHandler): if reasoning_out.get("text"): asm["reasoning_content"] = reasoning_out["text"] _ds_store_assistant(last_resp_id[0], asm) - print(f"[{self._session_id}] [freebuff] retry stream done status={last_status[0]} in {time.time()-t0:.1f}s", file=sys.stderr) + print(f"[{self._session_id}] [codebuff] retry stream done status={last_status[0]} in {time.time()-t0:.1f}s", file=sys.stderr) else: raw = upstream.read().decode() chat_resp = json.loads(raw) @@ -5461,9 +5683,9 @@ class Handler(http.server.BaseHTTPRequestHandler): rid = result.get("id") if rid: store_response(rid, input_data, result.get("output", [])) - print(f"[{self._session_id}] [freebuff] retry non-stream done in {time.time()-t0:.1f}s", file=sys.stderr) + print(f"[{self._session_id}] [codebuff] retry non-stream done in {time.time()-t0:.1f}s", file=sys.stderr) finally: - _freebuff_finish_run(token, run_id, "completed") + _codebuff_finish_run(token, run_id, "completed") def _handle_auto(self, body, model, stream, tracker=None): """Auto-sensing backend: probe schema, adapt, retry on errors. @@ -5741,12 +5963,15 @@ class Handler(http.server.BaseHTTPRequestHandler): store_response(rid, input_data, result.get("output", [])) def send_json(self, status, data): - body = json.dumps(data).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) + try: + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass def stream_buffered_events(self, event_iter, flush_interval=0.03, max_bytes=4096, on_event=None): buf = bytearray() @@ -5792,8 +6017,14 @@ def main(): global SERVER, _START_TIME _START_TIME = time.time() _init_runtime() - signal.signal(signal.SIGTERM, _handle_shutdown_signal) signal.signal(signal.SIGINT, _handle_shutdown_signal) + if _IS_WINDOWS: + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _handle_shutdown_signal) + import atexit + atexit.register(lambda: setattr(sys.modules[__name__], '_SHUTDOWN_REQUESTED', True)) + else: + signal.signal(signal.SIGTERM, _handle_shutdown_signal) try: from http.server import ThreadingHTTPServer as _BaseSrv except ImportError: @@ -5807,10 +6038,10 @@ def main(): 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 BACKEND == "freebuff": - _fb_pool.load_accounts(force=True) - fb_status = _fb_pool.status() - print(f"[multi-account] freebuff: {len(fb_status)} accounts loaded {[a['id'] for a in fb_status]}", flush=True) + if BACKEND in ("codebuff", "freebuff"): + _cb_pool.load_accounts(force=True) + fb_status = _cb_pool.status() + print(f"[multi-account] codebuff: {len(fb_status)} accounts loaded {[a['id'] for a in fb_status]}", flush=True) if OAUTH_PROVIDER and OAUTH_PROVIDER.startswith("google"): pool = _google_antigravity_pool if OAUTH_PROVIDER == "google-antigravity" else _google_cli_pool pool.load_accounts(force=True) @@ -6000,7 +6231,7 @@ Postamble text.""" _check("FIX23 explore nested JSON: parsed", len(_calls_m) == 1, f"got {len(_calls_m)} calls") if _calls_m: _args_m = json.loads(_calls_m[0].get("arguments", "{}")) - _check("FIX23 explore nested JSON: cmd has curl", "curl" in _args_m.get("cmd", ""), f"got {_args_m.get('cmd')!r}") + _check("FIX23 explore nested JSON: cmd has fetch cmd", "curl" in _args_m.get("cmd", "") or "Invoke-WebRequest" in _args_m.get("cmd", ""), f"got {_args_m.get('cmd')!r}") _check("FIX23 explore nested JSON: URL in cmd", "github.rommark.dev" in _args_m.get("cmd", ""), f"missing URL in cmd") # Pattern N: require_escalation block (FIX 24) @@ -6010,7 +6241,7 @@ Postamble text.""" if _calls_n: _args_n = json.loads(_calls_n[0].get("arguments", "{}")) _check("FIX24 require_escalation: name is exec_command", _calls_n[0].get("name") == "exec_command", f"got {_calls_n[0].get('name')}") - _check("FIX24 require_escalation: cmd has curl or echo", "curl" in _args_n.get("cmd", "") or "echo" in _args_n.get("cmd", ""), f"got {_args_n.get('cmd')!r}") + _check("FIX24 require_escalation: cmd has fetch or echo", "curl" in _args_n.get("cmd", "") or "echo" in _args_n.get("cmd", "") or "Invoke-WebRequest" in _args_n.get("cmd", "") or "Write-Output" in _args_n.get("cmd", ""), f"got {_args_n.get('cmd')!r}") # Pattern N2: bare request_escalation_permission tag (FIX 24b) _esc_bare = 'I want to proceed.\n\nPlease let me continue.' @@ -6022,13 +6253,13 @@ Postamble text.""" # Pattern O: _build_explore_cmd module-level function (FIX 23/25) _cmd_o, _just_o = _build_explore_cmd("https://github.rommark.dev/admin/Z.AI-Chat-for-Android") _check("FIX23/25 _build_explore_cmd: returns cmd", _cmd_o is not None, "returned None") - _check("FIX23/25 _build_explore_cmd: has curl", _cmd_o and "curl" in _cmd_o, f"no curl in {_cmd_o!r}") + _check("FIX23/25 _build_explore_cmd: has fetch cmd", _cmd_o and ("curl" in _cmd_o or "Invoke-WebRequest" in _cmd_o), f"no fetch cmd in {_cmd_o!r}") _check("FIX23/25 _build_explore_cmd: has api path", _cmd_o and "/api/v1/repos/" in _cmd_o, f"no api path in {_cmd_o!r}") # Pattern O2: _build_explore_cmd with JSON array containing URL _cmd_o2, _ = _build_explore_cmd('[{"content": "https://github.rommark.dev/admin/Z.AI-Chat-for-Android"}]') _check("FIX23/25 _build_explore_cmd from JSON array: returns cmd", _cmd_o2 is not None, "returned None") - _check("FIX23/25 _build_explore_cmd from JSON array: has curl", _cmd_o2 and "curl" in _cmd_o2, f"no curl in {_cmd_o2!r}") + _check("FIX23/25 _build_explore_cmd from JSON array: has fetch cmd", _cmd_o2 and ("curl" in _cmd_o2 or "Invoke-WebRequest" in _cmd_o2), f"no fetch cmd in {_cmd_o2!r}") print(f"[CC-SELF-TEST] Results: {_counts[0]} passed, {_counts[1]} failed", file=sys.stderr)