diff --git a/codex-launcher-gui b/codex-launcher-gui deleted file mode 100755 index e86d395..0000000 --- a/codex-launcher-gui +++ /dev/null @@ -1,5073 +0,0 @@ -#!/usr/bin/env python3 -"""Codex Launcher GUI — manage endpoints, launch Desktop or CLI with any provider.""" - -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib -import subprocess, os, signal, sys, threading, time, json, urllib.request, urllib.parse, urllib.error, tempfile, shutil -import hashlib, socket, ssl, contextlib, re, collections -import base64, secrets, uuid, webbrowser -from pathlib import Path - -HOME = Path.home() -START_SH = Path("/opt/codex-desktop/start.sh") -CONFIG = HOME / ".codex/config.toml" -CONFIG_BAK = HOME / ".codex/config.toml.launcher-bak" -CLEANUP = HOME / ".local/bin/cleanup-codex-stale.sh" -PROXY = HOME / ".local/bin/translate-proxy.py" -ENDPOINTS_FILE = HOME / ".codex/endpoints.json" -BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json" -LOG_DIR = HOME / ".cache/codex-desktop" -LAUNCH_LOG = LOG_DIR / "launcher.log" -PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy" -DEFAULT_CONFIG = """model = "" -model_provider = "" -model_catalog_json = "" -""" - -CHANGELOG = [ - ("3.9.6", "2026-05-25", [ - "Fix Gemini follow-up turns returning text-only instead of tool calls", - "Enforce latest user instruction as final Gemini content turn", - "Edit-intent detection with tool-use nudge for file modification requests", - "Debug logging: contents count, latest user text, final content preview", - "Thought signature preservation for Gemini 3 tool-call continuity", - "thought_signature field on all functionCall parts (snake_case)", - "Smart tool output compaction: old=3000, recent=20000 chars", - "Follow-through guardrail system instruction for autonomous agent behavior", - "Stream hang fix for function-call-only responses", - "Multi-account rotation for freebuff, Google OAuth, API keys", - "/v1/accounts endpoint for account pool status", - ]), - ("3.9.0", "2026-05-24", [ - "Multi-account rotation for OAuth providers (freebuff, Google, API keys)", - "Automatic failover: when one account hits rate limit, next is used", - "Freebuff: supports accounts[] array in credentials.json", - "Google OAuth: supports multiple token files (google-*-oauth-token-N.json)", - "API keys: comma-separated keys rotate on 429 errors", - "New /v1/accounts endpoint shows account pool status", - "Added x-freebuff-model and x-freebuff-instance-id headers", - ]), - ("3.8.4", "2026-05-24", [ - "FIXED: Freebuff streaming — SSE events now reach Codex client", - "Root cause: stream_buffered_events was never called for freebuff", - "Freebuff stream uses buffered flushing (30ms / 4KB / urgent)", - "Freebuff OAuth — built-in login flow (no external CLI needed)", - "Freebuff API: reverse-engineered www.codebuff.com endpoints", - "Freebuff session management with instance ID (waiting room)", - "Freebuff agent run lifecycle (start/finish) with model routing", - "Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7", - "Reasoning mode works with freebuff (thinking tokens supported)", - "GUI: Sandbox mode selector (Read-only / Workspace / Full Access)", - "GUI: Approval mode selector (Untrusted / On Request / Full Auto)", - "GUI: Freebuff Login button in endpoint editor", - "Fixed _STATS undefined error in /health endpoint", - "Fixed freebuff credential path (reads default account)", - ]), - ("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 — gives models room for both reasoning and content", - ]), - ("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 = { - "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", - "oauth_provider": "freebuff", - "models": [ - "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", - "moonshotai/kimi-k2.6", "minimax/minimax-m2.7", - ], - }, -} - -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 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 - -def _doctor_check_streaming(base_url, key, bt, model, add): - if bt == "anthropic": - test_url = f"{base_url}/v1/messages" - headers = {"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 = {"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 = {"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 = {"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 "" - - # 1. URL format - 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) - - # 2. DNS resolution - 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 - - # 3. TCP/TLS connection - 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 - - # 4. Auth + /models (backend-aware) - 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={ - "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 = Path.home() / f".cache/codex-proxy/{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, f"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]) - - # 5. Streaming smoke test - if bt not in ("native", "command-code"): - _doctor_check_streaming(url, key, bt, model, add) - - # 6. Tool-call support test - if bt not in ("native", "command-code"): - _doctor_check_toolcall(url, key, bt, model, add) - - return checks - -def _show_doctor_results(parent, endpoint_name, checks): - dlg = Gtk.Dialog(title=f"Doctor: {endpoint_name}", parent=parent, modal=True) - dlg.add_button("Close", Gtk.ResponseType.CLOSE) - dlg.set_default_size(480, 400) - area = dlg.get_content_area() - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(4) - 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 = Gtk.Label() - hdr.set_markup(f'{endpoint_name} ' - f'{passed} passed ' - f'{failed} failed ' - f'{warned} warnings') - area.pack_start(hdr, False, False, 6) - sep = Gtk.Separator() - area.pack_start(sep, False, False, 4) - for name, ok, detail in checks: - row = Gtk.Box(spacing=6) - if ok is True: - color, sym = "#27ae60", "\u2713" - elif ok is False: - color, sym = "#e74c3c", "\u2717" - else: - color, sym = "#f39c12", "\u25CB" - icon = Gtk.Label() - icon.set_markup(f'{sym}') - row.pack_start(icon, False, False, 0) - lbl = Gtk.Label() - lbl.set_markup(f'{name}') - row.pack_start(lbl, False, False, 0) - if detail: - det = Gtk.Label() - det.set_markup(f'{detail}') - det.set_line_wrap(True) - row.pack_end(det, False, False, 0) - area.pack_start(row, False, False, 2) - dlg.show_all() - dlg.run() - dlg.destroy() - -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 = {} - if backend == "anthropic": - if key: - headers["x-api-key"] = key - headers["anthropic-version"] = "2023-06-01" - elif key: - headers["Authorization"] = f"Bearer {key}" - return headers - -def fetch_models_for_endpoint(endpoint, timeout=10): - url = endpoint_models_url(endpoint) - if not url: - return None, "Base URL is empty" - try: - req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint)) - raw = urllib.request.urlopen(req, timeout=timeout).read() - payload = json.loads(raw) - items = payload.get("data") or payload.get("models") or [] - ids = [] - seen = set() - for item in items: - mid = item.get("id") if isinstance(item, dict) else None - if mid and mid not in seen: - seen.add(mid) - ids.append(mid) - if not ids: - return None, "No models returned" - return ids, None - except Exception as e: - return None, str(e) - -def refresh_endpoint_models(endpoint): - ids, err = fetch_models_for_endpoint(endpoint) - if not ids: - return None, err - updated = dict(endpoint) - updated["models"] = ids - if updated.get("default_model") not in ids: - updated["default_model"] = ids[0] - return updated, None - -# ═══════════════════════════════════════════════════════════════════ -# Endpoint storage -# ═══════════════════════════════════════════════════════════════════ - -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 - -def now_utc_iso(): - return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - -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") - - # Keep a local rollback point before overwriting the current profile. - 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 - -# ═══════════════════════════════════════════════════════════════════ -# 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 write_secure_text(path, text): - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(text, encoding="utf-8") - os.chmod(str(tmp), 0o600) - os.replace(str(tmp), str(path)) - -CONFIG_TXN = HOME / ".codex/config.toml.launcher-txn.json" - -def begin_config_transaction(reason): - txn = {"started_at": time.time(), "reason": reason, - "config_existed": CONFIG.exists(), "backup_path": str(CONFIG_BAK)} - if CONFIG.exists(): - backup_config() - CONFIG_TXN.parent.mkdir(parents=True, exist_ok=True) - CONFIG_TXN.write_text(json.dumps(txn, indent=2)) - -def end_config_transaction(): - CONFIG_TXN.unlink(missing_ok=True) - -def recover_config_if_needed(logfn=None): - if not CONFIG_TXN.exists(): - return - try: - txn = json.loads(CONFIG_TXN.read_text()) - if txn.get("config_existed") and CONFIG_BAK.exists(): - restore_config() - if logfn: - logfn("Recovered Codex config from interrupted session.") - elif CONFIG.exists(): - CONFIG.unlink() - if logfn: - logfn("Removed generated config from interrupted session.") - finally: - CONFIG_TXN.unlink(missing_ok=True) - -def write_config_for_native(endpoint, selected_model): - """Write config for native OpenAI (no proxy needed).""" - 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)) - - lines = [ - f'model = "{_toml_safe(selected_model)}"\n', - f'model_provider = "{_toml_safe(endpoint["name"])}"\n', - f'model_catalog_json = "{mc_path}"\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_path}"\n', - f'service_tier = "default"\n', - f'approvals_reviewer = "user"\n', - ] - write_secure_text(CONFIG, "".join(lines)) - -def _toml_safe(val): - val = str(val).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 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)) - - lines = [ - f'model = "{_toml_safe(selected_model)}"\n', - f'model_provider = "{_toml_safe(endpoint["name"])}"\n', - f'model_catalog_json = "{mc_path}"\n', - f'\n[model_providers."{endpoint["name"]}"]\n', - f'name = "{_toml_safe(endpoint["name"])}"\n', - f'base_url = "http://127.0.0.1:{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_path}"\n', - f'service_tier = "fast"\n', - f'approvals_reviewer = "user"\n', - ] - write_secure_text(CONFIG, "".join(lines)) - -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} - -# ═══════════════════════════════════════════════════════════════════ -# Proxy management -# ═══════════════════════════════════════════════════════════════════ - -_proxy_proc = None -_proxy_port = None - -PID_REGISTRY = HOME / ".cache" / "codex-launcher" / "pids.json" - -def _pick_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - -def _load_pid_registry(): - if PID_REGISTRY.exists(): - try: - return json.loads(PID_REGISTRY.read_text()) - except Exception: - pass - return {} - -def _save_pid_registry(data): - PID_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - tmp = PID_REGISTRY.with_suffix(".tmp") - tmp.write_text(json.dumps(data, indent=2)) - os.replace(str(tmp), str(PID_REGISTRY)) - -def _register_pgid(kind, pid): - data = _load_pid_registry() - try: - pgid = os.getpgid(pid) - except ProcessLookupError: - return - data[kind] = {"pid": pid, "pgid": pgid, "ts": time.time()} - _save_pid_registry(data) - -def safe_cleanup_owned(logfn=None): - data = _load_pid_registry() - changed = False - for kind, meta in list(data.items()): - pgid = meta.get("pgid") - if not pgid: - continue - try: - os.killpg(pgid, signal.SIGTERM) - if logfn: - logfn(f"Stopped {kind} (pgid {pgid})") - changed = True - except ProcessLookupError: - changed = True - except Exception as e: - if logfn: - logfn(f"Could not stop {kind}: {e}") - if changed: - _save_pid_registry({}) - -def _start_proxy_for(endpoint, logfn): - global _proxy_proc, _proxy_port - # Clear stale Python bytecode cache so proxy picks up latest source changes - import shutil - pycache = os.path.join(os.path.dirname(os.path.abspath(__file__)), '__pycache__') - if os.path.isdir(pycache): - shutil.rmtree(pycache, ignore_errors=True) - _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 = os.path.expanduser(f"~/.cache/codex-proxy/{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"), - "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 - _proxy_proc = subprocess.Popen( - ["python3", str(PROXY), "--config", str(pcfg_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - preexec_fn=os.setsid, - text=True, - ) - _register_pgid("proxy", _proxy_proc.pid) - - def _pipe_stderr(): - if not _proxy_proc.stderr: - return - for line in _proxy_proc.stderr: - GLib.idle_add(logfn, f"[proxy] {line.rstrip()}") - threading.Thread(target=_pipe_stderr, daemon=True).start() - - deadline = time.time() + 15 - last_err = None - while time.time() < deadline: - if _proxy_proc.poll() is not None: - raise RuntimeError(f"Proxy exited early with code {_proxy_proc.returncode}") - try: - urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=2) - logfn(f"Proxy ready on port {port}") - return - except Exception as e: - last_err = e - time.sleep(0.3) - try: - os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM) - _proxy_proc.wait(timeout=3) - except Exception: - with contextlib.suppress(Exception): - os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL) - raise RuntimeError(f"Proxy failed health check on port {port}: {last_err}") - -def _stop_proxy(): - global _proxy_proc - if _proxy_proc and _proxy_proc.poll() is None: - try: - os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM) - time.sleep(0.5) - if _proxy_proc.poll() is None: - os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass - _proxy_proc = None - -def _kill_existing_desktop(logfn=None): - import subprocess as _sp - try: - out = _sp.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}") - -def _run_cleanup(logfn=None): - safe_cleanup_owned(logfn) - -def _last_log_lines(n=15): - try: - t = LAUNCH_LOG.read_text() - return "\n".join(t.splitlines()[-n:]) - except Exception: - return "(no log file)" - -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 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)) - -# ═══════════════════════════════════════════════════════════════════ -# AI Monitoring — Self-Healing Watchdog -# ═══════════════════════════════════════════════════════════════════ - -MONITORING_FILE = Path.home() / ".cache/codex-proxy/monitoring-config.json" -INCIDENT_STORE_FILE = Path.home() / ".cache/codex-proxy/incident-store.json" -MONITORING_LOG = Path.home() / ".cache/codex-proxy/monitoring.log" - -_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 = Path.home() / ".cache/codex-proxy/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) - - -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(Path.home() / ".cache/codex-proxy/cc-debug.log"), - str(Path.home() / ".cache/codex-proxy/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) - - -class AIMonitoringWindow(Gtk.Window): - def __init__(self, parent=None): - super().__init__(title="AI Monitoring") - self.set_transient_for(parent) - self.set_default_size(580, 520) - self.set_border_width(12) - self._cfg = _load_monitoring_config() - self._store = _load_incident_store() - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self.add(vbox) - - hdr = Gtk.Box(spacing=8) - vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label() - lbl.set_markup("AI Monitoring") - lbl.set_use_markup(True) - hdr.pack_start(lbl, False, False, 0) - self._toggle = Gtk.Switch() - self._toggle.set_active(self._cfg.get("enabled", False)) - self._toggle.connect("state-set", self._on_toggle) - hdr.pack_end(self._toggle, False, False, 0) - lbl2 = Gtk.Label(label="Enabled") - hdr.pack_end(lbl2, False, False, 0) - - frame = Gtk.Frame(label="Diagnostic Agent") - vbox.pack_start(frame, False, False, 0) - grid = Gtk.Grid(column_spacing=8, row_spacing=6, margin=8) - frame.add(grid) - - grid.attach(Gtk.Label(label="Provider URL:", halign=Gtk.Align.END), 0, 0, 1, 1) - self._url_entry = Gtk.Entry(hexpand=True) - self._url_entry.set_text(self._cfg.get("provider_url", "")) - self._url_entry.set_placeholder_text("https://api.openai.com/v1/chat/completions") - grid.attach(self._url_entry, 1, 0, 2, 1) - - grid.attach(Gtk.Label(label="Model:", halign=Gtk.Align.END), 0, 1, 1, 1) - self._model_entry = Gtk.Entry(hexpand=True) - self._model_entry.set_text(self._cfg.get("model", "")) - self._model_entry.set_placeholder_text("gpt-4o-mini or Qwen/Qwen3-32B") - grid.attach(self._model_entry, 1, 1, 2, 1) - - grid.attach(Gtk.Label(label="API Key:", halign=Gtk.Align.END), 0, 2, 1, 1) - self._key_entry = Gtk.Entry(hexpand=True, visibility=False) - self._key_entry.set_text(self._cfg.get("api_key", "")) - self._key_entry.set_placeholder_text("sk-...") - grid.attach(self._key_entry, 1, 2, 1, 1) - self._reveal_btn = Gtk.ToggleButton(label="Show") - self._reveal_btn.connect("toggled", lambda b: self._key_entry.set_visibility(b.get_active())) - grid.attach(self._reveal_btn, 2, 2, 1, 1) - - grid.attach(Gtk.Label(label="Health Check:", halign=Gtk.Align.END), 0, 3, 1, 1) - adj = Gtk.Adjustment(value=self._cfg.get("health_check_interval_s", 5), lower=2, upper=30, step_increment=1) - self._interval_spin = Gtk.SpinButton(adjustment=adj) - self._interval_spin.set_numeric(True) - grid.attach(self._interval_spin, 1, 3, 1, 1) - grid.attach(Gtk.Label(label="seconds"), 2, 3, 1, 1) - - opts_box = Gtk.Box(spacing=12, margin_top=4) - grid.attach(opts_box, 0, 4, 3, 1) - self._auto_restart_cb = Gtk.CheckButton(label="Auto-restart proxy on crash") - self._auto_restart_cb.set_active(self._cfg.get("auto_restart_proxy", True)) - opts_box.pack_start(self._auto_restart_cb, False, False, 0) - self._auto_switch_cb = Gtk.CheckButton(label="Auto-switch provider on repeated failure") - self._auto_switch_cb.set_active(self._cfg.get("auto_switch_provider", False)) - opts_box.pack_start(self._auto_switch_cb, False, False, 0) - - save_btn = Gtk.Button(label="Save Configuration") - save_btn.get_style_context().add_class("suggested-action") - save_btn.connect("clicked", self._on_save) - grid.attach(save_btn, 0, 5, 3, 1) - - stats_box = Gtk.Box(spacing=16) - vbox.pack_start(stats_box, False, False, 0) - stats = self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}) - self._stats_lbl = Gtk.Label() - self._stats_lbl.set_markup( - 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', {}))}" - ) - self._stats_lbl.set_use_markup(True) - stats_box.pack_start(self._stats_lbl, False, False, 0) - - frame2 = Gtk.Frame(label="Recent Incidents") - vbox.pack_start(frame2, True, True, 0) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - frame2.add(sw) - self._inc_buf = Gtk.TextBuffer() - tv = Gtk.TextView(buffer=self._inc_buf) - tv.set_editable(False) - tv.set_cursor_visible(False) - tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - sw.add(tv) - self._refresh_incidents() - - bb = Gtk.Box(spacing=8) - vbox.pack_start(bb, False, False, 0) - view_btn = Gtk.Button(label="View Monitoring Log") - view_btn.connect("clicked", lambda b: subprocess.Popen(["xdg-open", str(MONITORING_LOG)])) - bb.pack_start(view_btn, False, False, 0) - clear_btn = Gtk.Button(label="Clear Incident Store") - clear_btn.connect("clicked", self._on_clear_store) - bb.pack_start(clear_btn, False, False, 0) - close_btn = Gtk.Button(label="Close") - close_btn.connect("clicked", lambda b: self.destroy()) - bb.pack_end(close_btn, False, False, 0) - - self.show_all() - - def _on_toggle(self, switch, state): - self._cfg["enabled"] = state - _save_monitoring_config(self._cfg) - - def _on_save(self, btn): - self._cfg["provider_url"] = self._url_entry.get_text().strip() - self._cfg["model"] = self._model_entry.get_text().strip() - self._cfg["api_key"] = self._key_entry.get_text().strip() - self._cfg["health_check_interval_s"] = int(self._interval_spin.get_value()) - self._cfg["auto_restart_proxy"] = self._auto_restart_cb.get_active() - self._cfg["auto_switch_provider"] = self._auto_switch_cb.get_active() - _save_monitoring_config(self._cfg) - self._inc_buf.set_text("Configuration saved.\n") - - def _on_clear_store(self, btn): - _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) - bar = "+" * min(int(rate * 10), 10) + "-" * (10 - min(int(rate * 10), 10)) - lines.append( - f"[{inc.get('last_seen', '?')[:16]}] {pattern}\n" - f" fix={inc.get('fix', '?')} success_rate={rate:.0%} [{bar}] " - f"seen={inc.get('occurrences', 0)}x\n" - ) - if not lines: - lines.append("No incidents recorded yet.\n") - lines.append("\nEnable AI Monitoring and use Codex to populate the store.\n") - self._inc_buf.set_text("\n".join(lines)) - - -# ═══════════════════════════════════════════════════════════════════ -# Main window -# ═══════════════════════════════════════════════════════════════════ - -class LauncherWin(Gtk.Window): - def __init__(self): - super().__init__(title="Codex Launcher") - self.set_default_size(560, 460) - self.set_border_width(12) - self.set_position(Gtk.WindowPosition.CENTER) - self._proc = None - self._endpoints_data = load_endpoints() - recover_config_if_needed() - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self.add(vbox) - - # header row - hdr = Gtk.Box(spacing=8) - vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Codex Launcher v3.9.6") - lbl.set_use_markup(True) - hdr.pack_start(lbl, False, False, 0) - changelog_btn = Gtk.Button(label="Changelog") - changelog_btn.connect("clicked", lambda b: self._show_changelog()) - hdr.pack_end(changelog_btn, False, False, 0) - history_btn = Gtk.Button(label="History") - history_btn.connect("clicked", lambda b: self._open_history()) - hdr.pack_end(history_btn, False, False, 0) - bench_btn = Gtk.Button(label="Benchmark") - bench_btn.connect("clicked", lambda b: self._open_benchmark()) - hdr.pack_end(bench_btn, False, False, 0) - usage_btn = Gtk.Button(label="Usage") - usage_btn.connect("clicked", lambda b: self._open_usage()) - hdr.pack_end(usage_btn, False, False, 0) - bgp_btn = Gtk.Button(label="AI BGP") - bgp_btn.connect("clicked", lambda b: self._open_bgp()) - hdr.pack_end(bgp_btn, False, False, 0) - mon_btn = Gtk.Button(label="AI Monitor") - mon_btn.connect("clicked", lambda b: self._open_monitoring()) - hdr.pack_end(mon_btn, False, False, 0) - mgr_btn = Gtk.Button(label="Manage Endpoints") - mgr_btn.connect("clicked", lambda b: self._open_mgr()) - hdr.pack_end(mgr_btn, False, False, 0) - - # verification status bar - self._cli_info = _detect_codex_cli() - self._desktop_info = _detect_codex_desktop() - ver_box = Gtk.Box(spacing=12) - vbox.pack_start(ver_box, False, False, 0) - - if self._cli_info: - cli_path, cli_ver = self._cli_info - cli_lbl = Gtk.Label() - cli_lbl.set_markup(f"✔ Codex CLI {cli_ver} ({cli_path})") - cli_lbl.set_use_markup(True) - ver_box.pack_start(cli_lbl, False, False, 0) - else: - cli_lbl = Gtk.Label() - cli_lbl.set_markup("✘ Codex CLI — not found") - cli_lbl.set_use_markup(True) - ver_box.pack_start(cli_lbl, False, False, 0) - cli_install_btn = Gtk.Button(label="Install") - cli_install_btn.connect("clicked", lambda b: self._show_install_guide("cli")) - ver_box.pack_start(cli_install_btn, False, False, 0) - - ver_box.pack_start(Gtk.Label(label=" "), False, False, 0) - - if self._desktop_info: - desk_lbl = Gtk.Label() - desk_lbl.set_markup(f"✔ Codex Desktop ({self._desktop_info})") - desk_lbl.set_use_markup(True) - ver_box.pack_start(desk_lbl, False, False, 0) - else: - desk_lbl = Gtk.Label() - desk_lbl.set_markup("✘ Codex Desktop — not found") - desk_lbl.set_use_markup(True) - ver_box.pack_start(desk_lbl, False, False, 0) - desk_install_btn = Gtk.Button(label="Install") - desk_install_btn.connect("clicked", lambda b: self._show_install_guide("desktop")) - ver_box.pack_start(desk_install_btn, False, False, 0) - - self._missing = [] - if not self._cli_info: - self._missing.append("cli") - if not self._desktop_info: - self._missing.append("desktop") - - auth_box = Gtk.Box(spacing=12) - vbox.pack_start(auth_box, False, False, 0) - self._auth_label = Gtk.Label() - self._auth_label.set_markup("Checking auth…") - self._auth_label.set_use_markup(True) - self._auth_label.set_ellipsize(3) - auth_box.pack_start(self._auth_label, False, False, 0) - self._relogin_btn = Gtk.Button(label="Re-login") - self._relogin_btn.set_sensitive(False) - self._relogin_btn.connect("clicked", lambda b: self._codex_relogin()) - auth_box.pack_end(self._relogin_btn, False, False, 0) - threading.Thread(target=self._check_auth_async, daemon=True).start() - - ops_box = Gtk.Box(spacing=8) - vbox.pack_start(ops_box, False, False, 0) - self._refresh_all_btn = Gtk.Button(label="Refresh Models") - self._refresh_all_btn.connect("clicked", lambda b: self._refresh_all_models()) - ops_box.pack_start(self._refresh_all_btn, False, False, 0) - self._backup_btn = Gtk.Button(label="Backup Profile") - self._backup_btn.connect("clicked", lambda b: self._backup_profile()) - ops_box.pack_start(self._backup_btn, False, False, 0) - self._import_btn = Gtk.Button(label="Import Profile") - self._import_btn.connect("clicked", lambda b: self._import_profile()) - ops_box.pack_start(self._import_btn, False, False, 0) - - # endpoint selector - sel_box = Gtk.Box(spacing=6) - vbox.pack_start(sel_box, False, False, 4) - sel_box.pack_start(Gtk.Label(label="Endpoint:"), False, False, 0) - self._combo = Gtk.ComboBoxText() - self._combo.connect("changed", lambda c: self._on_endpoint_changed()) - sel_box.pack_start(self._combo, True, True, 0) - - # model selector - sel_box.pack_start(Gtk.Label(label="Model:"), False, False, 0) - self._model_combo = Gtk.ComboBoxText() - sel_box.pack_start(self._model_combo, True, True, 0) - - # sandbox mode selector - sel_box.pack_start(Gtk.Label(label="Sandbox:"), False, False, 0) - self._sandbox_combo = Gtk.ComboBoxText() - for v, l in [("read-only", "Read-only"), - ("workspace-write", "Workspace"), - ("danger-full-access", "Full Access")]: - self._sandbox_combo.append(v, l) - self._sandbox_combo.set_active_id("workspace-write") - sel_box.pack_start(self._sandbox_combo, True, True, 0) - - # approval mode selector - sel_box.pack_start(Gtk.Label(label="Approval:"), False, False, 0) - self._approval_combo = Gtk.ComboBoxText() - for v, l in [("untrusted", "Untrusted"), - ("on-request", "On Request"), - ("never", "Never (Full Auto)")]: - self._approval_combo.append(v, l) - self._approval_combo.set_active_id("on-request") - sel_box.pack_start(self._approval_combo, True, True, 0) - - # launch buttons - btn_box = Gtk.Box(spacing=8, homogeneous=True) - vbox.pack_start(btn_box, False, False, 8) - self._btn_desktop = Gtk.Button(label="Launch Desktop") - self._btn_desktop.connect("clicked", lambda b: self._launch("desktop")) - if "desktop" in self._missing: - self._btn_desktop.set_tooltip_text("Codex Desktop is not installed") - self._btn_desktop.set_sensitive(False) - btn_box.pack_start(self._btn_desktop, True, True, 0) - self._btn_cli = Gtk.Button(label="Launch CLI") - self._btn_cli.connect("clicked", lambda b: self._launch("cli")) - if "cli" in self._missing: - self._btn_cli.set_tooltip_text("Codex CLI is not installed") - self._btn_cli.set_sensitive(False) - btn_box.pack_start(self._btn_cli, True, True, 0) - - btn_box2 = Gtk.Box(spacing=8, homogeneous=True) - vbox.pack_start(btn_box2, False, False, 0) - self._btn_codex_desktop = Gtk.Button(label="Codex Default (Desktop)") - self._btn_codex_desktop.connect("clicked", lambda b: self._launch_codex_default("desktop")) - if "desktop" in self._missing: - self._btn_codex_desktop.set_tooltip_text("Codex Desktop is not installed") - self._btn_codex_desktop.set_sensitive(False) - btn_box2.pack_start(self._btn_codex_desktop, True, True, 0) - self._btn_codex_cli = Gtk.Button(label="Codex Default (CLI)") - self._btn_codex_cli.connect("clicked", lambda b: self._launch_codex_default("cli")) - if "cli" in self._missing: - self._btn_codex_cli.set_tooltip_text("Codex CLI is not installed") - self._btn_codex_cli.set_sensitive(False) - btn_box2.pack_start(self._btn_codex_cli, True, True, 0) - - # status - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - vbox.pack_start(sw, True, True, 0) - self._buf = Gtk.TextBuffer() - self._tv = Gtk.TextView(buffer=self._buf) - self._tv.set_editable(False) - self._tv.set_cursor_visible(False) - self._tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - sw.add(self._tv) - - # bottom bar - bb = Gtk.Box(spacing=8) - vbox.pack_start(bb, False, False, 0) - assist_btn = Gtk.Button(label="AI Assistant") - assist_btn.get_style_context().add_class("suggested-action") - assist_btn.connect("clicked", lambda b: self._open_assistant()) - assist_btn.set_tooltip_text("Open AI coding assistant with streaming, tools, and session management") - bb.pack_start(assist_btn, False, False, 0) - self._kill_btn = Gtk.Button(label="Kill && Cleanup") - self._kill_btn.connect("clicked", lambda b: self._kill()) - self._kill_btn.set_sensitive(False) - bb.pack_start(self._kill_btn, True, True, 0) - self._view_log_btn = Gtk.Button(label="View Log") - self._view_log_btn.connect("clicked", lambda b: subprocess.Popen(["xdg-open", str(LAUNCH_LOG)])) - bb.pack_start(self._view_log_btn, False, False, 0) - self._close_btn = Gtk.Button(label="Close") - self._close_btn.connect("clicked", lambda b: self._do_close()) - bb.pack_start(self._close_btn, False, False, 0) - - self.show_all() - self._rebuild_combo() - self._log_dependency_status() - self._start_watcher() - - # ── helpers ────────────────────────────────────────────────── - - def log(self, msg): - GLib.idle_add(self._append_log, msg) - - def _append_log(self, msg): - e = self._buf.get_end_iter() - self._buf.insert(e, msg + "\n") - m = self._buf.create_mark(None, e, False) - self._tv.scroll_to_mark(m, 0.0, True, 0.0, 0.5) - self._buf.delete_mark(m) - - 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. Click 'Install' above.") - if self._desktop_info: - self.log(f"✔ Codex Desktop detected ({self._desktop_info})") - else: - self.log("✘ Codex Desktop NOT found — Desktop launch disabled. Click 'Install' above.") - if self._missing: - self.log("⚠ Install missing tools before using the launcher.") - else: - self.log("All dependencies OK.") - - def _check_auth_async(self): - status, msg = _check_codex_auth() - GLib.idle_add(self._update_auth_status, status, msg) - - def _update_auth_status(self, status, msg): - if status == "logged_in": - self._auth_label.set_markup(f"✔ Auth: {msg}") - self._relogin_btn.set_sensitive("cli" not in self._missing) - elif status == "not_installed": - self._auth_label.set_markup("Auth: N/A (CLI not installed)") - else: - self._auth_label.set_markup(f"⚠ Auth: {msg}") - self._relogin_btn.set_sensitive("cli" not in self._missing) - return False - - def _codex_relogin(self): - self.log("Opening codex login in terminal…") - terms = [ - ("x-terminal-emulator", ["-e"]), - ("kgx", ["--"]), - ("gnome-terminal", ["--"]), - ("konsole", ["-e"]), - ("xterm", ["-e"]), - ] - term = None - term_args = None - for t in terms: - if shutil.which(t[0]): - term = t[0] - term_args = t[1] - break - if not term: - self.log("ERROR: no terminal emulator found for re-login") - return - cmd_parts = [term] + term_args + ["codex", "login"] - subprocess.Popen(cmd_parts, preexec_fn=os.setsid) - self.log("Login flow started in terminal. Re-checking auth in 30s…") - self._auth_label.set_markup("Auth: waiting for login…") - threading.Thread(target=self._delayed_auth_check, daemon=True).start() - - def _delayed_auth_check(self): - time.sleep(30) - self._check_auth_async() - - def _set_busy(self, busy): - def _update(): - has_cli = "cli" not in self._missing - has_desk = "desktop" not in self._missing - self._btn_desktop.set_sensitive(not busy and has_desk) - self._btn_cli.set_sensitive(not busy and has_cli) - self._btn_codex_desktop.set_sensitive(not busy and has_desk) - self._btn_codex_cli.set_sensitive(not busy and has_cli) - self._kill_btn.set_sensitive(busy) - GLib.idle_add(_update) - - def _rebuild_combo(self): - self._endpoints_data = load_endpoints() - self._combo.remove_all() - names = [e["name"] for e in self._endpoints_data["endpoints"]] - for n in names: - self._combo.append_text(n) - bgp_names = [p["name"] for p in load_bgp_pools().get("pools", [])] - for n in bgp_names: - self._combo.append_text(f"🔀 {n}") - if names or bgp_names: - default = self._endpoints_data.get("default") - if default and default in names: - self._combo.set_active(names.index(default)) - else: - self._combo.set_active(0) - self._on_endpoint_changed() - - def _on_endpoint_changed(self): - name = self._combo.get_active_text() - is_bgp = name and name.startswith("🔀 ") - bgp_name = name[2:] if is_bgp else None - ep = get_endpoint(name) if name and not is_bgp else None - self._model_combo.remove_all() - if is_bgp: - pool = None - for p in load_bgp_pools().get("pools", []): - if p["name"] == bgp_name: - pool = p - break - if pool: - seen = set() - for r in pool.get("routes", []): - m = r.get("model", "") - if m and m not in seen: - self._model_combo.append_text(m) - seen.add(m) - if seen: - self._model_combo.set_active(0) - elif ep: - for m in ep.get("models", []): - self._model_combo.append_text(m) - GLib.idle_add(self._select_default_model, ep) - - def _select_default_model(self, ep): - dm = ep.get("default_model", "") - models = ep.get("models", []) - if dm in models: - self._model_combo.set_active(models.index(dm)) - elif models: - self._model_combo.set_active(0) - - # ── endpoint mgr ───────────────────────────────────────────── - - def _open_mgr(self): - try: - self._mgr_window = EndpointMgr(self) - self._mgr_window.connect("destroy", lambda *_: setattr(self, "_mgr_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_bgp(self): - try: - self._bgp_window = BGPPoolMgr(self) - self._bgp_window.connect("destroy", lambda *_: setattr(self, "_bgp_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_monitoring(self): - try: - self._monitoring_window = AIMonitoringWindow(self) - self._monitoring_window.connect("destroy", lambda *_: setattr(self, "_monitoring_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _start_watcher(self): - cfg = _load_monitoring_config() - if not cfg.get("enabled"): - return - self._watcher = HealthWatcher( - on_failure=self._on_watcher_failure, - on_recovery=self._on_watcher_recovery, - on_signal=self._on_watcher_signal, - on_action=self._on_watcher_action, - ) - self._watcher.start() - self.log("AI Monitoring: watchdog started") - - def _on_watcher_failure(self, count): - GLib.idle_add(self.log, f"[AI Monitor] Proxy unresponsive (failures={count})") - - def _on_watcher_recovery(self): - GLib.idle_add(self.log, "[AI Monitor] Proxy recovered") - - def _on_watcher_signal(self, fault_id, category, line): - pass - - def _on_watcher_action(self, action, trigger): - cfg = _load_monitoring_config() - if action == "restart_proxy" and cfg.get("auto_restart_proxy"): - GLib.idle_add(self.log, f"[AI Monitor] Auto-restarting proxy (trigger: {trigger})") - GLib.idle_add(self._restart_proxy_from_watcher) - elif action == "clear_schema_cache": - try: - cap_file = Path.home() / ".cache/codex-proxy/provider-caps.json" - if cap_file.exists(): - cap_file.unlink() - GLib.idle_add(self.log, "[AI Monitor] Cleared corrupt schema cache") - except Exception as e: - GLib.idle_add(self.log, f"[AI Monitor] Failed to clear cache: {e}") - elif action == "delete_provider_caps": - try: - cap_file = Path.home() / ".cache/codex-proxy/provider-caps.json" - if cap_file.exists(): - cap_file.unlink() - GLib.idle_add(self.log, "[AI Monitor] Deleted corrupted provider-caps.json") - except Exception as e: - GLib.idle_add(self.log, f"[AI Monitor] Failed: {e}") - elif action == "kill_stale_restart": - GLib.idle_add(self.log, f"[AI Monitor] Killing stale processes + restarting (trigger: {trigger})") - self._kill() - GLib.idle_add(self._restart_proxy_from_watcher) - else: - GLib.idle_add(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: - self._start_proxy(ep) - break - except Exception as e: - self.log(f"[AI Monitor] Proxy restart failed: {e}") - - def _open_usage(self): - try: - self._usage_window = UsageWindow(self) - self._usage_window.connect("destroy", lambda *_: setattr(self, "_usage_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_history(self): - try: - self._history_window = RequestHistoryWindow(self) - self._history_window.connect("destroy", lambda *_: setattr(self, "_history_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_benchmark(self): - try: - self._benchmark_window = BenchmarkWindow(self) - self._benchmark_window.connect("destroy", lambda *_: setattr(self, "_benchmark_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_assistant(self): - import subprocess, sys - _py = str(Path(__file__).resolve().parent / "flet-codex-assist.py") - subprocess.Popen([sys.executable, _py], start_new_session=True) - - def _backup_profile(self): - chooser = Gtk.FileChooserDialog( - title="Backup Codex Profile", - parent=self, - action=Gtk.FileChooserAction.SAVE, - ) - chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_SAVE, Gtk.ResponseType.OK) - chooser.set_do_overwrite_confirmation(True) - chooser.set_current_name(f"codex-profile-{time.strftime('%Y%m%d-%H%M%S')}.json") - resp = chooser.run() - filename = chooser.get_filename() if resp == Gtk.ResponseType.OK else None - chooser.destroy() - if not filename: - return - try: - save_profile_bundle(filename) - self.log(f"Profile backed up to {filename}") - except Exception as e: - self._show_message(Gtk.MessageType.ERROR, f"Backup failed:\n{e}") - - def _refresh_all_models(self): - if getattr(self, "_refresh_running", False): - return - self._refresh_running = True - self._refresh_all_btn.set_sensitive(False) - 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) - - GLib.idle_add(self._finish_refresh_all_models, updated, failed) - except Exception as e: - GLib.idle_add(self._finish_refresh_all_models_error, str(e)) - - def _finish_refresh_all_models(self, updated, failed): - try: - if updated: - self._rebuild_combo() - if getattr(self, "_mgr_window", None): - try: - self._mgr_window._rebuild() - except Exception: - pass - self.log(f"Refreshed models for {updated} provider(s)") - - if failed: - self._show_message( - Gtk.MessageType.WARNING, - "Some providers could not auto-fetch models.\n\n" - + "\n".join(failed) - + "\n\nThose providers were left unchanged so you can manage them manually." - ) - elif updated: - self._show_message(Gtk.MessageType.INFO, f"Refreshed models for {updated} provider(s).") - else: - self._show_message(Gtk.MessageType.INFO, "No providers were refreshed.") - finally: - self._refresh_running = False - self._refresh_all_btn.set_sensitive(True) - return False - - def _finish_refresh_all_models_error(self, err): - try: - self._show_message(Gtk.MessageType.ERROR, f"Refresh failed:\n{err}") - finally: - self._refresh_running = False - self._refresh_all_btn.set_sensitive(True) - return False - - def _import_profile(self): - if self._proc and self._proc.poll() is None: - self._show_message(Gtk.MessageType.WARNING, "Stop Codex before importing a profile.") - return - - chooser = Gtk.FileChooserDialog( - title="Import Codex Profile", - parent=self, - action=Gtk.FileChooserAction.OPEN, - ) - chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_OPEN, Gtk.ResponseType.OK) - resp = chooser.run() - filename = chooser.get_filename() if resp == Gtk.ResponseType.OK else None - chooser.destroy() - if not filename: - return - - confirm = Gtk.MessageDialog( - self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, - "Importing will replace the current endpoints and Codex config. Continue?" - ) - ok = confirm.run() == Gtk.ResponseType.YES - confirm.destroy() - if not ok: - return - - try: - import_profile_bundle(filename) - self._rebuild_combo() - self.log(f"Profile imported from {filename}") - self._show_message(Gtk.MessageType.INFO, "Profile imported successfully.") - except Exception as e: - self._show_message(Gtk.MessageType.ERROR, f"Import failed:\n{e}") - - def _on_endpoints_updated(self): - self._rebuild_combo() - - def _show_message(self, msg_type, text): - d = Gtk.MessageDialog(self, 0, msg_type, Gtk.ButtonsType.OK, text) - d.run() - d.destroy() - - def _show_changelog(self): - d = Gtk.Dialog(title="Changelog", transient_for=self, modal=True) - d.set_default_size(520, 480) - d.add_button("Close", Gtk.ResponseType.CLOSE) - area = d.get_content_area() - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - area.pack_start(sw, True, True, 0) - buf = Gtk.TextBuffer() - tv = Gtk.TextView(buffer=buf) - tv.set_editable(False) - tv.set_cursor_visible(False) - tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - sw.add(tv) - lines = [] - for ver, date, items in CHANGELOG: - lines.append(f"v{ver} ({date})") - for item in items: - lines.append(f" \u2022 {item}") - lines.append("") - txt = "\n".join(lines).strip() - buf.insert(buf.get_end_iter(), txt) - d.show_all() - d.run() - d.destroy() - - def _show_install_guide(self, which): - if which == "cli": - title = "Install Codex 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: - title = "Install Codex Desktop" - guide = ( - "Codex Desktop is required to use Desktop launch features.\n\n" - "Expected location: /opt/codex-desktop/start.sh\n\n" - "Download from:\n" - " https://codex.desktop.openai.com\n\n" - "After installing, restart the launcher." - ) - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, guide) - d.set_title(title) - d.run() - d.destroy() - - # ── launch ─────────────────────────────────────────────────── - - def _launch(self, target): - name = self._combo.get_active_text() - if not name: - self.log("ERROR: no endpoint selected") - return - model = self._model_combo.get_active_text() - if not model: - self.log("ERROR: no model selected") - return - - is_bgp = bool(name and name.startswith("🔀 ")) - 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) - self.log(f"=== 🔀 BGP: {pool_name} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===") - 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) - self.log(f"=== {ep['name']} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===") - 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": - d = Gtk.MessageDialog( - self, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO, - f"Codex auth check: {msg}\n\n" - "Launch may fail without valid authentication.\n" - "Continue anyway?" - ) - r = d.run() - d.destroy() - if r != Gtk.ResponseType.YES: - self._set_busy(False) - return - self._set_busy(True) - self.log(f"=== Codex Default (OAuth) → {'Desktop' if target == 'desktop' else 'CLI'} ===") - 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…") - _run_cleanup(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: - GLib.idle_add(self._show_error_dialog, "Proxy startup failed", str(e)) - return - self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})…") - begin_config_transaction(f"launch:{ep['name']}") - write_config_for_translated(ep, model, proxy_port) - else: - self.log(f"Configuring Codex for {ep['name']} (native)…") - begin_config_transaction(f"launch:{ep['name']}") - write_config_for_native(ep, model) - - if target == "desktop": - 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…") - _run_cleanup(self.log) - recover_config_if_needed(self.log) - - port = _pick_free_port() - self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes on :{port}…") - bgp_ep = { - "name": pool["name"], - "backend_type": "openai-compat", - "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)) - try: - _start_proxy_with_config(pcfg_path, port, self.log) - except RuntimeError as e: - GLib.idle_add(self._show_error_dialog, "BGP proxy startup failed", str(e)) - return - - 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 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_codex_default(self, target): - try: - self.log("Cleaning up stale processes…") - _run_cleanup(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 _show_error_dialog(self, title, message): - dialog = Gtk.MessageDialog( - transient_for=self, flags=0, - message_type=Gtk.MessageType.ERROR, - buttons=Gtk.ButtonsType.CLOSE, text=str(title)) - dialog.format_secondary_text(str(message)) - dialog.run() - dialog.destroy() - - def _launch_desktop(self, ep, model): - args = [str(START_SH)] - if ep["backend_type"] != "native": - args += ["--", "--ozone-platform=wayland"] - - self._proc = subprocess.Popen(args, 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 20 s — 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. Kill && retry if needed.") - 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): - """Launch codex CLI in a terminal with the selected endpoint.""" - self.log(f"Launching Codex CLI with {ep['name']}…") - - terms = [ - ("x-terminal-emulator", ["-e"]), - ("kgx", ["--"]), - ("gnome-terminal", ["--"]), - ("konsole", ["-e"]), - ("xterm", ["-e"]), - ] - term = None - term_args = None - for t in terms: - if shutil.which(t[0]): - term = t[0] - term_args = t[1] - break - - if not term: - self.log("ERROR: no terminal emulator found (tried x-terminal-emulator, kgx, gnome-terminal, konsole, xterm)") - return - - sandbox = self._sandbox_combo.get_active_id() or "workspace-write" - approval = self._approval_combo.get_active_id() or "on-request" - - cmd_parts = [term] + term_args - - if ep["backend_type"] == "native": - cmd_parts.extend(["codex", "-c", f"model={model}", - "-s", sandbox, "-a", approval]) - else: - cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}", - "-s", sandbox, "-a", approval]) - - self.log(f"Running: {' '.join(cmd_parts)}") - self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) - pid = self._proc.pid - self.log(f"CLI started in terminal (PID {pid})") - - # Wait for terminal process - 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)…") - self._proc = subprocess.Popen( - [str(START_SH), "--", "--ozone-platform=wayland"], - 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.") - self.log(f"--- last log lines ---\n{_last_log_lines()}") - self._proc = None - - def _launch_cli_default(self): - self.log("Launching Codex CLI (default OAuth)…") - terms = [ - ("x-terminal-emulator", ["-e"]), - ("kgx", ["--"]), - ("gnome-terminal", ["--"]), - ("konsole", ["-e"]), - ("xterm", ["-e"]), - ] - term = None - term_args = None - for t in terms: - if shutil.which(t[0]): - term = t[0] - term_args = t[1] - break - - if not term: - self.log("ERROR: no terminal emulator found") - return - - sandbox = self._sandbox_combo.get_active_id() or "workspace-write" - approval = self._approval_combo.get_active_id() or "on-request" - cmd_parts = [term] + term_args + ["codex", "-s", sandbox, "-a", approval] - self.log(f"Running: {' '.join(cmd_parts)}") - 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: - pgid = os.getpgid(self._proc.pid) - os.killpg(pgid, signal.SIGTERM) - time.sleep(1) - if self._proc.poll() is None: - os.killpg(pgid, signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass - self._proc = None - _stop_proxy() - _run_cleanup(self.log) - restore_config() - end_config_transaction() - LOG_DIR.mkdir(parents=True, exist_ok=True) - LAUNCH_LOG.unlink(missing_ok=True) - self.log("Cleanup complete") - self._set_busy(False) - self.log("Ready.") - - def _do_close(self): - if self._proc and self._proc.poll() is None: - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, - "Codex is still running. Kill it?") - r = d.run() - d.destroy() - if r != Gtk.ResponseType.YES: - return - self._kill() - _stop_proxy() - Gtk.main_quit() - -# ═══════════════════════════════════════════════════════════════════ -# Endpoint manager dialog -# ═══════════════════════════════════════════════════════════════════ - -class EndpointMgr(Gtk.Window): - def __init__(self, parent): - super().__init__(title="Manage Endpoints") - self.set_transient_for(parent) - self.set_modal(True) - self._parent = parent - self.set_default_size(500, 350) - self.set_border_width(12) - self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self.add(vbox) - - title_lbl = Gtk.Label(label="Endpoints") - title_lbl.set_use_markup(True) - vbox.pack_start(title_lbl, False, False, 0) - - sw = Gtk.ScrolledWindow() - vbox.pack_start(sw, True, True, 0) - self._store = Gtk.ListStore(str, str, str, str) # name, provider, backend, default_model - self._tree = Gtk.TreeView(model=self._store) - for i, title in enumerate(["Name", "Provider", "Type", "Default Model"]): - col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i) - col.set_resizable(True) - self._tree.append_column(col) - sw.add(self._tree) - - btn_bar = Gtk.Box(spacing=8) - vbox.pack_start(btn_bar, False, False, 0) - self._add_btn = Gtk.Button(label="Add") - self._add_btn.connect("clicked", lambda b: self._add()) - btn_bar.pack_start(self._add_btn, False, False, 0) - self._edit_btn = Gtk.Button(label="Edit") - self._edit_btn.connect("clicked", lambda b: self._edit()) - btn_bar.pack_start(self._edit_btn, False, False, 0) - self._delete_btn = Gtk.Button(label="Delete") - self._delete_btn.connect("clicked", lambda b: self._delete()) - btn_bar.pack_start(self._delete_btn, False, False, 0) - self._default_btn = Gtk.Button(label="Set Default") - self._default_btn.connect("clicked", lambda b: self._set_default()) - btn_bar.pack_start(self._default_btn, False, False, 0) - self._doctor_btn = Gtk.Button(label="Doctor") - self._doctor_btn.connect("clicked", lambda b: self._doctor_selected()) - btn_bar.pack_start(self._doctor_btn, False, False, 0) - self._doctor_all_btn = Gtk.Button(label="Doctor All") - self._doctor_all_btn.connect("clicked", lambda b: self._doctor_all()) - btn_bar.pack_start(self._doctor_all_btn, False, False, 0) - self._mgr_close_btn = Gtk.Button(label="Close") - self._mgr_close_btn.connect("clicked", lambda b: self.destroy()) - btn_bar.pack_end(self._mgr_close_btn, False, False, 0) - - self._rebuild() - self.show_all() - - def _rebuild(self): - data = load_endpoints() - self._store.clear() - for ep in data["endpoints"]: - provider = ep.get("provider_preset", "Custom") - bt = label_for_backend(ep["backend_type"]) - self._store.append([ep["name"], provider, bt, ep.get("default_model", "")]) - - def _selected(self): - sel = self._tree.get_selection() - m, i = sel.get_selected() - if i is None: - return None - return self._store[i][0] - - def _add(self): - try: - self._dialog = EditEndpointDialog(self, None) - self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _edit(self): - name = self._selected() - if name: - try: - self._dialog = EditEndpointDialog(self, name) - self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _delete(self): - name = self._selected() - if not name: - return - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, - f'Delete endpoint "{name}"?') - r = d.run() - d.destroy() - if r != Gtk.ResponseType.YES: - 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() - self._parent._on_endpoints_updated() - - def _set_default(self): - name = self._selected() - if not name: - return - data = load_endpoints() - data["default"] = name - save_endpoints(data) - self._rebuild() - self._parent._on_endpoints_updated() - - def _doctor_selected(self): - name = self._selected() - if not name: - return - ep = get_endpoint(name) - if not ep: - return - wait_dlg = Gtk.Dialog(title=f"Doctor: {name}…", parent=self, modal=True) - wait_dlg.set_default_size(280, 80) - lbl = Gtk.Label(label=f"Running diagnostics for {name}…") - lbl.set_margin_top(16) - lbl.set_margin_bottom(16) - wait_dlg.get_content_area().pack_start(lbl, True, True, 0) - wait_dlg.show_all() - - def _run(): - checks = run_endpoint_doctor(ep) - GLib.idle_add(wait_dlg.destroy) - GLib.idle_add(_show_doctor_results, self, name, checks) - - threading.Thread(target=_run, daemon=True).start() - wait_dlg.run() - - def _doctor_all(self): - data = load_endpoints() - endpoints = data.get("endpoints", []) - if not endpoints: - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, "No endpoints configured.") - d.run() - d.destroy() - return - wait_dlg = Gtk.Dialog(title="Doctor All…", parent=self, modal=True) - wait_dlg.set_default_size(320, 80) - lbl = Gtk.Label(label=f"Testing {len(endpoints)} endpoints…") - lbl.set_margin_top(16) - lbl.set_margin_bottom(16) - wait_dlg.get_content_area().pack_start(lbl, True, True, 0) - wait_dlg.show_all() - - 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])] - GLib.idle_add(wait_dlg.destroy) - GLib.idle_add(self._show_doctor_all_results, all_results) - - threading.Thread(target=_run, daemon=True).start() - wait_dlg.run() - - def _show_doctor_all_results(self, all_results): - dlg = Gtk.Dialog(title="Doctor All Results", parent=self, modal=True) - dlg.add_button("Close", Gtk.ResponseType.CLOSE) - dlg.set_default_size(560, 450) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - sw.add(area) - 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) - if failed: - color, status = "#e74c3c", f"{failed} failed" - else: - color, status = "#27ae60", f"{passed} passed" - hdr = Gtk.Label() - hdr.set_markup(f'{ep_name} {status}') - hdr.set_xalign(0) - area.pack_start(hdr, False, False, 4) - for name, ok, detail in checks: - if ok is True: - sym, sc = "\u2713", "#27ae60" - elif ok is False: - sym, sc = "\u2717", "#e74c3c" - else: - sym, sc = "\u25CB", "#f39c12" - row = Gtk.Box(spacing=4) - row.set_margin_start(12) - icon = Gtk.Label() - icon.set_markup(f'{sym}') - lbl = Gtk.Label() - lbl.set_markup(f'{name}' - + (f' {detail}' if detail else '') - + '') - lbl.set_xalign(0) - row.pack_start(icon, False, False, 0) - row.pack_start(lbl, False, False, 0) - area.pack_start(row, False, False, 1) - sep = Gtk.Separator() - area.pack_start(sep, False, False, 4) - dlg.get_content_area().pack_start(sw, True, True, 0) - dlg.show_all() - dlg.run() - dlg.destroy() - -class EditEndpointDialog(Gtk.Dialog): - def __init__(self, parent, existing_name): - title = "Edit Endpoint" if existing_name else "Add Endpoint" - Gtk.Dialog.__init__(self, title=title) - self.set_transient_for(parent) - self.set_modal(True) - self._parent_mgr = parent - self._existing_name = existing_name - self._data = get_endpoint(existing_name) if existing_name else { - "name": "", "backend_type": "openai-compat", - "base_url": "", "api_key": "", "default_model": "", "models": [], - "provider_preset": "Custom", - } - self.set_default_size(480, 520) - - area = self.get_content_area() - area.set_spacing(6) - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - - grid = Gtk.Grid(column_spacing=8, row_spacing=6) - area.pack_start(grid, False, False, 0) - - def add_row(row, label, widget): - grid.attach(Gtk.Label(label=label, xalign=1), 0, row, 1, 1) - grid.attach(widget, 1, row, 1, 1) - - self._entry_name = Gtk.Entry(text=self._data.get("name", "")) - add_row(0, "Name:", self._entry_name) - - self._combo_preset = Gtk.ComboBoxText() - self._preset_names = list(PROVIDER_PRESETS.keys()) - for preset_name in self._preset_names: - self._combo_preset.append_text(preset_name) - self._combo_preset.set_active(self._preset_names.index(self._data.get("provider_preset", "Custom")) if self._data.get("provider_preset", "Custom") in self._preset_names else 0) - self._combo_preset.connect("changed", lambda c: self._apply_selected_preset()) - add_row(1, "Preset:", self._combo_preset) - - self._combo_type = Gtk.ComboBoxText() - for val, lab in [("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.append(val, lab) - bt = self._data.get("backend_type", "openai-compat") - self._combo_type.set_active_id(bt) - add_row(2, "Type:", self._combo_type) - - self._entry_url = Gtk.Entry(text=self._data.get("base_url", "")) - add_row(3, "Base URL:", self._entry_url) - - self._entry_key = Gtk.Entry(text=self._data.get("api_key", "")) - self._entry_key.set_visibility(False) - key_box = Gtk.Box(spacing=6) - key_box.pack_start(self._entry_key, True, True, 0) - self._oauth_btn = Gtk.Button(label="OAuth Login") - self._oauth_btn.connect("clicked", lambda b: self._do_oauth_login()) - key_box.pack_start(self._oauth_btn, False, False, 0) - add_row(4, "API Key:", key_box) - self._oauth_btn.set_visible(False) - - self._entry_cc_ver = Gtk.Entry(text=self._data.get("cc_version", "")) - self._entry_cc_ver.set_placeholder_text("e.g. 0.26.8 (Command Code only)") - add_row(5, "CC Version:", self._entry_cc_ver) - - reasoning_css = b""" - switch.reasoning-toggle { - min-width: 56px; min-height: 28px; - border-radius: 14px; - background: #e67e22; - border: 2px solid #cf6d17; - } - switch.reasoning-toggle:checked { - background: #2ecc71; - border: 2px solid #27ae60; - } - switch.reasoning-toggle slider { - min-width: 24px; min-height: 24px; - border-radius: 12px; - background: white; - border: 1px solid #bbb; - } - """ - reasoning_box = Gtk.Box(spacing=10) - self._switch_reasoning = Gtk.Switch() - self._switch_reasoning.set_name("reasoning-toggle") - ctx = self._switch_reasoning.get_style_context() - ctx.add_class("reasoning-toggle") - try: - css_prov = Gtk.CssProvider() - css_prov.load_from_data(reasoning_css) - ctx.add_provider(css_prov, Gtk.STYLE_PROVIDER_PRIORITY_USER) - except Exception: - pass - self._switch_reasoning.set_active(self._data.get("reasoning_enabled", True)) - self._switch_reasoning.connect("notify::active", lambda *a: self._on_reasoning_toggled()) - reasoning_box.pack_start(self._switch_reasoning, False, False, 0) - self._lbl_reasoning = Gtk.Label() - reasoning_box.pack_start(self._lbl_reasoning, False, False, 0) - add_row(6, "Reasoning:", reasoning_box) - - self._combo_effort = Gtk.ComboBoxText() - for ev, el in [("none", "None"), ("minimal", "Minimal"), ("low", "Low"), - ("medium", "Medium"), ("high", "High"), ("max", "Max")]: - self._combo_effort.append(ev, el) - saved_effort = self._data.get("reasoning_effort", "medium") - self._combo_effort.set_active_id(saved_effort if saved_effort in ("none","minimal","low","medium","high","max") else "medium") - add_row(7, "Effort:", self._combo_effort) - self._on_reasoning_toggled() - - # Models - mlbl = Gtk.Label(label="Models:", xalign=0) - area.pack_start(mlbl, False, False, 4) - - mbox = Gtk.Box(spacing=6) - area.pack_start(mbox, False, False, 0) - self._entry_model = Gtk.Entry() - mbox.pack_start(self._entry_model, True, True, 0) - self._add_model_btn = Gtk.Button(label="Add") - self._add_model_btn.connect("clicked", lambda b: self._add_model()) - mbox.pack_start(self._add_model_btn, False, False, 0) - self._add_list_btn = Gtk.Button(label="Add List") - self._add_list_btn.connect("clicked", lambda b: self._add_models_from_text()) - mbox.pack_start(self._add_list_btn, False, False, 0) - self._fetch_models_btn = Gtk.Button(label="Fetch from API") - self._fetch_models_btn.connect("clicked", lambda b: self._fetch_models()) - mbox.pack_start(self._fetch_models_btn, False, False, 0) - self._test_btn = Gtk.Button(label="Test Endpoint") - self._test_btn.connect("clicked", lambda b: self._diagnose_endpoint()) - mbox.pack_start(self._test_btn, False, False, 0) - - bulk_lbl = Gtk.Label(label="Bulk add models (one per line or comma-separated):", xalign=0) - area.pack_start(bulk_lbl, False, False, 2) - bulk_sw = Gtk.ScrolledWindow() - bulk_sw.set_min_content_height(72) - area.pack_start(bulk_sw, False, False, 0) - self._bulk_buf = Gtk.TextBuffer() - self._bulk_text = Gtk.TextView(buffer=self._bulk_buf) - self._bulk_text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - bulk_sw.add(self._bulk_text) - - sw = Gtk.ScrolledWindow() - sw.set_min_content_height(120) - area.pack_start(sw, True, True, 0) - self._model_store = Gtk.ListStore(str) - self._model_tree = Gtk.TreeView(model=self._model_store) - self._model_tree.append_column(Gtk.TreeViewColumn("Model ID", Gtk.CellRendererText(), text=0)) - self._model_tree.set_rules_hint(True) - sw.add(self._model_tree) - self._model_tree.connect("row-activated", lambda t, p, c: self._remove_model(p)) - - for m in self._data.get("models", []): - self._model_store.append([m]) - - # Default model combo - dbox = Gtk.Box(spacing=6) - area.pack_start(dbox, False, False, 0) - dbox.pack_start(Gtk.Label(label="Default Model:"), False, False, 0) - self._combo_default = Gtk.ComboBoxText() - self._refresh_default_combo() - dbox.pack_start(self._combo_default, True, True, 0) - dm = self._data.get("default_model", "") - if dm: - self._combo_default.set_active_id(dm) - - self._apply_selected_preset(initial=True) - - # Buttons - self.add_button("Cancel", Gtk.ResponseType.CANCEL) - self.add_button("Save", Gtk.ResponseType.OK) - self.connect("response", self._on_response) - self.show_all() - - def _add_model(self): - m = normalize_model_id(self._entry_model.get_text()) - if m: - current = self._combo_default.get_active_text() - self._model_store.append([m]) - self._refresh_default_combo(current or m) - self._entry_model.set_text("") - - def _add_models_from_text(self): - buf = self._bulk_buf.get_text(self._bulk_buf.get_start_iter(), self._bulk_buf.get_end_iter(), True) - models = parse_model_list(buf) - if not models: - return - current = self._combo_default.get_active_text() - existing = {self._model_store[i][0] for i in range(len(self._model_store))} - added = False - for mid in models: - if mid not in existing: - self._model_store.append([mid]) - existing.add(mid) - added = True - if added: - self._refresh_default_combo(current or models[0]) - self._bulk_buf.set_text("") - - def _apply_selected_preset(self, initial=False): - preset_name = self._combo_preset.get_active_text() or "Custom" - preset = PROVIDER_PRESETS.get(preset_name, PROVIDER_PRESETS["Custom"]) - oauth_provider = preset.get("oauth_provider", "") - is_oauth = bool(oauth_provider) - self._oauth_btn.set_visible(is_oauth) - if oauth_provider == "freebuff": - self._oauth_btn.set_label("Freebuff Login") - self._entry_key.set_placeholder_text("Auto-filled by freebuff login") - elif is_oauth: - self._oauth_btn.set_label("OAuth Login") - self._entry_key.set_placeholder_text("Auto-filled by OAuth") - else: - self._entry_key.set_placeholder_text("") - if not initial or self._existing_name is None: - self._combo_type.set_active_id(preset.get("backend_type", "openai-compat")) - self._entry_url.set_text(preset.get("base_url", "")) - if not self._entry_key.get_text().strip(): - self._entry_key.set_text("") - cc_ver = preset.get("cc_version", "") - if cc_ver and not self._entry_cc_ver.get_text().strip(): - self._entry_cc_ver.set_text(cc_ver) - if preset.get("models") and len(self._model_store) == 0: - for mid in preset["models"]: - self._model_store.append([mid]) - self._refresh_default_combo(preset["models"][0]) - if initial and self._data.get("models"): - self._refresh_default_combo(self._data.get("default_model", "")) - - def _on_reasoning_toggled(self, *_): - active = self._switch_reasoning.get_active() - self._combo_effort.set_sensitive(active) - if active: - self._lbl_reasoning.set_markup('ON') - else: - self._lbl_reasoning.set_markup('OFF') - - def _do_oauth_login(self): - preset_name = self._combo_preset.get_active_text() or "Custom" - preset = PROVIDER_PRESETS.get(preset_name, {}) - provider = preset.get("oauth_provider", "") - if provider == "freebuff": - self._freebuff_oauth_flow() - elif (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 = os.path.expanduser("~/.cache/codex-proxy/google-antigravity-oauth-token.json" if is_antigravity else "~/.cache/codex-proxy/google-cli-oauth-token.json") - - if is_antigravity: - CLIENT_ID = "REDACTED_ANTIGRAVITY_CLIENT_ID" - CLIENT_SECRET = "REDACTED_ANTIGRAVITY_SECRET" - SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/cclog", - "https://www.googleapis.com/auth/experimentsandconfigs", - ] - port = 51121 - redirect_uri = f"http://localhost:{port}/oauth-callback" - callback_path = "/oauth-callback" - provider_kind = "antigravity" - else: - CLIENT_ID = "REDACTED_GEMINI_CLI_CLIENT_ID" - CLIENT_SECRET = "REDACTED_GEMINI_CLI_SECRET" - SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - ] - port = 0 - redirect_uri = None - callback_path = "/oauth2callback" - provider_kind = "cli" - - import http.server - - 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" - ) - - dlg = Gtk.Dialog(title="Google OAuth (Gemini Mode)", parent=self, modal=True) - dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) - dlg.set_default_size(520, 280) - area = dlg.get_content_area() - area.set_margin_start(16) - area.set_margin_end(16) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(8) - - area.pack_start(Gtk.Label(label="Sign in with Google", use_markup=True, xalign=0), False, False, 0) - area.pack_start(Gtk.Label(label="Emulating Gemini CLI OAuth — no client_secret.json needed.", xalign=0), False, False, 0) - - link_lbl = Gtk.Label() - link_lbl.set_markup(f'Click here to open Google authorization') - link_lbl.set_line_wrap(True) - area.pack_start(link_lbl, False, False, 4) - - self._oauth_status = Gtk.Label(label="Opening browser…", xalign=0) - area.pack_start(self._oauth_status, False, False, 4) - - spinner = Gtk.Spinner() - spinner.start() - area.pack_start(spinner, False, False, 8) - - area.show_all() - - 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] - with open("/tmp/codex-oauth-debug.log", "a") as _dbg: - _dbg.write(f"[{time.strftime('%H:%M:%S')}] GET {self2.path} state={received_state[0]} code={'code' in params}\n") - 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"
" - b"