From 17126bc11105ec50b3eb7d6ba8357757107a8500 Mon Sep 17 00:00:00 2001 From: cobra91 Date: Mon, 25 May 2026 15:09:07 +0200 Subject: [PATCH] chore: merge windows/ into src/ and remove duplicate folder Cross-platform support lives in src/ via _IS_WINDOWS checks. Merges latest upstream additions from windows/ (OAuth secrets, ANTIGRAVITY_MODELS, changelog v3.10.5) into src/ files, then removes the redundant windows/ folder. --- src/codex-launcher-gui.py | 124 +- src/codex_launcher_lib.py | 101 ++ windows/codex-launcher-gui.py | 2786 --------------------------------- windows/codex_launcher_lib.py | 2073 ------------------------ 4 files changed, 206 insertions(+), 4878 deletions(-) delete mode 100644 windows/codex-launcher-gui.py delete mode 100644 windows/codex_launcher_lib.py diff --git a/src/codex-launcher-gui.py b/src/codex-launcher-gui.py index 3d53629..c33f5fa 100644 --- a/src/codex-launcher-gui.py +++ b/src/codex-launcher-gui.py @@ -30,7 +30,8 @@ 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, + PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH, + ANTIGRAVITY_MODELS, 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, @@ -48,6 +49,7 @@ from codex_launcher_lib import ( load_incident_store, save_incident_store, load_usage_stats, monitoring_log, IncidentStore, AIDiagnosticAgent, HealthWatcher, + load_oauth_secrets, save_oauth_secrets, _usage_theme, UA, ) @@ -378,9 +380,11 @@ class EditEndpointDialog: 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")) + _sec = load_oauth_secrets().get("antigravity" if is_antigravity else "gemini_cli", {}) + CLIENT_ID = _sec.get("client_id", "") + CLIENT_SECRET = _sec.get("client_secret", "") + if is_antigravity: - CLIENT_ID = "" - CLIENT_SECRET = "" SCOPES = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", @@ -393,8 +397,6 @@ class EditEndpointDialog: 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", @@ -436,7 +438,7 @@ class EditEndpointDialog: 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") + tk.Label(oauth_dlg, text=f"Using OAuth credentials from {OAUTH_SECRETS_PATH}").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") @@ -535,12 +537,7 @@ class EditEndpointDialog: 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", - ] + found_models = list(ANTIGRAVITY_MODELS) else: found_models = ["gemini-2.5-flash", "gemini-2.5-pro"] if found_models: @@ -1944,21 +1941,20 @@ class BenchmarkWindow: 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 = ttk.Frame(root, padding=16) main.pack(fill="both", expand=True) + main.pack_propagate(False) + # 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") + ttk.Label(hdr, text=f"Codex Launcher v{CHANGELOG[0][0]}", font=("Segoe UI", 13, "bold")).pack(side="left") # Toolbar — two rows to fit all buttons tb1 = ttk.Frame(main) @@ -1969,6 +1965,7 @@ class LauncherWin: 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="OAuth Secrets", command=self._edit_oauth_secrets).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 @@ -2054,8 +2051,8 @@ class LauncherWin: 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 = scrolledtext.ScrolledText(main, height=10, state="disabled", wrap="word", + font=("Consolas", 9)) self._log_text.pack(fill="both", expand=True, pady=(8, 0)) # Bottom bar @@ -2199,6 +2196,92 @@ class LauncherWin: if Path(assist_path).exists(): subprocess.Popen([sys.executable, assist_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if IS_WINDOWS else 0) + def _edit_oauth_secrets(self): + data = load_oauth_secrets() + if not data: + data = {"antigravity": {"client_id": "", "client_secret": ""}, + "gemini_cli": {"client_id": "", "client_secret": ""}} + + dlg = tk.Toplevel(self._root) + dlg.title("OAuth 2.0 Client Secrets") + dlg.geometry("600x450") + dlg.transient(self._root) + dlg.grab_set() + + frame = ttk.Frame(dlg, padding=16) + frame.pack(fill="both", expand=True) + + ttk.Label(frame, text="Google OAuth 2.0 credentials", font=("Segoe UI", 10, "bold")).pack(anchor="w") + ttk.Label(frame, text=f"Stored locally in {OAUTH_SECRETS_PATH}", foreground="gray").pack(anchor="w", pady=(0, 8)) + + fields = {} + nf = ttk.Frame(frame) + nf.pack(fill="x") + row = 0 + for section_key, section_label in [("antigravity", "Antigravity (CloudCode)"), ("gemini_cli", "Gemini CLI")]: + ttk.Label(nf, text=f"\n{section_label}", font=("Segoe UI", 9, "bold")).grid(row=row, column=0, columnspan=3, sticky="w", pady=(8, 2)) + row += 1 + sec = data.get(section_key, {}) + import_btn = ttk.Button(nf, text="Import JSON", + command=lambda sk=section_key: self._import_oauth_json(fields, sk)) + import_btn.grid(row=row, column=2, padx=(4, 0), pady=2, sticky="e") + for fk, fl in [("client_id", "Client ID"), ("client_secret", "Client Secret")]: + ttk.Label(nf, text=fl + ":").grid(row=row, column=0, sticky="w", padx=(8, 4), pady=2) + entry = ttk.Entry(nf, width=60) + entry.insert(0, sec.get(fk, "")) + entry.grid(row=row, column=1, sticky="ew", pady=2) + if fk == "client_secret": + entry.configure(show="*") + fields[(section_key, fk)] = entry + row += 1 + + nf.columnconfigure(1, weight=1) + + ttk.Label(frame, text="\nImport a client_secret_*.json from Google Cloud Console\nconsole.cloud.google.com → Credentials", foreground="gray").pack(anchor="w") + + btnf = ttk.Frame(frame) + btnf.pack(fill="x", pady=(12, 0)) + ttk.Button(btnf, text="Cancel", command=dlg.destroy).pack(side="right", padx=(4, 0)) + save_btn = ttk.Button(btnf, text="Save") + save_btn.pack(side="right", padx=(4, 0)) + + def _save(): + for (sk, fk), entry in fields.items(): + if sk not in data: + data[sk] = {} + data[sk][fk] = entry.get().strip() + try: + save_oauth_secrets(data) + except Exception as e: + messagebox.showerror("Save failed", str(e), parent=dlg) + return + dlg.destroy() + + save_btn.configure(command=_save) + + def _import_oauth_json(self, fields, section_key): + path = filedialog.askopenfilename( + title="Import Google OAuth Client Secret JSON", + filetypes=[("JSON files", "*.json")]) + if not path: + return + try: + with open(path, encoding="utf-8") as f: + raw = json.load(f) + creds = raw.get("installed") or raw.get("web") or raw + cid = creds.get("client_id", "") + csec = creds.get("client_secret", "") + if not cid or not csec: + raise ValueError("JSON does not contain client_id and client_secret") + if (section_key, "client_id") in fields: + fields[(section_key, "client_id")].delete(0, "end") + fields[(section_key, "client_id")].insert(0, cid) + if (section_key, "client_secret") in fields: + fields[(section_key, "client_secret")].delete(0, "end") + fields[(section_key, "client_secret")].insert(0, csec) + except Exception as e: + messagebox.showerror("Import failed", str(e)) + # ── Watcher ────────────────────────────────────────────────────── def _start_watcher(self): @@ -2696,5 +2779,8 @@ if __name__ == "__main__": create_default_endpoints() root = tk.Tk() + root.title("Codex Launcher") + root.geometry("800x680") + root.minsize(640, 520) app = LauncherWin(root) root.mainloop() diff --git a/src/codex_launcher_lib.py b/src/codex_launcher_lib.py index e38b118..abd2e6a 100644 --- a/src/codex_launcher_lib.py +++ b/src/codex_launcher_lib.py @@ -66,6 +66,7 @@ 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" +OAUTH_SECRETS_PATH = HOME / ".config" / "codex-launcher" / "oauth-secrets.json" if IS_WINDOWS: PROXY = BIN_DIR / "translate-proxy.py" @@ -82,6 +83,69 @@ model_catalog_json = "" """ CHANGELOG = [ + ("3.10.5", "2026-05-25", [ + "Context compaction for Antigravity/Gemini OAuth — prevents token limit errors", + "Aggressive compaction policies at 60% of model context limit", + "Compaction for cloudcode-pa and googleapis provider policies", + "REST model IDs added to context size map (gemini-3-flash, etc.)", + "OAuth Secrets editor in GUI — update client ID/secret without editing files", + "Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)", + "Import JSON button — import client_secret_*.json from Google Cloud Console", + "All hardcoded OAuth secrets removed from source code", + "Antigravity model IDs fixed: display names → slug model IDs for REST API", + "Git history scrubbed of leaked credentials; pre-push hook installed", + "Antigravity REST API model IDs verified with live API testing", + "Gemini 3.5 Flash, 3.1 Pro, Claude 4.6, GPT-OSS 120B all working", + ]), + ("3.9.9", "2026-05-25", [ + "Refresh Antigravity preset: Gemini 3.5 Flash, Gemini 3.1 Pro, Claude 4.6, GPT-OSS", + "Fix Antigravity alias map for tiered model IDs (high/medium/low/thinking)", + "Model context sizes for Gemini 3.5 Flash, 3.1 Pro, Claude 4.6, GPT-OSS 120B", + ]), + ("3.9.8", "2026-05-25", [ + "Fix Desktop model leak — remap gpt-5.4-mini to user-selected model", + "send_json() catches BrokenPipeError globally — no crashes on disconnect", + "Proxy remaps Desktop forced models via CODEX_LAUNCHER_MODEL env", + ]), + ("3.9.7", "2026-05-25", [ + "Forward real Codebuff error messages instead of generic 429", + "Return HTTP 200 with Responses API format for rate limits", + "Extract retryAfterMs from Codebuff 429 responses for cooldown", + "RateLimitError carries upstream message through all paths", + "BrokenPipeError fix on 'all accounts exhausted' response", + "Fix 3 SyntaxWarnings for invalid escape sequences", + "_codebuff_start_run returns actual error body", + ]), + ("3.9.6", "2026-05-25", [ + "Fix Gemini follow-up turns: enforce latest user instruction as final turn", + "Edit-intent detection with tool-use nudge for file modifications", + "Thought signature preservation for Gemini 3 tool-call continuity", + "Smart tool output compaction: old=3000, recent=20000 chars", + "Multi-account rotation for codebuff, Google OAuth, API keys", + "/v1/accounts endpoint for account pool status", + ]), + ("3.9.0", "2026-05-24", [ + "Multi-account rotation for OAuth providers (codebuff, Google, API keys)", + "Automatic failover on rate limit — next account used", + "Codebuff: accounts[] array in credentials.json", + "Google OAuth: multiple token files (google-*-oauth-token-N.json)", + "API keys: comma-separated keys rotate on 429 errors", + "/v1/accounts endpoint shows account pool status", + "x-codebuff-model and x-codebuff-instance-id headers", + ]), + ("3.8.4", "2026-05-24", [ + "Codebuff streaming — SSE events reach Codex client", + "stream_buffered_events now called for codebuff", + "Codebuff OAuth built-in login flow (no external CLI)", + "Codebuff API: reverse-engineered www.codebuff.com endpoints", + "Codebuff session management with instance ID", + "Codebuff agent run lifecycle (start/finish) with model routing", + "Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7", + "Reasoning mode works with codebuff (thinking tokens supported)", + "GUI: Sandbox mode selector (Read-only / Workspace / Full Access)", + "GUI: Approval mode selector (Untrusted / On Request / Full Auto)", + "GUI: Codebuff Login button in endpoint editor", + ]), ("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", @@ -974,6 +1038,9 @@ def endpoint_model_headers(endpoint): def fetch_models_for_endpoint(endpoint, timeout=10): + bt = endpoint.get("backend_type", "") + if bt == "gemini-oauth-antigravity": + return list(ANTIGRAVITY_MODELS), None url = endpoint_models_url(endpoint) if not url: return None, "Base URL is empty" @@ -1006,6 +1073,40 @@ def refresh_endpoint_models(endpoint): updated["default_model"] = ids[0] return updated, None + +# ═══════════════════════════════════════════════════════════════════════ +# Antigravity model list (static — no /v1/models REST endpoint) +# ═══════════════════════════════════════════════════════════════════════ + +ANTIGRAVITY_MODELS = [ + "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)", + "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)", + "Claude Sonnet 4.6 (Thinking)", + "Claude Opus 4.6 (Thinking)", + "GPT-OSS 120B (Medium)", +] + + +# ═══════════════════════════════════════════════════════════════════════ +# OAuth secrets (local, never in repo) +# ═══════════════════════════════════════════════════════════════════════ + +def load_oauth_secrets(): + try: + with open(OAUTH_SECRETS_PATH, encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def save_oauth_secrets(data): + os.makedirs(os.path.dirname(OAUTH_SECRETS_PATH), exist_ok=True) + tmp = str(OAUTH_SECRETS_PATH) + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, OAUTH_SECRETS_PATH) + + # ═══════════════════════════════════════════════════════════════════════ # Doctor checks # ═══════════════════════════════════════════════════════════════════════ diff --git a/windows/codex-launcher-gui.py b/windows/codex-launcher-gui.py deleted file mode 100644 index c33f5fa..0000000 --- a/windows/codex-launcher-gui.py +++ /dev/null @@ -1,2786 +0,0 @@ -#!/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, OAUTH_SECRETS_PATH, - ANTIGRAVITY_MODELS, - 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, - load_oauth_secrets, save_oauth_secrets, - _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")) - - _sec = load_oauth_secrets().get("antigravity" if is_antigravity else "gemini_cli", {}) - CLIENT_ID = _sec.get("client_id", "") - CLIENT_SECRET = _sec.get("client_secret", "") - - if is_antigravity: - 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: - 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=f"Using OAuth credentials from {OAUTH_SECRETS_PATH}").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 = list(ANTIGRAVITY_MODELS) - 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._proc = None - self._endpoints_data = load_endpoints() - self._refresh_running = False - recover_config_if_needed() - - main = ttk.Frame(root, padding=16) - main.pack(fill="both", expand=True) - main.pack_propagate(False) - - - # Title - hdr = ttk.Frame(main) - hdr.pack(fill="x") - ttk.Label(hdr, text=f"Codex Launcher v{CHANGELOG[0][0]}", 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="OAuth Secrets", command=self._edit_oauth_secrets).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=10, 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) - - def _edit_oauth_secrets(self): - data = load_oauth_secrets() - if not data: - data = {"antigravity": {"client_id": "", "client_secret": ""}, - "gemini_cli": {"client_id": "", "client_secret": ""}} - - dlg = tk.Toplevel(self._root) - dlg.title("OAuth 2.0 Client Secrets") - dlg.geometry("600x450") - dlg.transient(self._root) - dlg.grab_set() - - frame = ttk.Frame(dlg, padding=16) - frame.pack(fill="both", expand=True) - - ttk.Label(frame, text="Google OAuth 2.0 credentials", font=("Segoe UI", 10, "bold")).pack(anchor="w") - ttk.Label(frame, text=f"Stored locally in {OAUTH_SECRETS_PATH}", foreground="gray").pack(anchor="w", pady=(0, 8)) - - fields = {} - nf = ttk.Frame(frame) - nf.pack(fill="x") - row = 0 - for section_key, section_label in [("antigravity", "Antigravity (CloudCode)"), ("gemini_cli", "Gemini CLI")]: - ttk.Label(nf, text=f"\n{section_label}", font=("Segoe UI", 9, "bold")).grid(row=row, column=0, columnspan=3, sticky="w", pady=(8, 2)) - row += 1 - sec = data.get(section_key, {}) - import_btn = ttk.Button(nf, text="Import JSON", - command=lambda sk=section_key: self._import_oauth_json(fields, sk)) - import_btn.grid(row=row, column=2, padx=(4, 0), pady=2, sticky="e") - for fk, fl in [("client_id", "Client ID"), ("client_secret", "Client Secret")]: - ttk.Label(nf, text=fl + ":").grid(row=row, column=0, sticky="w", padx=(8, 4), pady=2) - entry = ttk.Entry(nf, width=60) - entry.insert(0, sec.get(fk, "")) - entry.grid(row=row, column=1, sticky="ew", pady=2) - if fk == "client_secret": - entry.configure(show="*") - fields[(section_key, fk)] = entry - row += 1 - - nf.columnconfigure(1, weight=1) - - ttk.Label(frame, text="\nImport a client_secret_*.json from Google Cloud Console\nconsole.cloud.google.com → Credentials", foreground="gray").pack(anchor="w") - - btnf = ttk.Frame(frame) - btnf.pack(fill="x", pady=(12, 0)) - ttk.Button(btnf, text="Cancel", command=dlg.destroy).pack(side="right", padx=(4, 0)) - save_btn = ttk.Button(btnf, text="Save") - save_btn.pack(side="right", padx=(4, 0)) - - def _save(): - for (sk, fk), entry in fields.items(): - if sk not in data: - data[sk] = {} - data[sk][fk] = entry.get().strip() - try: - save_oauth_secrets(data) - except Exception as e: - messagebox.showerror("Save failed", str(e), parent=dlg) - return - dlg.destroy() - - save_btn.configure(command=_save) - - def _import_oauth_json(self, fields, section_key): - path = filedialog.askopenfilename( - title="Import Google OAuth Client Secret JSON", - filetypes=[("JSON files", "*.json")]) - if not path: - return - try: - with open(path, encoding="utf-8") as f: - raw = json.load(f) - creds = raw.get("installed") or raw.get("web") or raw - cid = creds.get("client_id", "") - csec = creds.get("client_secret", "") - if not cid or not csec: - raise ValueError("JSON does not contain client_id and client_secret") - if (section_key, "client_id") in fields: - fields[(section_key, "client_id")].delete(0, "end") - fields[(section_key, "client_id")].insert(0, cid) - if (section_key, "client_secret") in fields: - fields[(section_key, "client_secret")].delete(0, "end") - fields[(section_key, "client_secret")].insert(0, csec) - except Exception as e: - messagebox.showerror("Import failed", str(e)) - - # ── 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() - root.title("Codex Launcher") - root.geometry("800x680") - root.minsize(640, 520) - app = LauncherWin(root) - root.mainloop() diff --git a/windows/codex_launcher_lib.py b/windows/codex_launcher_lib.py deleted file mode 100644 index abd2e6a..0000000 --- a/windows/codex_launcher_lib.py +++ /dev/null @@ -1,2073 +0,0 @@ -#!/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" -OAUTH_SECRETS_PATH = HOME / ".config" / "codex-launcher" / "oauth-secrets.json" - -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.10.5", "2026-05-25", [ - "Context compaction for Antigravity/Gemini OAuth — prevents token limit errors", - "Aggressive compaction policies at 60% of model context limit", - "Compaction for cloudcode-pa and googleapis provider policies", - "REST model IDs added to context size map (gemini-3-flash, etc.)", - "OAuth Secrets editor in GUI — update client ID/secret without editing files", - "Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)", - "Import JSON button — import client_secret_*.json from Google Cloud Console", - "All hardcoded OAuth secrets removed from source code", - "Antigravity model IDs fixed: display names → slug model IDs for REST API", - "Git history scrubbed of leaked credentials; pre-push hook installed", - "Antigravity REST API model IDs verified with live API testing", - "Gemini 3.5 Flash, 3.1 Pro, Claude 4.6, GPT-OSS 120B all working", - ]), - ("3.9.9", "2026-05-25", [ - "Refresh Antigravity preset: Gemini 3.5 Flash, Gemini 3.1 Pro, Claude 4.6, GPT-OSS", - "Fix Antigravity alias map for tiered model IDs (high/medium/low/thinking)", - "Model context sizes for Gemini 3.5 Flash, 3.1 Pro, Claude 4.6, GPT-OSS 120B", - ]), - ("3.9.8", "2026-05-25", [ - "Fix Desktop model leak — remap gpt-5.4-mini to user-selected model", - "send_json() catches BrokenPipeError globally — no crashes on disconnect", - "Proxy remaps Desktop forced models via CODEX_LAUNCHER_MODEL env", - ]), - ("3.9.7", "2026-05-25", [ - "Forward real Codebuff error messages instead of generic 429", - "Return HTTP 200 with Responses API format for rate limits", - "Extract retryAfterMs from Codebuff 429 responses for cooldown", - "RateLimitError carries upstream message through all paths", - "BrokenPipeError fix on 'all accounts exhausted' response", - "Fix 3 SyntaxWarnings for invalid escape sequences", - "_codebuff_start_run returns actual error body", - ]), - ("3.9.6", "2026-05-25", [ - "Fix Gemini follow-up turns: enforce latest user instruction as final turn", - "Edit-intent detection with tool-use nudge for file modifications", - "Thought signature preservation for Gemini 3 tool-call continuity", - "Smart tool output compaction: old=3000, recent=20000 chars", - "Multi-account rotation for codebuff, Google OAuth, API keys", - "/v1/accounts endpoint for account pool status", - ]), - ("3.9.0", "2026-05-24", [ - "Multi-account rotation for OAuth providers (codebuff, Google, API keys)", - "Automatic failover on rate limit — next account used", - "Codebuff: accounts[] array in credentials.json", - "Google OAuth: multiple token files (google-*-oauth-token-N.json)", - "API keys: comma-separated keys rotate on 429 errors", - "/v1/accounts endpoint shows account pool status", - "x-codebuff-model and x-codebuff-instance-id headers", - ]), - ("3.8.4", "2026-05-24", [ - "Codebuff streaming — SSE events reach Codex client", - "stream_buffered_events now called for codebuff", - "Codebuff OAuth built-in login flow (no external CLI)", - "Codebuff API: reverse-engineered www.codebuff.com endpoints", - "Codebuff session management with instance ID", - "Codebuff agent run lifecycle (start/finish) with model routing", - "Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7", - "Reasoning mode works with codebuff (thinking tokens supported)", - "GUI: Sandbox mode selector (Read-only / Workspace / Full Access)", - "GUI: Approval mode selector (Untrusted / On Request / Full Auto)", - "GUI: Codebuff Login button in endpoint editor", - ]), - ("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): - bt = endpoint.get("backend_type", "") - if bt == "gemini-oauth-antigravity": - return list(ANTIGRAVITY_MODELS), None - 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 - - -# ═══════════════════════════════════════════════════════════════════════ -# Antigravity model list (static — no /v1/models REST endpoint) -# ═══════════════════════════════════════════════════════════════════════ - -ANTIGRAVITY_MODELS = [ - "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)", - "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)", - "Claude Sonnet 4.6 (Thinking)", - "Claude Opus 4.6 (Thinking)", - "GPT-OSS 120B (Medium)", -] - - -# ═══════════════════════════════════════════════════════════════════════ -# OAuth secrets (local, never in repo) -# ═══════════════════════════════════════════════════════════════════════ - -def load_oauth_secrets(): - try: - with open(OAUTH_SECRETS_PATH, encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - - -def save_oauth_secrets(data): - os.makedirs(os.path.dirname(OAUTH_SECRETS_PATH), exist_ok=True) - tmp = str(OAUTH_SECRETS_PATH) + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - os.replace(tmp, OAUTH_SECRETS_PATH) - - -# ═══════════════════════════════════════════════════════════════════════ -# 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)