diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0434d99..ba58ebf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog
+## v3.10.8 (2026-05-25)
+
+**OAuth & Antigravity Endpoint Fixes**
+
+### Re-OAuth Buttons Fixed
+- Linux GUI: `load_oauth_secrets()` was undefined — buttons crashed silently on click
+- Now loads OAuth secrets inline from `~/.config/codex-launcher/oauth-secrets.json`
+- Both Linux and Windows Re-OAuth use PKCE + localhost callback (was deprecated OOB paste)
+
+### Antigravity Staging/Sandbox Blocked by Default
+- Proxy: production `cloudcode-pa.googleapis.com` tried FIRST, sandbox/daily/autopush as fallback only
+- Proxy: 403 SERVICE_DISABLED now falls through to next endpoint instead of returning error immediately
+- Project discovery: validates against production endpoint, not staging-cloudaicompanion.sandbox
+- Antigravity preset `base_url` changed to production (was `daily-cloudcode-pa.sandbox.googleapis.com`)
+- `[antigravity-endpoint]` log line shows which endpoints are being tried
+
+### Other Fixes
+- GLib.idle_add lambda returning truthy tuple fixed (caused repeated callbacks)
+- Windows GUI project discovery also uses production endpoint
+
## v3.10.7 (2026-05-25)
**Prompt Enhancer — Fix Lost Context After Compaction**
diff --git a/codex-launcher-gui b/codex-launcher-gui
new file mode 100755
index 0000000..21c75c2
--- /dev/null
+++ b/codex-launcher-gui
@@ -0,0 +1,5726 @@
+#!/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.10.4", "2026-05-25", [
+ "OAuth Secrets editor in GUI — update client ID/secret without editing files",
+ "Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
+ ]),
+ ("3.10.3", "2026-05-25", [
+ "Fix Antigravity 404: map display names to verified REST API model IDs",
+ "REST API uses slugs (gemini-3-flash) not display names (Gemini 3.5 Flash)",
+ "Match agy CLI model list: Gemini 3.5 Flash (H/M/L), 3.1 Pro (H/L), Claude 4.6, GPT-OSS",
+ ]),
+ ("3.10.2", "2026-05-25", [
+ "Fetch from API now works for Antigravity — returns current model list",
+ ]),
+ ("3.10.0", "2026-05-25", [
+ "Provider editor: Remove Selected, Clear All, Sync from Preset buttons for model list",
+ "Sync from Preset replaces model list with current preset models",
+ "Stale saved Antigravity models auto-refreshed on preset sync",
+ ]),
+ ("3.9.9", "2026-05-25", [
+ "Refresh Antigravity preset: Gemini 3.5 Flash, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6, GPT-OSS 120B",
+ "Fix Antigravity alias map for new tiered model IDs (high/medium/low/thinking)",
+ "Add model context sizes for Gemini 3.5 Flash, Gemini 3.1 Pro, Claude 4.6, GPT-OSS 120B",
+ ]),
+ ("3.9.8", "2026-05-25", [
+ "Fix Codex Desktop sending wrong model (gpt-5.4-mini) instead of selected model",
+ "Proxy remaps Desktop forced models to user-selected model via CODEX_LAUNCHER_MODEL",
+ "Write review_model + wire_api + retries to config.toml for Desktop compatibility",
+ "send_json() globally catches BrokenPipeError — no more crashes on disconnect",
+ ]),
+ ("3.9.7", "2026-05-25", [
+ "Forward real Codebuff error messages to user (not generic 429)",
+ "Return HTTP 200 with Responses API format for rate limits so Codex displays message",
+ "Extract retryAfterMs from Codebuff 429 responses for accurate cooldown",
+ "RateLimitError carries upstream message through session + chat error paths",
+ "BrokenPipeError crash fix on 'all accounts exhausted' response",
+ "Fix 3 SyntaxWarnings for invalid escape sequences in docstrings",
+ "_codebuff_start_run returns actual error body instead of None",
+ ]),
+ ("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 codebuff, Google OAuth, API keys",
+ "/v1/accounts endpoint for account pool status",
+ ]),
+ ("3.9.0", "2026-05-24", [
+ "Multi-account rotation for OAuth providers (codebuff, Google, API keys)",
+ "Automatic failover: when one account hits rate limit, next is used",
+ "Codebuff: 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-codebuff-model and x-codebuff-instance-id headers",
+ ]),
+ ("3.8.4", "2026-05-24", [
+ "FIXED: Codebuff streaming — SSE events now reach Codex client",
+ "Root cause: stream_buffered_events was never called for codebuff",
+ "Codebuff stream uses buffered flushing (30ms / 4KB / urgent)",
+ "Codebuff OAuth — built-in login flow (no external CLI needed)",
+ "Codebuff API: reverse-engineered www.codebuff.com endpoints",
+ "Codebuff session management with instance ID (waiting room)",
+ "Codebuff agent run lifecycle (start/finish) with model routing",
+ "Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
+ "Reasoning mode works with codebuff (thinking tokens supported)",
+ "GUI: Sandbox mode selector (Read-only / Workspace / Full Access)",
+ "GUI: Approval mode selector (Untrusted / On Request / Full Auto)",
+ "GUI: Codebuff Login button in endpoint editor",
+ "Fixed _STATS undefined error in /health endpoint",
+ "Fixed codebuff credential path (reads default account)",
+ ]),
+ ("3.8.1", "2026-05-24", [
+ "Codebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
+ "Codebuff 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://cloudcode-pa.googleapis.com",
+ "oauth_provider": "google-antigravity",
+ "models": [
+ "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)",
+ "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)",
+ "Claude Sonnet 4.6 (Thinking)",
+ "Claude Opus 4.6 (Thinking)",
+ "GPT-OSS 120B (Medium)",
+ ],
+ },
+ "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",
+ ],
+ },
+ "Codebuff (Free DeepSeek/Kimi)": {
+ "backend_type": "codebuff",
+ "base_url": "https://www.codebuff.com",
+ "oauth_provider": "codebuff",
+ "models": [
+ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
+ "moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
+ ],
+ },
+ "Freebuff (Free DeepSeek/Kimi)": {
+ "backend_type": "codebuff",
+ "base_url": "https://www.codebuff.com",
+ "oauth_provider": "codebuff",
+ "models": [
+ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
+ "moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
+ ],
+ },
+ "FreeBuff": {
+ "backend_type": "codebuff",
+ "base_url": "https://www.codebuff.com",
+ "oauth_provider": "codebuff",
+ "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",
+ "codebuff": "Codebuff (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
+
+_ANTIGRAVITY_MODELS = [
+ "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)",
+ "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)",
+ "Claude Sonnet 4.6 (Thinking)",
+ "Claude Opus 4.6 (Thinking)",
+ "GPT-OSS 120B (Medium)",
+]
+
+def fetch_models_for_endpoint(endpoint, timeout=10):
+ bt = endpoint.get("backend_type", "")
+ if bt == "gemini-oauth-antigravity":
+ return list(_ANTIGRAVITY_MODELS), None
+ url = endpoint_models_url(endpoint)
+ if not url:
+ return None, "Base URL is empty"
+ try:
+ req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint))
+ raw = urllib.request.urlopen(req, timeout=timeout).read()
+ payload = json.loads(raw)
+ items = payload.get("data") or payload.get("models") or []
+ ids = []
+ seen = set()
+ for item in items:
+ mid = item.get("id") if isinstance(item, dict) else None
+ if mid and mid not in seen:
+ seen.add(mid)
+ ids.append(mid)
+ if not ids:
+ return None, "No models returned"
+ return ids, None
+ except Exception as e:
+ return None, str(e)
+
+def refresh_endpoint_models(endpoint):
+ ids, err = fetch_models_for_endpoint(endpoint)
+ if not ids:
+ return None, err
+ updated = dict(endpoint)
+ updated["models"] = ids
+ if updated.get("default_model") not in ids:
+ updated["default_model"] = ids[0]
+ return updated, None
+
+# ═══════════════════════════════════════════════════════════════════
+# 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'review_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'wire_api = "responses"\n',
+ f'request_max_retries = 1\n',
+ f'stream_max_retries = 0\n',
+ f'stream_idle_timeout_ms = 600000\n',
+ f'\n[profiles."{endpoint["name"]}"]\n',
+ f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
+ f'model = "{_toml_safe(selected_model)}"\n',
+ f'review_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
+# ═══════════════════════════════════════════════════════════════════
+
+def _oauth_discover_project(access_token, token_path, tokens):
+ project_id = ""
+ try:
+ lr = urllib.request.Request(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ data=json.dumps({}).encode(),
+ headers={"Content-Type": "application/json",
+ "Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ lresp = urllib.request.urlopen(lr, timeout=15)
+ ldata = json.loads(lresp.read())
+ p = ldata.get("cloudaicompanionProject", "")
+ if isinstance(p, dict):
+ project_id = p.get("id", "")
+ elif isinstance(p, str):
+ project_id = p
+ except Exception:
+ pass
+ if not project_id:
+ return ""
+ try:
+ test_url = f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={project_id}"
+ test_req = urllib.request.Request(test_url,
+ headers={"Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ urllib.request.urlopen(test_req, timeout=10)
+ except urllib.error.HTTPError as e:
+ if e.code == 403 and "SERVICE_DISABLED" in (e.read().decode()[:500]):
+ print(f"[oauth] project {project_id} has API disabled, searching for valid project...", file=sys.stderr)
+ try:
+ list_req = urllib.request.Request(
+ "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE",
+ headers={"Authorization": f"Bearer {access_token}"})
+ list_resp = urllib.request.urlopen(list_req, timeout=15)
+ projects = json.loads(list_resp.read()).get("projects", [])
+ for proj in projects:
+ pid = proj.get("projectId", "")
+ if not pid or pid == project_id:
+ continue
+ try:
+ t2 = urllib.request.Request(
+ f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={pid}",
+ headers={"Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ urllib.request.urlopen(t2, timeout=10)
+ project_id = pid
+ print(f"[oauth] found working project: {pid}", file=sys.stderr)
+ break
+ except Exception:
+ continue
+ except Exception:
+ pass
+ tokens["project_id"] = project_id
+ with open(token_path, "w") as f:
+ json.dump(tokens, f, indent=2)
+ os.chmod(token_path, 0o600)
+ return project_id
+
+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.10.7")
+ 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)
+ oauth_btn = Gtk.Button(label="OAuth Secrets")
+ oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
+ hdr.pack_end(oauth_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._clear_log_btn = Gtk.Button(label="Clear Log")
+ self._clear_log_btn.connect("clicked", lambda b: self._buf.set_text(""))
+ bb.pack_start(self._clear_log_btn, False, False, 0)
+ self._restart_btn = Gtk.Button(label="Restart Proxy")
+ self._restart_btn.connect("clicked", lambda b: self._manual_restart_proxy())
+ self._restart_btn.set_sensitive(False)
+ bb.pack_start(self._restart_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)
+ self._restart_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 _manual_restart_proxy(self):
+ self._kill()
+ time.sleep(1)
+ try:
+ ep_name = load_endpoints().get("default")
+ if not ep_name:
+ self.log("No default endpoint set")
+ return
+ for ep in load_endpoints().get("endpoints", []):
+ if ep.get("name") == ep_name:
+ self._start_proxy(ep)
+ self.log("Proxy restarted")
+ break
+ except Exception as e:
+ self.log(f"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…")
+ os.environ["CODEX_LAUNCHER_MODEL"] = model
+ try:
+ proxy_port = _start_proxy_for(ep, self.log)
+ except RuntimeError as e:
+ GLib.idle_add(self._show_error_dialog, "Proxy startup failed", str(e))
+ return
+ self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})…")
+ begin_config_transaction(f"launch:{ep['name']}")
+ write_config_for_translated(ep, model, proxy_port)
+ else:
+ self.log(f"Configuring Codex for {ep['name']} (native)…")
+ begin_config_transaction(f"launch:{ep['name']}")
+ write_config_for_native(ep, model)
+
+ if target == "desktop":
+ 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()
+
+ def _google_reoauth(self, provider, parent_dlg=None):
+ import http.server
+ is_antigravity = provider == "google-antigravity"
+ sec_key = "antigravity" if is_antigravity else "gemini_cli"
+ _sp = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json")
+ try:
+ with open(_sp) as _f:
+ _secrets_data = json.load(_f)
+ except Exception:
+ _secrets_data = {}
+ sec = _secrets_data.get(sec_key, {})
+ CLIENT_ID = sec.get("client_id", "")
+ CLIENT_SECRET = sec.get("client_secret", "")
+ if not CLIENT_ID or not CLIENT_SECRET:
+ self._show_error_dialog("Missing OAuth secrets",
+ f"No client_id/client_secret for {sec_key}.\nSet them in OAuth Secrets first.")
+ return
+ token_file = "google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json"
+ token_path = os.path.expanduser(f"~/.cache/codex-proxy/{token_file}")
+ provider_kind = "antigravity" if is_antigravity else "cli"
+
+ if is_antigravity:
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+ ]
+ port = 51121
+ redirect_uri = f"http://localhost:{port}/oauth-callback"
+ callback_path = "/oauth-callback"
+ else:
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ ]
+ 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"
+ callback_path = "/oauth2callback"
+
+ state = secrets.token_hex(32)
+ verifier = secrets.token_urlsafe(64)
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+
+ scope_str = " ".join(SCOPES)
+ auth_url = (
+ f"https://accounts.google.com/o/oauth2/v2/auth?"
+ f"client_id={CLIENT_ID}"
+ f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
+ f"&response_type=code"
+ f"&scope={urllib.parse.quote(scope_str)}"
+ f"&access_type=offline"
+ f"&prompt=select_account%20consent"
+ f"&state={state}"
+ f"&code_challenge={challenge}"
+ f"&code_challenge_method=S256"
+ )
+
+ oauth_dlg = Gtk.Dialog(title=f"Re-OAuth: {'Antigravity' if is_antigravity else 'Gemini CLI'}", parent=parent_dlg or self, modal=True)
+ oauth_dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ oauth_dlg.set_default_size(520, 200)
+ ca = oauth_dlg.get_content_area()
+ ca.set_margin_start(12)
+ ca.set_margin_end(12)
+ ca.set_spacing(6)
+ ca.pack_start(Gtk.Label(label=f"Re-authenticating {'Antigravity' if is_antigravity else 'Gemini CLI'}", use_markup=True, xalign=0), False, False, 0)
+ link_lbl = Gtk.Label(label="Click here to open Google authorization", use_markup=True, xalign=0)
+ link_lbl.set_markup(f'Click here to open Google authorization')
+ ca.pack_start(link_lbl, False, False, 4)
+ status_lbl = Gtk.Label(label="Waiting for browser callback...", xalign=0)
+ ca.pack_start(status_lbl, False, False, 4)
+ ca.show_all()
+
+ code_holder = [None]
+ error_holder = [None]
+
+ class OAuthHandler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self2):
+ qs = urllib.parse.urlparse(self2.path).query
+ params = urllib.parse.parse_qs(qs)
+ if "code" in params:
+ if params.get("state", [None])[0] != state:
+ self2.send_response(400)
+ self2.end_headers()
+ self2.wfile.write(b"CSRF state mismatch")
+ error_holder[0] = "CSRF state mismatch"
+ return
+ code_holder[0] = params["code"][0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini")
+ self2.end_headers()
+ else:
+ error_holder[0] = params.get("error", ["unknown"])[0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
+ self2.end_headers()
+ def log_message(self2, fmt, *args):
+ pass
+
+ try:
+ bind_host = "localhost" if is_antigravity else "127.0.0.1"
+ server = http.server.HTTPServer((bind_host, port), OAuthHandler)
+ except OSError:
+ status_lbl.set_text(f"Port {port} in use — close other apps and retry.")
+ oauth_dlg.run()
+ oauth_dlg.destroy()
+ return
+
+ def _wait():
+ deadline = time.time() + 120
+ while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
+ server.handle_request()
+ server.server_close()
+ if code_holder[0]:
+ try:
+ tok_data = urllib.parse.urlencode({
+ "code": code_holder[0], "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
+ "redirect_uri": redirect_uri, "grant_type": "authorization_code",
+ "code_verifier": verifier,
+ }).encode()
+ req = urllib.request.Request("https://oauth2.googleapis.com/token", data=tok_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
+ resp = urllib.request.urlopen(req, timeout=30)
+ tokens = json.loads(resp.read())
+ tokens["client_id"] = CLIENT_ID
+ tokens["client_secret"] = CLIENT_SECRET
+ tokens["provider_kind"] = provider_kind
+ tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
+ os.makedirs(os.path.dirname(token_path), exist_ok=True)
+ with open(token_path, "w") as f:
+ json.dump(tokens, f, indent=2)
+ os.chmod(token_path, 0o600)
+ project_id = _oauth_discover_project(tokens["access_token"], token_path, tokens)
+ def _on_success():
+ status_lbl.set_text(f"Authorization successful! Project: {project_id or 'none'}")
+ GLib.timeout_add_seconds(2, lambda: oauth_dlg.destroy())
+ return False
+ GLib.idle_add(_on_success)
+ except Exception as e:
+ def _on_err(exc=str(e)):
+ status_lbl.set_text(f"Token exchange failed: {exc[:200]}")
+ return False
+ GLib.idle_add(_on_err)
+ else:
+ def _on_fail(err=error_holder[0]):
+ status_lbl.set_text(f"Failed: {err or 'No code received'}")
+ return False
+ GLib.idle_add(_on_fail)
+
+ webbrowser.open(auth_url)
+ threading.Thread(target=_wait, daemon=True).start()
+ oauth_dlg.run()
+ oauth_dlg.destroy()
+
+ def _codebuff_reoauth(self):
+ self._codebuff_oauth_standalone()
+
+ def _codebuff_oauth_standalone(self):
+ import uuid
+ dlg = Gtk.Dialog(title="Freebuff / Codebuff Login", parent=self, modal=True)
+ dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ dlg.set_default_size(500, 240)
+ 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 GitHub via Codebuff", use_markup=True, xalign=0), False, False, 0)
+ status_lbl = Gtk.Label(label="Requesting login URL…", xalign=0)
+ status_lbl.set_line_wrap(True)
+ status_lbl.set_max_width_chars(60)
+ area.pack_start(status_lbl, False, False, 4)
+ link_lbl = Gtk.Label(xalign=0)
+ link_lbl.set_line_wrap(True)
+ link_lbl.set_max_width_chars(60)
+ area.pack_start(link_lbl, False, False, 4)
+ spinner = Gtk.Spinner()
+ spinner.start()
+ area.pack_start(spinner, False, False, 8)
+ area.show_all()
+ link_lbl.set_visible(False)
+ result = {"success": False, "user": None, "error": None}
+
+ def _thread():
+ try:
+ fp_id = str(uuid.uuid4())
+ body = json.dumps({"fingerprintId": fp_id}).encode()
+ req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code",
+ data=body, headers={"Content-Type": "application/json", "User-Agent": "codex-launcher/3.10.7"})
+ resp = urllib.request.urlopen(req, timeout=30)
+ rdata = json.loads(resp.read())
+ login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "")
+ fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "")
+ expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0)
+ if not login_url:
+ result["error"] = "No login URL"
+ GLib.idle_add(_done)
+ return
+ GLib.idle_add(lambda: (status_lbl.set_text("Open this URL in your browser:"),
+ link_lbl.set_markup(f'{login_url}'),
+ link_lbl.set_visible(True)))
+ webbrowser.open(login_url)
+ poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}"
+ deadline = time.time() + 300
+ while time.time() < deadline:
+ time.sleep(2)
+ try:
+ pr = urllib.request.Request(poll, headers={"User-Agent": "codex-launcher/3.10.7"})
+ pd = json.loads(urllib.request.urlopen(pr, timeout=10).read())
+ if pd.get("user", {}).get("authToken"):
+ result["success"] = True
+ result["user"] = pd["user"]
+ GLib.idle_add(_done)
+ return
+ except Exception:
+ pass
+ result["error"] = "Timed out"
+ except Exception as e:
+ result["error"] = str(e)[:200]
+ GLib.idle_add(_done)
+
+ def _done():
+ spinner.stop()
+ if result["success"] and result["user"]:
+ u = result["user"]
+ cp = os.path.expanduser("~/.config/manicode/credentials.json")
+ os.makedirs(os.path.dirname(cp), exist_ok=True)
+ creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""),
+ "email": u.get("email", ""), "authToken": u.get("authToken", ""),
+ "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}}
+ with open(cp, "w") as f:
+ json.dump(creds, f, indent=2)
+ os.chmod(cp, 0o600)
+ status_lbl.set_text(f"Logged in as {u.get('email', 'OK')}")
+ link_lbl.set_visible(False)
+ GLib.timeout_add_seconds(2, dlg.destroy)
+ else:
+ status_lbl.set_text(f"Failed: {result.get('error', 'unknown')}")
+
+ threading.Thread(target=_thread, daemon=True).start()
+ dlg.connect("response", lambda d, r: d.destroy())
+ dlg.run()
+
+ def _edit_oauth_secrets(self):
+ secrets_path = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json")
+ try:
+ with open(secrets_path) as f:
+ data = json.load(f)
+ except Exception:
+ data = {"antigravity": {"client_id": "", "client_secret": ""},
+ "gemini_cli": {"client_id": "", "client_secret": ""}}
+
+ dlg = Gtk.Dialog(title="OAuth Secrets & Credentials", parent=self, modal=True)
+ dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ dlg.add_button("Save", Gtk.ResponseType.OK)
+ dlg.set_default_size(580, 650)
+ 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(6)
+
+ sw = Gtk.ScrolledWindow()
+ sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ sw.add(vbox)
+ area.pack_start(sw, True, True, 0)
+
+ vbox.pack_start(Gtk.Label(label="Google OAuth 2.0 Client Credentials\n~/.config/codex-launcher/oauth-secrets.json", use_markup=True, xalign=0), False, False, 4)
+
+ google_token_dir = os.path.expanduser("~/.cache/codex-proxy")
+ fields = {}
+ for section_key, section_label, oauth_prov, token_file in [
+ ("antigravity", "Antigravity (CloudCode)", "google-antigravity", "google-antigravity-oauth-token.json"),
+ ("gemini_cli", "Gemini CLI", "google-cli", "google-cli-oauth-token.json"),
+ ]:
+ section_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ hdr_row = Gtk.Box(spacing=6)
+ hdr_row.pack_start(Gtk.Label(label=f"\n{section_label}", use_markup=True, xalign=0), True, True, 0)
+ reauth_btn = Gtk.Button(label="Re-OAuth")
+ reauth_btn.set_size_request(80, -1)
+ reauth_btn.connect("clicked", lambda b, p=oauth_prov: self._google_reoauth(p, dlg))
+ hdr_row.pack_end(reauth_btn, False, False, 0)
+ import_btn = Gtk.Button(label="Import JSON")
+ import_btn.set_size_request(100, -1)
+ hdr_row.pack_end(import_btn, False, False, 0)
+ section_box.pack_start(hdr_row, False, False, 2)
+
+ token_path = os.path.join(google_token_dir, token_file)
+ has_token = os.path.exists(token_path)
+ try:
+ with open(token_path) as tf:
+ td = json.load(tf)
+ has_token = bool(td.get("refresh_token") or td.get("access_token"))
+ except Exception:
+ pass
+ tok_status = "Token: valid" if has_token else "Token: missing"
+ section_box.pack_start(Gtk.Label(label=tok_status, use_markup=True, xalign=0), False, False, 0)
+
+ sec = data.get(section_key, {})
+ for fk, fl in [("client_id", "Client ID"), ("client_secret", "Client Secret")]:
+ row = Gtk.Box(spacing=6)
+ lbl = Gtk.Label(label=fl + ":", xalign=0)
+ lbl.set_size_request(100, -1)
+ entry = Gtk.Entry()
+ entry.set_text(sec.get(fk, ""))
+ entry.set_size_request(360, -1)
+ if fk == "client_secret":
+ entry.set_visibility(False)
+ entry.set_invisible_char("*")
+ row.pack_start(lbl, False, False, 0)
+ row.pack_start(entry, True, True, 0)
+ section_box.pack_start(row, False, False, 2)
+ fields[(section_key, fk)] = entry
+ import_btn.connect("clicked", lambda b, sk=section_key: self._import_oauth_json(fields, sk))
+ vbox.pack_start(section_box, False, False, 0)
+
+ vbox.pack_start(Gtk.Label(label="Import client_secret_*.json from Google Cloud Console → Credentials", use_markup=True, xalign=0), False, False, 4)
+
+ sep = Gtk.Separator()
+ vbox.pack_start(sep, False, False, 8)
+
+ vbox.pack_start(Gtk.Label(label="\nFreebuff / Codebuff Credentials\n~/.config/manicode/credentials.json", use_markup=True, xalign=0), False, False, 4)
+
+ cb_creds_path = os.path.expanduser("~/.config/manicode/credentials.json")
+ cb_fields = {}
+ try:
+ with open(cb_creds_path) as f:
+ cb_data = json.load(f)
+ except Exception:
+ cb_data = {}
+ cb_default = cb_data.get("default", {})
+ cb_status_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+
+ cb_info = f"Email: {cb_default.get('email', 'not logged in')}"
+ cb_name = cb_default.get("name", "")
+ if cb_name:
+ cb_info = f"{cb_name} — {cb_info}"
+ has_cb_token = bool(cb_default.get("authToken", ""))
+ status_text = "Logged in" if has_cb_token else "Not logged in"
+ status_color = "#27ae60" if has_cb_token else "#e67e22"
+ cb_info_lbl = Gtk.Label(label=f"{cb_info}\nStatus: {status_text}", use_markup=True, xalign=0)
+ cb_status_box.pack_start(cb_info_lbl, False, False, 2)
+
+ for fk, fl in [("authToken", "Auth Token"), ("fingerprintId", "Fingerprint ID")]:
+ row = Gtk.Box(spacing=6)
+ lbl = Gtk.Label(label=fl + ":", xalign=0)
+ lbl.set_size_request(110, -1)
+ entry = Gtk.Entry()
+ entry.set_text(cb_default.get(fk, ""))
+ entry.set_size_request(360, -1)
+ entry.set_visibility(False)
+ entry.set_invisible_char("*")
+ row.pack_start(lbl, False, False, 0)
+ row.pack_start(entry, True, True, 0)
+ cb_status_box.pack_start(row, False, False, 2)
+ cb_fields[fk] = entry
+
+ cb_btn_row = Gtk.Box(spacing=6)
+ cb_login_btn = Gtk.Button(label="Re-OAuth (GitHub Login)")
+ cb_login_btn.connect("clicked", lambda b: self._codebuff_reoauth())
+ cb_btn_row.pack_start(cb_login_btn, False, False, 0)
+ cb_status_box.pack_start(cb_btn_row, False, False, 4)
+
+ vbox.pack_start(cb_status_box, False, False, 0)
+
+ cb_accounts = cb_data.get("accounts", [])
+ if cb_accounts:
+ vbox.pack_start(Gtk.Label(label=f"\nAdditional accounts: {len(cb_accounts)} (edit credentials.json manually)", use_markup=True, xalign=0), False, False, 2)
+
+ vbox.show_all()
+ sw.show_all()
+
+ if dlg.run() == Gtk.ResponseType.OK:
+ for (sk, fk), entry in fields.items():
+ if sk not in data:
+ data[sk] = {}
+ data[sk][fk] = entry.get_text().strip()
+ try:
+ os.makedirs(os.path.dirname(secrets_path), exist_ok=True)
+ with open(secrets_path, "w") as f:
+ json.dump(data, f, indent=2)
+ os.chmod(secrets_path, 0o600)
+ except Exception as e:
+ self._show_error_dialog("Save failed", str(e))
+ cb_updated = dict(cb_default)
+ for fk, entry in cb_fields.items():
+ val = entry.get_text().strip()
+ if val:
+ cb_updated[fk] = val
+ if cb_updated:
+ cb_data["default"] = cb_updated
+ try:
+ os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True)
+ with open(cb_creds_path, "w") as f:
+ json.dump(cb_data, f, indent=2)
+ os.chmod(cb_creds_path, 0o600)
+ except Exception as e:
+ self._show_error_dialog("Save failed", str(e))
+ dlg.destroy()
+
+ def _import_oauth_json(self, fields, section_key):
+ chooser = Gtk.FileChooserDialog(
+ title="Import Google OAuth Client Secret JSON",
+ parent=self, action=Gtk.FileChooserAction.OPEN)
+ chooser.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ chooser.add_button("Open", Gtk.ResponseType.OK)
+ filt = Gtk.FileFilter()
+ filt.set_name("JSON files")
+ filt.add_pattern("*.json")
+ chooser.add_filter(filt)
+ if chooser.run() == Gtk.ResponseType.OK:
+ path = chooser.get_filename()
+ try:
+ with open(path) as f:
+ raw = json.load(f)
+ creds = raw.get("installed") or raw.get("web") or raw
+ cid = creds.get("client_id", "")
+ csec = creds.get("client_secret", "")
+ if not cid or not csec:
+ raise ValueError("JSON does not contain client_id and client_secret")
+ fields[(section_key, "client_id")].set_text(cid)
+ fields[(section_key, "client_secret")].set_text(csec)
+ except Exception as e:
+ self._show_error_dialog("Import failed", str(e))
+ chooser.destroy()
+
+# ═══════════════════════════════════════════════════════════════════
+# 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)"),
+ ("codebuff", "Codebuff - 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()
+
+ enhancer_box = Gtk.Box(spacing=6)
+ self._switch_enhancer = Gtk.Switch()
+ self._switch_enhancer.set_active(self._data.get("prompt_enhancer", False))
+ enhancer_box.pack_start(self._switch_enhancer, False, False, 0)
+ self._enhancer_status_lbl = Gtk.Label()
+ enhancer_box.pack_start(self._enhancer_status_lbl, False, False, 0)
+ self._switch_enhancer.connect("notify::active", lambda *a: self._on_enhancer_toggled())
+ self._combo_enhancer_mode = Gtk.ComboBoxText()
+ for mode in ["offline", "ai-powered"]:
+ self._combo_enhancer_mode.append(mode, mode.capitalize())
+ self._combo_enhancer_mode.set_active_id(self._data.get("prompt_enhancer_mode", "offline"))
+ enhancer_box.pack_start(self._combo_enhancer_mode, False, False, 6)
+ add_row(8, "Prompt Enhancer:", enhancer_box)
+ self._on_enhancer_toggled()
+
+ self._entry_enhancer_model = Gtk.Entry()
+ self._entry_enhancer_model.set_placeholder_text("e.g. deepseek/deepseek-v4-flash (ai-powered mode only)")
+ self._entry_enhancer_model.set_text(self._data.get("prompt_enhancer_model", ""))
+ add_row(9, "Enhancer Model:", self._entry_enhancer_model)
+
+ self._entry_enhancer_url = Gtk.Entry()
+ self._entry_enhancer_url.set_placeholder_text("e.g. https://www.codebuff.com/api/v1 (ai-powered mode only)")
+ self._entry_enhancer_url.set_text(self._data.get("prompt_enhancer_url", ""))
+ add_row(10, "Enhancer URL:", self._entry_enhancer_url)
+
+ self._entry_enhancer_key = Gtk.Entry()
+ self._entry_enhancer_key.set_placeholder_text("API key for enhancer model (ai-powered mode only)")
+ self._entry_enhancer_key.set_text(self._data.get("prompt_enhancer_key", ""))
+ self._entry_enhancer_key.set_visibility(False)
+ self._entry_enhancer_key.set_invisible_char("*")
+ add_row(11, "Enhancer Key:", self._entry_enhancer_key)
+
+ # 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))
+
+ model_btn_box = Gtk.Box(spacing=6)
+ area.pack_start(model_btn_box, False, False, 0)
+ self._remove_model_btn = Gtk.Button(label="Remove Selected")
+ self._remove_model_btn.connect("clicked", lambda b: self._remove_selected_model())
+ model_btn_box.pack_start(self._remove_model_btn, False, False, 0)
+ self._clear_models_btn = Gtk.Button(label="Clear All")
+ self._clear_models_btn.connect("clicked", lambda b: self._clear_all_models())
+ model_btn_box.pack_start(self._clear_models_btn, False, False, 0)
+ self._sync_preset_btn = Gtk.Button(label="Sync from Preset")
+ self._sync_preset_btn.connect("clicked", lambda b: self._apply_selected_preset())
+ model_btn_box.pack_start(self._sync_preset_btn, False, False, 0)
+
+ 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 == "codebuff":
+ self._oauth_btn.set_label("Codebuff Login")
+ self._entry_key.set_placeholder_text("Auto-filled by codebuff 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 (not initial or len(self._model_store) == 0):
+ current = self._combo_default.get_active_text()
+ self._model_store.clear()
+ for mid in preset["models"]:
+ self._model_store.append([mid])
+ self._refresh_default_combo(current or 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 _on_enhancer_toggled(self, *_):
+ active = self._switch_enhancer.get_active()
+ if active:
+ self._enhancer_status_lbl.set_markup('ON')
+ else:
+ self._enhancer_status_lbl.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 == "codebuff":
+ self._codebuff_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")
+
+ _oauth_secrets_path = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json")
+ try:
+ with open(_oauth_secrets_path) as _f:
+ _oauth_secrets = json.load(_f)
+ except Exception:
+ _oauth_secrets = {}
+
+ if is_antigravity:
+ _sec = _oauth_secrets.get("antigravity", {})
+ CLIENT_ID = _sec.get("client_id", "")
+ CLIENT_SECRET = _sec.get("client_secret", "")
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+ ]
+ port = 51121
+ redirect_uri = f"http://localhost:{port}/oauth-callback"
+ callback_path = "/oauth-callback"
+ provider_kind = "antigravity"
+ else:
+ _sec = _oauth_secrets.get("gemini_cli", {})
+ CLIENT_ID = _sec.get("client_id", "")
+ CLIENT_SECRET = _sec.get("client_secret", "")
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ ]
+ port = 0
+ redirect_uri = None
+ callback_path = "/oauth2callback"
+ provider_kind = "cli"
+
+ 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"CSRF state mismatch.
")
+ error_holder[0] = "CSRF state mismatch"
+ return
+ code_holder[0] = params["code"][0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini")
+ self2.end_headers()
+ else:
+ error_holder[0] = params.get("error", ["unknown"])[0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
+ self2.end_headers()
+ def log_message(self2, fmt, *args):
+ with open("/tmp/codex-oauth-debug.log", "a") as _dbg:
+ _dbg.write(f"[{time.strftime('%H:%M:%S')}] {fmt % args}\n")
+
+ try:
+ bind_host = "localhost" if is_antigravity else "127.0.0.1"
+ server = http.server.HTTPServer((bind_host, port), OAuthHandler)
+ except OSError:
+ self._oauth_status.set_text(f"Port {port} already in use — close other apps and retry.")
+ spinner.stop()
+ dlg.run(); dlg.destroy()
+ return
+
+ def _oauth_log(msg):
+ with open("/tmp/codex-oauth-debug.log", "a") as _f:
+ _f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
+
+ _oauth_log(f"Starting OAuth: port={port} redirect_uri={redirect_uri}")
+
+ def wait_for_code():
+ _oauth_log("wait_for_code thread started")
+ deadline = time.time() + 120
+ while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
+ server.handle_request()
+ server.server_close()
+ _oauth_log(f"Server closed. code={'yes' if code_holder[0] else 'no'} error={'yes' if error_holder[0] else 'no'}")
+ if code_holder[0]:
+ try:
+ _oauth_log("Exchanging code for token...")
+ token_data = urllib.parse.urlencode({
+ "code": code_holder[0],
+ "client_id": CLIENT_ID,
+ "client_secret": CLIENT_SECRET,
+ "redirect_uri": redirect_uri,
+ "grant_type": "authorization_code",
+ "code_verifier": verifier,
+ }).encode()
+ req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
+ resp = urllib.request.urlopen(req, timeout=30)
+ tokens = json.loads(resp.read())
+ tokens["client_id"] = CLIENT_ID
+ tokens["client_secret"] = CLIENT_SECRET
+ tokens["provider_kind"] = provider_kind
+ tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
+ os.makedirs(os.path.dirname(token_path), exist_ok=True)
+ with open(token_path, "w") as f:
+ json.dump(tokens, f, indent=2)
+ os.chmod(token_path, 0o600)
+ _oauth_log(f"Token saved to {token_path}")
+ project_id = _oauth_discover_project(tokens["access_token"], token_path, tokens)
+ _oauth_log(f"Project ID: {project_id or '(none)'}")
+ if is_antigravity:
+ found_models = [
+ "gemini-2.5-flash", "gemini-2.5-pro",
+ "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
+ "gemini-3-pro-low", "gemini-3-pro-high",
+ "gemini-3.1-pro-low", "gemini-3.1-pro-high",
+ "gemini-3-flash-low", "gemini-3-flash-medium", "gemini-3-flash-high",
+ "claude-sonnet-4-6", "claude-opus-4-6-thinking",
+ "claude-opus-4-6-thinking-low", "claude-opus-4-6-thinking-medium", "claude-opus-4-6-thinking-high",
+ "gemini-claude-sonnet-4-6",
+ "gemini-claude-opus-4-6-thinking-low", "gemini-claude-opus-4-6-thinking-medium", "gemini-claude-opus-4-6-thinking-high",
+ "gemini-3-pro-image",
+ ]
+ probe_candidates = [
+ "gemini-2.5-flash", "gemini-2.5-pro",
+ "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
+ ]
+ _oauth_log(f"Probing {len(probe_candidates)} model candidates...")
+ for mc in probe_candidates:
+ try:
+ pr = urllib.request.Request(
+ "https://cloudcode-pa.googleapis.com/v1internal:generateContent",
+ data=json.dumps({
+ "project": project_id,
+ "model": mc,
+ "request": {"contents": [{"role": "user", "parts": [{"text": "x"}]}],
+ "generationConfig": {"maxOutputTokens": 1}},
+ }).encode(),
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {tokens['access_token']}",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
+ })
+ pr.get_method = lambda: "POST"
+ resp = urllib.request.urlopen(pr, timeout=10)
+ resp.read()
+ found_models.append(mc)
+ _oauth_log(f" {mc} → available")
+ except urllib.error.HTTPError as e:
+ if e.code == 429:
+ found_models.append(mc)
+ _oauth_log(f" {mc} → available (rate limited)")
+ else:
+ e.read()
+ _oauth_log(f" {mc} → HTTP {e.code}")
+ except Exception as e:
+ _oauth_log(f" {mc} → error: {e}")
+ else:
+ found_models = ["gemini-2.5-flash", "gemini-2.5-pro"]
+ if found_models:
+ tokens["available_models"] = found_models
+ with open(token_path, "w") as f3:
+ json.dump(tokens, f3, indent=2)
+ os.chmod(token_path, 0o600)
+ _oauth_log(f"Discovered {len(found_models)} models: {found_models}")
+ else:
+ _oauth_log("No models discovered (will use defaults)")
+ GLib.idle_add(self._oauth_success, dlg, tokens.get("access_token", ""), spinner)
+ return
+ except urllib.error.HTTPError as e:
+ body = e.read().decode(errors='replace')
+ _oauth_log(f"Token exchange HTTP {e.code}: {body}")
+ GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed ({e.code}): {body[:200]}", spinner)
+ return
+ except Exception as e:
+ _oauth_log(f"Token exchange FAILED: {e}")
+ GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed: {e}", spinner)
+ return
+ _oauth_log(f"OAuth failed: {error_holder[0] or 'timeout'}")
+ GLib.idle_add(self._oauth_failed, dlg,
+ error_holder[0] or "No authorization code received.", spinner)
+
+ threading.Thread(target=wait_for_code, daemon=True).start()
+ subprocess.Popen(["xdg-open", auth_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ dlg.connect("response", lambda d, r: d.destroy())
+ dlg.run()
+
+ def _codebuff_oauth_flow(self):
+ dlg = Gtk.Dialog(title="Codebuff Login", parent=self, modal=True)
+ dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ dlg.set_default_size(500, 240)
+ 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 GitHub via Codebuff", use_markup=True, xalign=0), False, False, 0)
+
+ self._oauth_status = Gtk.Label(label="Requesting login URL…", xalign=0)
+ self._oauth_status.set_line_wrap(True)
+ self._oauth_status.set_max_width_chars(60)
+ area.pack_start(self._oauth_status, False, False, 4)
+
+ link_lbl = Gtk.Label(xalign=0)
+ link_lbl.set_line_wrap(True)
+ link_lbl.set_max_width_chars(60)
+ area.pack_start(link_lbl, False, False, 4)
+
+ spinner = Gtk.Spinner()
+ spinner.start()
+ area.pack_start(spinner, False, False, 8)
+
+ area.show_all()
+ link_lbl.set_visible(False)
+
+ self._fb_oauth_result = {"success": False, "user": None, "error": None}
+
+ def _codebuff_auth_thread():
+ try:
+ fingerprint_id = str(uuid.uuid4())
+ auth_url = "https://www.codebuff.com/api/auth/cli/code"
+ body = json.dumps({"fingerprintId": fingerprint_id}).encode()
+ req = urllib.request.Request(auth_url, data=body,
+ headers={"Content-Type": "application/json", "User-Agent": "codex-launcher/3.10.7"})
+ resp = urllib.request.urlopen(req, timeout=30)
+ data = json.loads(resp.read())
+ login_url = data.get("loginUrl", "") or data.get("login_url", "")
+ fingerprint_hash = data.get("fingerprintHash", "") or data.get("fingerprint_hash", "")
+ expires_at = data.get("expiresAt", 0) or data.get("expires_at", 0)
+ if not login_url:
+ self._fb_oauth_result["error"] = "Server returned no login URL"
+ GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
+ return
+
+ def _set_link():
+ self._oauth_status.set_text("Open this URL in your browser to log in:")
+ link_lbl.set_markup(f'{login_url}')
+ link_lbl.set_visible(True)
+ GLib.idle_add(_set_link)
+
+ webbrowser.open(login_url)
+
+ poll_url = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fingerprint_id)}&fingerprintHash={urllib.parse.quote(fingerprint_hash)}&expiresAt={expires_at}"
+ deadline = time.time() + 300
+ while time.time() < deadline:
+ time.sleep(2)
+ try:
+ poll_req = urllib.request.Request(poll_url,
+ headers={"User-Agent": "codex-launcher/3.10.7"})
+ poll_resp = urllib.request.urlopen(poll_req, timeout=10)
+ poll_data = json.loads(poll_resp.read())
+ user = poll_data.get("user")
+ if user and user.get("authToken"):
+ self._fb_oauth_result["success"] = True
+ self._fb_oauth_result["user"] = user
+ GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
+ return
+ except urllib.error.HTTPError:
+ pass
+ except Exception:
+ pass
+ self._fb_oauth_result["error"] = "Login timed out after 5 minutes."
+ GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
+ except Exception as e:
+ self._fb_oauth_result["error"] = str(e)[:200]
+ GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
+
+ threading.Thread(target=_codebuff_auth_thread, daemon=True).start()
+ dlg.connect("response", lambda d, r: d.destroy())
+ dlg.run()
+
+ def _codebuff_oauth_done(self, dlg, spinner):
+ spinner.stop()
+ if self._fb_oauth_result["success"] and self._fb_oauth_result["user"]:
+ user = self._fb_oauth_result["user"]
+ creds_path = os.path.expanduser("~/.config/manicode/credentials.json")
+ os.makedirs(os.path.dirname(creds_path), exist_ok=True)
+ creds = {"default": {
+ "id": user.get("id", ""),
+ "name": user.get("name", ""),
+ "email": user.get("email", ""),
+ "authToken": user.get("authToken", ""),
+ "fingerprintId": user.get("fingerprintId", ""),
+ "fingerprintHash": user.get("fingerprintHash", ""),
+ }}
+ with open(creds_path, "w") as f:
+ json.dump(creds, f, indent=2)
+ os.chmod(creds_path, 0o600)
+ self._entry_key.set_text(user.get("authToken", ""))
+ self._oauth_status.set_markup('Authorization successful! Credentials saved.')
+ dlg.set_title("Codebuff Login – Success")
+ GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK))
+ else:
+ self._oauth_status.set_markup(f'{self._fb_oauth_result["error"] or "Login failed."}')
+ GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL))
+
+ def _oauth_success(self, dlg, access_token, spinner):
+ spinner.stop()
+ self._entry_key.set_text(access_token)
+ self._oauth_status.set_markup('Authorization successful! Token saved.')
+ dlg.set_title("Google OAuth — Success")
+ GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK))
+
+ def _oauth_failed(self, dlg, msg, spinner):
+ spinner.stop()
+ self._oauth_status.set_markup(f'{msg}')
+ GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL))
+
+ def _remove_model(self, path):
+ current = self._combo_default.get_active_text()
+ self._model_store.remove(self._model_store.get_iter(path))
+ self._refresh_default_combo(current)
+
+ def _remove_selected_model(self):
+ sel = self._model_tree.get_selection()
+ model, paths = sel.get_selected_rows()
+ if not paths:
+ return
+ current = self._combo_default.get_active_text()
+ for p in reversed(paths):
+ self._model_store.remove(self._model_store.get_iter(p))
+ self._refresh_default_combo(current)
+
+ def _clear_all_models(self):
+ current = self._combo_default.get_active_text()
+ self._model_store.clear()
+ self._refresh_default_combo(current)
+
+ def _refresh_default_combo(self, active=None):
+ if active is None:
+ active = self._combo_default.get_active_text()
+ self._combo_default.remove_all()
+ for row in self._model_store:
+ self._combo_default.append(row[0], row[0])
+ if active and any(row[0] == active for row in self._model_store):
+ self._combo_default.set_active_id(active)
+ elif len(self._model_store) > 0:
+ self._combo_default.set_active(0)
+
+ def _fetch_models(self):
+ ok, err = self._try_fetch_models()
+ if not ok:
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK,
+ f"Failed to fetch models:\n{err}")
+ d.run()
+ d.destroy()
+
+ def _try_fetch_models(self):
+ endpoint = {
+ "base_url": self._entry_url.get_text().strip(),
+ "api_key": self._entry_key.get_text().strip(),
+ "backend_type": self._combo_type.get_active_id() or "openai-compat",
+ }
+ ids, err = fetch_models_for_endpoint(endpoint)
+ if ids:
+ current = self._combo_default.get_active_text()
+ added = 0
+ for mid in ids:
+ # check dupes
+ found = any(self._model_store[i][0] == mid for i in range(len(self._model_store)))
+ if not found:
+ self._model_store.append([mid])
+ added += 1
+ self._refresh_default_combo(current)
+ return True, None
+ return False, err or "No models returned by endpoint"
+
+ def _diagnose_endpoint(self):
+ ep = {
+ "base_url": self._entry_url.get_text().strip(),
+ "api_key": self._entry_key.get_text().strip(),
+ "backend_type": self._combo_type.get_active_id() or "openai-compat",
+ "default_model": self._combo_default.get_active_text() or "",
+ }
+ name = ep.get("default_model") or "endpoint"
+ wait_dlg = Gtk.Dialog(title="Running Doctor…", parent=self, modal=True)
+ wait_dlg.set_default_size(280, 80)
+ lbl = Gtk.Label(label="Running endpoint diagnostics…")
+ 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 _on_response(self, dialog, response):
+ if response != Gtk.ResponseType.OK:
+ self.destroy()
+ return
+
+ name = self._entry_name.get_text().strip()
+ if not name:
+ self._show_error("Name is required")
+ return
+ bt = self._combo_type.get_active_id() or PROVIDER_PRESETS.get(self._combo_preset.get_active_text() or "", {}).get("backend_type") or "openai-compat"
+ url = self._entry_url.get_text().strip()
+ key = self._entry_key.get_text().strip()
+ models = [self._model_store[i][0] for i in range(len(self._model_store))]
+ if not models:
+ ok, err = self._try_fetch_models()
+ if ok:
+ models = [self._model_store[i][0] for i in range(len(self._model_store))]
+ else:
+ d = Gtk.MessageDialog(
+ self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
+ f"Auto-fetch failed ({err}).\n\nAdd models manually now?"
+ )
+ r = d.run()
+ d.destroy()
+ if r == Gtk.ResponseType.YES:
+ self._entry_model.grab_focus()
+ return
+ self.destroy()
+ return
+
+ if not models:
+ self._show_error("At least one model is required")
+ self._entry_model.grab_focus()
+ return
+ default = self._combo_default.get_active_text() or models[0]
+
+ data = load_endpoints()
+
+ # If renaming, remove old entry
+ if self._existing_name and self._existing_name != name:
+ data["endpoints"] = [e for e in data["endpoints"] if e["name"] != self._existing_name]
+
+ # Check for duplicate name
+ existing = [e for e in data["endpoints"] if e["name"] == name and e != self._data]
+ if existing:
+ self._show_error(f'Endpoint "{name}" already exists')
+ return
+
+ new_ep = {"name": name, "backend_type": bt, "base_url": url,
+ "api_key": key, "default_model": default, "models": models,
+ "provider_preset": self._combo_preset.get_active_text() or "Custom"}
+ cc_ver = self._entry_cc_ver.get_text().strip()
+ if cc_ver:
+ new_ep["cc_version"] = cc_ver
+ new_ep["reasoning_enabled"] = self._switch_reasoning.get_active()
+ new_ep["reasoning_effort"] = self._combo_effort.get_active_id() or "medium"
+ new_ep["prompt_enhancer"] = self._switch_enhancer.get_active()
+ new_ep["prompt_enhancer_mode"] = self._combo_enhancer_mode.get_active_id() or "offline"
+ enh_model = self._entry_enhancer_model.get_text().strip()
+ enh_url = self._entry_enhancer_url.get_text().strip()
+ enh_key = self._entry_enhancer_key.get_text().strip()
+ if enh_model:
+ new_ep["prompt_enhancer_model"] = enh_model
+ if enh_url:
+ new_ep["prompt_enhancer_url"] = enh_url
+ if enh_key:
+ new_ep["prompt_enhancer_key"] = enh_key
+ preset_name = self._combo_preset.get_active_text() or "Custom"
+ preset = PROVIDER_PRESETS.get(preset_name, {})
+ if preset.get("oauth_provider"):
+ new_ep["oauth_provider"] = preset["oauth_provider"]
+ new_ep["base_url"] = normalize_base_url(new_ep["base_url"])
+
+ # Update or append
+ found = False
+ for i, e in enumerate(data["endpoints"]):
+ if e["name"] == name:
+ data["endpoints"][i] = new_ep
+ found = True
+ break
+ if not found:
+ data["endpoints"].append(new_ep)
+ if data.get("default") is None:
+ data["default"] = name
+
+ save_endpoints(data)
+ self._parent_mgr._rebuild()
+ self._parent_mgr._parent._on_endpoints_updated()
+ self.destroy()
+
+ def _show_error(self, msg):
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
+ d.run(); d.destroy()
+
+# ═══════════════════════════════════════════════════════════════════
+# Entry point
+# ═══════════════════════════════════════════════════════════════════
+
+# ═══════════════════════════════════════════════════════════════════
+# BGP Pool Manager
+# ═══════════════════════════════════════════════════════════════════
+
+class BGPPoolMgr(Gtk.Window):
+ def __init__(self, parent):
+ super().__init__(title="AI BGP — Pool Manager")
+ self.set_transient_for(parent)
+ self.set_default_size(620, 440)
+ self._parent = parent
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ vbox.set_margin_start(12)
+ vbox.set_margin_end(12)
+ vbox.set_margin_top(12)
+ vbox.set_margin_bottom(12)
+ self.add(vbox)
+
+ hdr = Gtk.Box(spacing=8)
+ vbox.pack_start(hdr, False, False, 0)
+ hdr.pack_start(Gtk.Label(label="AI BGP Pools — multi-provider routing with automatic failover", use_markup=True), False, False, 0)
+
+ self._store = Gtk.ListStore(str, str, str)
+ self._tree = Gtk.TreeView(model=self._store)
+ for i, (title, w) in enumerate([("Pool Name", 200), ("Routes", 250), ("Strategy", 100)]):
+ r = Gtk.CellRendererText()
+ c = Gtk.TreeViewColumn(title, r, text=i)
+ c.set_min_width(w)
+ self._tree.append_column(c)
+ self._tree.set_headers_visible(True)
+ sw = Gtk.ScrolledWindow()
+ sw.add(self._tree)
+ vbox.pack_start(sw, True, True, 0)
+
+ sel = self._tree.get_selection()
+ sel.connect("changed", lambda *_: self._on_select())
+
+ bbox = Gtk.Box(spacing=8)
+ vbox.pack_start(bbox, False, False, 0)
+ self._add_btn = Gtk.Button(label="Create Pool")
+ self._add_btn.connect("clicked", lambda b: self._add_pool())
+ bbox.pack_start(self._add_btn, True, True, 0)
+ self._edit_btn = Gtk.Button(label="Edit Pool")
+ self._edit_btn.connect("clicked", lambda b: self._edit_pool())
+ self._edit_btn.set_sensitive(False)
+ bbox.pack_start(self._edit_btn, True, True, 0)
+ self._del_btn = Gtk.Button(label="Delete Pool")
+ self._del_btn.connect("clicked", lambda b: self._del_pool())
+ self._del_btn.set_sensitive(False)
+ bbox.pack_start(self._del_btn, True, True, 0)
+ close_btn = Gtk.Button(label="Close")
+ close_btn.connect("clicked", lambda b: self.destroy())
+ bbox.pack_start(close_btn, True, True, 0)
+
+ self._rebuild()
+ self.show_all()
+
+ def _rebuild(self):
+ self._store.clear()
+ for pool in load_bgp_pools().get("pools", []):
+ routes_str = " → ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", []))
+ self._store.append([pool["name"], routes_str, pool.get("strategy", "failover")])
+
+ def _selected_name(self):
+ sel = self._tree.get_selection()
+ m, i = sel.get_selected()
+ return self._store[i][0] if i else None
+
+ def _on_select(self):
+ name = self._selected_name()
+ self._edit_btn.set_sensitive(bool(name))
+ self._del_btn.set_sensitive(bool(name))
+
+ def _add_pool(self):
+ d = BGPPoolEditDialog(self, None)
+ d.connect("response", lambda *_: self._rebuild())
+
+ def _edit_pool(self):
+ name = self._selected_name()
+ if name:
+ d = BGPPoolEditDialog(self, name)
+ d.connect("response", lambda *_: self._rebuild())
+
+ def _del_pool(self):
+ name = self._selected_name()
+ if not name:
+ return
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
+ f'Delete BGP pool "{name}"?')
+ r = d.run(); d.destroy()
+ if r != Gtk.ResponseType.YES:
+ return
+ data = load_bgp_pools()
+ data["pools"] = [p for p in data["pools"] if p["name"] != name]
+ save_bgp_pools(data)
+ self._rebuild()
+ self._parent._on_endpoints_updated()
+
+
+class BGPPoolEditDialog(Gtk.Dialog):
+ def __init__(self, parent, existing_name):
+ title = "Edit BGP Pool" if existing_name else "Create BGP Pool"
+ Gtk.Dialog.__init__(self, title=title, parent=parent, modal=True)
+ self.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ self.add_button("Save", Gtk.ResponseType.OK)
+ self.set_default_size(580, 480)
+
+ self._existing_name = existing_name
+ self._parent_mgr = parent
+
+ data = load_bgp_pools()
+ pool = None
+ if existing_name:
+ for p in data.get("pools", []):
+ if p["name"] == existing_name:
+ pool = p
+ break
+ if not pool:
+ pool = {"name": "", "strategy": "failover", "routes": []}
+
+ area = self.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(8)
+
+ grid = Gtk.Grid(column_spacing=8, row_spacing=6)
+ area.pack_start(grid, False, False, 0)
+
+ grid.attach(Gtk.Label(label="Pool Name:", xalign=1), 0, 0, 1, 1)
+ self._entry_name = Gtk.Entry(text=pool["name"])
+ grid.attach(self._entry_name, 1, 0, 1, 1)
+
+ grid.attach(Gtk.Label(label="Strategy:", xalign=1), 0, 1, 1, 1)
+ self._combo_strategy = Gtk.ComboBoxText()
+ self._combo_strategy.append("failover", "Failover (try primary, fall back on error)")
+ self._combo_strategy.append("race", "Race (send to all, return fastest)")
+ self._combo_strategy.set_active_id(pool.get("strategy", "failover"))
+ grid.attach(self._combo_strategy, 1, 1, 1, 1)
+
+ area.pack_start(Gtk.Label(label="Routes (drag to reorder priority)", use_markup=True, xalign=0), False, False, 8)
+
+ self._route_store = Gtk.ListStore(str, str, str, str, str, str)
+ for r in pool.get("routes", []):
+ self._route_store.append([
+ r.get("name", ""), r.get("endpoint_name", ""),
+ r.get("target_url", ""), r.get("api_key", ""),
+ r.get("model", ""), str(r.get("priority", 99))
+ ])
+
+ self._route_tree = Gtk.TreeView(model=self._route_store)
+ for i, (title, w) in enumerate([
+ ("Route Name", 120), ("Endpoint", 120), ("URL", 150),
+ ("API Key", 80), ("Model", 120), ("Priority", 60)
+ ]):
+ renderer = Gtk.CellRendererText()
+ renderer.set_property("editable", False)
+ col = Gtk.TreeViewColumn(title, renderer, text=i)
+ col.set_min_width(w)
+ col.set_resizable(True)
+ self._route_tree.append_column(col)
+ self._route_tree.set_headers_visible(True)
+ sw = Gtk.ScrolledWindow()
+ sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ sw.add(self._route_tree)
+ sw.set_min_content_height(200)
+ area.pack_start(sw, True, True, 0)
+
+ bbox = Gtk.Box(spacing=6)
+ area.pack_start(bbox, False, False, 0)
+ add_r = Gtk.Button(label="Add Route")
+ add_r.connect("clicked", lambda b: self._add_route())
+ bbox.pack_start(add_r, True, True, 0)
+ edit_r = Gtk.Button(label="Edit Route")
+ edit_r.connect("clicked", lambda b: self._edit_route())
+ bbox.pack_start(edit_r, True, True, 0)
+ rm_r = Gtk.Button(label="Remove Route")
+ rm_r.connect("clicked", lambda b: self._remove_route())
+ bbox.pack_start(rm_r, True, True, 0)
+ up_r = Gtk.Button(label="↑ Up")
+ up_r.connect("clicked", lambda b: self._move_route(-1))
+ bbox.pack_start(up_r, True, True, 0)
+ down_r = Gtk.Button(label="↓ Down")
+ down_r.connect("clicked", lambda b: self._move_route(1))
+ bbox.pack_start(down_r, True, True, 0)
+
+ self.show_all()
+
+ if self.run() == Gtk.ResponseType.OK:
+ self._save()
+
+ self.destroy()
+
+ def _save(self):
+ name = self._entry_name.get_text().strip()
+ if not name:
+ return
+ strategy = self._combo_strategy.get_active_id() or "failover"
+ routes = []
+ for i, row in enumerate(self._route_store):
+ if not row[2]:
+ continue
+ routes.append({
+ "name": row[0] or f"Route {i+1}",
+ "endpoint_name": row[1],
+ "target_url": row[2],
+ "api_key": row[3],
+ "model": row[4],
+ "priority": i + 1,
+ "reasoning_enabled": True,
+ "reasoning_effort": "medium",
+ })
+ data = load_bgp_pools()
+ if self._existing_name:
+ data["pools"] = [p for p in data["pools"] if p["name"] != self._existing_name]
+ data["pools"].append({"name": name, "strategy": strategy, "routes": routes})
+ save_bgp_pools(data)
+ self._parent_mgr._parent._on_endpoints_updated()
+
+ def _add_route(self):
+ endpoints = load_endpoints().get("endpoints", [])
+ if not endpoints:
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK,
+ "No endpoints configured. Add endpoints in Manage Endpoints first.")
+ d.run(); d.destroy()
+ return
+ d = BGPRouteDialog(self, endpoints, None)
+ if d.result:
+ r = d.result
+ self._route_store.append([
+ r.get("name", ""), r.get("endpoint_name", ""),
+ r.get("target_url", ""), r.get("api_key", ""),
+ r.get("model", ""), str(r.get("priority", 99))
+ ])
+
+ def _edit_route(self):
+ sel = self._route_tree.get_selection()
+ m, i = sel.get_selected()
+ if not i:
+ return
+ endpoints = load_endpoints().get("endpoints", [])
+ existing = {
+ "name": m[i][0], "endpoint_name": m[i][1],
+ "target_url": m[i][2], "api_key": m[i][3],
+ "model": m[i][4], "priority": int(m[i][5]) if m[i][5] else 99,
+ }
+ d = BGPRouteDialog(self, endpoints, existing)
+ if d.result:
+ r = d.result
+ m[i][0] = r.get("name", "")
+ m[i][1] = r.get("endpoint_name", "")
+ m[i][2] = r.get("target_url", "")
+ m[i][3] = r.get("api_key", "")
+ m[i][4] = r.get("model", "")
+ m[i][5] = str(r.get("priority", 99))
+
+ def _remove_route(self):
+ sel = self._route_tree.get_selection()
+ m, i = sel.get_selected()
+ if i:
+ self._route_store.remove(i)
+
+ def _move_route(self, direction):
+ sel = self._route_tree.get_selection()
+ m, i = sel.get_selected()
+ if not i:
+ return
+ path = m.get_path(i)
+ idx = path.get_indices()[0]
+ new_idx = idx + direction
+ if new_idx < 0 or new_idx >= len(self._route_store):
+ return
+ row_data = [m[idx][c] for c in range(6)]
+ self._route_store.remove(m.get_iter(Gtk.TreePath(idx)))
+ new_iter = self._route_store.insert(new_idx)
+ for c, v in enumerate(row_data):
+ self._route_store.set_value(new_iter, c, v)
+
+
+class BGPRouteDialog(Gtk.Dialog):
+ def __init__(self, parent, endpoints, existing):
+ Gtk.Dialog.__init__(self, title="BGP Route", parent=parent, modal=True)
+ self.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ self.add_button("OK", Gtk.ResponseType.OK)
+ self.set_default_size(440, 300)
+ self.result = None
+
+ area = self.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(6)
+
+ 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=existing.get("name", "") if existing else "")
+ add_row(0, "Route Name:", self._entry_name)
+
+ self._combo_ep = Gtk.ComboBoxText()
+ ep_names = [e["name"] for e in endpoints]
+ for en in ep_names:
+ self._combo_ep.append(en, en)
+ if existing and existing.get("endpoint_name") in ep_names:
+ self._combo_ep.set_active_id(existing["endpoint_name"])
+ elif ep_names:
+ self._combo_ep.set_active(0)
+ self._combo_ep.connect("changed", lambda b: self._on_ep_changed(endpoints))
+ add_row(1, "Endpoint:", self._combo_ep)
+
+ self._entry_url = Gtk.Entry()
+ add_row(2, "URL:", self._entry_url)
+
+ self._entry_key = Gtk.Entry()
+ self._entry_key.set_visibility(False)
+ add_row(3, "API Key:", self._entry_key)
+
+ self._combo_model = Gtk.ComboBoxText()
+ add_row(4, "Model:", self._combo_model)
+
+ if existing:
+ self._entry_url.set_text(existing.get("target_url", ""))
+ self._entry_key.set_text(existing.get("api_key", ""))
+ self._on_ep_changed(endpoints)
+ if existing and existing.get("model"):
+ self._combo_model.set_active_id(existing["model"])
+
+ self.show_all()
+ if self.run() == Gtk.ResponseType.OK:
+ ep_name = self._combo_ep.get_active_text() or ""
+ ep = None
+ for e in endpoints:
+ if e["name"] == ep_name:
+ ep = e
+ break
+ self.result = {
+ "name": self._entry_name.get_text().strip() or ep_name,
+ "endpoint_name": ep_name,
+ "target_url": self._entry_url.get_text().strip(),
+ "api_key": self._entry_key.get_text().strip(),
+ "model": self._combo_model.get_active_text() or "",
+ "priority": 99,
+ }
+ if ep:
+ self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True)
+ self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium")
+ self.result["oauth_provider"] = ep.get("oauth_provider", "")
+ self.destroy()
+
+ def _on_ep_changed(self, endpoints):
+ ep_name = self._combo_ep.get_active_text()
+ ep = None
+ for e in endpoints:
+ if e["name"] == ep_name:
+ ep = e
+ break
+ if ep:
+ self._entry_url.set_text(normalize_base_url(ep.get("base_url", "")))
+ self._entry_key.set_text(ep.get("api_key", ""))
+ self._combo_model.remove_all()
+ for m in ep.get("models", []):
+ mid = normalize_model_id(m) if m else ""
+ self._combo_model.append(mid, m)
+ if ep.get("default_model"):
+ self._combo_model.set_active_id(normalize_model_id(ep["default_model"]))
+ elif len(ep.get("models", [])) > 0:
+ self._combo_model.set_active(0)
+
+
+_U = {
+ "base": "#0C0E16", "surface0": "#161928", "surface1": "#1E2235",
+ "surface2": "#2A2F47", "text": "#E4E6F0", "subtext": "#B0B4C8",
+ "dim": "#5C6180", "accent": "#7EB8F7", "blue": "#5DA4E8",
+ "sapphire": "#4EC5C1", "green": "#59D4A0", "yellow": "#F0C75E",
+ "red": "#F06A77", "peach": "#F09860", "teal": "#4EC5C1",
+ "lavender": "#A899F0", "sky": "#70C8E8", "maroon": "#C44B5C",
+ "flamingo": "#E878B0", "rosewater": "#F0D0C0",
+ "model_palette": ["#F09860", "#4EC5C1", "#5DA4E8", "#59D4A0",
+ "#F0C75E", "#A899F0", "#70C8E8", "#E878B0",
+ "#C44B5C", "#F0D0C0", "#7EB8F7", "#F06A77"],
+}
+
+_USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json"
+
+def _load_usage_stats():
+ try:
+ if _USAGE_STATS_FILE.exists():
+ return json.loads(_USAGE_STATS_FILE.read_text())
+ except Exception:
+ pass
+ return {"providers": {}, "updated": None}
+
+def _fmt_tok(n):
+ if n >= 1_000_000:
+ return f"{n/1_000_000:.1f}M"
+ if n >= 1_000:
+ return f"{n/1_000:.1f}K"
+ return str(n)
+
+def _fmt_dur(s):
+ if s >= 3600:
+ return f"{s/3600:.1f}h"
+ if s >= 60:
+ return f"{s/60:.1f}m"
+ return f"{s:.1f}s"
+
+def _status_pill(success_rate, fail_pct):
+ if fail_pct > 0.15:
+ return ("ERR", _U["red"])
+ if fail_pct > 0.05:
+ return ("WARN", _U["yellow"])
+ return ("OK", _U["green"])
+
+def _make_css_widget(css_str):
+ p = Gtk.CssProvider()
+ p.load_from_data(css_str.encode())
+ return p
+
+def _apply_css(widget, css_str):
+ ctx = widget.get_style_context()
+ ctx.add_provider(_make_css_widget(css_str), Gtk.STYLE_PROVIDER_PRIORITY_USER)
+
+
+class UsageWindow(Gtk.Window):
+ def __init__(self, parent):
+ super().__init__(title="Usage Dashboard")
+ self.set_transient_for(parent)
+ self.set_default_size(720, 640)
+ self.set_position(Gtk.WindowPosition.CENTER)
+ self._parent = parent
+
+ _apply_css(self, f"""
+ window {{ background-color: {_U["base"]}; }}
+ separator {{ background-color: {_U["surface1"]}; }}
+ """)
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ self.add(vbox)
+
+ self._build_header(vbox)
+ self._build_summary_strip(vbox)
+ sep = Gtk.Separator()
+ vbox.pack_start(sep, False, False, 0)
+
+ self._cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ self._cards_box.set_margin_top(8)
+ sw = Gtk.ScrolledWindow()
+ sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ sw.add(self._cards_box)
+ vbox.pack_start(sw, True, True, 0)
+
+ self._refresh()
+ self.show_all()
+
+ def _build_header(self, parent):
+ hdr = Gtk.Box(spacing=8)
+ hdr.set_margin_start(16)
+ hdr.set_margin_end(16)
+ hdr.set_margin_top(12)
+ hdr.set_margin_bottom(6)
+ parent.pack_start(hdr, False, False, 0)
+
+ bolt = Gtk.Label()
+ bolt.set_markup(f'\u26A1')
+ hdr.pack_start(bolt, False, False, 0)
+
+ title = Gtk.Label()
+ title.set_markup(f'Usage Dashboard')
+ hdr.pack_start(title, False, False, 0)
+
+ self._status_dots = Gtk.Label()
+ hdr.pack_start(self._status_dots, False, False, 8)
+
+ self._updated_lbl = Gtk.Label()
+ self._updated_lbl.set_markup(f'Never')
+ hdr.pack_end(self._updated_lbl, False, False, 4)
+
+ refresh_btn = Gtk.Button(label="Refresh")
+ _apply_css(refresh_btn, f"""
+ button {{ color: {_U["text"]}; background-color: {_U["surface0"]};
+ border: 1px solid {_U["surface1"]}; border-radius: 6px; padding: 4px 12px; }}
+ button:hover {{ background-color: {_U["surface1"]}; }}
+ """)
+ refresh_btn.connect("clicked", lambda b: self._refresh())
+ hdr.pack_end(refresh_btn, False, False, 0)
+
+ def _build_summary_strip(self, parent):
+ strip = Gtk.Box(spacing=0)
+ strip.set_margin_start(16)
+ strip.set_margin_end(16)
+ strip.set_margin_bottom(6)
+ _apply_css(strip, f"box {{ background-color: {_U["surface0"]}; border-radius: 8px; padding: 8px 12px; }}")
+ parent.pack_start(strip, False, False, 0)
+
+ self._kpi_boxes = {}
+ for key, label, icon in [
+ ("providers", "Providers", "\U0001F4CA"),
+ ("requests", "Requests", "\u26A1"),
+ ("tokens", "Tokens", "\U0001F9E0"),
+ ("latency", "Avg Latency", "\u23F1"),
+ ]:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
+ lbl = Gtk.Label()
+ lbl.set_markup(f'{icon} {label}')
+ lbl.set_xalign(0)
+ box.pack_start(lbl, False, False, 0)
+ val = Gtk.Label()
+ val.set_markup(f'-')
+ val.set_xalign(0)
+ box.pack_start(val, False, False, 0)
+ box.set_margin_end(20)
+ strip.pack_start(box, False, False, 0)
+ self._kpi_boxes[key] = val
+
+ def _refresh(self):
+ for c in self._cards_box.get_children():
+ self._cards_box.remove(c)
+ stats = _load_usage_stats()
+ updated = stats.get("updated")
+ if updated:
+ self._updated_lbl.set_markup(f'{updated}')
+ providers = stats.get("providers", {})
+ if not providers:
+ empty = Gtk.Label()
+ empty.set_markup(f'No usage data yet.\nLaunch a session to start tracking.')
+ empty.set_margin_top(60)
+ self._cards_box.pack_start(empty, False, False, 0)
+ self._cards_box.show_all()
+ return
+
+ total_req = 0
+ total_tok_in = 0
+ total_tok_out = 0
+ total_dur = 0.0
+ n_ok = 0
+ n_warn = 0
+ n_err = 0
+
+ sorted_providers = sorted(providers.items(), key=lambda x: x[1].get("total_requests", 0), reverse=True)
+ for prov_name, prov_data in sorted_providers:
+ t = prov_data.get("total_requests", 0)
+ total_req += t
+ total_tok_in += prov_data.get("total_tokens_in", 0)
+ total_tok_out += prov_data.get("total_tokens_out", 0)
+ total_dur += prov_data.get("total_duration_s", 0.0)
+ fail = prov_data.get("failures", 0)
+ fail_pct = fail / t if t > 0 else 0
+ _, sc = _status_pill(0, fail_pct)
+ if fail_pct > 0.15:
+ n_err += 1
+ elif fail_pct > 0.05:
+ n_warn += 1
+ else:
+ n_ok += 1
+
+ self._kpi_boxes["providers"].set_markup(
+ f'{len(providers)}')
+ self._kpi_boxes["requests"].set_markup(
+ f'{total_req:,}')
+ tok_sum = total_tok_in + total_tok_out
+ tok_str = f"{_fmt_tok(tok_sum)} in:{_fmt_tok(total_tok_in)} out:{_fmt_tok(total_tok_out)}" if tok_sum else "N/A"
+ self._kpi_boxes["tokens"].set_markup(
+ f'{tok_str}')
+ avg_lat = total_dur / total_req if total_req > 0 else 0
+ self._kpi_boxes["latency"].set_markup(
+ f'{_fmt_dur(avg_lat)}')
+
+ dots_parts = []
+ if n_ok:
+ dots_parts.append(f'\u25CF{n_ok}')
+ if n_warn:
+ dots_parts.append(f'\u25D0{n_warn}')
+ if n_err:
+ dots_parts.append(f'\u2717{n_err}')
+ if dots_parts:
+ self._status_dots.set_markup(" ".join(dots_parts))
+
+ for prov_name, prov_data in sorted_providers:
+ card = self._build_card(prov_name, prov_data)
+ self._cards_box.pack_start(card, False, False, 0)
+ self._cards_box.show_all()
+
+ def _build_card(self, name, data):
+ card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ card.set_margin_start(12)
+ card.set_margin_end(12)
+ _apply_css(card, f"""
+ box {{ background-color: {_U["surface0"]}; border-radius: 10px;
+ border: 1px solid {_U["surface1"]}; }}
+ """)
+
+ total = data.get("total_requests", 0)
+ ok = data.get("successes", 0)
+ fail = data.get("failures", 0)
+ success_rate = ok / total if total > 0 else 1.0
+ fail_pct = fail / total if total > 0 else 0
+ status_text, status_color = _status_pill(success_rate, fail_pct)
+
+ border_color = status_color
+ _apply_css(card, f"""
+ box {{ background-color: {_U["surface0"]}; border-radius: 10px;
+ border: 1px solid {border_color}; }}
+ """)
+
+ inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
+ inner.set_margin_start(14)
+ inner.set_margin_end(14)
+ inner.set_margin_top(10)
+ inner.set_margin_bottom(10)
+ card.pack_start(inner, False, False, 0)
+
+ top = Gtk.Box(spacing=6)
+ inner.pack_start(top, False, False, 0)
+
+ dot = Gtk.Label()
+ dot.set_markup(f'\u25CF')
+ top.pack_start(dot, False, False, 0)
+
+ name_lbl = Gtk.Label()
+ short = name.replace("https://", "").replace("http://", "").split("/")[0]
+ name_lbl.set_markup(f'{short}')
+ top.pack_start(name_lbl, False, False, 0)
+
+ pill = Gtk.Label()
+ pill.set_markup(f' {status_text} ')
+ top.pack_start(pill, False, False, 4)
+
+ req_lbl = Gtk.Label()
+ req_lbl.set_markup(f'{total} req')
+ top.pack_start(req_lbl, False, False, 6)
+
+ last_used = data.get("last_used", "")
+ if last_used:
+ lu_lbl = Gtk.Label()
+ lu_lbl.set_markup(f'{last_used}')
+ top.pack_end(lu_lbl, False, False, 0)
+
+ sep1 = Gtk.Separator()
+ _apply_css(sep1, f"separator {{ background-color: {status_color}; margin-top: 4px; }}")
+ inner.pack_start(sep1, False, False, 0)
+
+ gauge_box = Gtk.Box(spacing=4)
+ gauge_box.set_margin_top(4)
+ inner.pack_start(gauge_box, False, False, 0)
+
+ gauge_label = Gtk.Label()
+ gauge_label.set_markup(f'\u26A1')
+ gauge_box.pack_start(gauge_label, False, False, 0)
+
+ bar = Gtk.ProgressBar()
+ bar.set_fraction(success_rate)
+ bar_pct = int(success_rate * 100)
+ bar.set_text(f"{bar_pct}%")
+ bar.set_show_text(True)
+ bar_css = f"""
+ progress {{ background-color: {status_color}; border-radius: 6px; }}
+ trough {{ background-color: {_U["surface1"]}; border-radius: 6px; min-height: 12px; }}
+ """
+ _apply_css(bar, bar_css)
+ bar.set_hexpand(True)
+ gauge_box.pack_start(bar, True, True, 0)
+
+ if fail > 0:
+ fail_lbl = Gtk.Label()
+ fail_lbl.set_markup(f'{fail} fail')
+ gauge_box.pack_end(fail_lbl, False, False, 0)
+
+ metrics_box = Gtk.Box(spacing=0)
+ metrics_box.set_margin_top(4)
+ inner.pack_start(metrics_box, False, False, 0)
+
+ t_in = data.get("total_tokens_in", 0)
+ t_out = data.get("total_tokens_out", 0)
+ dur = data.get("total_duration_s", 0.0)
+ avg_dur = dur / total if total > 0 else 0
+
+ for label, value, color in [
+ ("Tokens In", f"{_fmt_tok(t_in)}", _U["sapphire"]),
+ ("Tokens Out", f"{_fmt_tok(t_out)}", _U["peach"]),
+ ("Avg Latency", _fmt_dur(avg_dur), _U["sky"]),
+ ("Duration", _fmt_dur(dur), _U["lavender"]),
+ ]:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ l = Gtk.Label()
+ l.set_markup(f'{label}')
+ l.set_xalign(0)
+ box.pack_start(l, False, False, 0)
+ v = Gtk.Label()
+ v.set_markup(f'{value}')
+ v.set_xalign(0)
+ box.pack_start(v, False, False, 0)
+ box.set_margin_end(16)
+ metrics_box.pack_start(box, False, False, 0)
+
+ models = data.get("models", {})
+ if models:
+ self._build_models_section(inner, models, total)
+
+ last_err = data.get("last_error")
+ if last_err:
+ err_box = Gtk.Box(spacing=4)
+ err_box.set_margin_top(4)
+ inner.pack_start(err_box, False, False, 0)
+ icon = Gtk.Label()
+ icon.set_markup(f'\u26A0')
+ err_box.pack_start(icon, False, False, 0)
+ err_lbl = Gtk.Label()
+ err_lbl.set_markup(f'{last_err}')
+ err_lbl.set_xalign(0)
+ err_lbl.set_line_wrap(True)
+ err_box.pack_start(err_lbl, False, False, 0)
+
+ return card
+
+ def _build_models_section(self, parent, models, total_req):
+ sep_m = Gtk.Separator()
+ _apply_css(sep_m, f"separator {{ background-color: {_U["lavender"]}; margin-top: 4px; margin-bottom: 2px; }}")
+ parent.pack_start(sep_m, False, False, 0)
+
+ header = Gtk.Box(spacing=4)
+ header.set_margin_top(2)
+ parent.pack_start(header, False, False, 0)
+ icon = Gtk.Label()
+ icon.set_markup(f'\U0001F916')
+ header.pack_start(icon, False, False, 0)
+ lbl = Gtk.Label()
+ lbl.set_markup(f'Models')
+ header.pack_start(lbl, False, False, 0)
+
+ sorted_models = sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True)
+
+ if total_req > 0:
+ comp_bar = Gtk.Box(spacing=0)
+ _apply_css(comp_bar, f"box {{ background-color: {_U["surface1"]}; border-radius: 4px; min-height: 8px; margin-top: 2px; }}")
+ parent.pack_start(comp_bar, False, False, 0)
+ for i, (mname, mdata) in enumerate(sorted_models):
+ m_req = mdata.get("requests", 0)
+ pct = m_req / total_req
+ if pct < 0.01:
+ continue
+ seg = Gtk.Box()
+ color = _U["model_palette"][i % len(_U["model_palette"])]
+ _apply_css(seg, f"box {{ background-color: {color}; min-height: 8px; }}")
+ seg.set_size_request(max(int(pct * 400), 4), 8)
+ comp_bar.pack_start(seg, False, False, 0)
+
+ models_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
+ models_box.set_margin_top(2)
+ parent.pack_start(models_box, False, False, 0)
+
+ for i, (mname, mdata) in enumerate(sorted_models[:6]):
+ row = Gtk.Box(spacing=6)
+ models_box.pack_start(row, False, False, 0)
+ color = _U["model_palette"][i % len(_U["model_palette"])]
+ dot = Gtk.Label()
+ dot.set_markup(f'\u25CF')
+ row.pack_start(dot, False, False, 0)
+ m_lbl = Gtk.Label()
+ m_lbl.set_markup(f'{mname}')
+ m_lbl.set_xalign(0)
+ m_lbl.set_size_request(120, -1)
+ row.pack_start(m_lbl, False, False, 0)
+
+ m_req = mdata.get("requests", 0)
+ pct = m_req / total_req * 100 if total_req > 0 else 0
+
+ m_bar = Gtk.ProgressBar()
+ m_bar.set_fraction(m_req / total_req if total_req > 0 else 0)
+ _apply_css(m_bar, f"""
+ progress {{ background-color: {color}; border-radius: 3px; }}
+ trough {{ background-color: {_U["surface1"]}; border-radius: 3px; min-height: 6px; }}
+ """)
+ m_bar.set_size_request(80, -1)
+ row.pack_start(m_bar, False, False, 0)
+
+ pct_lbl = Gtk.Label()
+ pct_lbl.set_markup(f'{pct:.0f}% ({m_req})')
+ row.pack_start(pct_lbl, False, False, 0)
+
+ m_in = mdata.get("tokens_in", 0)
+ m_out = mdata.get("tokens_out", 0)
+ if m_in or m_out:
+ tok_lbl = Gtk.Label()
+ tok_lbl.set_markup(f'in:{_fmt_tok(m_in)} out:{_fmt_tok(m_out)}')
+ row.pack_end(tok_lbl, False, False, 0)
+
+
+def main():
+ for d in [LOG_DIR, PROXY_CONFIG_DIR]:
+ d.mkdir(parents=True, exist_ok=True)
+
+ # Create default endpoints if none exist
+ if not ENDPOINTS_FILE.exists():
+ save_endpoints({
+ "default": "OpenAI",
+ "endpoints": [
+ {"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1",
+ "api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"],
+ "provider_preset": "OpenAI"},
+ {"name": "Z.AI", "backend_type": "openai-compat",
+ "base_url": "https://api.z.ai/api/coding/paas/v4",
+ "api_key": "", "default_model": "glm-5.1",
+ "models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"],
+ "provider_preset": "Custom"},
+ ],
+ })
+
+ w = LauncherWin()
+ w.connect("destroy", Gtk.main_quit)
+ Gtk.main()
+
+class RequestHistoryWindow(Gtk.Window):
+ _SNAP_DIR = Path.home() / ".cache/codex-proxy/requests"
+
+ def __init__(self, parent):
+ Gtk.Window.__init__(self, title="Request History")
+ self.set_transient_for(parent)
+ self.set_default_size(720, 500)
+ self.set_position(Gtk.WindowPosition.CENTER)
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ vbox.set_margin_start(10)
+ vbox.set_margin_end(10)
+ vbox.set_margin_top(10)
+ vbox.set_margin_bottom(10)
+ self.add(vbox)
+
+ hdr = Gtk.Box(spacing=8)
+ vbox.pack_start(hdr, False, False, 0)
+ lbl = Gtk.Label(label="Request History")
+ lbl.set_use_markup(True)
+ hdr.pack_start(lbl, False, False, 0)
+ refresh_btn = Gtk.Button(label="Refresh")
+ refresh_btn.connect("clicked", lambda b: self._load())
+ hdr.pack_end(refresh_btn, False, False, 0)
+ clear_btn = Gtk.Button(label="Clear All")
+ clear_btn.connect("clicked", lambda b: self._clear_all())
+ hdr.pack_end(clear_btn, False, False, 0)
+
+ paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
+ vbox.pack_start(paned, True, True, 0)
+
+ top_sw = Gtk.ScrolledWindow()
+ top_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ paned.pack1(top_sw, resize=True, shrink=False)
+
+ self._store = Gtk.ListStore(str, str, str, str, str, str)
+ self._tree = Gtk.TreeView(model=self._store)
+ for i, (title, w) in enumerate([("Time", 140), ("Model", 140), ("Status", 80), ("Duration", 70), ("ID", 180), ("Error", 120)]):
+ col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i)
+ col.set_resizable(True)
+ col.set_min_width(w)
+ self._tree.append_column(col)
+ self._tree.connect("row-activated", self._on_row_activated)
+ top_sw.add(self._tree)
+
+ self._detail = Gtk.TextView()
+ self._detail.set_editable(False)
+ self._detail.set_monospace(True)
+ self._detail.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+ bottom_sw = Gtk.ScrolledWindow()
+ bottom_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ bottom_sw.add(self._detail)
+ paned.pack2(bottom_sw, resize=True, shrink=False)
+
+ self._snapshots = []
+ self._load()
+ self.show_all()
+
+ def _load(self):
+ self._store.clear()
+ self._snapshots = []
+ snap_dir = self._SNAP_DIR
+ if not snap_dir.exists():
+ return
+ files = sorted(snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
+ for f in files[:200]:
+ try:
+ data = json.loads(f.read_text())
+ meta = data.get("_meta", {})
+ self._snapshots.append(data)
+ ts = meta.get("ts_iso", "")[:19].replace("T", " ")
+ model = meta.get("model", "?")
+ status = meta.get("status", "unknown")
+ dur = f"{meta['duration_s']:.1f}s" if meta.get("duration_s") is not None else "-"
+ rid = meta.get("request_id", "")[:28]
+ err = (meta.get("error") or "")[:60]
+ self._store.append([ts, model, status, dur, rid, err])
+ except Exception:
+ pass
+
+ def _on_row_activated(self, tree, path, column):
+ idx = path[0]
+ if idx < len(self._snapshots):
+ data = self._snapshots[idx]
+ buf = self._detail.get_buffer()
+ buf.set_text(json.dumps(data, indent=2, ensure_ascii=False)[:50000])
+
+ def _clear_all(self):
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO,
+ "Delete all request snapshots?")
+ r = d.run()
+ d.destroy()
+ if r != Gtk.ResponseType.YES:
+ return
+ snap_dir = self._SNAP_DIR
+ if snap_dir.exists():
+ for f in snap_dir.glob("*.json"):
+ try:
+ f.unlink()
+ except Exception:
+ pass
+ self._store.clear()
+ self._snapshots = []
+ self._detail.get_buffer().set_text("")
+
+class BenchmarkWindow(Gtk.Window):
+ _BENCH_PROMPT = "In exactly 3 bullet points, explain why the sky is blue."
+ _BENCH_TOOLS = [{"type": "function", "function": {"name": "get_weather",
+ "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}}}]
+
+ def __init__(self, parent):
+ Gtk.Window.__init__(self, title="Model Benchmark")
+ self.set_transient_for(parent)
+ self.set_default_size(820, 560)
+ self.set_position(Gtk.WindowPosition.CENTER)
+ self._running = False
+ self._ep_data = load_endpoints()
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ vbox.set_margin_start(10)
+ vbox.set_margin_end(10)
+ vbox.set_margin_top(10)
+ vbox.set_margin_bottom(10)
+ self.add(vbox)
+
+ hdr = Gtk.Box(spacing=8)
+ vbox.pack_start(hdr, False, False, 0)
+ lbl = Gtk.Label(label="Multi-Provider Benchmark")
+ lbl.set_use_markup(True)
+ hdr.pack_start(lbl, False, False, 0)
+ self._run_btn = Gtk.Button(label="Run Benchmark")
+ self._run_btn.connect("clicked", lambda b: self._run())
+ hdr.pack_end(self._run_btn, False, False, 0)
+
+ lanes_box = Gtk.Box(spacing=6)
+ vbox.pack_start(lanes_box, False, False, 0)
+
+ self._lanes = []
+ for i in range(3):
+ frame = Gtk.Frame(label=f"{'A' if i == 0 else 'B' if i == 1 else 'C'}" if i < 2 else None)
+ if i == 2:
+ self._c_frame = frame
+ self._c_check = Gtk.CheckButton(label="Enable Lane C")
+ self._c_check.set_active(False)
+ frame.set_label_widget(self._c_check)
+ frame.set_sensitive(False)
+ self._c_check.connect("toggled", lambda b: frame.set_sensitive(b.get_active()))
+ inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ inner.set_margin_start(6)
+ inner.set_margin_end(6)
+ inner.set_margin_top(4)
+ inner.set_margin_bottom(4)
+ frame.add(inner)
+ lanes_box.pack_start(frame, True, True, 0)
+
+ row_ep = Gtk.Box(spacing=4)
+ inner.pack_start(row_ep, False, False, 0)
+ row_ep.pack_start(Gtk.Label(label="Endpoint:"), False, False, 0)
+ ep_combo = Gtk.ComboBoxText()
+ for ep in self._ep_data.get("endpoints", []):
+ ep_combo.append(ep["name"], ep["name"])
+ row_ep.pack_start(ep_combo, True, True, 0)
+
+ row_m = Gtk.Box(spacing=4)
+ inner.pack_start(row_m, False, False, 0)
+ row_m.pack_start(Gtk.Label(label="Model:"), False, False, 0)
+ m_combo = Gtk.ComboBoxText()
+ m_combo.set_entry_text_column(0)
+ row_m.pack_start(m_combo, True, True, 0)
+
+ ep_combo.connect("changed", lambda b, mc=m_combo: self._update_lane_models(b, mc))
+
+ self._lanes.append({"ep": ep_combo, "model": m_combo})
+
+ default_name = self._ep_data.get("default")
+ if default_name:
+ self._lanes[0]["ep"].set_active_id(default_name)
+ eps = self._ep_data.get("endpoints", [])
+ if len(eps) > 1:
+ self._lanes[1]["ep"].set_active_id(eps[1]["name"])
+ elif eps:
+ self._lanes[1]["ep"].set_active_id(eps[0]["name"])
+ if len(eps) > 2:
+ self._lanes[2]["ep"].set_active_id(eps[2]["name"])
+ elif len(eps) > 1:
+ self._lanes[2]["ep"].set_active_id(eps[1]["name"])
+
+ tests_box = Gtk.Box(spacing=6)
+ vbox.pack_start(tests_box, False, False, 0)
+ self._test_ttft = Gtk.CheckButton(label="Time to First Token")
+ self._test_ttft.set_active(True)
+ tests_box.pack_start(self._test_ttft, False, False, 0)
+ self._test_total = Gtk.CheckButton(label="Total Latency")
+ self._test_total.set_active(True)
+ tests_box.pack_start(self._test_total, False, False, 0)
+ self._test_tools = Gtk.CheckButton(label="Tool Call")
+ self._test_tools.set_active(True)
+ tests_box.pack_start(self._test_tools, False, False, 0)
+ self._test_tps = Gtk.CheckButton(label="Tokens/sec")
+ self._test_tps.set_active(True)
+ tests_box.pack_start(self._test_tps, False, False, 0)
+
+ results_sw = Gtk.ScrolledWindow()
+ results_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ vbox.pack_start(results_sw, True, True, 0)
+
+ self._results_store = Gtk.ListStore(str, str, str, str, str)
+ self._results_tree = Gtk.TreeView(model=self._results_store)
+ for i, title in enumerate(["Test", "Lane A", "Lane B", "Lane C", "Winner"]):
+ col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i)
+ col.set_resizable(True)
+ self._results_tree.append_column(col)
+ results_sw.add(self._results_tree)
+
+ self._status = Gtk.Label(label="Select endpoints and models per lane, then Run Benchmark.")
+ self._status.set_xalign(0)
+ vbox.pack_start(self._status, False, False, 0)
+
+ self.show_all()
+
+ def _update_lane_models(self, ep_combo, model_combo):
+ name = ep_combo.get_active_text()
+ if not name:
+ return
+ ep = get_endpoint(name)
+ models = (ep or {}).get("models", [])
+ active = model_combo.get_active_text()
+ model_combo.remove_all()
+ for m in models:
+ model_combo.append(m, m)
+ if active and any(m == active for m in models):
+ model_combo.set_active_id(active)
+ elif models:
+ model_combo.set_active(0)
+
+ def _collect_lanes(self):
+ active = []
+ for i, lane in enumerate(self._lanes):
+ if i == 2 and not self._c_check.get_active():
+ continue
+ ep_name = lane["ep"].get_active_text()
+ model = lane["model"].get_active_text()
+ if not ep_name or not model:
+ continue
+ ep = get_endpoint(ep_name)
+ if not ep:
+ continue
+ active.append({"ep": ep, "model": model, "label": f"{ep_name}/{model}"})
+ return active
+
+ def _run(self):
+ if self._running:
+ return
+ lanes = self._collect_lanes()
+ if len(lanes) < 2:
+ self._status.set_text("Need at least 2 lanes with endpoint + model selected.")
+ return
+ self._running = True
+ self._run_btn.set_sensitive(False)
+ self._results_store.clear()
+ self._status.set_text("Running benchmark…")
+ threading.Thread(target=self._run_bench, args=(lanes,), daemon=True).start()
+
+ def _bench_single(self, ep, model, stream, with_tools=False):
+ url = normalize_base_url(ep.get("base_url", ""))
+ key = (ep.get("api_key") or "").strip()
+ bt = ep.get("backend_type", "openai-compat")
+ if bt == "anthropic":
+ test_url = f"{url}/v1/messages"
+ headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ body = {"model": model, "max_tokens": 100, "stream": stream,
+ "messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
+ if with_tools:
+ body["tools"] = self._BENCH_TOOLS
+ body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
+ data = json.dumps(body).encode()
+ 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}"
+ oauth_token = ""
+ if token_path.exists():
+ try:
+ td = json.loads(token_path.read_text())
+ oauth_token = td.get("access_token", "")
+ except Exception:
+ pass
+ test_url = f"{url}/v1/chat/completions"
+ headers = {"Authorization": f"Bearer {oauth_token}", "content-type": "application/json"}
+ body = {"model": model, "max_tokens": 100, "stream": stream,
+ "messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
+ if with_tools:
+ body["tools"] = self._BENCH_TOOLS
+ body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
+ data = json.dumps(body).encode()
+ else:
+ test_url = f"{url}/chat/completions"
+ headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
+ body = {"model": model, "max_tokens": 100, "stream": stream,
+ "messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
+ if with_tools:
+ body["tools"] = self._BENCH_TOOLS
+ body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
+ data = json.dumps(body).encode()
+
+ req = urllib.request.Request(test_url, data=data, headers=headers, method="POST")
+ t0 = time.time()
+ ttft = None
+ try:
+ resp = urllib.request.urlopen(req, timeout=60)
+ if stream:
+ first_chunk_time = None
+ chunks = []
+ while True:
+ chunk = resp.read(4096)
+ if not chunk:
+ break
+ if first_chunk_time is None:
+ first_chunk_time = time.time()
+ ttft = first_chunk_time - t0
+ chunks.append(chunk)
+ total = time.time() - t0
+ result_text = b"".join(chunks).decode(errors="replace")[:300]
+ else:
+ raw = resp.read()
+ total = time.time() - t0
+ result_text = raw.decode(errors="replace")[:300]
+ payload = json.loads(raw)
+ choices = payload.get("choices", [])
+ if choices:
+ msg = choices[0].get("message", {})
+ if with_tools:
+ tcs = msg.get("tool_calls", [])
+ has_tools = len(tcs) > 0
+ return {"ttft": ttft or total, "total": total,
+ "detail": f"tools={has_tools}, tok={payload.get('usage', {}).get('total_tokens', '?')}"}
+ content = msg.get("content", "")[:50]
+ return {"ttft": ttft or total, "total": total,
+ "detail": f"{content[:40]}… tok={payload.get('usage', {}).get('total_tokens', '?')}"}
+ return {"ttft": ttft or total, "total": total, "detail": result_text[:60]}
+ except Exception as e:
+ total = time.time() - t0
+ return {"ttft": ttft or total, "total": total, "detail": f"Error: {str(e)[:40]}"}
+
+ def _bench_tps(self, ep, model):
+ url = normalize_base_url(ep.get("base_url", ""))
+ key = (ep.get("api_key") or "").strip()
+ bt = ep.get("backend_type", "openai-compat")
+ prompt = "Write a detailed paragraph about artificial intelligence in at least 150 words."
+ max_tok = 512
+ if bt == "anthropic":
+ test_url = f"{url}/v1/messages"
+ headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
+ "messages": [{"role": "user", "content": prompt}]}).encode()
+ 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}"
+ oauth_token = ""
+ if token_path.exists():
+ try:
+ td = json.loads(token_path.read_text())
+ oauth_token = td.get("access_token", "")
+ except Exception:
+ pass
+ test_url = f"{url}/v1/chat/completions"
+ headers = {"Authorization": f"Bearer {oauth_token}", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
+ "messages": [{"role": "user", "content": prompt}]}).encode()
+ else:
+ test_url = f"{url}/chat/completions"
+ headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
+ "messages": [{"role": "user", "content": prompt}]}).encode()
+
+ req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
+ t0 = time.time()
+ first_token_t = None
+ token_count = 0
+ try:
+ resp = urllib.request.urlopen(req, timeout=90)
+ buf = b""
+ while True:
+ chunk = resp.read(4096)
+ if not chunk:
+ break
+ if first_token_t is None:
+ first_token_t = time.time()
+ buf += chunk
+ total = time.time() - t0
+ text = buf.decode(errors="replace")
+ if bt == "anthropic":
+ for line in text.split("\n"):
+ if "content_block_delta" in line and "text_delta" in line:
+ try:
+ idx = line.index("{")
+ evt = json.loads(line[idx:])
+ delta = evt.get("delta", {})
+ token_count += len(delta.get("text", "")) / 4
+ except Exception:
+ pass
+ if token_count == 0:
+ token_count = max(1, len(text) / 4)
+ else:
+ for line in text.split("\n"):
+ if line.startswith("data: ") and line != "data: [DONE]":
+ try:
+ d = json.loads(line[6:])
+ content = d.get("choices", [{}])[0].get("delta", {}).get("content", "")
+ if content:
+ token_count += max(1, len(content) / 4)
+ except Exception:
+ pass
+ if token_count == 0:
+ token_count = max(1, len(text) / 4)
+ gen_time = (time.time() - first_token_t) if first_token_t else total
+ tps = token_count / gen_time if gen_time > 0 else 0
+ return {"tps": tps, "tokens": int(token_count), "gen_time": gen_time, "total": total,
+ "detail": f"{int(token_count)} tok / {gen_time:.1f}s"}
+ except Exception as e:
+ total = time.time() - t0
+ return {"tps": 0, "tokens": 0, "gen_time": total, "total": total, "detail": f"Error: {str(e)[:40]}"}
+
+ def _run_bench(self, lanes):
+ results = []
+ tests = []
+ if self._test_ttft.get_active():
+ tests.append(("TTFT (stream)", True, False))
+ if self._test_total.get_active():
+ tests.append(("Total latency", False, False))
+ if self._test_tools.get_active():
+ tests.append(("Tool call", False, True))
+ run_tps = self._test_tps.get_active()
+
+ for test_name, stream, tools in tests:
+ lane_results = []
+ for lane in lanes:
+ label = lane["label"]
+ GLib.idle_add(self._status.set_text, f"{test_name}: {label}…")
+ r = self._bench_single(lane["ep"], lane["model"], stream, tools)
+ lane_results.append((label, r))
+
+ metric = "ttft" if stream else "total"
+ values = [(lr[0], lr[1][metric]) for lr in lane_results]
+ sorted_v = sorted(values, key=lambda x: x[1])
+ best_val = sorted_v[0][1]
+ second_val = sorted_v[1][1]
+ if best_val < second_val * 0.85:
+ winner = sorted_v[0][0]
+ else:
+ winner = "Tie"
+
+ cols = []
+ for lr in lane_results:
+ v = lr[1][metric]
+ cols.append(f"{v:.2f}s ({lr[1]['detail'][:30]})")
+ while len(cols) < 3:
+ cols.append("—")
+ cols.append(winner)
+ results.append(tuple([test_name] + cols))
+
+ if run_tps:
+ lane_tps = []
+ for lane in lanes:
+ label = lane["label"]
+ GLib.idle_add(self._status.set_text, f"Tokens/sec: {label}…")
+ r = self._bench_tps(lane["ep"], lane["model"])
+ lane_tps.append((label, r))
+
+ tps_vals = [(lt[0], lt[1]["tps"]) for lt in lane_tps]
+ sorted_tps = sorted(tps_vals, key=lambda x: x[1], reverse=True)
+ best_tps = sorted_tps[0][1]
+ second_tps = sorted_tps[1][1] if len(sorted_tps) > 1 else 0
+ if best_tps > 0 and second_tps > 0 and best_tps > second_tps * 1.15:
+ winner_tps = sorted_tps[0][0]
+ else:
+ winner_tps = "Tie"
+
+ cols_tps = []
+ for lt in lane_tps:
+ tps = lt[1]["tps"]
+ cols_tps.append(f"{tps:.1f} t/s ({lt[1]['detail'][:25]})")
+ while len(cols_tps) < 3:
+ cols_tps.append("—")
+ cols_tps.append(winner_tps)
+ results.append(tuple(["Tokens/sec"] + cols_tps))
+
+ def _show():
+ for row in results:
+ self._results_store.append(row)
+ self._status.set_text("Benchmark complete.")
+ self._running = False
+ self._run_btn.set_sensitive(True)
+
+ GLib.idle_add(_show)
+
+if __name__ == "__main__":
+ main()
diff --git a/codex-launcher-gui.py b/codex-launcher-gui.py
new file mode 100644
index 0000000..7049aba
--- /dev/null
+++ b/codex-launcher-gui.py
@@ -0,0 +1,3292 @@
+#!/usr/bin/env python3
+"""Codex Launcher GUI (tkinter) — manage endpoints, launch Desktop or CLI with any provider.
+
+Windows-native tkinter GUI mirroring all features of the GTK version.
+Imports process management, config engine, proxy lifecycle from codex_launcher_lib.
+"""
+
+import tkinter as tk
+from tkinter import ttk, filedialog, messagebox, scrolledtext
+import json
+import os
+import shutil
+import socket
+import ssl
+import subprocess
+import sys
+import threading
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+import base64
+import hashlib
+import secrets
+import http.server
+import collections
+from pathlib import Path
+
+from codex_launcher_lib import (
+ IS_WINDOWS, HOME, CONFIG, CONFIG_BAK, CONFIG_TXN,
+ ENDPOINTS_FILE, BGP_POOLS_FILE, LAUNCH_LOG, LOG_DIR,
+ PROXY_CONFIG_DIR, BIN_DIR, PROXY, CLEANUP, PID_REGISTRY,
+ PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH,
+ ANTIGRAVITY_MODELS,
+ safe_name, label_for_backend, normalize_model_id, normalize_base_url,
+ parse_model_list, now_utc_iso, apply_provider_preset,
+ load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools,
+ get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle,
+ backup_config, restore_config, begin_config_transaction, end_config_transaction,
+ recover_config_if_needed, write_config_for_native, write_config_for_translated,
+ endpoint_models_url, endpoint_model_headers, fetch_models_for_endpoint,
+ refresh_endpoint_models, run_endpoint_doctor,
+ detect_codex_cli, detect_codex_desktop, check_codex_auth,
+ last_log_lines, kill_existing_desktop, safe_cleanup_owned,
+ start_proxy_for, stop_proxy, start_bgp_proxy, get_proxy_state, set_proxy_state,
+ detect_terminal, open_url, open_file, write_secure_text,
+ ensure_dirs, create_default_endpoints,
+ load_monitoring_config, save_monitoring_config,
+ load_incident_store, save_incident_store, load_usage_stats,
+ monitoring_log,
+ IncidentStore, AIDiagnosticAgent, HealthWatcher,
+ load_oauth_secrets, save_oauth_secrets,
+ _usage_theme, UA,
+)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Helpers
+# ═══════════════════════════════════════════════════════════════════════
+
+def _fmt_tok(n):
+ if n >= 1_000_000:
+ return f"{n/1_000_000:.1f}M"
+ if n >= 1_000:
+ return f"{n/1_000:.1f}K"
+ return str(n)
+
+
+def _fmt_dur(s):
+ if s >= 3600:
+ return f"{s/3600:.1f}h"
+ if s >= 60:
+ return f"{s/60:.1f}m"
+ return f"{s:.1f}s"
+
+
+def _status_pill(success_rate, fail_pct):
+ U = _usage_theme()
+ if fail_pct > 0.15:
+ return ("ERR", U["red"])
+ if fail_pct > 0.05:
+ return ("WARN", U["yellow"])
+ return ("OK", U["green"])
+
+
+def _show_doctor_results_tk(parent, ep_name, checks):
+ dlg = tk.Toplevel(parent)
+ dlg.title(f"Doctor: {ep_name}")
+ dlg.geometry("520x420")
+ dlg.transient(parent)
+ dlg.grab_set()
+
+ passed = sum(1 for _, ok, _ in checks if ok is True)
+ failed = sum(1 for _, ok, _ in checks if ok is False)
+ warned = sum(1 for _, ok, _ in checks if ok is None)
+
+ hdr = tk.Label(dlg, text=f"{ep_name} {passed} passed {failed} failed {warned} warnings",
+ font=("Segoe UI", 10, "bold"))
+ hdr.pack(padx=12, pady=(12, 4), anchor="w")
+
+ ttk.Separator(dlg).pack(fill="x", padx=12)
+
+ canvas = tk.Canvas(dlg)
+ scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview)
+ inner = tk.Frame(canvas)
+ inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
+ canvas.create_window((0, 0), window=inner, anchor="nw")
+ canvas.configure(yscrollcommand=scrollbar.set)
+
+ for name, ok, detail in checks:
+ row = tk.Frame(inner)
+ row.pack(fill="x", padx=12, pady=1)
+ if ok is True:
+ color, sym = "#27ae60", "✓"
+ elif ok is False:
+ color, sym = "#e74c3c", "✗"
+ else:
+ color, sym = "#f39c12", "○"
+ tk.Label(row, text=sym, fg=color, font=("Segoe UI", 11, "bold")).pack(side="left")
+ tk.Label(row, text=name, font=("Segoe UI", 9, "bold")).pack(side="left", padx=(4, 0))
+ if detail:
+ tk.Label(row, text=detail, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="right")
+
+ canvas.pack(side="left", fill="both", expand=True, padx=(12, 0), pady=6)
+ scrollbar.pack(side="right", fill="y", pady=6)
+
+ btn_frame = tk.Frame(dlg)
+ btn_frame.pack(pady=(0, 10))
+ ttk.Button(btn_frame, text="Close", command=dlg.destroy).pack()
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# EditEndpointDialog
+# ═══════════════════════════════════════════════════════════════════════
+
+class EditEndpointDialog:
+ def __init__(self, parent, existing_name=None):
+ self.result = False
+ self._existing_name = existing_name
+ self._parent_mgr = parent
+
+ if existing_name:
+ self._data = get_endpoint(existing_name) or {}
+ else:
+ self._data = {
+ "name": "", "backend_type": "openai-compat",
+ "base_url": "", "api_key": "", "default_model": "",
+ "models": [], "provider_preset": "Custom",
+ }
+
+ self._dlg = tk.Toplevel(parent)
+ title = "Edit Endpoint" if existing_name else "Add Endpoint"
+ self._dlg.title(title)
+ self._dlg.geometry("520x600")
+ self._dlg.transient(parent)
+ self._dlg.grab_set()
+
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
+
+ grid = ttk.Frame(main)
+ grid.pack(fill="x")
+
+ row_idx = [0]
+
+ def add_field(label, widget_factory):
+ ttk.Label(grid, text=label).grid(row=row_idx[0], column=0, sticky="e", padx=(0, 6), pady=2)
+ w = widget_factory()
+ w.grid(row=row_idx[0], column=1, sticky="ew", pady=2)
+ row_idx[0] += 1
+ return w
+
+ self._entry_name = add_field("Name:", lambda: ttk.Entry(grid))
+ self._entry_name.insert(0, self._data.get("name", ""))
+
+ self._combo_preset = ttk.Combobox(grid, values=list(PROVIDER_PRESETS.keys()), state="readonly")
+ preset = self._data.get("provider_preset", "Custom")
+ self._combo_preset.set(preset)
+ add_field("Preset:", lambda: self._combo_preset)
+ self._combo_preset.bind("<>", lambda e: self._apply_selected_preset(initial=False))
+
+ backend_types = [
+ ("openai-compat", "OpenAI-compatible (needs proxy)"),
+ ("anthropic", "Anthropic (needs proxy)"),
+ ("command-code", "Command Code (needs proxy)"),
+ ("freebuff", "Freebuff - Free DeepSeek/Kimi (needs proxy)"),
+ ("gemini-oauth-cli", "Gemini CLI OAuth (needs proxy)"),
+ ("gemini-oauth-antigravity", "Antigravity OAuth (needs proxy)"),
+ ("native", "Native OpenAI (no proxy)"),
+ ]
+ self._combo_type = ttk.Combobox(grid, values=[f"{v} - {l}" for v, l in backend_types], state="readonly")
+ bt = self._data.get("backend_type", "openai-compat")
+ bt_display = next((f"{v} - {l}" for v, l in backend_types if v == bt), backend_types[0][0] + " - " + backend_types[0][1])
+ self._combo_type.set(bt_display)
+ add_field("Type:", lambda: self._combo_type)
+ self._bt_map = {f"{v} - {l}": v for v, l in backend_types}
+
+ self._entry_url = add_field("Base URL:", lambda: ttk.Entry(grid))
+ self._entry_url.insert(0, self._data.get("base_url", ""))
+
+ key_frame = ttk.Frame(grid)
+ self._entry_key = ttk.Entry(key_frame, show="*")
+ self._entry_key.pack(side="left", fill="x", expand=True)
+ self._entry_key.insert(0, self._data.get("api_key", ""))
+ self._reveal_var = tk.BooleanVar(value=False)
+ ttk.Checkbutton(key_frame, text="Show", variable=self._reveal_var,
+ command=lambda: self._entry_key.configure(show="" if self._reveal_var.get() else "*")).pack(side="left", padx=(4, 0))
+ self._oauth_btn = ttk.Button(key_frame, text="OAuth Login", command=self._do_oauth_login)
+ self._oauth_btn.pack(side="left", padx=(4, 0))
+ add_field("API Key:", lambda: key_frame)
+
+ self._entry_cc_ver = add_field("CC Version:", lambda: ttk.Entry(grid))
+ self._entry_cc_ver.insert(0, self._data.get("cc_version", ""))
+
+ reason_frame = ttk.Frame(grid)
+ self._reason_var = tk.BooleanVar(value=self._data.get("reasoning_enabled", True))
+ self._reason_cb = ttk.Checkbutton(reason_frame, text="Reasoning ON", variable=self._reason_var,
+ command=self._on_reasoning_toggled)
+ self._reason_cb.pack(side="left")
+ self._combo_effort = ttk.Combobox(reason_frame, values=["none", "minimal", "low", "medium", "high", "max"],
+ state="readonly", width=10)
+ self._combo_effort.set(self._data.get("reasoning_effort", "medium"))
+ self._combo_effort.pack(side="left", padx=(8, 0))
+ ttk.Label(reason_frame, text="Effort").pack(side="left", padx=(4, 0))
+ add_field("Reasoning:", lambda: reason_frame)
+ self._on_reasoning_toggled()
+
+ enhancer_frame = ttk.Frame(grid)
+ self._enhancer_var = tk.BooleanVar(value=self._data.get("prompt_enhancer", False))
+ self._enhancer_cb = ttk.Checkbutton(enhancer_frame, text="Prompt Enhancer", variable=self._enhancer_var, command=self._on_enhancer_toggled)
+ self._enhancer_cb.pack(side="left")
+ self._enhancer_status_lbl = ttk.Label(enhancer_frame, text="", foreground="gray")
+ self._enhancer_status_lbl.pack(side="left", padx=(6, 0))
+ self._enhancer_mode = ttk.Combobox(enhancer_frame, values=["offline", "ai-powered"], state="readonly", width=10)
+ self._enhancer_mode.set(self._data.get("prompt_enhancer_mode", "offline"))
+ self._enhancer_mode.pack(side="left", padx=(8, 0))
+ add_field("Prompt Enhancer:", lambda: enhancer_frame)
+ self._on_enhancer_toggled()
+
+ self._entry_enhancer_model = ttk.Entry(grid)
+ self._entry_enhancer_model.insert(0, self._data.get("prompt_enhancer_model", ""))
+ add_field("Enhancer Model:", lambda: self._entry_enhancer_model)
+
+ self._entry_enhancer_url = ttk.Entry(grid)
+ self._entry_enhancer_url.insert(0, self._data.get("prompt_enhancer_url", ""))
+ add_field("Enhancer URL:", lambda: self._entry_enhancer_url)
+
+ self._entry_enhancer_key = ttk.Entry(grid, show="*")
+ self._entry_enhancer_key.insert(0, self._data.get("prompt_enhancer_key", ""))
+ add_field("Enhancer Key:", lambda: self._entry_enhancer_key)
+
+ grid.columnconfigure(1, weight=1)
+
+ ttk.Label(main, text="Models:").pack(anchor="w", pady=(8, 2))
+
+ model_input_frame = ttk.Frame(main)
+ model_input_frame.pack(fill="x")
+ self._entry_model = ttk.Entry(model_input_frame)
+ self._entry_model.pack(side="left", fill="x", expand=True)
+ ttk.Button(model_input_frame, text="Add", command=self._add_model).pack(side="left", padx=(4, 0))
+ ttk.Button(model_input_frame, text="Bulk Add", command=self._add_models_from_text).pack(side="left", padx=(4, 0))
+ ttk.Button(model_input_frame, text="Fetch from API", command=self._fetch_models).pack(side="left", padx=(4, 0))
+ ttk.Button(model_input_frame, text="Sync from Preset", command=lambda: self._apply_selected_preset_force()).pack(side="left", padx=(4, 0))
+ ttk.Button(model_input_frame, text="Test Endpoint", command=self._diagnose_endpoint).pack(side="left", padx=(4, 0))
+
+ ttk.Label(main, text="Bulk add (one per line or comma-separated):").pack(anchor="w", pady=(4, 0))
+ self._bulk_text = tk.Text(main, height=3, wrap="word")
+ self._bulk_text.pack(fill="x", pady=(2, 4))
+
+ list_frame = ttk.Frame(main)
+ list_frame.pack(fill="both", expand=True)
+ self._model_listbox = tk.Listbox(list_frame, height=6)
+ sb = ttk.Scrollbar(list_frame, orient="vertical", command=self._model_listbox.yview)
+ self._model_listbox.configure(yscrollcommand=sb.set)
+ self._model_listbox.pack(side="left", fill="both", expand=True)
+ sb.pack(side="right", fill="y")
+ self._model_listbox.bind("", lambda e: self._remove_selected_model())
+ for m in self._data.get("models", []):
+ self._model_listbox.insert("end", m)
+
+ default_frame = ttk.Frame(main)
+ default_frame.pack(fill="x", pady=(4, 0))
+ ttk.Label(default_frame, text="Default Model:").pack(side="left")
+ self._combo_default = ttk.Combobox(default_frame, state="readonly")
+ self._combo_default.pack(side="left", fill="x", expand=True, padx=(6, 0))
+ self._refresh_default_combo()
+ dm = self._data.get("default_model", "")
+ if dm:
+ self._combo_default.set(dm)
+
+ self._apply_selected_preset(initial=True)
+
+ btn_frame = ttk.Frame(main)
+ btn_frame.pack(fill="x", pady=(8, 0))
+ ttk.Button(btn_frame, text="Cancel", command=self._cancel).pack(side="right")
+ ttk.Button(btn_frame, text="Save", command=self._save).pack(side="right", padx=(8, 0))
+
+ def _on_reasoning_toggled(self):
+ state = "readonly" if self._reason_var.get() else "disabled"
+ self._combo_effort.configure(state=state)
+
+ def _on_enhancer_toggled(self):
+ if self._enhancer_var.get():
+ self._enhancer_status_lbl.configure(text="ON", foreground="#2ea043")
+ else:
+ self._enhancer_status_lbl.configure(text="OFF", foreground="#888888")
+
+ def _apply_selected_preset(self, initial=False):
+ preset_name = self._combo_preset.get() or "Custom"
+ preset = PROVIDER_PRESETS.get(preset_name, {})
+ is_oauth = bool(preset.get("oauth_provider"))
+ self._oauth_btn.configure(state="normal" if is_oauth else "disabled")
+
+ if not initial or self._existing_name is None:
+ bt = preset.get("backend_type", "openai-compat")
+ bt_display = next((k for k, v in self._bt_map.items() if v == bt), list(self._bt_map.keys())[0])
+ self._combo_type.set(bt_display)
+ self._entry_url.delete(0, "end")
+ self._entry_url.insert(0, preset.get("base_url", ""))
+ cc_ver = preset.get("cc_version", "")
+ if cc_ver and not self._entry_cc_ver.get().strip():
+ self._entry_cc_ver.delete(0, "end")
+ self._entry_cc_ver.insert(0, cc_ver)
+ if preset.get("models") and self._model_listbox.size() == 0:
+ self._model_listbox.delete(0, "end")
+ for mid in preset["models"]:
+ self._model_listbox.insert("end", mid)
+ self._refresh_default_combo()
+ if preset["models"]:
+ self._combo_default.set(preset["models"][0])
+
+ def _apply_selected_preset_force(self):
+ preset_name = self._combo_preset.get() or "Custom"
+ preset = PROVIDER_PRESETS.get(preset_name, {})
+ bt = preset.get("backend_type", "openai-compat")
+ bt_display = next((k for k, v in self._bt_map.items() if v == bt), list(self._bt_map.keys())[0])
+ self._combo_type.set(bt_display)
+ self._entry_url.delete(0, "end")
+ self._entry_url.insert(0, preset.get("base_url", ""))
+ cc_ver = preset.get("cc_version", "")
+ if cc_ver:
+ self._entry_cc_ver.delete(0, "end")
+ self._entry_cc_ver.insert(0, cc_ver)
+ if preset.get("models"):
+ self._model_listbox.delete(0, "end")
+ for mid in preset["models"]:
+ self._model_listbox.insert("end", mid)
+ self._refresh_default_combo()
+ if preset["models"]:
+ self._combo_default.set(preset["models"][0])
+
+ def _add_model(self):
+ m = normalize_model_id(self._entry_model.get())
+ if m:
+ self._model_listbox.insert("end", m)
+ self._refresh_default_combo()
+ self._entry_model.delete(0, "end")
+
+ def _add_models_from_text(self):
+ text = self._bulk_text.get("1.0", "end")
+ models = parse_model_list(text)
+ existing = set(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
+ for mid in models:
+ if mid not in existing:
+ self._model_listbox.insert("end", mid)
+ self._bulk_text.delete("1.0", "end")
+ self._refresh_default_combo()
+
+ def _remove_selected_model(self):
+ sel = self._model_listbox.curselection()
+ if sel:
+ self._model_listbox.delete(sel[0])
+ self._refresh_default_combo()
+
+ def _refresh_default_combo(self):
+ models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
+ current = self._combo_default.get()
+ self._combo_default["values"] = models
+ if current in models:
+ self._combo_default.set(current)
+ elif models:
+ self._combo_default.set(models[0])
+ else:
+ self._combo_default.set("")
+
+ def _fetch_models(self):
+ ep = self._make_endpoint_snapshot()
+ ids, err = fetch_models_for_endpoint(ep)
+ if ids:
+ existing = set(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
+ for mid in ids:
+ if mid not in existing:
+ self._model_listbox.insert("end", mid)
+ self._refresh_default_combo()
+ else:
+ messagebox.showerror("Fetch Models", f"Failed:\n{err}", parent=self._dlg)
+
+ def _diagnose_endpoint(self):
+ ep = self._make_endpoint_snapshot()
+ wait = tk.Toplevel(self._dlg)
+ wait.title("Running Doctor...")
+ wait.geometry("280x80")
+ wait.transient(self._dlg)
+ wait.grab_set()
+ tk.Label(wait, text="Running endpoint diagnostics...").pack(expand=True)
+
+ def _run():
+ checks = run_endpoint_doctor(ep)
+ self._dlg.after(0, lambda: (wait.destroy(), _show_doctor_results_tk(self._dlg, ep.get("default_model", "endpoint"), checks)))
+
+ threading.Thread(target=_run, daemon=True).start()
+
+ def _make_endpoint_snapshot(self):
+ bt_display = self._combo_type.get()
+ bt = self._bt_map.get(bt_display, "openai-compat")
+ return {
+ "base_url": self._entry_url.get().strip(),
+ "api_key": self._entry_key.get().strip(),
+ "backend_type": bt,
+ "default_model": self._combo_default.get() or "",
+ }
+
+ def _do_oauth_login(self):
+ preset_name = self._combo_preset.get() or "Custom"
+ preset = PROVIDER_PRESETS.get(preset_name, {})
+ provider = preset.get("oauth_provider", "")
+ if provider == "codebuff":
+ self._codebuff_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 = str(PROXY_CONFIG_DIR / ("google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json"))
+
+ _sec = load_oauth_secrets().get("antigravity" if is_antigravity else "gemini_cli", {})
+ CLIENT_ID = _sec.get("client_id", "")
+ CLIENT_SECRET = _sec.get("client_secret", "")
+
+ if is_antigravity:
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+ ]
+ port = 51121
+ redirect_uri = f"http://localhost:{port}/oauth-callback"
+ callback_path = "/oauth-callback"
+ provider_kind = "antigravity"
+ else:
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ ]
+ port = 0
+ redirect_uri = None
+ callback_path = "/oauth2callback"
+ provider_kind = "cli"
+
+ state = secrets.token_hex(32)
+ verifier = secrets.token_urlsafe(64)
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+
+ if port == 0:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ port = s.getsockname()[1]
+ redirect_uri = f"http://127.0.0.1:{port}/oauth2callback"
+
+ scope_str = " ".join(SCOPES)
+ auth_url = (
+ f"https://accounts.google.com/o/oauth2/v2/auth?"
+ f"client_id={CLIENT_ID}"
+ f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
+ f"&response_type=code"
+ f"&scope={urllib.parse.quote(scope_str)}"
+ f"&access_type=offline"
+ f"&prompt=select_account%20consent"
+ f"&state={state}"
+ f"&code_challenge={challenge}"
+ f"&code_challenge_method=S256"
+ )
+
+ oauth_dlg = tk.Toplevel(self._dlg)
+ oauth_dlg.title("Google OAuth (Gemini Mode)")
+ oauth_dlg.geometry("520x280")
+ oauth_dlg.transient(self._dlg)
+ oauth_dlg.grab_set()
+
+ tk.Label(oauth_dlg, text="Sign in with Google", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w")
+ tk.Label(oauth_dlg, text=f"Using OAuth credentials from {OAUTH_SECRETS_PATH}").pack(padx=16, anchor="w")
+
+ link_lbl = tk.Label(oauth_dlg, text="Click here to open Google authorization", fg="blue", cursor="hand2")
+ link_lbl.pack(padx=16, pady=(8, 0), anchor="w")
+ link_lbl.bind("", lambda e: open_url(auth_url))
+
+ self._oauth_status_var = tk.StringVar(value="Opening browser...")
+ tk.Label(oauth_dlg, textvariable=self._oauth_status_var).pack(padx=16, pady=(8, 0), anchor="w")
+
+ code_holder = [None]
+ error_holder = [None]
+ received_state = [None]
+
+ class OAuthHandler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self2):
+ qs = urllib.parse.urlparse(self2.path).query
+ params = urllib.parse.parse_qs(qs)
+ received_state[0] = params.get("state", [None])[0]
+ if self2.path.find(callback_path) == -1:
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
+ self2.end_headers()
+ error_holder[0] = "unexpected request"
+ return
+ if "code" in params:
+ if received_state[0] != state:
+ self2.send_response(400)
+ self2.send_header("Content-Type", "text/html")
+ self2.end_headers()
+ self2.wfile.write(b"CSRF state mismatch.
")
+ error_holder[0] = "CSRF state mismatch"
+ return
+ code_holder[0] = params["code"][0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini")
+ self2.end_headers()
+ else:
+ error_holder[0] = params.get("error", ["unknown"])[0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
+ self2.end_headers()
+ def log_message(self2, fmt, *args):
+ pass
+
+ try:
+ bind_host = "localhost" if is_antigravity else "127.0.0.1"
+ server = http.server.HTTPServer((bind_host, port), OAuthHandler)
+ except OSError:
+ self._oauth_status_var.set(f"Port {port} already in use -- close other apps and retry.")
+ return
+
+ def wait_for_code():
+ deadline = time.time() + 120
+ while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
+ server.handle_request()
+ server.server_close()
+ if code_holder[0]:
+ try:
+ token_data = urllib.parse.urlencode({
+ "code": code_holder[0], "client_id": CLIENT_ID,
+ "client_secret": CLIENT_SECRET, "redirect_uri": redirect_uri,
+ "grant_type": "authorization_code", "code_verifier": verifier,
+ }).encode()
+ req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
+ resp = urllib.request.urlopen(req, timeout=30)
+ tokens = json.loads(resp.read())
+ tokens["client_id"] = CLIENT_ID
+ tokens["client_secret"] = CLIENT_SECRET
+ tokens["provider_kind"] = provider_kind
+ tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
+ os.makedirs(os.path.dirname(token_path), exist_ok=True)
+ with open(token_path, "w") as f:
+ json.dump(tokens, f, indent=2)
+
+ project_id = _oauth_discover_project_win(tokens["access_token"], token_path, tokens)
+
+ found_models = []
+ if is_antigravity:
+ found_models = list(ANTIGRAVITY_MODELS)
+ else:
+ found_models = ["gemini-2.5-flash", "gemini-2.5-pro"]
+ if found_models:
+ tokens["available_models"] = found_models
+ with open(token_path, "w") as f3:
+ json.dump(tokens, f3, indent=2)
+
+ self._dlg.after(0, lambda: self._oauth_success(oauth_dlg, tokens.get("access_token", "")))
+ except Exception as e:
+ self._dlg.after(0, lambda: self._oauth_failed(oauth_dlg, str(e)))
+ else:
+ self._dlg.after(0, lambda: self._oauth_failed(oauth_dlg, error_holder[0] or "No authorization code received."))
+
+ threading.Thread(target=wait_for_code, daemon=True).start()
+ open_url(auth_url)
+
+ def _oauth_success(self, dlg, access_token):
+ self._entry_key.delete(0, "end")
+ self._entry_key.insert(0, access_token)
+ self._oauth_status_var.set("Authorization successful! Token saved.")
+ self._dlg.after(1500, dlg.destroy)
+
+ def _oauth_failed(self, dlg, msg):
+ self._oauth_status_var.set(f"Failed: {msg}")
+ self._dlg.after(3000, dlg.destroy)
+
+ def _codebuff_oauth_flow(self):
+ import uuid
+ oauth_dlg = tk.Toplevel(self._dlg)
+ oauth_dlg.title("Codebuff / Freebuff Login")
+ oauth_dlg.geometry("520x240")
+ oauth_dlg.transient(self._dlg)
+ oauth_dlg.grab_set()
+ tk.Label(oauth_dlg, text="Sign in with GitHub via Codebuff", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w")
+ self._cb_status_var = tk.StringVar(value="Requesting login URL...")
+ tk.Label(oauth_dlg, textvariable=self._cb_status_var).pack(padx=16, pady=(8, 0), anchor="w")
+ self._cb_link_lbl = tk.Label(oauth_dlg, text="", fg="blue", cursor="hand2")
+ self._cb_link_lbl.pack(padx=16, anchor="w")
+ self._cb_oauth_result = {"success": False, "user": None, "error": None}
+ self._cb_oauth_dlg = oauth_dlg
+
+ def _thread():
+ try:
+ fp_id = str(uuid.uuid4())
+ body = json.dumps({"fingerprintId": fp_id}).encode()
+ req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code",
+ data=body, headers={"Content-Type": "application/json", "User-Agent": UA})
+ resp = urllib.request.urlopen(req, timeout=30)
+ rdata = json.loads(resp.read())
+ login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "")
+ fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "")
+ expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0)
+ if not login_url:
+ self._cb_oauth_result["error"] = "No login URL"
+ self._dlg.after(0, self._codebuff_oauth_done)
+ return
+ def _set_link():
+ self._cb_status_var.set("Open this URL in your browser to log in:")
+ self._cb_link_lbl.configure(text=login_url)
+ self._cb_link_lbl.bind("", lambda e: open_url(login_url))
+ self._dlg.after(0, _set_link)
+ open_url(login_url)
+ poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}"
+ deadline = time.time() + 300
+ while time.time() < deadline:
+ time.sleep(2)
+ try:
+ pr = urllib.request.Request(poll, headers={"User-Agent": UA})
+ pd = json.loads(urllib.request.urlopen(pr, timeout=10).read())
+ if pd.get("user", {}).get("authToken"):
+ self._cb_oauth_result["success"] = True
+ self._cb_oauth_result["user"] = pd["user"]
+ self._dlg.after(0, self._codebuff_oauth_done)
+ return
+ except Exception:
+ pass
+ self._cb_oauth_result["error"] = "Timed out"
+ except Exception as e:
+ self._cb_oauth_result["error"] = str(e)[:200]
+ self._dlg.after(0, self._codebuff_oauth_done)
+
+ threading.Thread(target=_thread, daemon=True).start()
+
+ def _codebuff_oauth_done(self):
+ if self._cb_oauth_result["success"] and self._cb_oauth_result["user"]:
+ u = self._cb_oauth_result["user"]
+ cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json")
+ os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True)
+ creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""),
+ "email": u.get("email", ""), "authToken": u.get("authToken", ""),
+ "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}}
+ with open(cb_creds_path, "w") as f:
+ json.dump(creds, f, indent=2)
+ self._cb_status_var.set(f"Logged in as {u.get('email', 'OK')}")
+ self._cb_link_lbl.configure(text="")
+ self._entry_key.delete(0, "end")
+ self._entry_key.insert(0, u.get("authToken", ""))
+ self._dlg.after(2000, self._cb_oauth_dlg.destroy)
+ else:
+ self._cb_status_var.set(f"Failed: {self._cb_oauth_result.get('error', 'unknown')}")
+
+ def _cancel(self):
+ self._dlg.destroy()
+
+ def _save(self):
+ name = self._entry_name.get().strip()
+ if not name:
+ messagebox.showerror("Error", "Name is required", parent=self._dlg)
+ return
+ bt_display = self._combo_type.get()
+ bt = self._bt_map.get(bt_display, "openai-compat")
+ url = self._entry_url.get().strip()
+ key = self._entry_key.get().strip()
+ models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
+
+ if not models:
+ ep_snap = self._make_endpoint_snapshot()
+ ids, err = fetch_models_for_endpoint(ep_snap)
+ if ids:
+ for mid in ids:
+ self._model_listbox.insert("end", mid)
+ self._refresh_default_combo()
+ models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
+ else:
+ r = messagebox.askyesno("No Models", f"Auto-fetch failed ({err}).\n\nAdd models manually now?", parent=self._dlg)
+ if r:
+ self._entry_model.focus_set()
+ return
+ self._dlg.destroy()
+ return
+
+ if not models:
+ messagebox.showerror("Error", "At least one model is required", parent=self._dlg)
+ return
+
+ default = self._combo_default.get() or models[0]
+ data = load_endpoints()
+
+ if self._existing_name and self._existing_name != name:
+ data["endpoints"] = [e for e in data["endpoints"] if e["name"] != self._existing_name]
+
+ existing = [e for e in data["endpoints"] if e["name"] == name]
+ if existing:
+ messagebox.showerror("Error", f'Endpoint "{name}" already exists', parent=self._dlg)
+ return
+
+ new_ep = {
+ "name": name, "backend_type": bt, "base_url": normalize_base_url(url),
+ "api_key": key, "default_model": default, "models": models,
+ "provider_preset": self._combo_preset.get() or "Custom",
+ "reasoning_enabled": self._reason_var.get(),
+ "reasoning_effort": self._combo_effort.get() or "medium",
+ "prompt_enhancer": self._enhancer_var.get(),
+ "prompt_enhancer_mode": self._enhancer_mode.get() or "offline",
+ }
+ cc_ver = self._entry_cc_ver.get().strip()
+ if cc_ver:
+ new_ep["cc_version"] = cc_ver
+ enh_model = self._entry_enhancer_model.get().strip()
+ enh_url = self._entry_enhancer_url.get().strip()
+ enh_key = self._entry_enhancer_key.get().strip()
+ if enh_model:
+ new_ep["prompt_enhancer_model"] = enh_model
+ if enh_url:
+ new_ep["prompt_enhancer_url"] = enh_url
+ if enh_key:
+ new_ep["prompt_enhancer_key"] = enh_key
+ preset_name = self._combo_preset.get() or "Custom"
+ preset = PROVIDER_PRESETS.get(preset_name, {})
+ if preset.get("oauth_provider"):
+ new_ep["oauth_provider"] = preset["oauth_provider"]
+
+ found = False
+ for i, e in enumerate(data["endpoints"]):
+ if e["name"] == name:
+ data["endpoints"][i] = new_ep
+ found = True
+ break
+ if not found:
+ data["endpoints"].append(new_ep)
+ if data.get("default") is None:
+ data["default"] = name
+
+ save_endpoints(data)
+ self.result = True
+ self._dlg.destroy()
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# EndpointMgr
+# ═══════════════════════════════════════════════════════════════════════
+
+class EndpointMgr:
+ def __init__(self, parent, on_update=None):
+ self._parent = parent
+ self._on_update = on_update
+
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("Manage Endpoints")
+ self._dlg.geometry("600x400")
+ self._dlg.transient(parent)
+
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
+
+ ttk.Label(main, text="Endpoints", font=("Segoe UI", 11, "bold")).pack(anchor="w")
+
+ tree_frame = ttk.Frame(main)
+ tree_frame.pack(fill="both", expand=True, pady=(4, 0))
+ cols = ("name", "provider", "backend", "default_model")
+ self._tree = ttk.Treeview(tree_frame, columns=cols, show="headings", selectmode="browse")
+ for col, heading, width in [("name", "Name", 140), ("provider", "Provider", 160),
+ ("backend", "Type", 140), ("default_model", "Default Model", 140)]:
+ self._tree.heading(col, text=heading)
+ self._tree.column(col, width=width, minwidth=80)
+ sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview)
+ self._tree.configure(yscrollcommand=sb.set)
+ self._tree.pack(side="left", fill="both", expand=True)
+ sb.pack(side="right", fill="y")
+
+ btn_frame = ttk.Frame(main)
+ btn_frame.pack(fill="x", pady=(8, 0))
+ ttk.Button(btn_frame, text="Add", command=self._add).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Edit", command=self._edit).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Delete", command=self._delete).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Set Default", command=self._set_default).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Doctor", command=self._doctor_selected).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Doctor All", command=self._doctor_all).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right")
+
+ self._rebuild()
+
+ def _rebuild(self):
+ for item in self._tree.get_children():
+ self._tree.delete(item)
+ data = load_endpoints()
+ for ep in data["endpoints"]:
+ provider = ep.get("provider_preset", "Custom")
+ bt = label_for_backend(ep["backend_type"])
+ self._tree.insert("", "end", values=(ep["name"], provider, bt, ep.get("default_model", "")))
+
+ def _selected_name(self):
+ sel = self._tree.selection()
+ if not sel:
+ return None
+ return self._tree.item(sel[0])["values"][0]
+
+ def _add(self):
+ d = EditEndpointDialog(self._dlg, None)
+ self._dlg.wait_window(d._dlg)
+ if d.result:
+ self._rebuild()
+ if self._on_update:
+ self._on_update()
+
+ def _edit(self):
+ name = self._selected_name()
+ if not name:
+ return
+ d = EditEndpointDialog(self._dlg, name)
+ self._dlg.wait_window(d._dlg)
+ if d.result:
+ self._rebuild()
+ if self._on_update:
+ self._on_update()
+
+ def _delete(self):
+ name = self._selected_name()
+ if not name:
+ return
+ if not messagebox.askyesno("Delete", f'Delete endpoint "{name}"?', parent=self._dlg):
+ return
+ data = load_endpoints()
+ data["endpoints"] = [e for e in data["endpoints"] if e["name"] != name]
+ if data.get("default") == name:
+ data["default"] = data["endpoints"][0]["name"] if data["endpoints"] else None
+ save_endpoints(data)
+ self._rebuild()
+ if self._on_update:
+ self._on_update()
+
+ def _set_default(self):
+ name = self._selected_name()
+ if not name:
+ return
+ data = load_endpoints()
+ data["default"] = name
+ save_endpoints(data)
+ self._rebuild()
+ if self._on_update:
+ self._on_update()
+
+ def _doctor_selected(self):
+ name = self._selected_name()
+ if not name:
+ return
+ ep = get_endpoint(name)
+ if not ep:
+ return
+ wait = tk.Toplevel(self._dlg)
+ wait.title(f"Doctor: {name}...")
+ wait.geometry("280x80")
+ wait.transient(self._dlg)
+ wait.grab_set()
+ tk.Label(wait, text=f"Running diagnostics for {name}...").pack(expand=True)
+
+ def _run():
+ checks = run_endpoint_doctor(ep)
+ self._dlg.after(0, lambda: (wait.destroy(), _show_doctor_results_tk(self._dlg, name, checks)))
+
+ threading.Thread(target=_run, daemon=True).start()
+
+ def _doctor_all(self):
+ data = load_endpoints()
+ endpoints = data.get("endpoints", [])
+ if not endpoints:
+ messagebox.showinfo("Doctor All", "No endpoints configured.", parent=self._dlg)
+ return
+
+ wait = tk.Toplevel(self._dlg)
+ wait.title("Doctor All...")
+ wait.geometry("320x80")
+ wait.transient(self._dlg)
+ wait.grab_set()
+ tk.Label(wait, text=f"Testing {len(endpoints)} endpoints...").pack(expand=True)
+
+ all_results = {}
+
+ def _run():
+ for ep in endpoints:
+ try:
+ all_results[ep["name"]] = run_endpoint_doctor(ep)
+ except Exception as e:
+ all_results[ep["name"]] = [("Doctor run", False, str(e)[:100])]
+
+ def _show():
+ wait.destroy()
+ dlg = tk.Toplevel(self._dlg)
+ dlg.title("Doctor All Results")
+ dlg.geometry("580x480")
+ dlg.transient(self._dlg)
+
+ canvas = tk.Canvas(dlg)
+ scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview)
+ inner = tk.Frame(canvas)
+ inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
+ canvas.create_window((0, 0), window=inner, anchor="nw")
+ canvas.configure(yscrollcommand=scrollbar.set)
+
+ for ep_name, checks in all_results.items():
+ passed = sum(1 for _, ok, _ in checks if ok is True)
+ failed = sum(1 for _, ok, _ in checks if ok is False)
+ color = "#e74c3c" if failed else "#27ae60"
+ status = f"{failed} failed" if failed else f"{passed} passed"
+ tk.Label(inner, text=f"{ep_name} {status}", fg=color,
+ font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=12, pady=(8, 2))
+ for name, ok, detail in checks:
+ if ok is True:
+ sym, sc = "✓", "#27ae60"
+ elif ok is False:
+ sym, sc = "✗", "#e74c3c"
+ else:
+ sym, sc = "○", "#f39c12"
+ row = tk.Frame(inner)
+ row.pack(anchor="w", padx=24, pady=0)
+ tk.Label(row, text=sym, fg=sc, font=("Segoe UI", 9, "bold")).pack(side="left")
+ txt = name
+ if detail:
+ txt += f" {detail}"
+ tk.Label(row, text=txt, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="left")
+ ttk.Separator(inner).pack(fill="x", padx=12, pady=4)
+
+ canvas.pack(side="left", fill="both", expand=True, padx=(12, 0))
+ scrollbar.pack(side="right", fill="y")
+ ttk.Button(dlg, text="Close", command=dlg.destroy).pack(pady=8)
+
+ self._dlg.after(0, _show)
+
+ threading.Thread(target=_run, daemon=True).start()
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# BGP Pool Manager
+# ═══════════════════════════════════════════════════════════════════════
+
+class BGPRouteDialog:
+ def __init__(self, parent, endpoints, existing=None):
+ self.result = None
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("BGP Route")
+ self._dlg.geometry("440x300")
+ self._dlg.transient(parent)
+ self._dlg.grab_set()
+
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
+
+ ttk.Label(main, text="Route Name:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2)
+ self._entry_name = ttk.Entry(main)
+ self._entry_name.grid(row=0, column=1, sticky="ew", pady=2)
+ if existing:
+ self._entry_name.insert(0, existing.get("name", ""))
+
+ ttk.Label(main, text="Endpoint:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2)
+ ep_names = [e["name"] for e in endpoints]
+ self._combo_ep = ttk.Combobox(main, values=ep_names, state="readonly")
+ self._combo_ep.grid(row=1, column=1, sticky="ew", pady=2)
+ if existing and existing.get("endpoint_name") in ep_names:
+ self._combo_ep.set(existing["endpoint_name"])
+ elif ep_names:
+ self._combo_ep.set(ep_names[0])
+
+ ttk.Label(main, text="URL:").grid(row=2, column=0, sticky="e", padx=(0, 6), pady=2)
+ self._entry_url = ttk.Entry(main)
+ self._entry_url.grid(row=2, column=1, sticky="ew", pady=2)
+
+ ttk.Label(main, text="API Key:").grid(row=3, column=0, sticky="e", padx=(0, 6), pady=2)
+ self._entry_key = ttk.Entry(main, show="*")
+ self._entry_key.grid(row=3, column=1, sticky="ew", pady=2)
+
+ ttk.Label(main, text="Model:").grid(row=4, column=0, sticky="e", padx=(0, 6), pady=2)
+ self._combo_model = ttk.Combobox(main, state="readonly")
+ self._combo_model.grid(row=4, column=1, sticky="ew", pady=2)
+
+ main.columnconfigure(1, weight=1)
+
+ self._endpoints = endpoints
+ self._combo_ep.bind("<>", lambda e: self._on_ep_changed())
+ self._on_ep_changed()
+
+ if existing:
+ self._entry_url.delete(0, "end")
+ self._entry_url.insert(0, existing.get("target_url", ""))
+ self._entry_key.delete(0, "end")
+ self._entry_key.insert(0, existing.get("api_key", ""))
+ if existing.get("model"):
+ self._combo_model.set(existing["model"])
+
+ btn_frame = ttk.Frame(main)
+ btn_frame.grid(row=5, column=0, columnspan=2, pady=(12, 0))
+ ttk.Button(btn_frame, text="Cancel", command=self._dlg.destroy).pack(side="right")
+ ttk.Button(btn_frame, text="OK", command=self._ok).pack(side="right", padx=(8, 0))
+
+ self._dlg.wait_window()
+
+ def _on_ep_changed(self):
+ ep_name = self._combo_ep.get()
+ ep = None
+ for e in self._endpoints:
+ if e["name"] == ep_name:
+ ep = e
+ break
+ if ep:
+ self._entry_url.delete(0, "end")
+ self._entry_url.insert(0, normalize_base_url(ep.get("base_url", "")))
+ self._entry_key.delete(0, "end")
+ self._entry_key.insert(0, ep.get("api_key", ""))
+ models = ep.get("models", [])
+ self._combo_model["values"] = models
+ if ep.get("default_model") and ep["default_model"] in models:
+ self._combo_model.set(ep["default_model"])
+ elif models:
+ self._combo_model.set(models[0])
+
+ def _ok(self):
+ ep_name = self._combo_ep.get()
+ ep = None
+ for e in self._endpoints:
+ if e["name"] == ep_name:
+ ep = e
+ break
+ self.result = {
+ "name": self._entry_name.get().strip() or ep_name,
+ "endpoint_name": ep_name,
+ "target_url": self._entry_url.get().strip(),
+ "api_key": self._entry_key.get().strip(),
+ "model": self._combo_model.get() or "",
+ "priority": 99,
+ }
+ if ep:
+ self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True)
+ self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium")
+ self.result["oauth_provider"] = ep.get("oauth_provider", "")
+ self._dlg.destroy()
+
+
+class BGPPoolEditDialog:
+ def __init__(self, parent, existing_name=None):
+ self.result = False
+ self._existing_name = existing_name
+ self._parent_mgr = parent
+
+ self._dlg = tk.Toplevel(parent._dlg if hasattr(parent, "_dlg") else parent)
+ title = "Edit BGP Pool" if existing_name else "Create BGP Pool"
+ self._dlg.title(title)
+ self._dlg.geometry("620x500")
+ self._dlg.transient(parent._dlg if hasattr(parent, "_dlg") else parent)
+ self._dlg.grab_set()
+
+ data = load_bgp_pools()
+ pool = None
+ if existing_name:
+ for p in data.get("pools", []):
+ if p["name"] == existing_name:
+ pool = p
+ break
+ if not pool:
+ pool = {"name": "", "strategy": "failover", "routes": []}
+
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
+
+ grid = ttk.Frame(main)
+ grid.pack(fill="x")
+ ttk.Label(grid, text="Pool Name:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2)
+ self._entry_name = ttk.Entry(grid)
+ self._entry_name.grid(row=0, column=1, sticky="ew", pady=2)
+ self._entry_name.insert(0, pool["name"])
+
+ ttk.Label(grid, text="Strategy:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2)
+ self._combo_strategy = ttk.Combobox(grid, values=["failover", "race"], state="readonly")
+ self._combo_strategy.grid(row=1, column=1, sticky="ew", pady=2)
+ self._combo_strategy.set(pool.get("strategy", "failover"))
+ grid.columnconfigure(1, weight=1)
+
+ ttk.Label(main, text="Routes (double-click to remove):", font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(8, 2))
+
+ tree_frame = ttk.Frame(main)
+ tree_frame.pack(fill="both", expand=True)
+ cols = ("name", "endpoint", "url", "model", "priority")
+ self._route_tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=8)
+ for col, heading, w in [("name", "Route Name", 100), ("endpoint", "Endpoint", 120),
+ ("url", "URL", 160), ("model", "Model", 120), ("priority", "Priority", 60)]:
+ self._route_tree.heading(col, text=heading)
+ self._route_tree.column(col, width=w, minwidth=50)
+ rsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._route_tree.yview)
+ self._route_tree.configure(yscrollcommand=rsb.set)
+ self._route_tree.pack(side="left", fill="both", expand=True)
+ rsb.pack(side="right", fill="y")
+
+ self._routes = []
+ for r in pool.get("routes", []):
+ self._routes.append(dict(r))
+ self._route_tree.insert("", "end", values=(
+ r.get("name", ""), r.get("endpoint_name", ""),
+ r.get("target_url", ""), r.get("model", ""), r.get("priority", 99)))
+
+ btn_frame = ttk.Frame(main)
+ btn_frame.pack(fill="x", pady=(6, 0))
+ ttk.Button(btn_frame, text="Add Route", command=self._add_route).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Edit Route", command=self._edit_route).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Remove Route", command=self._remove_route).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Up", command=lambda: self._move_route(-1)).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Down", command=lambda: self._move_route(1)).pack(side="left", padx=(0, 4))
+
+ save_frame = ttk.Frame(main)
+ save_frame.pack(fill="x", pady=(8, 0))
+ ttk.Button(save_frame, text="Cancel", command=self._dlg.destroy).pack(side="right")
+ ttk.Button(save_frame, text="Save", command=self._save).pack(side="right", padx=(8, 0))
+
+ def _add_route(self):
+ endpoints = load_endpoints().get("endpoints", [])
+ if not endpoints:
+ messagebox.showinfo("Info", "No endpoints configured. Add endpoints first.", parent=self._dlg)
+ return
+ d = BGPRouteDialog(self._dlg, endpoints, None)
+ if d.result:
+ r = d.result
+ self._routes.append(r)
+ self._route_tree.insert("", "end", values=(
+ r.get("name", ""), r.get("endpoint_name", ""),
+ r.get("target_url", ""), r.get("model", ""), r.get("priority", 99)))
+
+ def _edit_route(self):
+ sel = self._route_tree.selection()
+ if not sel:
+ return
+ idx = self._route_tree.index(sel[0])
+ endpoints = load_endpoints().get("endpoints", [])
+ d = BGPRouteDialog(self._dlg, endpoints, self._routes[idx])
+ if d.result:
+ r = d.result
+ self._routes[idx] = r
+ self._route_tree.item(sel[0], values=(
+ r.get("name", ""), r.get("endpoint_name", ""),
+ r.get("target_url", ""), r.get("model", ""), r.get("priority", 99)))
+
+ def _remove_route(self):
+ sel = self._route_tree.selection()
+ if not sel:
+ return
+ idx = self._route_tree.index(sel[0])
+ self._route_tree.delete(sel[0])
+ del self._routes[idx]
+
+ def _move_route(self, direction):
+ sel = self._route_tree.selection()
+ if not sel:
+ return
+ idx = self._route_tree.index(sel[0])
+ new_idx = idx + direction
+ if new_idx < 0 or new_idx >= len(self._routes):
+ return
+ route = self._routes.pop(idx)
+ self._routes.insert(new_idx, route)
+ self._rebuild_routes_tree(new_idx)
+
+ def _rebuild_routes_tree(self, select_idx=None):
+ for item in self._route_tree.get_children():
+ self._route_tree.delete(item)
+ for r in self._routes:
+ self._route_tree.insert("", "end", values=(
+ r.get("name", ""), r.get("endpoint_name", ""),
+ r.get("target_url", ""), r.get("model", ""), r.get("priority", 99)))
+ if select_idx is not None:
+ children = self._route_tree.get_children()
+ if select_idx < len(children):
+ self._route_tree.selection_set(children[select_idx])
+
+ def _save(self):
+ name = self._entry_name.get().strip()
+ if not name:
+ return
+ strategy = self._combo_strategy.get() or "failover"
+ routes = []
+ for i, r in enumerate(self._routes):
+ if not r.get("target_url"):
+ continue
+ routes.append({
+ "name": r.get("name") or f"Route {i+1}",
+ "endpoint_name": r.get("endpoint_name", ""),
+ "target_url": r.get("target_url", ""),
+ "api_key": r.get("api_key", ""),
+ "model": r.get("model", ""),
+ "priority": i + 1,
+ "reasoning_enabled": True,
+ "reasoning_effort": "medium",
+ })
+ data = load_bgp_pools()
+ if self._existing_name:
+ data["pools"] = [p for p in data["pools"] if p["name"] != self._existing_name]
+ data["pools"].append({"name": name, "strategy": strategy, "routes": routes})
+ save_bgp_pools(data)
+ self.result = True
+ self._dlg.destroy()
+
+
+class BGPPoolMgr:
+ def __init__(self, parent, on_update=None):
+ self._parent = parent
+ self._on_update = on_update
+
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("AI BGP -- Pool Manager")
+ self._dlg.geometry("660x440")
+ self._dlg.transient(parent)
+
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
+
+ ttk.Label(main, text="AI BGP Pools -- multi-provider routing with automatic failover",
+ font=("Segoe UI", 10, "bold")).pack(anchor="w")
+
+ tree_frame = ttk.Frame(main)
+ tree_frame.pack(fill="both", expand=True, pady=(8, 0))
+ cols = ("name", "routes", "strategy")
+ self._tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=10)
+ for col, heading, w in [("name", "Pool Name", 180), ("routes", "Routes", 280), ("strategy", "Strategy", 100)]:
+ self._tree.heading(col, text=heading)
+ self._tree.column(col, width=w, minwidth=60)
+ sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview)
+ self._tree.configure(yscrollcommand=sb.set)
+ self._tree.pack(side="left", fill="both", expand=True)
+ sb.pack(side="right", fill="y")
+
+ btn_frame = ttk.Frame(main)
+ btn_frame.pack(fill="x", pady=(8, 0))
+ ttk.Button(btn_frame, text="Create Pool", command=self._add_pool).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Edit Pool", command=self._edit_pool).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Delete Pool", command=self._del_pool).pack(side="left", padx=(0, 4))
+ ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right")
+
+ self._rebuild()
+
+ def _rebuild(self):
+ for item in self._tree.get_children():
+ self._tree.delete(item)
+ for pool in load_bgp_pools().get("pools", []):
+ routes_str = " -> ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", []))
+ self._tree.insert("", "end", values=(pool["name"], routes_str, pool.get("strategy", "failover")))
+
+ def _selected_name(self):
+ sel = self._tree.selection()
+ if not sel:
+ return None
+ return self._tree.item(sel[0])["values"][0]
+
+ def _add_pool(self):
+ d = BGPPoolEditDialog(self, None)
+ self._dlg.wait_window(d._dlg)
+ if d.result:
+ self._rebuild()
+ if self._on_update:
+ self._on_update()
+
+ def _edit_pool(self):
+ name = self._selected_name()
+ if not name:
+ return
+ d = BGPPoolEditDialog(self, name)
+ self._dlg.wait_window(d._dlg)
+ if d.result:
+ self._rebuild()
+ if self._on_update:
+ self._on_update()
+
+ def _del_pool(self):
+ name = self._selected_name()
+ if not name:
+ return
+ if not messagebox.askyesno("Delete", f'Delete BGP pool "{name}"?', parent=self._dlg):
+ return
+ data = load_bgp_pools()
+ data["pools"] = [p for p in data["pools"] if p["name"] != name]
+ save_bgp_pools(data)
+ self._rebuild()
+ if self._on_update:
+ self._on_update()
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# AI Monitoring Window
+# ═══════════════════════════════════════════════════════════════════════
+
+class AIMonitoringWindow:
+ def __init__(self, parent):
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("AI Monitoring")
+ self._dlg.geometry("580x520")
+ self._dlg.transient(parent)
+
+ self._cfg = load_monitoring_config()
+ self._store = load_incident_store()
+
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
+
+ hdr = ttk.Frame(main)
+ hdr.pack(fill="x")
+ ttk.Label(hdr, text="AI Monitoring", font=("Segoe UI", 11, "bold")).pack(side="left")
+ self._toggle_var = tk.BooleanVar(value=self._cfg.get("enabled", False))
+ ttk.Checkbutton(hdr, text="Enabled", variable=self._toggle_var,
+ command=self._on_toggle).pack(side="right")
+
+ frame = ttk.LabelFrame(main, text="Diagnostic Agent", padding=8)
+ frame.pack(fill="x", pady=(8, 0))
+
+ grid = ttk.Frame(frame)
+ grid.pack(fill="x")
+ grid.columnconfigure(1, weight=1)
+
+ ttk.Label(grid, text="Provider URL:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2)
+ self._url_entry = ttk.Entry(grid)
+ self._url_entry.grid(row=0, column=1, sticky="ew", pady=2)
+ self._url_entry.insert(0, self._cfg.get("provider_url", ""))
+
+ ttk.Label(grid, text="Model:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2)
+ self._model_entry = ttk.Entry(grid)
+ self._model_entry.grid(row=1, column=1, sticky="ew", pady=2)
+ self._model_entry.insert(0, self._cfg.get("model", ""))
+
+ ttk.Label(grid, text="API Key:").grid(row=2, column=0, sticky="e", padx=(0, 6), pady=2)
+ key_frame = ttk.Frame(grid)
+ key_frame.grid(row=2, column=1, sticky="ew", pady=2)
+ self._key_entry = ttk.Entry(key_frame, show="*")
+ self._key_entry.pack(side="left", fill="x", expand=True)
+ self._key_entry.insert(0, self._cfg.get("api_key", ""))
+ self._reveal_key = tk.BooleanVar(value=False)
+ ttk.Checkbutton(key_frame, text="Show", variable=self._reveal_key,
+ command=lambda: self._key_entry.configure(show="" if self._reveal_key.get() else "*")).pack(side="left", padx=(4, 0))
+
+ ttk.Label(grid, text="Health Check:").grid(row=3, column=0, sticky="e", padx=(0, 6), pady=2)
+ spin_frame = ttk.Frame(grid)
+ spin_frame.grid(row=3, column=1, sticky="w", pady=2)
+ self._interval_spin = ttk.Spinbox(spin_frame, from_=2, to=30, width=5)
+ self._interval_spin.set(self._cfg.get("health_check_interval_s", 5))
+ self._interval_spin.pack(side="left")
+ ttk.Label(spin_frame, text="seconds").pack(side="left", padx=(4, 0))
+
+ opts_frame = ttk.Frame(frame)
+ opts_frame.pack(fill="x", pady=(4, 0))
+ self._auto_restart_var = tk.BooleanVar(value=self._cfg.get("auto_restart_proxy", True))
+ ttk.Checkbutton(opts_frame, text="Auto-restart proxy on crash",
+ variable=self._auto_restart_var).pack(side="left")
+ self._auto_switch_var = tk.BooleanVar(value=self._cfg.get("auto_switch_provider", False))
+ ttk.Checkbutton(opts_frame, text="Auto-switch provider on repeated failure",
+ variable=self._auto_switch_var).pack(side="left", padx=(12, 0))
+
+ ttk.Button(frame, text="Save Configuration", command=self._on_save).pack(pady=(8, 0))
+
+ stats = self._store.get("stats", {"ai_calls": 0, "tokens_used": 0})
+ stats_text = (f"AI diagnostic calls: {stats.get('ai_calls', 0)} | "
+ f"Tokens used: {stats.get('tokens_used', 0):,} | "
+ f"Known patterns: {len(self._store.get('incidents', {}))}")
+ ttk.Label(main, text=stats_text, font=("Segoe UI", 8)).pack(anchor="w", pady=(8, 0))
+
+ inc_frame = ttk.LabelFrame(main, text="Recent Incidents", padding=4)
+ inc_frame.pack(fill="both", expand=True, pady=(4, 0))
+ self._inc_text = tk.Text(inc_frame, height=8, wrap="word", state="disabled")
+ inc_sb = ttk.Scrollbar(inc_frame, orient="vertical", command=self._inc_text.yview)
+ self._inc_text.configure(yscrollcommand=inc_sb.set)
+ self._inc_text.pack(side="left", fill="both", expand=True)
+ inc_sb.pack(side="right", fill="y")
+ self._refresh_incidents()
+
+ btn_frame = ttk.Frame(main)
+ btn_frame.pack(fill="x", pady=(8, 0))
+ ttk.Button(btn_frame, text="View Monitoring Log",
+ command=lambda: open_file(str(PROXY_CONFIG_DIR / "monitoring.log"))).pack(side="left")
+ ttk.Button(btn_frame, text="Clear Incident Store", command=self._on_clear_store).pack(side="left", padx=(8, 0))
+ ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right")
+
+ def _on_toggle(self):
+ self._cfg["enabled"] = self._toggle_var.get()
+ save_monitoring_config(self._cfg)
+
+ def _on_save(self):
+ self._cfg["provider_url"] = self._url_entry.get().strip()
+ self._cfg["model"] = self._model_entry.get().strip()
+ self._cfg["api_key"] = self._key_entry.get().strip()
+ try:
+ self._cfg["health_check_interval_s"] = int(self._interval_spin.get())
+ except ValueError:
+ pass
+ self._cfg["auto_restart_proxy"] = self._auto_restart_var.get()
+ self._cfg["auto_switch_provider"] = self._auto_switch_var.get()
+ save_monitoring_config(self._cfg)
+ self._inc_text.configure(state="normal")
+ self._inc_text.delete("1.0", "end")
+ self._inc_text.insert("end", "Configuration saved.\n")
+ self._inc_text.configure(state="disabled")
+
+ def _on_clear_store(self):
+ save_incident_store({"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}})
+ self._store = {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}
+ self._refresh_incidents()
+
+ def _refresh_incidents(self):
+ lines = []
+ for pattern, inc in sorted(self._store.get("incidents", {}).items(),
+ key=lambda x: x[1].get("last_seen", ""), reverse=True):
+ sc = inc.get("success_count", 0)
+ fc = inc.get("fail_count", 0)
+ rate = sc / max(sc + fc, 1)
+ lines.append(
+ f"[{inc.get('last_seen', '?')[:16]}] {pattern}\n"
+ f" fix={inc.get('fix', '?')} success_rate={rate:.0%} seen={inc.get('occurrences', 0)}x\n"
+ )
+ if not lines:
+ lines.append("No incidents recorded yet.\n\nEnable AI Monitoring and use Codex to populate the store.\n")
+ self._inc_text.configure(state="normal")
+ self._inc_text.delete("1.0", "end")
+ self._inc_text.insert("end", "\n".join(lines))
+ self._inc_text.configure(state="disabled")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Usage Dashboard
+# ═══════════════════════════════════════════════════════════════════════
+
+class UsageWindow:
+ def __init__(self, parent):
+ self._U = _usage_theme()
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("Usage Dashboard")
+ self._dlg.geometry("720x640")
+ self._dlg.transient(parent)
+ self._dlg.configure(bg=self._U["base"])
+
+ self._build_header()
+ self._build_summary_strip()
+ ttk.Separator(self._dlg).pack(fill="x", padx=16)
+
+ self._cards_frame = tk.Frame(self._dlg, bg=self._U["base"])
+ canvas = tk.Canvas(self._cards_frame, bg=self._U["base"], highlightthickness=0)
+ scrollbar = ttk.Scrollbar(self._cards_frame, orient="vertical", command=canvas.yview)
+ self._cards_inner = tk.Frame(canvas, bg=self._U["base"])
+ self._cards_inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
+ canvas.create_window((0, 0), window=self._cards_inner, anchor="nw")
+ canvas.configure(yscrollcommand=scrollbar.set)
+ canvas.pack(side="left", fill="both", expand=True, padx=(16, 0))
+ scrollbar.pack(side="right", fill="y")
+ self._cards_frame.pack(fill="both", expand=True, pady=(8, 0))
+
+ self._refresh()
+
+ def _build_header(self):
+ U = self._U
+ hdr = tk.Frame(self._dlg, bg=U["base"])
+ hdr.pack(fill="x", padx=16, pady=(12, 6))
+ tk.Label(hdr, text="⚡", fg=U["accent"], bg=U["base"], font=("Segoe UI", 14)).pack(side="left")
+ tk.Label(hdr, text="Usage Dashboard", fg=U["text"], bg=U["base"],
+ font=("Segoe UI", 14, "bold")).pack(side="left", padx=(4, 0))
+ self._status_dots = tk.Label(hdr, text="", fg=U["text"], bg=U["base"], font=("Segoe UI", 9))
+ self._status_dots.pack(side="left", padx=(8, 0))
+ self._updated_lbl = tk.Label(hdr, text="Never", fg=U["dim"], bg=U["base"], font=("Segoe UI", 8))
+ self._updated_lbl.pack(side="right")
+ refresh_btn = tk.Button(hdr, text="Refresh", fg=U["text"], bg=U["surface0"],
+ activebackground=U["surface1"], relief="flat", bd=0,
+ command=self._refresh, padx=12, pady=2)
+ refresh_btn.pack(side="right", padx=(8, 0))
+
+ def _build_summary_strip(self):
+ U = self._U
+ strip = tk.Frame(self._dlg, bg=U["surface0"], padx=12, pady=8)
+ strip.pack(fill="x", padx=16, pady=(0, 6))
+ self._kpi_labels = {}
+ for key, label, icon in [("providers", "Providers", "\U0001F4CA"),
+ ("requests", "Requests", "⚡"),
+ ("tokens", "Tokens", "\U0001F9E0"),
+ ("latency", "Avg Latency", "⏱")]:
+ box = tk.Frame(strip, bg=U["surface0"])
+ box.pack(side="left", padx=(0, 20))
+ tk.Label(box, text=f"{icon} {label}", fg=U["dim"], bg=U["surface0"],
+ font=("Segoe UI", 8), anchor="w").pack(anchor="w")
+ val = tk.Label(box, text="-", fg=U["text"], bg=U["surface0"],
+ font=("Segoe UI", 9, "bold"), anchor="w")
+ val.pack(anchor="w")
+ self._kpi_labels[key] = val
+
+ def _refresh(self):
+ for w in self._cards_inner.winfo_children():
+ w.destroy()
+ stats = load_usage_stats()
+ updated = stats.get("updated")
+ if updated:
+ self._updated_lbl.configure(text=updated)
+ providers = stats.get("providers", {})
+ if not providers:
+ tk.Label(self._cards_inner, text="No usage data yet.\nLaunch a session to start tracking.",
+ fg=self._U["dim"], bg=self._U["base"], font=("Segoe UI", 11)).pack(pady=60)
+ return
+
+ total_req = total_tok_in = total_tok_out = 0
+ total_dur = 0.0
+ n_ok = n_warn = n_err = 0
+
+ sorted_providers = sorted(providers.items(), key=lambda x: x[1].get("total_requests", 0), reverse=True)
+ for prov_name, prov_data in sorted_providers:
+ t = prov_data.get("total_requests", 0)
+ total_req += t
+ total_tok_in += prov_data.get("total_tokens_in", 0)
+ total_tok_out += prov_data.get("total_tokens_out", 0)
+ total_dur += prov_data.get("total_duration_s", 0.0)
+ fail = prov_data.get("failures", 0)
+ fail_pct = fail / t if t > 0 else 0
+ if fail_pct > 0.15:
+ n_err += 1
+ elif fail_pct > 0.05:
+ n_warn += 1
+ else:
+ n_ok += 1
+
+ self._kpi_labels["providers"].configure(text=str(len(providers)))
+ self._kpi_labels["requests"].configure(text=f"{total_req:,}")
+ tok_sum = total_tok_in + total_tok_out
+ tok_str = f"{_fmt_tok(tok_sum)} in:{_fmt_tok(total_tok_in)} out:{_fmt_tok(total_tok_out)}" if tok_sum else "N/A"
+ self._kpi_labels["tokens"].configure(text=tok_str)
+ avg_lat = total_dur / total_req if total_req > 0 else 0
+ self._kpi_labels["latency"].configure(text=_fmt_dur(avg_lat))
+
+ dots = ""
+ if n_ok:
+ dots += f"●{n_ok} "
+ if n_warn:
+ dots += f"◐{n_warn} "
+ if n_err:
+ dots += f"✗{n_err}"
+ self._status_dots.configure(text=dots)
+
+ for prov_name, prov_data in sorted_providers:
+ self._build_card(prov_name, prov_data)
+
+ def _build_card(self, name, data):
+ U = self._U
+ total = data.get("total_requests", 0)
+ ok = data.get("successes", 0)
+ fail = data.get("failures", 0)
+ success_rate = ok / total if total > 0 else 1.0
+ fail_pct = fail / total if total > 0 else 0
+ status_text, status_color = _status_pill(success_rate, fail_pct)
+
+ card = tk.Frame(self._cards_inner, bg=U["surface0"], padx=14, pady=10,
+ highlightbackground=status_color, highlightthickness=1)
+ card.pack(fill="x", pady=(0, 6))
+
+ top = tk.Frame(card, bg=U["surface0"])
+ top.pack(fill="x")
+ tk.Label(top, text="●", fg=status_color, bg=U["surface0"], font=("Segoe UI", 10)).pack(side="left")
+ short = name.replace("https://", "").replace("http://", "").split("/")[0]
+ tk.Label(top, text=short, fg=U["text"], bg=U["surface0"],
+ font=("Segoe UI", 10, "bold")).pack(side="left", padx=(4, 0))
+ tk.Label(top, text=f" {status_text} ", fg=U["base"], bg=status_color,
+ font=("Segoe UI", 8, "bold")).pack(side="left", padx=(4, 0))
+ tk.Label(top, text=f"{total} req", fg=U["subtext"], bg=U["surface0"],
+ font=("Segoe UI", 8)).pack(side="left", padx=(6, 0))
+ last_used = data.get("last_used", "")
+ if last_used:
+ tk.Label(top, text=last_used, fg=U["dim"], bg=U["surface0"],
+ font=("Segoe UI", 7)).pack(side="right")
+
+ gauge = tk.Frame(card, bg=U["surface0"])
+ gauge.pack(fill="x", pady=(4, 0))
+ bar_frame = tk.Frame(gauge, bg=U["surface1"], height=12)
+ bar_frame.pack(fill="x", side="left", expand=True)
+ bar_frame.pack_propagate(False)
+ fill_pct = int(success_rate * 100)
+ fill_frame = tk.Frame(bar_frame, bg=status_color, height=12)
+ fill_frame.place(relwidth=success_rate, relheight=1.0)
+ tk.Label(gauge, text=f"{fill_pct}%", fg=U["subtext"], bg=U["surface0"],
+ font=("Segoe UI", 8)).pack(side="left", padx=(4, 0))
+ if fail > 0:
+ tk.Label(gauge, text=f"{fail} fail", fg=U["red"], bg=U["surface0"],
+ font=("Segoe UI", 8)).pack(side="right")
+
+ metrics = tk.Frame(card, bg=U["surface0"])
+ metrics.pack(fill="x", pady=(4, 0))
+ t_in = data.get("total_tokens_in", 0)
+ t_out = data.get("total_tokens_out", 0)
+ dur = data.get("total_duration_s", 0.0)
+ avg_dur = dur / total if total > 0 else 0
+ for label, value, color in [("Tokens In", _fmt_tok(t_in), U["sapphire"]),
+ ("Tokens Out", _fmt_tok(t_out), U["peach"]),
+ ("Avg Latency", _fmt_dur(avg_dur), U["sky"]),
+ ("Duration", _fmt_dur(dur), U["lavender"])]:
+ box = tk.Frame(metrics, bg=U["surface0"])
+ box.pack(side="left", padx=(0, 16))
+ tk.Label(box, text=label, fg=U["dim"], bg=U["surface0"], font=("Segoe UI", 7)).pack(anchor="w")
+ tk.Label(box, text=value, fg=color, bg=U["surface0"],
+ font=("Segoe UI", 9, "bold")).pack(anchor="w")
+
+ models = data.get("models", {})
+ if models:
+ models_frame = tk.Frame(card, bg=U["surface0"])
+ models_frame.pack(fill="x", pady=(4, 0))
+ tk.Label(models_frame, text="Models:", fg=U["lavender"], bg=U["surface0"],
+ font=("Segoe UI", 8, "bold")).pack(anchor="w")
+ sorted_models = sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True)
+ for i, (mname, mdata) in enumerate(sorted_models[:6]):
+ m_req = mdata.get("requests", 0)
+ pct = m_req / total * 100 if total > 0 else 0
+ color = U["model_palette"][i % len(U["model_palette"])]
+ row = tk.Frame(models_frame, bg=U["surface0"])
+ row.pack(fill="x")
+ tk.Label(row, text=f"● {mname}", fg=color, bg=U["surface0"],
+ font=("Segoe UI", 7)).pack(side="left")
+ tk.Label(row, text=f"{pct:.0f}% ({m_req})", fg=U["dim"], bg=U["surface0"],
+ font=("Segoe UI", 7)).pack(side="left", padx=(8, 0))
+
+ last_err = data.get("last_error")
+ if last_err:
+ err_frame = tk.Frame(card, bg=U["surface0"])
+ err_frame.pack(fill="x", pady=(4, 0))
+ tk.Label(err_frame, text=f"⚠ {last_err}", fg=U["red"], bg=U["surface0"],
+ font=("Segoe UI", 7)).pack(anchor="w")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Request History Window
+# ═══════════════════════════════════════════════════════════════════════
+
+class RequestHistoryWindow:
+ def __init__(self, parent):
+ self._snap_dir = PROXY_CONFIG_DIR / "requests"
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("Request History")
+ self._dlg.geometry("720x500")
+ self._dlg.transient(parent)
+
+ main = ttk.Frame(self._dlg, padding=10)
+ main.pack(fill="both", expand=True)
+
+ hdr = ttk.Frame(main)
+ hdr.pack(fill="x")
+ ttk.Label(hdr, text="Request History", font=("Segoe UI", 11, "bold")).pack(side="left")
+ ttk.Button(hdr, text="Clear All", command=self._clear_all).pack(side="right")
+ ttk.Button(hdr, text="Refresh", command=self._load).pack(side="right", padx=(0, 4))
+
+ paned = ttk.PanedWindow(main, orient="vertical")
+ paned.pack(fill="both", expand=True, pady=(6, 0))
+
+ top_frame = ttk.Frame(paned)
+ cols = ("time", "model", "status", "duration", "id", "error")
+ self._tree = ttk.Treeview(top_frame, columns=cols, show="headings", height=10)
+ for col, heading, w in [("time", "Time", 140), ("model", "Model", 140), ("status", "Status", 80),
+ ("duration", "Duration", 70), ("id", "ID", 180), ("error", "Error", 120)]:
+ self._tree.heading(col, text=heading)
+ self._tree.column(col, width=w, minwidth=50)
+ tree_sb = ttk.Scrollbar(top_frame, orient="vertical", command=self._tree.yview)
+ self._tree.configure(yscrollcommand=tree_sb.set)
+ self._tree.pack(side="left", fill="both", expand=True)
+ tree_sb.pack(side="right", fill="y")
+ paned.add(top_frame, weight=1)
+
+ bottom_frame = ttk.Frame(paned)
+ self._detail = tk.Text(bottom_frame, height=10, wrap="word", font=("Consolas", 9))
+ detail_sb = ttk.Scrollbar(bottom_frame, orient="vertical", command=self._detail.yview)
+ self._detail.configure(yscrollcommand=detail_sb.set)
+ self._detail.pack(side="left", fill="both", expand=True)
+ detail_sb.pack(side="right", fill="y")
+ paned.add(bottom_frame, weight=1)
+
+ self._tree.bind("<>", self._on_select)
+
+ self._snapshots = []
+ self._load()
+
+ def _load(self):
+ for item in self._tree.get_children():
+ self._tree.delete(item)
+ self._snapshots = []
+ if not self._snap_dir.exists():
+ return
+ files = sorted(self._snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
+ for f in files[:200]:
+ try:
+ data = json.loads(f.read_text())
+ meta = data.get("_meta", {})
+ self._snapshots.append(data)
+ ts = meta.get("ts_iso", "")[:19].replace("T", " ")
+ model = meta.get("model", "?")
+ status = meta.get("status", "unknown")
+ dur = f"{meta['duration_s']:.1f}s" if meta.get("duration_s") is not None else "-"
+ rid = meta.get("request_id", "")[:28]
+ err = (meta.get("error") or "")[:60]
+ self._tree.insert("", "end", values=(ts, model, status, dur, rid, err))
+ except Exception:
+ pass
+
+ def _on_select(self, event):
+ sel = self._tree.selection()
+ if not sel:
+ return
+ idx = self._tree.index(sel[0])
+ if idx < len(self._snapshots):
+ data = self._snapshots[idx]
+ self._detail.delete("1.0", "end")
+ self._detail.insert("end", json.dumps(data, indent=2, ensure_ascii=False)[:50000])
+
+ def _clear_all(self):
+ if not messagebox.askyesno("Clear All", "Delete all request snapshots?", parent=self._dlg):
+ return
+ if self._snap_dir.exists():
+ for f in self._snap_dir.glob("*.json"):
+ try:
+ f.unlink()
+ except Exception:
+ pass
+ for item in self._tree.get_children():
+ self._tree.delete(item)
+ self._snapshots = []
+ self._detail.delete("1.0", "end")
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Benchmark Window
+# ═══════════════════════════════════════════════════════════════════════
+
+class BenchmarkWindow:
+ _BENCH_PROMPT = "In exactly 3 bullet points, explain why the sky is blue."
+ _BENCH_TOOLS = [{"type": "function", "function": {"name": "get_weather",
+ "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}}}]
+
+ def __init__(self, parent):
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("Model Benchmark")
+ self._dlg.geometry("820x560")
+ self._dlg.transient(parent)
+ self._running = False
+ self._ep_data = load_endpoints()
+
+ main = ttk.Frame(self._dlg, padding=10)
+ main.pack(fill="both", expand=True)
+
+ hdr = ttk.Frame(main)
+ hdr.pack(fill="x")
+ ttk.Label(hdr, text="Multi-Provider Benchmark", font=("Segoe UI", 11, "bold")).pack(side="left")
+ self._run_btn = ttk.Button(hdr, text="Run Benchmark", command=self._run)
+ self._run_btn.pack(side="right")
+
+ lanes_frame = ttk.Frame(main)
+ lanes_frame.pack(fill="x", pady=(8, 0))
+
+ self._lanes = []
+ self._c_var = tk.BooleanVar(value=False)
+ for i, lane_label in enumerate(["A", "B", "C"]):
+ if i == 2:
+ lf = ttk.LabelFrame(lanes_frame, text="Lane C (optional)")
+ cb = ttk.Checkbutton(lanes_frame, text="Enable Lane C", variable=self._c_var,
+ command=lambda: lf.configure() if not self._c_var.get() else None)
+ else:
+ lf = ttk.LabelFrame(lanes_frame, text=f"Lane {lane_label}")
+ lf.pack(side="left", fill="both", expand=True, padx=(0, 4 if i < 2 else 0))
+
+ ep_frame = ttk.Frame(lf, padding=4)
+ ep_frame.pack(fill="x")
+ ttk.Label(ep_frame, text="Endpoint:").pack(side="left")
+ ep_combo = ttk.Combobox(ep_frame, values=[e["name"] for e in self._ep_data.get("endpoints", [])], state="readonly")
+ ep_combo.pack(side="left", fill="x", expand=True, padx=(4, 0))
+
+ m_frame = ttk.Frame(lf, padding=4)
+ m_frame.pack(fill="x")
+ ttk.Label(m_frame, text="Model:").pack(side="left")
+ m_combo = ttk.Combobox(m_frame, state="readonly")
+ m_combo.pack(side="left", fill="x", expand=True, padx=(4, 0))
+
+ ep_combo.bind("<>", lambda e, mc=m_combo: self._update_lane_models(ep_combo, mc))
+ self._lanes.append({"ep": ep_combo, "model": m_combo})
+
+ default_name = self._ep_data.get("default")
+ eps = self._ep_data.get("endpoints", [])
+ if default_name:
+ self._lanes[0]["ep"].set(default_name)
+ if len(eps) > 1:
+ self._lanes[1]["ep"].set(eps[1]["name"])
+ elif eps:
+ self._lanes[1]["ep"].set(eps[0]["name"])
+ if len(eps) > 2:
+ self._lanes[2]["ep"].set(eps[2]["name"])
+ elif len(eps) > 1:
+ self._lanes[2]["ep"].set(eps[1]["name"])
+
+ tests_frame = ttk.Frame(main)
+ tests_frame.pack(fill="x", pady=(8, 0))
+ self._test_ttft = tk.BooleanVar(value=True)
+ self._test_total = tk.BooleanVar(value=True)
+ self._test_tools = tk.BooleanVar(value=True)
+ self._test_tps = tk.BooleanVar(value=True)
+ ttk.Checkbutton(tests_frame, text="Time to First Token", variable=self._test_ttft).pack(side="left")
+ ttk.Checkbutton(tests_frame, text="Total Latency", variable=self._test_total).pack(side="left", padx=(8, 0))
+ ttk.Checkbutton(tests_frame, text="Tool Call", variable=self._test_tools).pack(side="left", padx=(8, 0))
+ ttk.Checkbutton(tests_frame, text="Tokens/sec", variable=self._test_tps).pack(side="left", padx=(8, 0))
+
+ results_frame = ttk.Frame(main)
+ results_frame.pack(fill="both", expand=True, pady=(8, 0))
+ cols = ("test", "a", "b", "c", "winner")
+ self._results_tree = ttk.Treeview(results_frame, columns=cols, show="headings", height=6)
+ for col, heading in [("test", "Test"), ("a", "Lane A"), ("b", "Lane B"), ("c", "Lane C"), ("winner", "Winner")]:
+ self._results_tree.heading(col, text=heading)
+ self._results_tree.column(col, width=150, minwidth=80)
+ rsb = ttk.Scrollbar(results_frame, orient="vertical", command=self._results_tree.yview)
+ self._results_tree.configure(yscrollcommand=rsb.set)
+ self._results_tree.pack(side="left", fill="both", expand=True)
+ rsb.pack(side="right", fill="y")
+
+ self._status_var = tk.StringVar(value="Select endpoints and models per lane, then Run Benchmark.")
+ ttk.Label(main, textvariable=self._status_var).pack(anchor="w", pady=(4, 0))
+
+ def _update_lane_models(self, ep_combo, model_combo):
+ name = ep_combo.get()
+ if not name:
+ return
+ ep = get_endpoint(name)
+ models = (ep or {}).get("models", [])
+ model_combo["values"] = models
+ if models:
+ model_combo.set(models[0])
+
+ def _collect_lanes(self):
+ active = []
+ for i, lane in enumerate(self._lanes):
+ if i == 2 and not self._c_var.get():
+ continue
+ ep_name = lane["ep"].get()
+ model = lane["model"].get()
+ if not ep_name or not model:
+ continue
+ ep = get_endpoint(ep_name)
+ if not ep:
+ continue
+ active.append({"ep": ep, "model": model, "label": f"{ep_name}/{model}"})
+ return active
+
+ def _bench_single(self, ep, model, stream, with_tools=False):
+ url = normalize_base_url(ep.get("base_url", ""))
+ key = (ep.get("api_key") or "").strip()
+ bt = ep.get("backend_type", "openai-compat")
+ if bt == "anthropic":
+ test_url = f"{url}/v1/messages"
+ headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ body = {"model": model, "max_tokens": 100, "stream": stream,
+ "messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
+ if with_tools:
+ body["tools"] = self._BENCH_TOOLS
+ body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
+ data = json.dumps(body).encode()
+ else:
+ test_url = f"{url}/chat/completions"
+ headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"}
+ body = {"model": model, "max_tokens": 100, "stream": stream,
+ "messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
+ if with_tools:
+ body["tools"] = self._BENCH_TOOLS
+ body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
+ data = json.dumps(body).encode()
+
+ req = urllib.request.Request(test_url, data=data, headers=headers, method="POST")
+ t0 = time.time()
+ ttft = None
+ try:
+ resp = urllib.request.urlopen(req, timeout=60)
+ if stream:
+ first_chunk_time = None
+ chunks = []
+ while True:
+ chunk = resp.read(4096)
+ if not chunk:
+ break
+ if first_chunk_time is None:
+ first_chunk_time = time.time()
+ ttft = first_chunk_time - t0
+ chunks.append(chunk)
+ total = time.time() - t0
+ result_text = b"".join(chunks).decode(errors="replace")[:300]
+ else:
+ raw = resp.read()
+ total = time.time() - t0
+ result_text = raw.decode(errors="replace")[:300]
+ payload = json.loads(raw)
+ choices = payload.get("choices", [])
+ if choices:
+ msg = choices[0].get("message", {})
+ if with_tools:
+ tcs = msg.get("tool_calls", [])
+ has_tools = len(tcs) > 0
+ return {"ttft": ttft or total, "total": total,
+ "detail": f"tools={has_tools}, tok={payload.get('usage', {}).get('total_tokens', '?')}"}
+ content = msg.get("content", "")[:50]
+ return {"ttft": ttft or total, "total": total,
+ "detail": f"{content[:40]}... tok={payload.get('usage', {}).get('total_tokens', '?')}"}
+ return {"ttft": ttft or total, "total": total, "detail": result_text[:60]}
+ except Exception as e:
+ total = time.time() - t0
+ return {"ttft": ttft or total, "total": total, "detail": f"Error: {str(e)[:40]}"}
+
+ def _bench_tps(self, ep, model):
+ url = normalize_base_url(ep.get("base_url", ""))
+ key = (ep.get("api_key") or "").strip()
+ bt = ep.get("backend_type", "openai-compat")
+ prompt = "Write a detailed paragraph about artificial intelligence in at least 150 words."
+ max_tok = 512
+ if bt == "anthropic":
+ test_url = f"{url}/v1/messages"
+ headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ else:
+ test_url = f"{url}/chat/completions"
+ headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
+ "messages": [{"role": "user", "content": prompt}]}).encode()
+ req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
+ t0 = time.time()
+ first_token_t = None
+ token_count = 0
+ try:
+ resp = urllib.request.urlopen(req, timeout=90)
+ buf = b""
+ while True:
+ chunk = resp.read(4096)
+ if not chunk:
+ break
+ if first_token_t is None:
+ first_token_t = time.time()
+ buf += chunk
+ total = time.time() - t0
+ text = buf.decode(errors="replace")
+ for line in text.split("\n"):
+ if line.startswith("data: ") and line != "data: [DONE]":
+ try:
+ d = json.loads(line[6:])
+ content = d.get("choices", [{}])[0].get("delta", {}).get("content", "")
+ if content:
+ token_count += max(1, len(content) / 4)
+ except Exception:
+ pass
+ if token_count == 0:
+ token_count = max(1, len(text) / 4)
+ gen_time = (time.time() - first_token_t) if first_token_t else total
+ tps = token_count / gen_time if gen_time > 0 else 0
+ return {"tps": tps, "tokens": int(token_count), "gen_time": gen_time, "total": total,
+ "detail": f"{int(token_count)} tok / {gen_time:.1f}s"}
+ except Exception as e:
+ total = time.time() - t0
+ return {"tps": 0, "tokens": 0, "gen_time": total, "total": total, "detail": f"Error: {str(e)[:40]}"}
+
+ def _run(self):
+ if self._running:
+ return
+ lanes = self._collect_lanes()
+ if len(lanes) < 2:
+ self._status_var.set("Need at least 2 lanes with endpoint + model selected.")
+ return
+ self._running = True
+ self._run_btn.configure(state="disabled")
+ for item in self._results_tree.get_children():
+ self._results_tree.delete(item)
+ self._status_var.set("Running benchmark...")
+ threading.Thread(target=self._run_bench, args=(lanes,), daemon=True).start()
+
+ def _run_bench(self, lanes):
+ results = []
+ tests = []
+ if self._test_ttft.get():
+ tests.append(("TTFT (stream)", True, False))
+ if self._test_total.get():
+ tests.append(("Total latency", False, False))
+ if self._test_tools.get():
+ tests.append(("Tool call", False, True))
+ run_tps = self._test_tps.get()
+
+ for test_name, stream, tools in tests:
+ lane_results = []
+ for lane in lanes:
+ label = lane["label"]
+ self._dlg.after(0, lambda l=label: self._status_var.set(f"Running {test_name}: {l}..."))
+ r = self._bench_single(lane["ep"], lane["model"], stream, tools)
+ lane_results.append((label, r))
+
+ metric = "ttft" if stream else "total"
+ values = [(lr[0], lr[1][metric]) for lr in lane_results]
+ sorted_v = sorted(values, key=lambda x: x[1])
+ best_val = sorted_v[0][1]
+ second_val = sorted_v[1][1] if len(sorted_v) > 1 else best_val + 1
+ if best_val < second_val * 0.85:
+ winner = sorted_v[0][0]
+ else:
+ winner = "Tie"
+
+ cols = []
+ for lr in lane_results:
+ v = lr[1][metric]
+ cols.append(f"{v:.2f}s ({lr[1]['detail'][:30]})")
+ while len(cols) < 3:
+ cols.append("--")
+ cols.append(winner)
+ results.append(tuple([test_name] + cols))
+
+ if run_tps:
+ lane_tps = []
+ for lane in lanes:
+ label = lane["label"]
+ self._dlg.after(0, lambda l=label: self._status_var.set(f"Tokens/sec: {l}..."))
+ r = self._bench_tps(lane["ep"], lane["model"])
+ lane_tps.append((label, r))
+
+ tps_vals = [(lt[0], lt[1]["tps"]) for lt in lane_tps]
+ sorted_tps = sorted(tps_vals, key=lambda x: x[1], reverse=True)
+ best_tps = sorted_tps[0][1]
+ second_tps = sorted_tps[1][1] if len(sorted_tps) > 1 else 0
+ if best_tps > 0 and second_tps > 0 and best_tps > second_tps * 1.15:
+ winner_tps = sorted_tps[0][0]
+ else:
+ winner_tps = "Tie"
+
+ cols_tps = []
+ for lt in lane_tps:
+ tps = lt[1]["tps"]
+ cols_tps.append(f"{tps:.1f} t/s ({lt[1]['detail'][:25]})")
+ while len(cols_tps) < 3:
+ cols_tps.append("--")
+ cols_tps.append(winner_tps)
+ results.append(tuple(["Tokens/sec"] + cols_tps))
+
+ def _show():
+ for row in results:
+ self._results_tree.insert("", "end", values=row)
+ self._status_var.set("Benchmark complete.")
+ self._running = False
+ self._run_btn.configure(state="normal")
+
+ self._dlg.after(0, _show)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Main Launcher Window
+# ═══════════════════════════════════════════════════════════════════════
+
+def _oauth_discover_project_win(access_token, token_path, tokens):
+ project_id = ""
+ try:
+ lr = urllib.request.Request(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ data=json.dumps({}).encode(),
+ headers={"Content-Type": "application/json",
+ "Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ lresp = urllib.request.urlopen(lr, timeout=15)
+ ldata = json.loads(lresp.read())
+ p = ldata.get("cloudaicompanionProject", "")
+ if isinstance(p, dict):
+ project_id = p.get("id", "")
+ elif isinstance(p, str):
+ project_id = p
+ except Exception:
+ pass
+ if not project_id:
+ return ""
+ try:
+ test_url = f"https://staging-cloudaicompanion.sandbox.googleapis.com/v1internal:listModels?project={project_id}"
+ test_req = urllib.request.Request(test_url,
+ headers={"Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ urllib.request.urlopen(test_req, timeout=10)
+ except urllib.error.HTTPError as e:
+ if e.code == 403 and "SERVICE_DISABLED" in (e.read().decode()[:500]):
+ try:
+ list_req = urllib.request.Request(
+ "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE",
+ headers={"Authorization": f"Bearer {access_token}"})
+ list_resp = urllib.request.urlopen(list_req, timeout=15)
+ projects = json.loads(list_resp.read()).get("projects", [])
+ for proj in projects:
+ pid = proj.get("projectId", "")
+ if not pid or pid == project_id:
+ continue
+ try:
+ t2 = urllib.request.Request(
+ f"https://staging-cloudaicompanion.sandbox.googleapis.com/v1internal:listModels?project={pid}",
+ headers={"Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ urllib.request.urlopen(t2, timeout=10)
+ project_id = pid
+ break
+ except Exception:
+ continue
+ except Exception:
+ pass
+ tokens["project_id"] = project_id
+ with open(token_path, "w") as f:
+ json.dump(tokens, f, indent=2)
+ return project_id
+
+class LauncherWin:
+ def __init__(self, root):
+ self._root = root
+ self._proc = None
+ self._endpoints_data = load_endpoints()
+ self._refresh_running = False
+ recover_config_if_needed()
+
+ main = ttk.Frame(root, padding=16)
+ main.pack(fill="both", expand=True)
+ main.pack_propagate(False)
+
+
+ # Title
+ hdr = ttk.Frame(main)
+ hdr.pack(fill="x")
+ ttk.Label(hdr, text=f"Codex Launcher v{CHANGELOG[0][0]}", font=("Segoe UI", 13, "bold")).pack(side="left")
+
+ # Toolbar — two rows to fit all buttons
+ tb1 = ttk.Frame(main)
+ tb1.pack(fill="x", pady=(6, 0))
+ ttk.Button(tb1, text="Endpoints...", command=self._open_mgr).pack(side="left")
+ ttk.Button(tb1, text="AI Monitor", command=self._open_monitoring).pack(side="left", padx=(6, 0))
+ ttk.Button(tb1, text="AI BGP", command=self._open_bgp).pack(side="left", padx=(6, 0))
+ ttk.Button(tb1, text="Usage", command=self._open_usage).pack(side="left", padx=(6, 0))
+ ttk.Button(tb1, text="Benchmark", command=self._open_benchmark).pack(side="left", padx=(6, 0))
+ ttk.Button(tb1, text="History", command=self._open_history).pack(side="left", padx=(6, 0))
+ ttk.Button(tb1, text="OAuth Secrets", command=self._edit_oauth_secrets).pack(side="left", padx=(6, 0))
+ ttk.Button(tb1, text="Changelog", command=self._show_changelog).pack(side="right")
+
+ # Detection status — one row per item so long paths don't truncate
+ self._cli_info = detect_codex_cli()
+ self._desktop_info = detect_codex_desktop()
+
+ cli_row = ttk.Frame(main)
+ cli_row.pack(fill="x", pady=(4, 0))
+ if self._cli_info:
+ cli_path, cli_ver = self._cli_info
+ ttk.Label(cli_row, text=f"✓ Codex CLI {cli_ver}", foreground="#2ea043").pack(side="left")
+ ttk.Label(cli_row, text=f" ({cli_path})", foreground="gray").pack(side="left")
+ else:
+ ttk.Label(cli_row, text="✗ Codex CLI -- not found", foreground="#d29922").pack(side="left")
+ ttk.Button(cli_row, text="Install", command=lambda: self._show_install_guide("cli")).pack(side="left", padx=(6, 0))
+
+ desk_row = ttk.Frame(main)
+ desk_row.pack(fill="x", pady=(2, 0))
+ if self._desktop_info:
+ ttk.Label(desk_row, text="✓ Codex Desktop", foreground="#2ea043").pack(side="left")
+ ttk.Label(desk_row, text=f" ({self._desktop_info})", foreground="gray").pack(side="left")
+ else:
+ ttk.Label(desk_row, text="✗ Codex Desktop -- not found", foreground="#d29922").pack(side="left")
+ ttk.Button(desk_row, text="Install", command=lambda: self._show_install_guide("desktop")).pack(side="left", padx=(6, 0))
+
+ self._missing = []
+ if not self._cli_info:
+ self._missing.append("cli")
+ if not self._desktop_info:
+ self._missing.append("desktop")
+
+ # Auth status
+ auth_frame = ttk.Frame(main)
+ auth_frame.pack(fill="x", pady=(6, 0))
+ self._auth_label = ttk.Label(auth_frame, text="Checking auth...")
+ self._auth_label.pack(side="left")
+ self._relogin_btn = ttk.Button(auth_frame, text="Re-login", command=self._codex_relogin, state="disabled")
+ self._relogin_btn.pack(side="right")
+ threading.Thread(target=self._check_auth_async, daemon=True).start()
+
+ # Ops bar
+ ops_frame = ttk.Frame(main)
+ ops_frame.pack(fill="x", pady=(6, 0))
+ self._refresh_all_btn = ttk.Button(ops_frame, text="Refresh Models", command=self._refresh_all_models)
+ self._refresh_all_btn.pack(side="left")
+ ttk.Button(ops_frame, text="Backup Profile", command=self._backup_profile).pack(side="left", padx=(8, 0))
+ ttk.Button(ops_frame, text="Import Profile", command=self._import_profile).pack(side="left", padx=(8, 0))
+
+ # Endpoint + Model selectors
+ sel_frame = ttk.Frame(main)
+ sel_frame.pack(fill="x", pady=(6, 0))
+ ttk.Label(sel_frame, text="Endpoint:").pack(side="left")
+ self._combo_ep = ttk.Combobox(sel_frame, state="readonly", width=24)
+ self._combo_ep.pack(side="left", padx=(4, 0))
+ self._combo_ep.bind("<>", lambda e: self._on_endpoint_changed())
+ ttk.Label(sel_frame, text="Model:").pack(side="left", padx=(12, 0))
+ self._combo_model = ttk.Combobox(sel_frame, state="readonly", width=24)
+ self._combo_model.pack(side="left", padx=(4, 0))
+
+ # Launch buttons
+ btn_frame1 = ttk.Frame(main)
+ btn_frame1.pack(fill="x", pady=(8, 0))
+ self._btn_desktop = ttk.Button(btn_frame1, text="Launch Desktop", command=lambda: self._launch("desktop"))
+ if "desktop" in self._missing:
+ self._btn_desktop.configure(state="disabled")
+ self._btn_desktop.pack(side="left", fill="x", expand=True, padx=(0, 4))
+ self._btn_cli = ttk.Button(btn_frame1, text="Launch CLI", command=lambda: self._launch("cli"))
+ if "cli" in self._missing:
+ self._btn_cli.configure(state="disabled")
+ self._btn_cli.pack(side="left", fill="x", expand=True)
+
+ btn_frame2 = ttk.Frame(main)
+ btn_frame2.pack(fill="x", pady=(4, 0))
+ self._btn_codex_desktop = ttk.Button(btn_frame2, text="Codex Default (Desktop)",
+ command=lambda: self._launch_codex_default("desktop"))
+ if "desktop" in self._missing:
+ self._btn_codex_desktop.configure(state="disabled")
+ self._btn_codex_desktop.pack(side="left", fill="x", expand=True, padx=(0, 4))
+ self._btn_codex_cli = ttk.Button(btn_frame2, text="Codex Default (CLI)",
+ command=lambda: self._launch_codex_default("cli"))
+ if "cli" in self._missing:
+ self._btn_codex_cli.configure(state="disabled")
+ self._btn_codex_cli.pack(side="left", fill="x", expand=True)
+
+ # Log area
+ self._log_text = scrolledtext.ScrolledText(main, height=10, state="disabled", wrap="word",
+ font=("Consolas", 9))
+ self._log_text.pack(fill="both", expand=True, pady=(8, 0))
+
+ # Bottom bar
+ bb = ttk.Frame(main)
+ bb.pack(fill="x", pady=(6, 0))
+ ttk.Button(bb, text="Clear Log", command=self._clear_log).pack(side="left")
+ self._restart_btn = ttk.Button(bb, text="Restart Proxy", command=self._restart_proxy, state="disabled")
+ self._restart_btn.pack(side="left", padx=(4, 0))
+ ttk.Button(bb, text="AI Assistant", command=self._open_assistant).pack(side="left", padx=(4, 0))
+ self._kill_btn = ttk.Button(bb, text="Kill && Cleanup", command=self._kill, state="disabled")
+ self._kill_btn.pack(side="left", fill="x", expand=True, padx=(8, 0))
+ ttk.Button(bb, text="View Log", command=self._open_proxy_log_dir).pack(side="left")
+ ttk.Button(bb, text="Close", command=self._do_close).pack(side="left", padx=(8, 0))
+
+ self._rebuild_combo()
+ self._log_dependency_status()
+ self._start_watcher()
+
+ # ── Logging ──────────────────────────────────────────────────────
+
+ def log(self, msg):
+ self._root.after(0, self._append_log, msg)
+
+ def _append_log(self, msg):
+ self._log_text.configure(state="normal")
+ self._log_text.insert("end", msg + "\n")
+ self._log_text.see("end")
+ self._log_text.configure(state="disabled")
+
+ def _clear_log(self):
+ self._log_text.configure(state="normal")
+ self._log_text.delete("1.0", "end")
+ self._log_text.configure(state="disabled")
+
+ def _restart_proxy(self):
+ self._kill()
+ ep_name = load_endpoints().get("default")
+ if not ep_name:
+ self.log("No default endpoint set.")
+ return
+ for ep in load_endpoints().get("endpoints", []):
+ if ep.get("name") == ep_name:
+ time.sleep(0.3)
+ start_proxy_for(ep, self.log)
+ self.log(f"Proxy restarted for {ep_name}")
+ return
+ self.log(f"Endpoint '{ep_name}' not found.")
+
+ def _log_dependency_status(self):
+ if self._cli_info:
+ _, ver = self._cli_info
+ self.log(f"✓ Codex CLI detected ({ver})")
+ else:
+ self.log("✗ Codex CLI NOT found -- CLI launch disabled.")
+ if self._desktop_info:
+ self.log(f"✓ Codex Desktop detected ({self._desktop_info})")
+ else:
+ self.log("✗ Codex Desktop NOT found -- Desktop launch disabled.")
+ if self._missing:
+ self.log("Install missing tools before using the launcher.")
+ else:
+ self.log("All dependencies OK.")
+
+ # ── Auth ─────────────────────────────────────────────────────────
+
+ def _check_auth_async(self):
+ status, msg = check_codex_auth()
+ self._root.after(0, lambda: self._update_auth_status(status, msg))
+
+ def _update_auth_status(self, status, msg):
+ if status == "logged_in":
+ self._auth_label.configure(text=f"✓ Auth: {msg}", foreground="#2ea043")
+ self._relogin_btn.configure(state="normal" if "cli" not in self._missing else "disabled")
+ elif status == "not_installed":
+ self._auth_label.configure(text="Auth: N/A (CLI not installed)", foreground="#888")
+ else:
+ self._auth_label.configure(text=f"⚠ Auth: {msg}", foreground="#d29922")
+ self._relogin_btn.configure(state="normal" if "cli" not in self._missing else "disabled")
+
+ def _codex_relogin(self):
+ self.log("Opening codex login in terminal...")
+ term = detect_terminal()
+ if not term:
+ self.log("ERROR: no terminal emulator found for re-login")
+ return
+ term_name, term_args, term_path = term
+ cmd_parts = [term_name] + term_args + ["codex", "login"]
+ if IS_WINDOWS:
+ subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
+ else:
+ subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
+ self.log("Login flow started in terminal. Re-checking auth in 30s...")
+ self._auth_label.configure(text="Auth: waiting for login...")
+ threading.Thread(target=lambda: (time.sleep(30), self._check_auth_async()), daemon=True).start()
+
+ # ── Combo management ─────────────────────────────────────────────
+
+ def _rebuild_combo(self):
+ self._endpoints_data = load_endpoints()
+ ep_names = [e["name"] for e in self._endpoints_data["endpoints"]]
+ bgp_names = [f"\U0001F500 {p['name']}" for p in load_bgp_pools().get("pools", [])]
+ all_names = ep_names + bgp_names
+ self._combo_ep["values"] = all_names
+ if all_names:
+ default = self._endpoints_data.get("default")
+ if default and default in ep_names:
+ self._combo_ep.set(default)
+ else:
+ self._combo_ep.set(all_names[0])
+ self._on_endpoint_changed()
+
+ def _on_endpoint_changed(self):
+ name = self._combo_ep.get()
+ is_bgp = name.startswith("\U0001F500 ")
+ bgp_name = name[2:] if is_bgp else None
+ ep = get_endpoint(name) if name and not is_bgp else None
+ models = []
+ if is_bgp:
+ for p in load_bgp_pools().get("pools", []):
+ if p["name"] == bgp_name:
+ seen = set()
+ for r in p.get("routes", []):
+ m = r.get("model", "")
+ if m and m not in seen:
+ models.append(m)
+ seen.add(m)
+ break
+ elif ep:
+ models = ep.get("models", [])
+ self._combo_model["values"] = models
+ if ep and ep.get("default_model") in models:
+ self._combo_model.set(ep["default_model"])
+ elif models:
+ self._combo_model.set(models[0])
+ else:
+ self._combo_model.set("")
+
+ # ── Window openers ───────────────────────────────────────────────
+
+ def _on_endpoints_updated(self):
+ self._rebuild_combo()
+
+ def _open_mgr(self):
+ EndpointMgr(self._root, on_update=self._on_endpoints_updated)
+
+ def _open_bgp(self):
+ BGPPoolMgr(self._root, on_update=self._on_endpoints_updated)
+
+ def _open_monitoring(self):
+ AIMonitoringWindow(self._root)
+
+ def _open_usage(self):
+ UsageWindow(self._root)
+
+ def _open_history(self):
+ RequestHistoryWindow(self._root)
+
+ def _open_benchmark(self):
+ BenchmarkWindow(self._root)
+
+ def _open_proxy_log_dir(self):
+ log_dir = str(PROXY_CONFIG_DIR)
+ req_log = PROXY_CONFIG_DIR / "requests.log"
+ if IS_WINDOWS:
+ if req_log.exists():
+ os.startfile(str(req_log))
+ else:
+ os.startfile(log_dir)
+ else:
+ import subprocess as _sp
+ _sp.Popen(["xdg-open", log_dir])
+
+ def _open_assistant(self):
+ assist_path = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
+ if Path(assist_path).exists():
+ subprocess.Popen([sys.executable, assist_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if IS_WINDOWS else 0)
+
+ def _google_reoauth(self, provider, parent_dlg=None):
+ import http.server
+ import tkinter.simpledialog
+ is_antigravity = provider == "google-antigravity"
+ sec_key = "antigravity" if is_antigravity else "gemini_cli"
+ secrets_data = load_oauth_secrets()
+ sec = secrets_data.get(sec_key, {})
+ CLIENT_ID = sec.get("client_id", "")
+ CLIENT_SECRET = sec.get("client_secret", "")
+ if not CLIENT_ID or not CLIENT_SECRET:
+ messagebox.showerror("Missing OAuth secrets",
+ f"No client_id/client_secret for {sec_key}.\nSet them in OAuth Secrets first.")
+ return
+ token_file = "google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json"
+ token_path = str(PROXY_CONFIG_DIR / token_file)
+ provider_kind = "antigravity" if is_antigravity else "cli"
+
+ if is_antigravity:
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+ ]
+ port = 51121
+ redirect_uri = f"http://localhost:{port}/oauth-callback"
+ callback_path = "/oauth-callback"
+ else:
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ ]
+ 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"
+ callback_path = "/oauth2callback"
+
+ state = secrets.token_hex(32)
+ verifier = secrets.token_urlsafe(64)
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+ scope_str = " ".join(SCOPES)
+ auth_url = (
+ f"https://accounts.google.com/o/oauth2/v2/auth?"
+ f"client_id={CLIENT_ID}"
+ f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
+ f"&response_type=code"
+ f"&scope={urllib.parse.quote(scope_str)}"
+ f"&access_type=offline"
+ f"&prompt=select_account%20consent"
+ f"&state={state}"
+ f"&code_challenge={challenge}"
+ f"&code_challenge_method=S256"
+ )
+
+ oauth_dlg = tk.Toplevel(parent_dlg or self._root)
+ oauth_dlg.title(f"Re-OAuth: {'Antigravity' if is_antigravity else 'Gemini CLI'}")
+ oauth_dlg.geometry("520x200")
+ oauth_dlg.transient(parent_dlg or self._root)
+ oauth_dlg.grab_set()
+ tk.Label(oauth_dlg, text=f"Re-authenticating {'Antigravity' if is_antigravity else 'Gemini CLI'}",
+ font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w")
+ link_lbl = tk.Label(oauth_dlg, text="Click here to open Google authorization", fg="blue", cursor="hand2")
+ link_lbl.pack(padx=16, anchor="w")
+ link_lbl.bind("", lambda e: open_url(auth_url))
+ status_var = tk.StringVar(value="Waiting for browser callback...")
+ tk.Label(oauth_dlg, textvariable=status_var).pack(padx=16, pady=(8, 0), anchor="w")
+
+ code_holder = [None]
+ error_holder = [None]
+
+ class OAuthHandler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self2):
+ qs = urllib.parse.urlparse(self2.path).query
+ params = urllib.parse.parse_qs(qs)
+ if "code" in params:
+ if params.get("state", [None])[0] != state:
+ self2.send_response(400)
+ self2.end_headers()
+ self2.wfile.write(b"CSRF state mismatch")
+ error_holder[0] = "CSRF state mismatch"
+ return
+ code_holder[0] = params["code"][0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini")
+ self2.end_headers()
+ else:
+ error_holder[0] = params.get("error", ["unknown"])[0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
+ self2.end_headers()
+ def log_message(self2, fmt, *args):
+ pass
+
+ try:
+ bind_host = "localhost" if is_antigravity else "127.0.0.1"
+ server = http.server.HTTPServer((bind_host, port), OAuthHandler)
+ except OSError:
+ status_var.set(f"Port {port} in use — close other apps and retry.")
+ return
+
+ def _wait():
+ deadline = time.time() + 120
+ while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
+ server.handle_request()
+ server.server_close()
+ if code_holder[0]:
+ try:
+ tok_data = urllib.parse.urlencode({
+ "code": code_holder[0], "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
+ "redirect_uri": redirect_uri, "grant_type": "authorization_code",
+ "code_verifier": verifier,
+ }).encode()
+ req = urllib.request.Request("https://oauth2.googleapis.com/token", data=tok_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
+ resp = urllib.request.urlopen(req, timeout=30)
+ tokens = json.loads(resp.read())
+ tokens["client_id"] = CLIENT_ID
+ tokens["client_secret"] = CLIENT_SECRET
+ tokens["provider_kind"] = provider_kind
+ tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
+ os.makedirs(os.path.dirname(token_path), exist_ok=True)
+ with open(token_path, "w") as f:
+ json.dump(tokens, f, indent=2)
+ project_id = _oauth_discover_project_win(tokens["access_token"], token_path, tokens)
+ self._root.after(0, lambda: status_var.set(f"OK! Project: {project_id or 'none'}"))
+ self._root.after(2000, oauth_dlg.destroy)
+ except Exception as e:
+ self._root.after(0, lambda: status_var.set(f"Failed: {str(e)[:200]}"))
+ else:
+ self._root.after(0, lambda: status_var.set(f"Failed: {error_holder[0] or 'No code received'}"))
+
+ open_url(auth_url)
+ threading.Thread(target=_wait, daemon=True).start()
+ oauth_dlg.wait_window()
+
+ def _codebuff_reoauth_standalone(self, parent_dlg=None):
+ import uuid
+ oauth_dlg = tk.Toplevel(parent_dlg or self._root)
+ oauth_dlg.title("Freebuff / Codebuff Login")
+ oauth_dlg.geometry("520x240")
+ if parent_dlg:
+ oauth_dlg.transient(parent_dlg)
+ else:
+ oauth_dlg.transient(self._root)
+ oauth_dlg.grab_set()
+ tk.Label(oauth_dlg, text="Sign in with GitHub via Codebuff", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w")
+ status_var = tk.StringVar(value="Requesting login URL...")
+ tk.Label(oauth_dlg, textvariable=status_var).pack(padx=16, pady=(8, 0), anchor="w")
+ link_lbl = tk.Label(oauth_dlg, text="", fg="blue", cursor="hand2")
+ link_lbl.pack(padx=16, anchor="w")
+ result = {"success": False, "user": None, "error": None}
+
+ def _thread():
+ try:
+ fp_id = str(uuid.uuid4())
+ body = json.dumps({"fingerprintId": fp_id}).encode()
+ req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code",
+ data=body, headers={"Content-Type": "application/json", "User-Agent": UA})
+ resp = urllib.request.urlopen(req, timeout=30)
+ rdata = json.loads(resp.read())
+ login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "")
+ fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "")
+ expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0)
+ if not login_url:
+ result["error"] = "No login URL"
+ self._root.after(0, _done)
+ return
+ def _set():
+ status_var.set("Open this URL in your browser to log in:")
+ link_lbl.configure(text=login_url)
+ link_lbl.bind("", lambda e: open_url(login_url))
+ self._root.after(0, _set)
+ open_url(login_url)
+ poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}"
+ deadline = time.time() + 300
+ while time.time() < deadline:
+ time.sleep(2)
+ try:
+ pr = urllib.request.Request(poll, headers={"User-Agent": UA})
+ pd = json.loads(urllib.request.urlopen(pr, timeout=10).read())
+ if pd.get("user", {}).get("authToken"):
+ result["success"] = True
+ result["user"] = pd["user"]
+ self._root.after(0, _done)
+ return
+ except Exception:
+ pass
+ result["error"] = "Timed out"
+ except Exception as e:
+ result["error"] = str(e)[:200]
+ self._root.after(0, _done)
+
+ def _done():
+ if result["success"] and result["user"]:
+ u = result["user"]
+ cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json")
+ os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True)
+ creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""),
+ "email": u.get("email", ""), "authToken": u.get("authToken", ""),
+ "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}}
+ with open(cb_creds_path, "w") as f:
+ json.dump(creds, f, indent=2)
+ status_var.set(f"Logged in as {u.get('email', 'OK')}")
+ link_lbl.configure(text="")
+ self._root.after(2000, oauth_dlg.destroy)
+ else:
+ status_var.set(f"Failed: {result.get('error', 'unknown')}")
+
+ threading.Thread(target=_thread, daemon=True).start()
+ oauth_dlg.wait_window()
+
+ def _edit_oauth_secrets(self):
+ import tkinter.simpledialog
+ data = load_oauth_secrets()
+ if not data:
+ data = {"antigravity": {"client_id": "", "client_secret": ""},
+ "gemini_cli": {"client_id": "", "client_secret": ""}}
+
+ dlg = tk.Toplevel(self._root)
+ dlg.title("OAuth Secrets & Credentials")
+ dlg.geometry("620x650")
+ dlg.transient(self._root)
+ dlg.grab_set()
+
+ canvas = tk.Canvas(dlg)
+ scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview)
+ frame = ttk.Frame(canvas, padding=16)
+ frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
+ canvas.create_window((0, 0), window=frame, anchor="nw")
+ canvas.configure(yscrollcommand=scrollbar.set)
+ canvas.pack(side="left", fill="both", expand=True)
+ scrollbar.pack(side="right", fill="y")
+
+ ttk.Label(frame, text="Google OAuth 2.0 Client Credentials", font=("Segoe UI", 10, "bold")).pack(anchor="w")
+ ttk.Label(frame, text=str(OAUTH_SECRETS_PATH), foreground="gray").pack(anchor="w", pady=(0, 8))
+
+ fields = {}
+ nf = ttk.Frame(frame)
+ nf.pack(fill="x")
+ row = 0
+ google_token_dir = str(PROXY_CONFIG_DIR)
+ for section_key, section_label, oauth_prov, token_file in [
+ ("antigravity", "Antigravity (CloudCode)", "google-antigravity", "google-antigravity-oauth-token.json"),
+ ("gemini_cli", "Gemini CLI", "google-cli", "google-cli-oauth-token.json"),
+ ]:
+ ttk.Label(nf, text=f"\n{section_label}", font=("Segoe UI", 9, "bold")).grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2))
+ row += 1
+ sec = data.get(section_key, {})
+ token_path = os.path.join(google_token_dir, token_file)
+ has_token = False
+ try:
+ with open(token_path) as tf:
+ td = json.load(tf)
+ has_token = bool(td.get("refresh_token") or td.get("access_token"))
+ except Exception:
+ pass
+ token_status = "Token: valid" if has_token else "Token: missing"
+ token_color = "#2ea043" if has_token else "#d29922"
+ ttk.Label(nf, text=token_status, foreground=token_color).grid(row=row, column=0, sticky="w", padx=(8, 4), pady=2)
+ import_btn = ttk.Button(nf, text="Import JSON",
+ command=lambda sk=section_key: self._import_oauth_json(fields, sk))
+ import_btn.grid(row=row, column=2, padx=(4, 0), pady=2, sticky="e")
+ reauth_btn = ttk.Button(nf, text="Re-OAuth",
+ command=lambda p=oauth_prov: self._google_reoauth(p, dlg))
+ reauth_btn.grid(row=row, column=3, padx=(4, 0), pady=2, sticky="e")
+ row += 1
+ for fk, fl in [("client_id", "Client ID"), ("client_secret", "Client Secret")]:
+ ttk.Label(nf, text=fl + ":").grid(row=row, column=0, sticky="w", padx=(8, 4), pady=2)
+ entry = ttk.Entry(nf, width=55)
+ entry.insert(0, sec.get(fk, ""))
+ entry.grid(row=row, column=1, columnspan=3, sticky="ew", pady=2)
+ if fk == "client_secret":
+ entry.configure(show="*")
+ fields[(section_key, fk)] = entry
+ row += 1
+
+ nf.columnconfigure(1, weight=1)
+
+ ttk.Label(frame, text="Import client_secret_*.json from Google Cloud Console → Credentials", foreground="gray").pack(anchor="w")
+
+ ttk.Separator(frame).pack(fill="x", pady=(12, 8))
+
+ ttk.Label(frame, text="Freebuff / Codebuff Credentials", font=("Segoe UI", 10, "bold")).pack(anchor="w")
+ ttk.Label(frame, text=str(HOME / ".config" / "manicode" / "credentials.json"), foreground="gray").pack(anchor="w", pady=(0, 8))
+
+ cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json")
+ cb_fields = {}
+ try:
+ with open(cb_creds_path) as f:
+ cb_data = json.load(f)
+ except Exception:
+ cb_data = {}
+ cb_default = cb_data.get("default", {})
+
+ cb_info = f"Email: {cb_default.get('email', 'not logged in')}"
+ cb_name = cb_default.get("name", "")
+ if cb_name:
+ cb_info = f"{cb_name} — {cb_info}"
+ has_cb_token = bool(cb_default.get("authToken", ""))
+ status_text = "Logged in" if has_cb_token else "Not logged in"
+ status_color = "#2ea043" if has_cb_token else "#d29922"
+ ttk.Label(frame, text=cb_info).pack(anchor="w")
+ ttk.Label(frame, text=f"Status: {status_text}", foreground=status_color, font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(0, 4))
+
+ cb_nf = ttk.Frame(frame)
+ cb_nf.pack(fill="x")
+ cb_row = [0]
+ for fk, fl in [("authToken", "Auth Token"), ("fingerprintId", "Fingerprint ID")]:
+ ttk.Label(cb_nf, text=fl + ":").grid(row=cb_row[0], column=0, sticky="w", padx=(8, 4), pady=2)
+ entry = ttk.Entry(cb_nf, width=55, show="*")
+ entry.insert(0, cb_default.get(fk, ""))
+ entry.grid(row=cb_row[0], column=1, sticky="ew", pady=2)
+ cb_fields[fk] = entry
+ cb_row[0] += 1
+ cb_nf.columnconfigure(1, weight=1)
+
+ ttk.Button(frame, text="Re-OAuth (GitHub Login)",
+ command=lambda: self._codebuff_reoauth_standalone(dlg)).pack(anchor="w", pady=(4, 0))
+
+ cb_accounts = cb_data.get("accounts", [])
+ if cb_accounts:
+ ttk.Label(frame, text=f"Additional accounts: {len(cb_accounts)} (edit credentials.json manually)", foreground="gray").pack(anchor="w")
+
+ btnf = ttk.Frame(frame)
+ btnf.pack(fill="x", pady=(12, 0))
+ ttk.Button(btnf, text="Cancel", command=dlg.destroy).pack(side="right", padx=(4, 0))
+ save_btn = ttk.Button(btnf, text="Save")
+ save_btn.pack(side="right", padx=(4, 0))
+
+ def _save():
+ for (sk, fk), entry in fields.items():
+ if sk not in data:
+ data[sk] = {}
+ data[sk][fk] = entry.get().strip()
+ try:
+ save_oauth_secrets(data)
+ except Exception as e:
+ messagebox.showerror("Save failed", str(e), parent=dlg)
+ return
+ cb_updated = dict(cb_default)
+ for fk, entry in cb_fields.items():
+ val = entry.get().strip()
+ if val:
+ cb_updated[fk] = val
+ if cb_updated:
+ cb_data["default"] = cb_updated
+ try:
+ os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True)
+ with open(cb_creds_path, "w") as f:
+ json.dump(cb_data, f, indent=2)
+ except Exception as e:
+ messagebox.showerror("Save failed", str(e), parent=dlg)
+ return
+ dlg.destroy()
+
+ save_btn.configure(command=_save)
+
+ def _import_oauth_json(self, fields, section_key):
+ path = filedialog.askopenfilename(
+ title="Import Google OAuth Client Secret JSON",
+ filetypes=[("JSON files", "*.json")])
+ if not path:
+ return
+ try:
+ with open(path, encoding="utf-8") as f:
+ raw = json.load(f)
+ creds = raw.get("installed") or raw.get("web") or raw
+ cid = creds.get("client_id", "")
+ csec = creds.get("client_secret", "")
+ if not cid or not csec:
+ raise ValueError("JSON does not contain client_id and client_secret")
+ if (section_key, "client_id") in fields:
+ fields[(section_key, "client_id")].delete(0, "end")
+ fields[(section_key, "client_id")].insert(0, cid)
+ if (section_key, "client_secret") in fields:
+ fields[(section_key, "client_secret")].delete(0, "end")
+ fields[(section_key, "client_secret")].insert(0, csec)
+ except Exception as e:
+ messagebox.showerror("Import failed", str(e))
+
+ # ── Watcher ──────────────────────────────────────────────────────
+
+ def _start_watcher(self):
+ cfg = load_monitoring_config()
+ if not cfg.get("enabled"):
+ return
+ self._watcher = HealthWatcher(
+ on_failure=lambda c: self.log(f"[AI Monitor] Proxy unresponsive (failures={c})"),
+ on_recovery=lambda: self.log("[AI Monitor] Proxy recovered"),
+ on_signal=lambda fid, cat, line: None,
+ on_action=self._on_watcher_action,
+ )
+ self._watcher.start()
+ self.log("AI Monitoring: watchdog started")
+
+ def _on_watcher_action(self, action, trigger):
+ cfg = load_monitoring_config()
+ if action == "restart_proxy" and cfg.get("auto_restart_proxy"):
+ self.log(f"[AI Monitor] Auto-restarting proxy (trigger: {trigger})")
+ self._root.after(0, self._restart_proxy_from_watcher)
+ elif action in ("clear_schema_cache", "delete_provider_caps"):
+ try:
+ cap_file = PROXY_CONFIG_DIR / "provider-caps.json"
+ if cap_file.exists():
+ cap_file.unlink()
+ self.log("[AI Monitor] Cleared corrupt schema cache")
+ except Exception as e:
+ self.log(f"[AI Monitor] Failed to clear cache: {e}")
+ elif action == "kill_stale_restart":
+ self.log(f"[AI Monitor] Killing stale processes + restarting (trigger: {trigger})")
+ self._kill()
+ self._root.after(0, self._restart_proxy_from_watcher)
+ else:
+ self.log(f"[AI Monitor] Alert: {action} (trigger: {trigger})")
+
+ def _restart_proxy_from_watcher(self):
+ try:
+ ep_name = load_endpoints().get("default")
+ if not ep_name:
+ return
+ for ep in load_endpoints().get("endpoints", []):
+ if ep.get("name") == ep_name:
+ start_proxy_for(ep, self.log)
+ break
+ except Exception as e:
+ self.log(f"[AI Monitor] Proxy restart failed: {e}")
+
+ # ── Profile operations ───────────────────────────────────────────
+
+ def _backup_profile(self):
+ filename = filedialog.asksaveasfilename(
+ title="Backup Codex Profile",
+ defaultextension=".json",
+ initialfile=f"codex-profile-{time.strftime('%Y%m%d-%H%M%S')}.json",
+ filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
+ )
+ if not filename:
+ return
+ try:
+ save_profile_bundle(filename)
+ self.log(f"Profile backed up to {filename}")
+ except Exception as e:
+ messagebox.showerror("Backup Failed", str(e))
+
+ def _refresh_all_models(self):
+ if self._refresh_running:
+ return
+ self._refresh_running = True
+ self._refresh_all_btn.configure(state="disabled")
+ self.log("Refreshing models for all providers...")
+ threading.Thread(target=self._refresh_all_models_worker, daemon=True).start()
+
+ def _refresh_all_models_worker(self):
+ try:
+ data = load_endpoints()
+ updated = 0
+ failed = []
+ for idx, ep in enumerate(list(data["endpoints"])):
+ refreshed, err = refresh_endpoint_models(ep)
+ if refreshed:
+ data["endpoints"][idx] = refreshed
+ updated += 1
+ else:
+ failed.append(f"{ep['name']}: {err}")
+ if updated:
+ save_endpoints(data)
+ self._root.after(0, lambda: self._finish_refresh(updated, failed))
+ except Exception as e:
+ self._root.after(0, lambda: self._finish_refresh_error(str(e)))
+
+ def _finish_refresh(self, updated, failed):
+ if updated:
+ self._rebuild_combo()
+ self.log(f"Refreshed models for {updated} provider(s)")
+ if failed:
+ messagebox.showwarning("Refresh", "Some providers could not auto-fetch models.\n\n" +
+ "\n".join(failed))
+ elif updated:
+ messagebox.showinfo("Refresh", f"Refreshed models for {updated} provider(s).")
+ else:
+ messagebox.showinfo("Refresh", "No providers were refreshed.")
+ self._refresh_running = False
+ self._refresh_all_btn.configure(state="normal")
+
+ def _finish_refresh_error(self, err):
+ messagebox.showerror("Refresh Failed", err)
+ self._refresh_running = False
+ self._refresh_all_btn.configure(state="normal")
+
+ def _import_profile(self):
+ if self._proc and self._proc.poll() is None:
+ messagebox.showwarning("Import", "Stop Codex before importing a profile.")
+ return
+ filename = filedialog.askopenfilename(
+ title="Import Codex Profile",
+ filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
+ )
+ if not filename:
+ return
+ if not messagebox.askyesno("Import",
+ "Importing will replace the current endpoints and Codex config. Continue?"):
+ return
+ try:
+ import_profile_bundle(filename)
+ self._rebuild_combo()
+ self.log(f"Profile imported from {filename}")
+ messagebox.showinfo("Import", "Profile imported successfully.")
+ except Exception as e:
+ messagebox.showerror("Import Failed", str(e))
+
+ # ── Dialogs ──────────────────────────────────────────────────────
+
+ def _show_changelog(self):
+ dlg = tk.Toplevel(self._root)
+ dlg.title("Changelog")
+ dlg.geometry("540x480")
+ dlg.transient(self._root)
+ text = scrolledtext.ScrolledText(dlg, wrap="word", font=("Segoe UI", 9))
+ text.pack(fill="both", expand=True, padx=12, pady=12)
+ for ver, date, items in CHANGELOG:
+ text.insert("end", f"v{ver} ({date})\n")
+ for item in items:
+ text.insert("end", f" • {item}\n")
+ text.insert("end", "\n")
+ text.configure(state="disabled")
+ ttk.Button(dlg, text="Close", command=dlg.destroy).pack(pady=(0, 10))
+
+ def _show_install_guide(self, which):
+ if which == "cli":
+ guide = ("Codex CLI is required to use CLI launch features.\n\n"
+ "Install with npm:\n npm install -g @openai/codex\n\n"
+ "Or download from:\n https://github.com/openai/codex\n\n"
+ "After installing, restart the launcher.")
+ else:
+ guide = ("Codex Desktop is required to use Desktop launch features.\n\n"
+ "Download from:\n https://codex.desktop.openai.com\n\n"
+ "After installing, restart the launcher.")
+ messagebox.showinfo(f"Install Codex {which.title()}", guide)
+
+ # ── Launch ───────────────────────────────────────────────────────
+
+ def _set_busy(self, busy):
+ has_cli = "cli" not in self._missing
+ has_desk = "desktop" not in self._missing
+ def _update():
+ self._btn_desktop.configure(state="disabled" if busy or not has_desk else "normal")
+ self._btn_cli.configure(state="disabled" if busy or not has_cli else "normal")
+ self._btn_codex_desktop.configure(state="disabled" if busy or not has_desk else "normal")
+ self._btn_codex_cli.configure(state="disabled" if busy or not has_cli else "normal")
+ self._kill_btn.configure(state="normal" if busy else "disabled")
+ self._restart_btn.configure(state="normal" if busy else "disabled")
+ self._root.after(0, _update)
+
+ def _launch(self, target):
+ name = self._combo_ep.get()
+ if not name:
+ self.log("ERROR: no endpoint selected")
+ return
+ model = self._combo_model.get()
+ if not model:
+ self.log("ERROR: no model selected")
+ return
+
+ is_bgp = name.startswith("\U0001F500 ")
+ if is_bgp:
+ pool_name = name[2:]
+ pool = None
+ for p in load_bgp_pools().get("pools", []):
+ if p["name"] == pool_name:
+ pool = p
+ break
+ if not pool:
+ self.log(f"ERROR: BGP pool '{pool_name}' not found")
+ return
+ self._set_busy(True)
+ target_name = "Desktop" if target == "desktop" else "CLI"
+ self.log(f"=== BGP: {pool_name} / {model} -> {target_name} ===")
+ threading.Thread(target=self._run_bgp, args=(pool, model, target), daemon=True).start()
+ return
+
+ ep = get_endpoint(name)
+ if not ep:
+ self.log("ERROR: endpoint not found")
+ return
+ self._set_busy(True)
+ target_name = "Desktop" if target == "desktop" else "CLI"
+ self.log(f"=== {ep['name']} / {model} -> {target_name} ===")
+ threading.Thread(target=self._run, args=(ep, model, target), daemon=True).start()
+
+ def _launch_codex_default(self, target):
+ if "cli" not in self._missing:
+ status, msg = check_codex_auth()
+ if status != "logged_in":
+ if not messagebox.askyesno("Auth Warning",
+ f"Codex auth check: {msg}\n\n"
+ "Launch may fail without valid authentication.\nContinue anyway?"):
+ self._set_busy(False)
+ return
+ self._set_busy(True)
+ target_name = "Desktop" if target == "desktop" else "CLI"
+ self.log(f"=== Codex Default (OAuth) -> {target_name} ===")
+ threading.Thread(target=self._run_codex_default, args=(target,), daemon=True).start()
+
+ def _run(self, ep, model, target):
+ keep_session_alive = False
+ try:
+ self.log("Cleaning up stale processes...")
+ safe_cleanup_owned(self.log)
+ recover_config_if_needed(self.log)
+
+ needs_proxy = ep["backend_type"] != "native"
+ if needs_proxy:
+ self.log("Starting translation proxy...")
+ try:
+ proxy_port = start_proxy_for(ep, self.log)
+ except RuntimeError as e:
+ self._root.after(0, lambda: messagebox.showerror("Proxy Failed", str(e)))
+ return
+ self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})...")
+ begin_config_transaction(f"launch:{ep['name']}")
+ write_config_for_translated(ep, model, proxy_port)
+ else:
+ self.log(f"Configuring Codex for {ep['name']} (native)...")
+ begin_config_transaction(f"launch:{ep['name']}")
+ write_config_for_native(ep, model)
+
+ if target == "desktop":
+ if needs_proxy:
+ kill_existing_desktop(self.log)
+ keep_session_alive = self._launch_desktop(ep, model)
+ else:
+ self._launch_cli(ep, model)
+ except Exception as e:
+ self.log(f"ERROR: {e}")
+ finally:
+ if keep_session_alive:
+ self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.")
+ self._set_busy(False)
+ self.log("Ready. Use Kill && Cleanup when finished.")
+ else:
+ stop_proxy()
+ restore_config()
+ end_config_transaction()
+ self._set_busy(False)
+ self.log("Ready.")
+
+ def _run_bgp(self, pool, model, target):
+ keep_session_alive = False
+ try:
+ self.log("Cleaning up stale processes...")
+ safe_cleanup_owned(self.log)
+ recover_config_if_needed(self.log)
+
+ self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes...")
+ port, bgp_ep = start_bgp_proxy(pool, model, self.log)
+
+ begin_config_transaction(f"launch:bgp:{pool['name']}")
+ write_config_for_translated(bgp_ep, model, port)
+
+ if target == "desktop":
+ kill_existing_desktop(self.log)
+ keep_session_alive = self._launch_desktop(bgp_ep, model)
+ else:
+ self._launch_cli(bgp_ep, model)
+ except Exception as e:
+ self.log(f"ERROR: {e}")
+ finally:
+ if keep_session_alive:
+ self.log("Warm-start handoff detected; keeping proxy/config active.")
+ self._set_busy(False)
+ self.log("Ready. Use Kill && Cleanup when finished.")
+ else:
+ stop_proxy()
+ restore_config()
+ end_config_transaction()
+ self._set_busy(False)
+ self.log("Ready.")
+
+ def _run_codex_default(self, target):
+ try:
+ self.log("Cleaning up stale processes...")
+ safe_cleanup_owned(self.log)
+ stop_proxy()
+ recover_config_if_needed(self.log)
+ self.log("Resetting config to Codex defaults (OAuth)...")
+ begin_config_transaction("launch:default")
+ if CONFIG.exists():
+ CONFIG.unlink()
+ if target == "desktop":
+ self._launch_desktop_direct()
+ else:
+ self._launch_cli_default()
+ except Exception as e:
+ self.log(f"ERROR: {e}")
+ finally:
+ restore_config()
+ end_config_transaction()
+ self._set_busy(False)
+ self.log("Ready.")
+
+ def _launch_desktop(self, ep, model):
+ desktop_path = self._desktop_info
+ if not desktop_path:
+ self.log("ERROR: Codex Desktop not found")
+ return False
+
+ if IS_WINDOWS:
+ self._proc = subprocess.Popen(
+ [desktop_path],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
+ else:
+ self._proc = subprocess.Popen(
+ [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ preexec_fn=os.setsid)
+
+ pid = self._proc.pid
+ self.log(f"Desktop started (PID {pid})")
+ self.log(f"Log: {LAUNCH_LOG}")
+
+ t0 = time.time()
+ stall_warned = False
+ while self._proc and self._proc.poll() is None:
+ time.sleep(1.5)
+ el = time.time() - t0
+ if el > 20 and not stall_warned:
+ self.log("Still starting after 20s -- possible stall. Click Kill if window doesn't appear.")
+ self.log(f"--- last log lines ---\n{last_log_lines()}")
+ stall_warned = True
+
+ if self._proc:
+ rc = self._proc.poll()
+ el = time.time() - t0
+ self.log(f"Desktop exited (code {rc}) after {el:.0f}s")
+ if el < 12:
+ self.log("TIP: Quick exit -- may be warm-start handoff (normal) or crash.")
+ last_lines = last_log_lines()
+ self.log(f"--- last log lines ---\n{last_lines}")
+ if rc == 0 and "warm-start" in last_lines.lower():
+ self._proc = None
+ return True
+ self._proc = None
+ return False
+
+ def _launch_cli(self, ep, model):
+ self.log(f"Launching Codex CLI with {ep['name']}...")
+ term = detect_terminal()
+ if not term:
+ self.log("ERROR: no terminal found")
+ return
+
+ term_name, term_args, _ = term
+ cmd_parts = [term_name] + term_args
+ if ep["backend_type"] == "native":
+ cmd_parts.extend(["codex", "-c", f"model={model}"])
+ else:
+ cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"])
+
+ self.log(f"Running: {' '.join(cmd_parts)}")
+ if IS_WINDOWS:
+ self._proc = subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
+ else:
+ self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
+ pid = self._proc.pid
+ self.log(f"CLI started in terminal (PID {pid})")
+
+ while self._proc and self._proc.poll() is None:
+ time.sleep(1.5)
+ if self._proc:
+ rc = self._proc.poll()
+ self.log(f"CLI exited (code {rc})")
+ self._proc = None
+
+ def _launch_desktop_direct(self):
+ self.log("Launching Codex Desktop (default OAuth)...")
+ desktop_path = self._desktop_info
+ if not desktop_path:
+ self.log("ERROR: Codex Desktop not found")
+ return
+ if IS_WINDOWS:
+ self._proc = subprocess.Popen(
+ [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
+ else:
+ self._proc = subprocess.Popen(
+ [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ preexec_fn=os.setsid)
+ pid = self._proc.pid
+ self.log(f"Desktop started (PID {pid})")
+
+ t0 = time.time()
+ stall_warned = False
+ while self._proc and self._proc.poll() is None:
+ time.sleep(1.5)
+ el = time.time() - t0
+ if el > 20 and not stall_warned:
+ self.log("Still starting after 20s -- possible stall.")
+ self.log(f"--- last log lines ---\n{last_log_lines()}")
+ stall_warned = True
+ if self._proc:
+ rc = self._proc.poll()
+ el = time.time() - t0
+ self.log(f"Desktop exited (code {rc}) after {el:.0f}s")
+ self._proc = None
+
+ def _launch_cli_default(self):
+ self.log("Launching Codex CLI (default OAuth)...")
+ term = detect_terminal()
+ if not term:
+ self.log("ERROR: no terminal found")
+ return
+ term_name, term_args, _ = term
+ cmd_parts = [term_name] + term_args + ["codex"]
+ self.log(f"Running: {' '.join(cmd_parts)}")
+ if IS_WINDOWS:
+ self._proc = subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
+ else:
+ self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
+ pid = self._proc.pid
+ self.log(f"CLI started in terminal (PID {pid})")
+ while self._proc and self._proc.poll() is None:
+ time.sleep(1.5)
+ if self._proc:
+ rc = self._proc.poll()
+ self.log(f"CLI exited (code {rc})")
+ self._proc = None
+
+ # ── Kill ─────────────────────────────────────────────────────────
+
+ def _kill(self):
+ self.log("=== Killing ===")
+ if self._proc and self._proc.poll() is None:
+ try:
+ if IS_WINDOWS:
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(self._proc.pid)],
+ capture_output=True, timeout=10)
+ else:
+ import signal as sig
+ pgid = os.getpgid(self._proc.pid)
+ os.killpg(pgid, sig.SIGTERM)
+ time.sleep(1)
+ if self._proc.poll() is None:
+ os.killpg(pgid, sig.SIGKILL)
+ except (ProcessLookupError, PermissionError):
+ pass
+ self._proc = None
+ stop_proxy()
+ safe_cleanup_owned(self.log)
+ restore_config()
+ end_config_transaction()
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
+ if LAUNCH_LOG.exists():
+ try:
+ LAUNCH_LOG.unlink()
+ except Exception:
+ pass
+ self.log("Cleanup complete")
+ self._set_busy(False)
+ self.log("Ready.")
+
+ def _do_close(self):
+ if self._proc and self._proc.poll() is None:
+ if not messagebox.askyesno("Confirm", "Codex is still running. Kill it?"):
+ return
+ self._kill()
+ stop_proxy()
+ self._root.destroy()
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Entry point
+# ═══════════════════════════════════════════════════════════════════════
+
+if __name__ == "__main__":
+ ensure_dirs()
+ create_default_endpoints()
+
+ root = tk.Tk()
+ root.title("Codex Launcher")
+ root.geometry("800x680")
+ root.minsize(640, 520)
+ app = LauncherWin(root)
+ root.mainloop()
diff --git a/codex-launcher_3.10.7_all.deb b/codex-launcher_3.10.7_all.deb
deleted file mode 100644
index 886b9c5..0000000
Binary files a/codex-launcher_3.10.7_all.deb and /dev/null differ
diff --git a/codex-launcher_3.10.8_all.deb b/codex-launcher_3.10.8_all.deb
new file mode 100644
index 0000000..239e7f1
Binary files /dev/null and b/codex-launcher_3.10.8_all.deb differ
diff --git a/codex_launcher_lib.py b/codex_launcher_lib.py
new file mode 100644
index 0000000..9e26c2c
--- /dev/null
+++ b/codex_launcher_lib.py
@@ -0,0 +1,2122 @@
+#!/usr/bin/env python3
+"""Codex Launcher shared library — pure Python stdlib, zero GUI dependencies.
+
+Provides cross-platform utility functions for both the GTK GUI (Linux) and
+the tkinter GUI (Windows). No pip dependencies. No GTK/PyGObject imports.
+"""
+
+import base64
+import collections
+import contextlib
+import hashlib
+import json
+import os
+import re
+import secrets
+import shutil
+import signal
+import socket
+import ssl
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+UA = "codex-launcher/1.0"
+
+# ═══════════════════════════════════════════════════════════════════════
+# Platform detection
+# ═══════════════════════════════════════════════════════════════════════
+
+IS_WINDOWS = sys.platform == "win32"
+HOME = Path.home()
+
+if IS_WINDOWS:
+ _LOCAL_APPDATA = Path(os.environ.get("LOCALAPPDATA", HOME / "AppData/Local"))
+ PROXY_CONFIG_DIR = _LOCAL_APPDATA / "codex-proxy"
+ CONFIG_DIR = HOME / ".codex"
+ BIN_DIR = _LOCAL_APPDATA / "Programs" / "Codex-Launcher"
+ LOG_DIR = _LOCAL_APPDATA / "codex-proxy"
+ PID_REGISTRY = _LOCAL_APPDATA / "codex-proxy" / "pids.json"
+ _USAGE_STATS_FILE = _LOCAL_APPDATA / "codex-proxy" / "usage-stats.json"
+ MONITORING_FILE = _LOCAL_APPDATA / "codex-proxy" / "monitoring-config.json"
+ INCIDENT_STORE_FILE = _LOCAL_APPDATA / "codex-proxy" / "incident-store.json"
+ MONITORING_LOG = _LOCAL_APPDATA / "codex-proxy" / "monitoring.log"
+ REQUEST_SNAP_DIR = _LOCAL_APPDATA / "codex-proxy" / "requests"
+else:
+ PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
+ CONFIG_DIR = HOME / ".codex"
+ BIN_DIR = HOME / ".local/bin"
+ LOG_DIR = HOME / ".cache/codex-proxy"
+ PID_REGISTRY = HOME / ".cache/codex-proxy" / "pids.json"
+ _USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json"
+ MONITORING_FILE = HOME / ".cache/codex-proxy/monitoring-config.json"
+ INCIDENT_STORE_FILE = HOME / ".cache/codex-proxy/incident-store.json"
+ MONITORING_LOG = HOME / ".cache/codex-proxy/monitoring.log"
+ REQUEST_SNAP_DIR = HOME / ".cache/codex-proxy/requests"
+
+CONFIG = CONFIG_DIR / "config.toml"
+CONFIG_BAK = CONFIG_DIR / "config.toml.launcher-bak"
+CONFIG_TXN = CONFIG_DIR / "config.toml.launcher-txn.json"
+ENDPOINTS_FILE = CONFIG_DIR / "endpoints.json"
+BGP_POOLS_FILE = CONFIG_DIR / "bgp-pools.json"
+LAUNCH_LOG = LOG_DIR / "launcher.log"
+OAUTH_SECRETS_PATH = HOME / ".config" / "codex-launcher" / "oauth-secrets.json"
+
+if IS_WINDOWS:
+ PROXY = BIN_DIR / "translate-proxy.py"
+ CLEANUP = BIN_DIR / "cleanup-codex-stale.py"
+ START_SH = None
+else:
+ PROXY = BIN_DIR / "translate-proxy.py"
+ CLEANUP = BIN_DIR / "cleanup-codex-stale.sh"
+ START_SH = Path("/opt/codex-desktop/start.sh")
+
+DEFAULT_CONFIG = """model = ""
+model_provider = ""
+model_catalog_json = ""
+"""
+
+CHANGELOG = [
+ ("3.10.8", "2026-05-25", [
+ "Re-OAuth: replaced deprecated OOB flow with PKCE + localhost callback",
+ "Project auto-discovery: validates project API enabled, searches alternatives if disabled",
+ "Windows GUI: _google_reoauth now uses PKCE + callback (was broken OOB paste)",
+ "Windows GUI: endpoint OAuth flow uses shared project discovery helper",
+ "Linux GUI: endpoint OAuth flow uses shared _oauth_discover_project helper",
+ ]),
+ ("3.10.7", "2026-05-25", [
+ "Prompt Enhancer: per-provider toggle to improve prompt clarity after compaction",
+ "Two modes: offline (template injection) and ai-powered (external LLM rewrites)",
+ "Offline mode: injects structured instructions to keep model focused post-compaction",
+ "AI-powered mode: uses configurable model/URL/key to rewrite prompts for clarity",
+ "Linux/Windows GUI: Prompt Enhancer switch + mode selector + model/URL/key fields",
+ "Prevents lost context issues in long sessions with aggressive compaction",
+ ]),
+ ("3.10.6", "2026-05-25", [
+ "Freebuff integration: free DeepSeek/Kimi via codebuff.com API",
+ "Fixed Freebuff User-Agent to match official SDK (ai-sdk/openai-compatible/1.0.25/codebuff)",
+ "Fixed Freebuff metadata: freebuff_instance_id + client_id (base36) + cost_mode: free",
+ "Fixed Codebuff OAuth: use www.codebuff.com (307 redirect on bare domain)",
+ "GUI preset aliases: Freebuff, FreeBuff, Codebuff all map to same backend",
+ "Windows GUI consolidated into src/ (merged by cobra91)",
+ "CROF adaptive logic gated to crof.ai only — no log pollution for other providers",
+ "Data dir consolidation: all data in codex-proxy/",
+ "Sticky proxy port: persists in .last-proxy-port for restart persistence",
+ "Adaptive compact budget raised 60% to 80% for large-context models",
+ "Config cleanup fix: stale proxy-*.json moved after _init_runtime()",
+ "Windows GUI: Clear Log, Restart Proxy, View Log buttons (cobra91 PR #3)",
+ "OAuth Secrets dialog shows all providers: Google + Freebuff/Codebuff",
+ "Re-OAuth buttons for each provider: re-authenticate Google or GitHub/Codebuff",
+ "Token status indicators (valid/missing) for each Google provider",
+ "Shows logged-in email and auth status for Freebuff/Codebuff",
+ "Linux/Windows feature parity: both GUIs have identical features",
+ "Windows: OAuth Secrets all-providers + Codebuff OAuth + Sync from Preset",
+ "Linux: Clear Log + Restart Proxy buttons added",
+ ]),
+ ("3.10.5", "2026-05-25", [
+ "Context compaction for Antigravity/Gemini OAuth — prevents token limit errors",
+ "Aggressive compaction policies at 60% of model context limit",
+ "Compaction for cloudcode-pa and googleapis provider policies",
+ "REST model IDs added to context size map (gemini-3-flash, etc.)",
+ "OAuth Secrets editor in GUI — update client ID/secret without editing files",
+ "Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
+ "Import JSON button — import client_secret_*.json from Google Cloud Console",
+ "All hardcoded OAuth secrets removed from source code",
+ "Antigravity model IDs fixed: display names → slug model IDs for REST API",
+ "Git history scrubbed of leaked credentials; pre-push hook installed",
+ "Antigravity REST API model IDs verified with live API testing",
+ "Gemini 3.5 Flash, 3.1 Pro, Claude 4.6, GPT-OSS 120B all working",
+ ]),
+ ("3.9.9", "2026-05-25", [
+ "Refresh Antigravity preset: Gemini 3.5 Flash, Gemini 3.1 Pro, Claude 4.6, GPT-OSS",
+ "Fix Antigravity alias map for tiered model IDs (high/medium/low/thinking)",
+ "Model context sizes for Gemini 3.5 Flash, 3.1 Pro, Claude 4.6, GPT-OSS 120B",
+ ]),
+ ("3.9.8", "2026-05-25", [
+ "Fix Desktop model leak — remap gpt-5.4-mini to user-selected model",
+ "send_json() catches BrokenPipeError globally — no crashes on disconnect",
+ "Proxy remaps Desktop forced models via CODEX_LAUNCHER_MODEL env",
+ ]),
+ ("3.9.7", "2026-05-25", [
+ "Forward real Codebuff error messages instead of generic 429",
+ "Return HTTP 200 with Responses API format for rate limits",
+ "Extract retryAfterMs from Codebuff 429 responses for cooldown",
+ "RateLimitError carries upstream message through all paths",
+ "BrokenPipeError fix on 'all accounts exhausted' response",
+ "Fix 3 SyntaxWarnings for invalid escape sequences",
+ "_codebuff_start_run returns actual error body",
+ ]),
+ ("3.9.6", "2026-05-25", [
+ "Fix Gemini follow-up turns: enforce latest user instruction as final turn",
+ "Edit-intent detection with tool-use nudge for file modifications",
+ "Thought signature preservation for Gemini 3 tool-call continuity",
+ "Smart tool output compaction: old=3000, recent=20000 chars",
+ "Multi-account rotation for codebuff, Google OAuth, API keys",
+ "/v1/accounts endpoint for account pool status",
+ ]),
+ ("3.9.0", "2026-05-24", [
+ "Multi-account rotation for OAuth providers (codebuff, Google, API keys)",
+ "Automatic failover on rate limit — next account used",
+ "Codebuff: accounts[] array in credentials.json",
+ "Google OAuth: multiple token files (google-*-oauth-token-N.json)",
+ "API keys: comma-separated keys rotate on 429 errors",
+ "/v1/accounts endpoint shows account pool status",
+ "x-codebuff-model and x-codebuff-instance-id headers",
+ ]),
+ ("3.8.4", "2026-05-24", [
+ "Codebuff streaming — SSE events reach Codex client",
+ "stream_buffered_events now called for codebuff",
+ "Codebuff OAuth built-in login flow (no external CLI)",
+ "Codebuff API: reverse-engineered www.codebuff.com endpoints",
+ "Codebuff session management with instance ID",
+ "Codebuff agent run lifecycle (start/finish) with model routing",
+ "Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
+ "Reasoning mode works with codebuff (thinking tokens supported)",
+ "GUI: Sandbox mode selector (Read-only / Workspace / Full Access)",
+ "GUI: Approval mode selector (Untrusted / On Request / Full Auto)",
+ "GUI: Codebuff Login button in endpoint editor",
+ ]),
+ ("3.8.1", "2026-05-24", [
+ "Freebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
+ "Freebuff backend: auto agent-run lifecycle, credential detection, model routing",
+ "Restored all provider presets (Command Code, Crof, OpenAdapter, OpenRouter, etc.)",
+ "AI Monitoring — self-healing watchdog with 3-tier response system",
+ "HealthWatcher: monitors proxy health every 5s, auto-restarts on crash",
+ "LogAnalyzer: tails debug logs for 18 failure signal patterns",
+ "Tier 1: 14 rule-based auto-recovery rules (< 1 s response)",
+ "Tier 2: Incident pattern store with success rate tracking",
+ "Tier 3: AI diagnostic agent — configurable provider/model for novel failures",
+ "30 fault types catalogued across 5 categories (A-E)",
+ "GUI: AI Monitor panel with ON/OFF, provider selector, incident log",
+ "Enhanced /health endpoint with memory and uptime metrics",
+ ]),
+ ("3.7.0", "2026-05-22", [
+ "Intelligence Routing — self-healing parser system for Command Code",
+ "Layer 1: Deep URL extraction from nested JSON in explore_agent blocks",
+ "Layer 2: Auto-proceed on require_escalation / request_escalation_permission blocks",
+ "Layer 3: Intent-based command synthesis when all parsers fail (5 heuristics)",
+ "Module-level _build_explore_cmd() — reuses URL extraction across parser + stream",
+ "54 self-test patterns covering all three Intelligence Routing layers",
+ ]),
+ ("3.6.0", "2026-05-22", [
+ "Connection pooling — persistent HTTPS connections per host",
+ "Stream idle timeout (300s) — kills silent streams instead of hanging",
+ "Retry-After header support on all retry paths",
+ "Bounded stream buffers (8MB) — prevents OOM",
+ "Dual logging to proxy.log + stderr",
+ ]),
+ ("3.5.0", "2026-05-22", [
+ "Command Code adapter overhaul — 17 patches for multi-format tool-call parsing",
+ "DSML, XML, explore_agent, bash blocks, raw JSON parser chain",
+ "Self-revive watchdog — auto-restarts proxy on crash",
+ "Debug-to-file logging in cc-debug.log",
+ "Inline self-test (19 patterns)",
+ ]),
+ ("3.3.0", "2026-05-20", [
+ "Antigravity + Gemini CLI OAuth — full Codex agent loop working",
+ "Auto-continue on MAX_TOKENS for Gemini/Antigravity",
+ "BGP++ route scoring and provider policy layer",
+ ]),
+ ("3.0.0", "2026-05-20", [
+ "Major overhaul — ThreadingHTTPServer, thread-safe state, graceful shutdown",
+ "Dynamic port allocation, proxy health gating, atomic config",
+ "Usage Dashboard v2 with dark theme",
+ ]),
+ ("2.7.0", "2026-05-20", [
+ "Usage Dashboard redesigned (OpenUsage-inspired dark theme)",
+ "TCP_NODELAY streaming, Anthropic prompt caching",
+ ]),
+ ("2.6.1", "2026-05-20", [
+ "Google OAuth rebuilt to emulate Gemini CLI — no client_secret.json needed",
+ "Uses Google's public OAuth client_id (same as gemini-cli)",
+ "PKCE + CSRF state protection for secure auth",
+ "Just click OAuth Login — browser opens — authorize — done",
+ "Includes cloud-platform scope for Gemini Code Assist compatibility",
+ ]),
+ ("2.6.0", "2026-05-20", [
+ "Usage Dashboard — per-provider request/token/latency tracking",
+ "Visual cards with success rate bars, model breakdown, error tracking",
+ "Google OAuth: browse for client_secret.json instead of fixed path",
+ ]),
+ ("2.5.1", "2026-05-20", [
+ "Adaptive retry for 429/502/503 errors with exponential backoff",
+ "BGP routes also retry transient errors before failing over",
+ "Proxy socket reuse — no more 'Address already in use' crashes",
+ "BGP route count shown at proxy startup",
+ ]),
+ ("2.5.0", "2026-05-20", [
+ "AI BGP — multi-provider routing with automatic failover",
+ "Create BGP pools with ordered routes from any configured endpoint",
+ "Each route uses its own endpoint URL, API key, and model",
+ "Failover strategy: tries primary, falls back on error/timeout",
+ "BGP pools appear in endpoint dropdown with shuffle icon",
+ "Up/down reordering for route priority in pool editor",
+ "Fixed TOML config breakage from multi-line paste in fields",
+ ]),
+ ("2.4.0", "2026-05-20", [
+ "Added OpenAdapter provider preset (api.openadapter.in)",
+ "One API key access to 40+ models — GLM, DeepSeek, Kimi, Qwen, Claude, GPT, Gemini",
+ "Fixed Add/Edit dialog crash (missing _on_reasoning_toggled method)",
+ "Redesigned Google OAuth flow with live status dialog",
+ ]),
+ ("2.3.2", "2026-05-20", [
+ "Added Google Gemini provider with OAuth support",
+ "Two presets: 'Google Gemini (API Key)' and 'Google Gemini (OAuth)'",
+ "OAuth Login button in endpoint editor — full Google OAuth2 flow with auto-refresh",
+ "Auto-refreshes OAuth access tokens when expired (no manual re-login needed)",
+ "Supports gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, and more",
+ "Uses Gemini's OpenAI-compatible endpoint — works with existing proxy",
+ ]),
+ ("2.3.0", "2026-05-20", [
+ "Adaptive Crof self-healing system — auto-adjusts to Crof model limits",
+ "Tracks per-model success/failure history, learns item count limits dynamically",
+ "Proactively compacts input when above learned limit before sending to Crof",
+ "Auto-retries on finish_reason=length — aggressively compacts and resends",
+ "Prevents 'stream disconnected' and 'incomplete' errors on long conversations",
+ ]),
+ ("2.2.1", "2026-05-20", [
+ "Fixed compaction orphaning function_call_output items — root cause of Crof incomplete responses",
+ "Compaction now respects function_call/function_call_output pairs — no more dangling tool results",
+ "Fixed reasoning control: reasoning_effort=none now always sends enable_thinking=false too",
+ ]),
+ ("2.2.0", "2026-05-20", [
+ "Added per-provider Reasoning On/Off toggle in endpoint editor",
+ "Added Reasoning Effort level per provider: None, Minimal, Low, Medium, High, Max",
+ "When reasoning is OFF: sends enable_thinking=false + reasoning_effort=none to upstream API",
+ "When reasoning is ON: sends user-selected effort level (default: Medium)",
+ "Fixes Crof mimo-v2.5-pro and similar reasoning models exhausting output tokens",
+ "Strip reasoning_content from proxy output — Codex doesn't use it",
+ "Force max_tokens=64000 minimum for openai-compat providers",
+ ]),
+ ("2.1.3", "2026-05-19", [
+ "Fixed Crof mimo-v2.5-pro stopping: reasoning_content exhausted all output tokens",
+ "Strip reasoning_content from proxy output — Codex doesn't use it, avoids token waste",
+ "Force max_tokens=64000 minimum for openai-compat providers",
+ ]),
+ ("2.1.2", "2026-05-19", [
+ "Fixed Crof.ai and providers stopping after first tool call (root cause: None tool IDs)",
+ "Codex sends function_call items with id=None — proxy now matches tool results to calls by position",
+ "Fixed orphan message output item when response has only tool calls (no text)",
+ "Auto-trims long conversations (>30 items) to prevent context overflow on providers like Crof",
+ "Added request/response logging to ~/.cache/codex-proxy/requests.log",
+ ]),
+ ("2.1.1", "2026-05-19", [
+ "Fixed proxy: map 'developer' role to 'system' for Chat Completions providers",
+ "Fixed proxy: map 'developer' role to 'user' for Anthropic providers",
+ "Forward 'instructions' field from Responses API as system message/param",
+ "Fixes DeepSeek and other providers rejecting unknown 'developer' role",
+ ]),
+ ("2.1.0", "2026-05-19", [
+ "Added Codex auth status detection (codex login status)",
+ "Added Re-login button to re-authenticate via codex login",
+ "Auto-checks auth before launching Codex Default mode",
+ "Warns if OAuth token expired or missing before launch",
+ ]),
+ ("2.0.1", "2026-05-19", [
+ "Added Codex CLI/Desktop installation verifier to main page",
+ "Disables Desktop/CLI launch buttons when corresponding tool is missing",
+ "Shows install instructions in status area on startup",
+ ]),
+ ("2.0.0", "2026-05-19", [
+ "Initial release: multi-provider Codex Launcher",
+ "Translation proxy: Responses API to Chat Completions + Anthropic Messages",
+ "GTK endpoint manager with 10+ provider presets",
+ "Codex Default mode (built-in OAuth, zero config)",
+ "Browser UA injection for Cloudflare-protected providers (OpenCode)",
+ "Streaming SSE, tool calls, reasoning content support",
+ "Profile backup/import, model auto-fetch, bulk import",
+ "Refresh Models in background thread",
+ "URL normalization to prevent double-path bugs",
+ "Config backup/restore around sessions",
+ ".deb installer package",
+ ]),
+]
+
+# ═══════════════════════════════════════════════════════════════════════
+# Provider presets (17 providers)
+# ═══════════════════════════════════════════════════════════════════════
+
+PROVIDER_PRESETS = {
+ "Custom": {
+ "backend_type": "openai-compat",
+ "base_url": "",
+ "models": [],
+ },
+ "OpenAI": {
+ "backend_type": "native",
+ "base_url": "https://api.openai.com/v1",
+ "models": ["gpt-4o", "gpt-4o-mini"],
+ },
+ "Anthropic": {
+ "backend_type": "anthropic",
+ "base_url": "https://api.anthropic.com/v1",
+ "models": ["claude-sonnet-4-5", "claude-3-5-haiku-latest"],
+ },
+ "OpenCode Zen (OpenAI-compatible)": {
+ "backend_type": "openai-compat",
+ "base_url": "https://opencode.ai/zen/v1",
+ "models": [
+ "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
+ "minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free",
+ "deepseek-v4-flash-free", "nemotron-3-super-free",
+ "qwen3.6-plus", "qwen3.5-plus", "big-pickle",
+ ],
+ },
+ "OpenCode Zen (Anthropic)": {
+ "backend_type": "anthropic",
+ "base_url": "https://opencode.ai/zen/v1",
+ "models": [
+ "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5",
+ "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5",
+ "claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku",
+ ],
+ },
+ "OpenCode Go (OpenAI-compatible)": {
+ "backend_type": "openai-compat",
+ "base_url": "https://opencode.ai/zen/go/v1",
+ "models": [
+ "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
+ "mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m2.5",
+ "qwen3.6-plus", "qwen3.5-plus", "deepseek-v4-pro", "deepseek-v4-flash",
+ ],
+ },
+ "OpenCode Go (Anthropic)": {
+ "backend_type": "anthropic",
+ "base_url": "https://opencode.ai/zen/go/v1",
+ "models": ["minimax-m2.7", "minimax-m2.5"],
+ },
+ "Crof.ai": {
+ "backend_type": "openai-compat",
+ "base_url": "https://crof.ai/v1",
+ "models": [],
+ },
+ "NVIDIA NIM": {
+ "backend_type": "openai-compat",
+ "base_url": "https://integrate.api.nvidia.com/v1",
+ "models": [],
+ },
+ "Kilo.ai Gateway": {
+ "backend_type": "openai-compat",
+ "base_url": "https://api.kilo.ai/api/gateway",
+ "models": [],
+ },
+ "Command Code": {
+ "backend_type": "command-code",
+ "base_url": "https://api.commandcode.ai",
+ "cc_version": "0.26.8",
+ "models": [
+ "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro",
+ "anthropic:claude-sonnet-4-6", "anthropic:claude-haiku-4-5-20251001",
+ "anthropic:claude-opus-4-7", "anthropic:claude-opus-4-6",
+ "openai:gpt-5.5", "openai:gpt-5.4", "openai:gpt-5.4-mini", "openai:gpt-5.3-codex",
+ "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5",
+ "zai-org/GLM-5.1", "zai-org/GLM-5",
+ "MiniMaxAI/MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.5",
+ "Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus",
+ "stepfun/Step-3.5-Flash", "google/gemini-3.1-flash-lite",
+ ],
+ },
+ "OpenRouter": {
+ "backend_type": "openai-compat",
+ "base_url": "https://openrouter.ai/api/v1",
+ "models": [],
+ },
+ "Google Gemini (API Key)": {
+ "backend_type": "openai-compat",
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
+ "models": [
+ "gemini-2.5-flash", "gemini-2.5-pro",
+ "gemini-2.0-flash", "gemini-2.0-flash-lite",
+ "gemini-2.5-flash-preview-native-audio-dialog",
+ ],
+ },
+ "Google Gemini (OAuth)": {
+ "backend_type": "gemini-oauth-cli",
+ "base_url": "https://cloudcode-pa.googleapis.com",
+ "oauth_provider": "google-cli",
+ "models": [
+ "gemini-2.5-flash", "gemini-2.5-pro",
+ ],
+ },
+ "Google Antigravity (OAuth)": {
+ "backend_type": "gemini-oauth-antigravity",
+ "base_url": "https://cloudcode-pa.googleapis.com",
+ "oauth_provider": "google-antigravity",
+ "models": [
+ "antigravity-gemini-3-flash",
+ "antigravity-gemini-3-pro",
+ "antigravity-gemini-3.1-pro",
+ "antigravity-claude-sonnet-4-6",
+ "antigravity-claude-opus-4-6-thinking",
+ "gemini-2.5-flash", "gemini-2.5-pro",
+ "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
+ ],
+ },
+ "OpenAdapter": {
+ "backend_type": "openai-compat",
+ "base_url": "https://api.openadapter.in/v1",
+ "models": [
+ "0G-DeepSeek-V3",
+ "0G-DeepSeek-v4-Pro",
+ "0G-GLM-5",
+ "0G-GLM-5.1",
+ "0G-Qwen3.6",
+ "0G-Qwen-VL",
+ ],
+ },
+ "Z.ai Coding": {
+ "backend_type": "openai-compat",
+ "base_url": "https://api.z.ai/api/coding/paas/v4",
+ "models": [
+ "glm-5.1", "glm-4.7", "GLM-4-Plus", "GLM-4-Long",
+ "GLM-4-Flash", "GLM-4-FlashX", "GLM-Z1-Flash",
+ ],
+ },
+ "Freebuff (Free DeepSeek/Kimi)": {
+ "backend_type": "freebuff",
+ "base_url": "https://freebuff.com",
+ "models": [
+ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
+ "moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
+ ],
+ },
+ "Ollama (local)": {
+ "backend_type": "openai-compat",
+ "base_url": "http://localhost:11434/v1",
+ "models": [],
+ },
+}
+
+# ═══════════════════════════════════════════════════════════════════════
+# Cross-platform process management
+# ═══════════════════════════════════════════════════════════════════════
+
+def _subprocess_new_group_flag():
+ if IS_WINDOWS:
+ return subprocess.CREATE_NEW_PROCESS_GROUP
+ return None
+
+
+def _subprocess_preexec_fn():
+ if IS_WINDOWS:
+ return None
+ return os.setsid
+
+
+def _kill_process_group(pid):
+ if IS_WINDOWS:
+ try:
+ subprocess.run(
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
+ capture_output=True, timeout=10,
+ )
+ except Exception:
+ pass
+ else:
+ try:
+ pgid = os.getpgid(pid)
+ os.killpg(pgid, signal.SIGTERM)
+ time.sleep(0.5)
+ try:
+ os.killpg(pgid, signal.SIGKILL)
+ except (ProcessLookupError, PermissionError):
+ pass
+ except (ProcessLookupError, PermissionError):
+ pass
+
+
+def _kill_process_group_soft(pid):
+ if IS_WINDOWS:
+ try:
+ subprocess.run(
+ ["taskkill", "/T", "/PID", str(pid)],
+ capture_output=True, timeout=10,
+ )
+ except Exception:
+ pass
+ else:
+ try:
+ pgid = os.getpgid(pid)
+ os.killpg(pgid, signal.SIGTERM)
+ except (ProcessLookupError, PermissionError):
+ pass
+
+
+def _register_pgid_entry(kind, pid):
+ data = _load_pid_registry()
+ if IS_WINDOWS:
+ data[kind] = {"pid": pid, "pgid": pid, "ts": time.time()}
+ else:
+ try:
+ pgid = os.getpgid(pid)
+ except ProcessLookupError:
+ return
+ data[kind] = {"pid": pid, "pgid": pgid, "ts": time.time()}
+ _save_pid_registry(data)
+
+# ═══════════════════════════════════════════════════════════════════════
+# Cross-platform terminal detection
+# ═══════════════════════════════════════════════════════════════════════
+
+def detect_terminal():
+ if IS_WINDOWS:
+ for term in ["wt.exe", "cmd.exe", "powershell.exe"]:
+ path = shutil.which(term)
+ if path:
+ return (term, [], path)
+ return None
+ terms = [
+ ("x-terminal-emulator", ["-e"]),
+ ("kgx", ["--"]),
+ ("gnome-terminal", ["--"]),
+ ("konsole", ["-e"]),
+ ("xterm", ["-e"]),
+ ]
+ for t in terms:
+ if shutil.which(t[0]):
+ return (t[0], t[1], shutil.which(t[0]))
+ return None
+
+# ═══════════════════════════════════════════════════════════════════════
+# Cross-platform URL/file opening
+# ═══════════════════════════════════════════════════════════════════════
+
+def open_url(url):
+ if IS_WINDOWS:
+ os.startfile(url)
+ elif shutil.which("xdg-open"):
+ subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ elif sys.platform == "darwin":
+ subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+
+def open_file(path):
+ open_url(str(path))
+
+# ═══════════════════════════════════════════════════════════════════════
+# String / utility helpers
+# ═══════════════════════════════════════════════════════════════════════
+
+def safe_name(name):
+ base = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name).strip("._-") or "endpoint"
+ digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
+ return f"{base}-{digest}"
+
+
+def label_for_backend(backend_type):
+ return {
+ "openai-compat": "OpenAI-compatible",
+ "anthropic": "Anthropic",
+ "command-code": "Command Code",
+ "freebuff": "Freebuff (Free AI)",
+ "native": "Native",
+ }.get(backend_type, backend_type)
+
+
+def normalize_model_id(text):
+ value = text.strip().lower()
+ if not value:
+ return ""
+ value = value.replace("/", "-")
+ value = value.replace("+", "plus")
+ value = "".join(ch if ch.isalnum() or ch in ".-" else "-" for ch in value)
+ while "--" in value:
+ value = value.replace("--", "-")
+ return value.strip("-.")
+
+
+def normalize_base_url(url):
+ base = (url or "").strip().rstrip("/")
+ for suffix in ("/chat/completions", "/responses", "/messages"):
+ if base.endswith(suffix):
+ base = base[: -len(suffix)]
+ break
+ return base.rstrip("/")
+
+
+def parse_model_list(text):
+ out = []
+ seen = set()
+ for raw in text.replace(",", "\n").splitlines():
+ mid = normalize_model_id(raw)
+ if mid and mid not in seen:
+ seen.add(mid)
+ out.append(mid)
+ return out
+
+
+def now_utc_iso():
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+
+
+def _fmt_tok(n):
+ if n >= 1_000_000:
+ return f"{n/1_000_000:.1f}M"
+ if n >= 1_000:
+ return f"{n/1_000:.1f}K"
+ return str(n)
+
+
+def _fmt_dur(s):
+ if s >= 3600:
+ return f"{s/3600:.1f}h"
+ if s >= 60:
+ return f"{s/60:.1f}m"
+ return f"{s:.1f}s"
+
+
+def _status_pill(success_rate, fail_pct):
+ _U = _usage_theme()
+ if fail_pct > 0.15:
+ return ("ERR", _U["red"])
+ if fail_pct > 0.05:
+ return ("WARN", _U["yellow"])
+ return ("OK", _U["green"])
+
+
+def _usage_theme():
+ return {
+ "base": "#0C0E16", "surface0": "#161928", "surface1": "#1E2235",
+ "surface2": "#2A2F47", "text": "#E4E6F0", "subtext": "#B0B4C8",
+ "dim": "#5C6180", "accent": "#7EB8F7", "blue": "#5DA4E8",
+ "sapphire": "#4EC5C1", "green": "#59D4A0", "yellow": "#F0C75E",
+ "red": "#F06A77", "peach": "#F09860", "teal": "#4EC5C1",
+ "lavender": "#A899F0", "sky": "#70C8E8", "maroon": "#C44B5C",
+ "flamingo": "#E878B0", "rosewater": "#F0D0C0",
+ "model_palette": ["#F09860", "#4EC5C1", "#5DA4E8", "#59D4A0",
+ "#F0C75E", "#A899F0", "#70C8E8", "#E878B0",
+ "#C44B5C", "#F0D0C0", "#7EB8F7", "#F06A77"],
+ }
+
+# ═══════════════════════════════════════════════════════════════════════
+# Provider preset helpers
+# ═══════════════════════════════════════════════════════════════════════
+
+def apply_provider_preset(endpoint, preset_name):
+ preset = PROVIDER_PRESETS.get(preset_name)
+ if not preset:
+ return endpoint
+ updated = dict(endpoint)
+ updated["provider_preset"] = preset_name
+ updated["backend_type"] = preset["backend_type"]
+ updated["base_url"] = normalize_base_url(preset["base_url"])
+ if preset.get("cc_version") and not updated.get("cc_version"):
+ updated["cc_version"] = preset["cc_version"]
+ if not updated.get("models") or (preset.get("backend_type") or "").startswith("gemini-oauth"):
+ updated["models"] = list(preset.get("models", []))
+ if preset.get("oauth_provider"):
+ updated["oauth_provider"] = preset["oauth_provider"]
+ if not updated.get("default_model") and updated.get("models"):
+ updated["default_model"] = updated["models"][0]
+ return updated
+
+# ═══════════════════════════════════════════════════════════════════════
+# Endpoint CRUD
+# ═══════════════════════════════════════════════════════════════════════
+
+def load_endpoints():
+ if ENDPOINTS_FILE.exists():
+ try:
+ return json.loads(ENDPOINTS_FILE.read_text())
+ except Exception:
+ pass
+ return {"default": None, "endpoints": []}
+
+
+def save_endpoints(data):
+ ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True)
+ ENDPOINTS_FILE.write_text(json.dumps(data, indent=2))
+
+
+def load_bgp_pools():
+ if BGP_POOLS_FILE.exists():
+ try:
+ return json.loads(BGP_POOLS_FILE.read_text())
+ except Exception:
+ pass
+ return {"pools": []}
+
+
+def save_bgp_pools(data):
+ BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True)
+ BGP_POOLS_FILE.write_text(json.dumps(data, indent=2))
+
+
+def get_endpoint(name):
+ for e in load_endpoints()["endpoints"]:
+ if e["name"] == name:
+ return e
+ return None
+
+# ═══════════════════════════════════════════════════════════════════════
+# Profile bundle import/export
+# ═══════════════════════════════════════════════════════════════════════
+
+def build_profile_bundle():
+ return {
+ "version": 1,
+ "exported_at": now_utc_iso(),
+ "endpoints": load_endpoints(),
+ "codex_config_toml": CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "",
+ }
+
+
+def save_profile_bundle(path):
+ bundle = build_profile_bundle()
+ Path(path).write_text(json.dumps(bundle, indent=2), encoding="utf-8")
+
+
+def import_profile_bundle(path):
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
+ if not isinstance(data, dict):
+ raise ValueError("Invalid profile bundle")
+
+ endpoints = data.get("endpoints")
+ if not isinstance(endpoints, dict) or "endpoints" not in endpoints:
+ raise ValueError("Profile bundle missing endpoints")
+
+ if CONFIG.exists():
+ shutil.copy2(str(CONFIG), str(CONFIG_BAK))
+ if ENDPOINTS_FILE.exists():
+ shutil.copy2(str(ENDPOINTS_FILE), str(ENDPOINTS_FILE.with_suffix(".json.import-bak")))
+
+ save_endpoints(endpoints)
+
+ cfg = data.get("codex_config_toml", "")
+ if isinstance(cfg, str) and cfg.strip():
+ CONFIG.parent.mkdir(parents=True, exist_ok=True)
+ CONFIG.write_text(cfg, encoding="utf-8")
+ return endpoints
+
+# ═══════════════════════════════════════════════════════════════════════
+# Secure file write
+# ═══════════════════════════════════════════════════════════════════════
+
+def write_secure_text(path, text):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(text, encoding="utf-8")
+ if not IS_WINDOWS:
+ os.chmod(str(tmp), 0o600)
+ os.replace(str(tmp), str(path))
+
+# ═══════════════════════════════════════════════════════════════════════
+# Config management
+# ═══════════════════════════════════════════════════════════════════════
+
+def backup_config():
+ if CONFIG.exists():
+ tmp = CONFIG_BAK.with_suffix(".tmp")
+ shutil.copy2(str(CONFIG), str(tmp))
+ os.replace(str(tmp), str(CONFIG_BAK))
+
+
+def restore_config():
+ if CONFIG_BAK.exists():
+ tmp = CONFIG.with_suffix(".tmp")
+ shutil.copy2(str(CONFIG_BAK), str(tmp))
+ os.replace(str(tmp), str(CONFIG))
+
+
+def begin_config_transaction(reason):
+ txn = {"started_at": time.time(), "reason": reason,
+ "config_existed": CONFIG.exists(), "backup_path": str(CONFIG_BAK)}
+ if CONFIG.exists():
+ backup_config()
+ CONFIG_TXN.parent.mkdir(parents=True, exist_ok=True)
+ CONFIG_TXN.write_text(json.dumps(txn, indent=2))
+
+
+def end_config_transaction():
+ CONFIG_TXN.unlink(missing_ok=True)
+
+
+def recover_config_if_needed(logfn=None):
+ if not CONFIG_TXN.exists():
+ return
+ try:
+ txn = json.loads(CONFIG_TXN.read_text())
+ if txn.get("config_existed") and CONFIG_BAK.exists():
+ restore_config()
+ if logfn:
+ logfn("Recovered Codex config from interrupted session.")
+ elif CONFIG.exists():
+ CONFIG.unlink()
+ if logfn:
+ logfn("Removed generated config from interrupted session.")
+ finally:
+ CONFIG_TXN.unlink(missing_ok=True)
+
+
+def _toml_safe(val):
+ val = str(val).replace("\\", "/").replace('"', '\\"')
+ return val.split('\n', 1)[0].strip()
+
+
+def _resolve_secret(value):
+ value = (value or "").strip()
+ m = re.fullmatch(r"\$\{ENV:([A-Z0-9_]+)\}", value)
+ if m:
+ return os.environ.get(m.group(1), "")
+ return value
+
+
+def _merge_toml(existing_text, new_sections_text):
+ """Merge launcher-generated TOML sections into an existing config.toml.
+
+ Preserves all existing sections/keys that are not overwritten by the
+ launcher. This is a simple line-based merge — good enough for the flat
+ TOML structure Codex uses.
+ """
+ if not existing_text:
+ return new_sections_text
+
+ new_lines = new_sections_text.rstrip().splitlines()
+
+ root_keys = []
+ new_section_blocks = {}
+ current_section = None
+ current_block_lines = []
+
+ for line in new_lines:
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+ if stripped.startswith("[") and not stripped.startswith("[["):
+ if current_section is not None:
+ new_section_blocks[current_section] = current_block_lines
+ current_section = stripped
+ current_block_lines = []
+ elif current_section is None:
+ root_keys.append(line)
+ else:
+ current_block_lines.append(line)
+ if current_section is not None:
+ new_section_blocks[current_section] = current_block_lines
+
+ existing_lines = existing_text.splitlines()
+ existing_sections = {}
+ existing_root_lines = []
+ existing_section_order = []
+ cur_sec = None
+
+ for line in existing_lines:
+ stripped = line.strip()
+ if stripped.startswith("[") and not stripped.startswith("[["):
+ if cur_sec is not None:
+ pass
+ cur_sec = stripped
+ existing_section_order.append(cur_sec)
+ existing_sections[cur_sec] = [line]
+ elif cur_sec is not None:
+ existing_sections[cur_sec].append(line)
+ else:
+ existing_root_lines.append(line)
+
+ merged_root = []
+ root_key_names = set()
+ for rk in root_keys:
+ key_name = rk.strip().split("=")[0].strip() if "=" in rk else ""
+ if key_name:
+ root_key_names.add(key_name)
+
+ for line in existing_root_lines:
+ stripped = line.strip()
+ if stripped.startswith("#") or not stripped:
+ merged_root.append(line)
+ continue
+ if "=" in stripped:
+ key_name = stripped.split("=")[0].strip()
+ if key_name in root_key_names:
+ continue
+ merged_root.append(line)
+
+ merged_root.extend(root_keys)
+
+ all_sections = list(existing_section_order)
+ for sec in new_section_blocks:
+ if sec not in all_sections:
+ all_sections.append(sec)
+
+ merged = list(merged_root)
+ if merged and merged[-1] != "":
+ merged.append("")
+ for sec in all_sections:
+ if sec in new_section_blocks:
+ merged.append(sec)
+ merged.extend(new_section_blocks[sec])
+ else:
+ merged.extend(existing_sections.get(sec, []))
+ merged.append("")
+
+ return "\n".join(merged).strip() + "\n"
+
+
+def _gen_model_catalog(endpoint, selected_model=None):
+ default_model = selected_model or endpoint.get("default_model")
+ models = []
+ for mid in endpoint.get("models", []):
+ models.append({
+ "slug": mid, "model": mid, "display_name": mid,
+ "description": f"{endpoint['name']} {mid}",
+ "hidden": False, "isDefault": mid == default_model,
+ "shell_type": "shell_command", "visibility": "list",
+ "default_reasoning_level": "medium",
+ "supported_reasoning_levels": [
+ {"effort": "low", "description": "Fast"},
+ {"effort": "medium", "description": "Balanced"},
+ {"effort": "high", "description": "Deep"},
+ {"effort": "xhigh", "description": "Extra deep"},
+ ],
+ "supportedReasoningEfforts": [
+ {"reasoningEffort": "low", "description": "Fast"},
+ {"reasoningEffort": "medium", "description": "Balanced"},
+ {"reasoningEffort": "high", "description": "Deep"},
+ {"reasoningEffort": "xhigh", "description": "Extra deep"},
+ ],
+ "priority": 30, "context_size": 128000,
+ "additional_speed_tiers": [], "service_tiers": [],
+ "supports_reasoning_summaries": True, "support_verbosity": True,
+ "reasoning": True, "tool_call": True,
+ "supports_parallel_tool_calls": True,
+ "experimental_supported_tools": [], "supported_in_api": True,
+ "truncation_policy": {"mode": "tokens", "limit": 128000},
+ "base_instructions": "You are Codex, a coding agent.",
+ })
+ return {"models": models}
+
+
+def write_config_for_native(endpoint, selected_model):
+ backup_config()
+ model_catalog = _gen_model_catalog(endpoint, selected_model)
+ mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
+ mc_path.parent.mkdir(parents=True, exist_ok=True)
+ mc_path.write_text(json.dumps(model_catalog, indent=2))
+
+ mc_str = str(mc_path).replace("\\", "/")
+ new_config = [
+ f'profile = "{_toml_safe(endpoint["name"])}"\n',
+ f'model = "{_toml_safe(selected_model)}"\n',
+ f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
+ f'model_catalog_json = "{mc_str}"\n',
+ f'\n[model_providers."{endpoint["name"]}"]\n',
+ f'name = "{_toml_safe(endpoint["name"])}"\n',
+ f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
+ f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n',
+ f'\n[profiles."{endpoint["name"]}"]\n',
+ f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
+ f'model = "{_toml_safe(selected_model)}"\n',
+ f'model_catalog_json = "{mc_str}"\n',
+ f'service_tier = "default"\n',
+ f'approvals_reviewer = "user"\n',
+ ]
+ existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
+ merged = _merge_toml(existing, "".join(new_config))
+ write_secure_text(CONFIG, merged)
+
+
+def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
+ backup_config()
+ model_catalog = _gen_model_catalog(endpoint, selected_model)
+ mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
+ mc_path.parent.mkdir(parents=True, exist_ok=True)
+ mc_path.write_text(json.dumps(model_catalog, indent=2))
+
+ mc_str = str(mc_path).replace("\\", "/")
+ new_config = [
+ f'profile = "{_toml_safe(endpoint["name"])}"\n',
+ f'model = "{_toml_safe(selected_model)}"\n',
+ f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
+ f'model_catalog_json = "{mc_str}"\n',
+ f'\n[model_providers."{endpoint["name"]}"]\n',
+ f'name = "{_toml_safe(endpoint["name"])}"\n',
+ f'base_url = "http://127.0.0.1:{proxy_port}"\n',
+ f'experimental_bearer_token = "codex-launcher-local"\n',
+ f'\n[profiles."{endpoint["name"]}"]\n',
+ f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
+ f'model = "{_toml_safe(selected_model)}"\n',
+ f'model_catalog_json = "{mc_str}"\n',
+ f'service_tier = "fast"\n',
+ f'approvals_reviewer = "user"\n',
+ ]
+ existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
+ merged = _merge_toml(existing, "".join(new_config))
+ write_secure_text(CONFIG, merged)
+
+# ═══════════════════════════════════════════════════════════════════════
+# Model fetching
+# ═══════════════════════════════════════════════════════════════════════
+
+def endpoint_models_url(endpoint):
+ base = normalize_base_url(endpoint.get("base_url") or "")
+ if not base:
+ return ""
+ return f"{base}/models"
+
+
+def endpoint_model_headers(endpoint):
+ key = (endpoint.get("api_key") or "").strip()
+ backend = endpoint.get("backend_type", "openai-compat")
+ headers = {"User-Agent": UA}
+ if backend == "anthropic":
+ if key:
+ headers["x-api-key"] = key
+ headers["anthropic-version"] = "2023-06-01"
+ elif key:
+ headers["Authorization"] = f"Bearer {key}"
+ return headers
+
+
+def fetch_models_for_endpoint(endpoint, timeout=10):
+ bt = endpoint.get("backend_type", "")
+ if bt == "gemini-oauth-antigravity":
+ return list(ANTIGRAVITY_MODELS), None
+ url = endpoint_models_url(endpoint)
+ if not url:
+ return None, "Base URL is empty"
+ try:
+ req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint))
+ raw = urllib.request.urlopen(req, timeout=timeout).read()
+ payload = json.loads(raw)
+ items = payload.get("data") or payload.get("models") or []
+ ids = []
+ seen = set()
+ for item in items:
+ mid = item.get("id") if isinstance(item, dict) else None
+ if mid and mid not in seen:
+ seen.add(mid)
+ ids.append(mid)
+ if not ids:
+ return None, "No models returned"
+ return ids, None
+ except Exception as e:
+ return None, str(e)
+
+
+def refresh_endpoint_models(endpoint):
+ ids, err = fetch_models_for_endpoint(endpoint)
+ if not ids:
+ return None, err
+ updated = dict(endpoint)
+ updated["models"] = ids
+ if updated.get("default_model") not in ids:
+ updated["default_model"] = ids[0]
+ return updated, None
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Antigravity model list (static — no /v1/models REST endpoint)
+# ═══════════════════════════════════════════════════════════════════════
+
+ANTIGRAVITY_MODELS = [
+ "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)",
+ "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)",
+ "Claude Sonnet 4.6 (Thinking)",
+ "Claude Opus 4.6 (Thinking)",
+ "GPT-OSS 120B (Medium)",
+]
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# OAuth secrets (local, never in repo)
+# ═══════════════════════════════════════════════════════════════════════
+
+def load_oauth_secrets():
+ try:
+ with open(OAUTH_SECRETS_PATH, encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ return {}
+
+
+def save_oauth_secrets(data):
+ os.makedirs(os.path.dirname(OAUTH_SECRETS_PATH), exist_ok=True)
+ tmp = str(OAUTH_SECRETS_PATH) + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+ os.replace(tmp, OAUTH_SECRETS_PATH)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# Doctor checks
+# ═══════════════════════════════════════════════════════════════════════
+
+def _doctor_check_streaming(base_url, key, bt, model, add):
+ if bt == "anthropic":
+ test_url = f"{base_url}/v1/messages"
+ headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1, "stream": True,
+ "messages": [{"role": "user", "content": "hi"}]}).encode()
+ else:
+ test_url = f"{base_url}/chat/completions"
+ headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": 1, "stream": True,
+ "messages": [{"role": "user", "content": "hi"}]}).encode()
+ try:
+ req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
+ t0 = time.time()
+ resp = urllib.request.urlopen(req, timeout=20)
+ content_type = resp.headers.get("content-type", "")
+ first_chunk = resp.read(512)
+ lat = (time.time() - t0) * 1000
+ is_sse = "text/event-stream" in content_type or first_chunk.startswith(b"data:")
+ if is_sse:
+ add("Streaming support", True, f"SSE OK in {lat:.0f}ms")
+ else:
+ add("Streaming support", False, f"Expected SSE, got {content_type[:60]}")
+ except urllib.error.HTTPError as e:
+ body_text = ""
+ try:
+ body_text = e.read(200).decode(errors="replace")
+ except Exception:
+ pass
+ if e.code == 429:
+ add("Streaming support", None, "Rate limited (skipped)")
+ elif e.code in (400, 404, 422):
+ add("Streaming support", False, f"HTTP {e.code}: {body_text[:80]}")
+ else:
+ add("Streaming support", False, f"HTTP {e.code}")
+ except Exception as e:
+ add("Streaming support", False, str(e)[:100])
+
+
+def _doctor_check_toolcall(base_url, key, bt, model, add):
+ tool = {"type": "function", "function": {"name": "test_tool", "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}}}
+ if bt == "anthropic":
+ test_url = f"{base_url}/v1/messages"
+ headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 50, "stream": False,
+ "tools": [tool], "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode()
+ else:
+ test_url = f"{base_url}/chat/completions"
+ headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": 50, "stream": False, "tools": [tool],
+ "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode()
+ try:
+ req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
+ t0 = time.time()
+ resp = urllib.request.urlopen(req, timeout=30)
+ raw = resp.read()
+ lat = (time.time() - t0) * 1000
+ payload = json.loads(raw)
+ has_tools = False
+ if bt == "anthropic":
+ for block in (payload.get("content") or []):
+ if block.get("type") == "tool_use":
+ has_tools = True
+ break
+ else:
+ choices = payload.get("choices") or []
+ for ch in choices:
+ if (ch.get("message", {}).get("tool_calls")):
+ has_tools = True
+ break
+ if has_tools:
+ add("Tool-call support", True, f"Tool call received in {lat:.0f}ms")
+ else:
+ add("Tool-call support", None, f"Responded but no tool_call ({lat:.0f}ms)")
+ except urllib.error.HTTPError as e:
+ if e.code == 429:
+ add("Tool-call support", None, "Rate limited (skipped)")
+ elif e.code in (400, 404, 422):
+ err_body = ""
+ try:
+ err_body = e.read(200).decode(errors="replace")
+ except Exception:
+ pass
+ add("Tool-call support", False, f"HTTP {e.code}: {err_body[:80]}")
+ else:
+ add("Tool-call support", False, f"HTTP {e.code}")
+ except Exception as e:
+ add("Tool-call support", False, str(e)[:100])
+
+
+def run_endpoint_doctor(endpoint):
+ """Comprehensive health checks for an endpoint. Returns [(name, ok, detail), ...].
+ ok: True=pass, False=fail, None=warn/skip."""
+ checks = []
+ def add(name, ok, detail=""):
+ checks.append((name, ok, detail))
+
+ url = normalize_base_url(endpoint.get("base_url") or "")
+ key = (endpoint.get("api_key") or "").strip()
+ bt = endpoint.get("backend_type", "openai-compat")
+ model = endpoint.get("default_model") or (endpoint.get("models", [""])[0] if endpoint.get("models") else "")
+
+ parsed = urllib.parse.urlparse(url)
+ has_url = bool(parsed.scheme and parsed.netloc)
+ add("URL format", has_url, url if has_url else "Missing scheme or host")
+ if not has_url:
+ return checks
+
+ host = parsed.hostname
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
+
+ try:
+ t0 = time.time()
+ addrs = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
+ dns_ms = (time.time() - t0) * 1000
+ add("DNS resolution", True, f"{addrs[0][4][0]} ({dns_ms:.0f}ms)")
+ except socket.gaierror as e:
+ add("DNS resolution", False, str(e))
+ return checks
+
+ try:
+ t0 = time.time()
+ sock = socket.create_connection((host, port), timeout=10)
+ tcp_ms = (time.time() - t0) * 1000
+ if parsed.scheme == "https":
+ ctx = ssl.create_default_context()
+ try:
+ ssock = ctx.wrap_socket(sock, server_hostname=host)
+ tls_ms = (time.time() - t0) * 1000
+ add("TLS connection", True, f"TCP {tcp_ms:.0f}ms + handshake {tls_ms:.0f}ms")
+ ssock.close()
+ except ssl.SSLError as e:
+ add("TLS certificate", False, str(e)[:120])
+ sock.close()
+ return checks
+ else:
+ add("TCP connection", True, f"{tcp_ms:.0f}ms")
+ sock.close()
+ except (socket.timeout, ConnectionRefusedError, OSError) as e:
+ add("TCP connection", False, str(e)[:100])
+ return checks
+
+ if bt == "anthropic":
+ add("/models endpoint", None, "Anthropic has no /models endpoint — testing via /messages")
+ try:
+ t0 = time.time()
+ msg_url = f"{url}/v1/messages"
+ body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1,
+ "messages": [{"role": "user", "content": "hi"}]}).encode()
+ req = urllib.request.Request(msg_url, data=body, headers={
+ "User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json",
+ }, method="POST")
+ urllib.request.urlopen(req, timeout=15)
+ lat = (time.time() - t0) * 1000
+ add("Auth valid", True, f"Responded in {lat:.0f}ms")
+ except urllib.error.HTTPError as e:
+ if e.code in (401, 403):
+ add("Auth valid", False, f"HTTP {e.code} — check API key")
+ elif e.code == 400:
+ add("Auth valid", True, "Authenticated (model or param error)")
+ else:
+ add("Auth valid", False, f"HTTP {e.code}")
+ except Exception as e:
+ add("Auth valid", False, str(e)[:100])
+ elif bt.startswith("gemini-oauth"):
+ token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json"
+ token_path = PROXY_CONFIG_DIR / token_name
+ if token_path.exists():
+ try:
+ td = json.loads(token_path.read_text())
+ exp = td.get("expires_at", 0)
+ if exp > time.time():
+ remaining = exp - time.time()
+ add("OAuth token", True, f"Valid ({remaining / 60:.0f} min remaining)")
+ else:
+ add("OAuth token", False, "Token expired — re-login required")
+ except Exception as e:
+ add("OAuth token", False, str(e)[:80])
+ else:
+ add("OAuth token", False, f"No token file ({token_name})")
+ try:
+ t0 = time.time()
+ ids, err = fetch_models_for_endpoint(endpoint)
+ lat = (time.time() - t0) * 1000
+ if ids:
+ add("Network reachable", True, f"{lat:.0f}ms")
+ add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)")
+ if model:
+ add("Selected model exists", model in ids,
+ model if model in ids else f"'{model}' not in {ids[:5]}...")
+ elif err and ("401" in str(err) or "403" in str(err)):
+ add("Network reachable", True, f"{lat:.0f}ms")
+ add("Auth valid", False, str(err)[:100])
+ else:
+ add("Network reachable", False, str(err or "no response")[:100])
+ except Exception as e:
+ add("Network", False, str(e)[:100])
+ else:
+ try:
+ t0 = time.time()
+ ids, err = fetch_models_for_endpoint(endpoint)
+ lat = (time.time() - t0) * 1000
+ if ids:
+ add("Network reachable", True, f"{lat:.0f}ms")
+ add("Auth valid", True)
+ add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)")
+ if model:
+ add("Selected model exists", model in ids,
+ model if model in ids else f"'{model}' not found in {len(ids)} models")
+ else:
+ add("Selected model", False, "No model selected")
+ elif err and ("401" in str(err) or "403" in str(err)):
+ add("Network reachable", True, f"{lat:.0f}ms")
+ add("Auth valid", False, "HTTP 401/403 — check API key")
+ elif err and "429" in str(err):
+ add("Network reachable", True, f"{lat:.0f}ms")
+ add("Auth valid", True, "Authenticated but rate-limited")
+ add("/models endpoint", None, "Rate limited — skipped")
+ else:
+ add("Network reachable", False, str(err or "no response")[:100])
+ except Exception as e:
+ add("Network", False, str(e)[:100])
+
+ if bt not in ("native", "command-code"):
+ _doctor_check_streaming(url, key, bt, model, add)
+
+ if bt not in ("native", "command-code"):
+ _doctor_check_toolcall(url, key, bt, model, add)
+
+ return checks
+
+# ═══════════════════════════════════════════════════════════════════════
+# PID registry
+# ═══════════════════════════════════════════════════════════════════════
+
+def _load_pid_registry():
+ if PID_REGISTRY.exists():
+ try:
+ return json.loads(PID_REGISTRY.read_text())
+ except Exception:
+ pass
+ return {}
+
+
+def _save_pid_registry(data):
+ PID_REGISTRY.parent.mkdir(parents=True, exist_ok=True)
+ tmp = PID_REGISTRY.with_suffix(".tmp")
+ tmp.write_text(json.dumps(data, indent=2))
+ os.replace(str(tmp), str(PID_REGISTRY))
+
+
+def safe_cleanup_owned(logfn=None):
+ data = _load_pid_registry()
+ changed = False
+ for kind, meta in list(data.items()):
+ pid = meta.get("pid") or meta.get("pgid")
+ if not pid:
+ continue
+ try:
+ _kill_process_group(pid)
+ if logfn:
+ logfn(f"Stopped {kind} (pid {pid})")
+ changed = True
+ except ProcessLookupError:
+ changed = True
+ except Exception as e:
+ if logfn:
+ logfn(f"Could not stop {kind}: {e}")
+ if changed:
+ _save_pid_registry({})
+
+# ═══════════════════════════════════════════════════════════════════════
+# Proxy lifecycle
+# ═══════════════════════════════════════════════════════════════════════
+
+_proxy_proc = None
+_proxy_port = None
+_PROXY_PORT_FILE = PROXY_CONFIG_DIR / ".last-proxy-port"
+
+
+def _pick_free_port():
+ saved = None
+ try:
+ saved = int(_PROXY_PORT_FILE.read_text().strip())
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", saved))
+ return saved
+ except (ValueError, OSError, FileNotFoundError):
+ pass
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def get_proxy_state():
+ return _proxy_proc, _proxy_port
+
+
+def set_proxy_state(proc, port):
+ global _proxy_proc, _proxy_port
+ _proxy_proc = proc
+ _proxy_port = port
+
+
+def stop_proxy():
+ global _proxy_proc
+ if _proxy_proc and _proxy_proc.poll() is None:
+ _kill_process_group(_proxy_proc.pid)
+ _proxy_proc = None
+
+
+def start_proxy_for(endpoint, logfn):
+ """Start the translation proxy for an endpoint. Returns the port.
+ logfn(msg) is used for status messages (may be called from any thread).
+ """
+ global _proxy_proc, _proxy_port
+ stop_proxy()
+ port = _pick_free_port()
+ _proxy_port = port
+ _PROXY_PORT_FILE.parent.mkdir(parents=True, exist_ok=True)
+ _PROXY_PORT_FILE.write_text(str(port))
+
+ model_list = endpoint.get("models", [])
+ if (endpoint.get("backend_type") or "").startswith("gemini-oauth") and (endpoint.get("oauth_provider") or "").startswith("google"):
+ token_name = "google-antigravity-oauth-token.json" if endpoint.get("oauth_provider") == "google-antigravity" else "google-cli-oauth-token.json"
+ token_path = PROXY_CONFIG_DIR / token_name
+ try:
+ with open(token_path) as tf:
+ td = json.load(tf)
+ discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", [])
+ if discovered:
+ model_list = discovered
+ except Exception:
+ pass
+
+ pcfg = {
+ "port": port,
+ "backend_type": endpoint["backend_type"],
+ "target_url": normalize_base_url(endpoint["base_url"]),
+ "api_key": endpoint["api_key"],
+ "cc_version": endpoint.get("cc_version", ""),
+ "oauth_provider": endpoint.get("oauth_provider", ""),
+ "reasoning_enabled": endpoint.get("reasoning_enabled", True),
+ "reasoning_effort": endpoint.get("reasoning_effort", "medium"),
+ "force_model": endpoint.get("default_model") or "",
+ "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
+ for m in model_list],
+ }
+ pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}-{port}.json"
+ pcfg_path.parent.mkdir(parents=True, exist_ok=True)
+ pcfg_path.write_text(json.dumps(pcfg, indent=2))
+ _start_proxy_with_config(pcfg_path, port, logfn)
+ return port
+
+
+def _start_proxy_with_config(pcfg_path, port, logfn):
+ global _proxy_proc
+ python_bin = sys.executable
+ proxy_script = str(PROXY)
+
+ popen_kwargs = {
+ "stdout": subprocess.DEVNULL,
+ "stderr": subprocess.PIPE,
+ "text": True,
+ }
+ if IS_WINDOWS:
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
+ else:
+ popen_kwargs["preexec_fn"] = os.setsid
+
+ _proxy_proc = subprocess.Popen(
+ [python_bin, proxy_script, "--config", str(pcfg_path)],
+ **popen_kwargs,
+ )
+ _register_pgid_entry("proxy", _proxy_proc.pid)
+
+ def _pipe_stderr():
+ if not _proxy_proc.stderr:
+ return
+ for line in _proxy_proc.stderr:
+ logfn(f"[proxy] {line.rstrip()}")
+
+ threading.Thread(target=_pipe_stderr, daemon=True).start()
+
+ deadline = time.time() + 15
+ last_err = None
+ while time.time() < deadline:
+ if _proxy_proc.poll() is not None:
+ raise RuntimeError(f"Proxy exited early with code {_proxy_proc.returncode}")
+ try:
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=2)
+ logfn(f"Proxy ready on port {port}")
+ return
+ except Exception as e:
+ last_err = e
+ time.sleep(0.3)
+
+ _kill_process_group(_proxy_proc.pid)
+ raise RuntimeError(f"Proxy failed health check on port {port}: {last_err}")
+
+
+def start_bgp_proxy(pool, model, logfn):
+ """Start a BGP proxy for a pool. Returns (port, bgp_endpoint, pcfg_path)."""
+ global _proxy_proc, _proxy_port
+ stop_proxy()
+ port = _pick_free_port()
+ _proxy_port = port
+ _PROXY_PORT_FILE.parent.mkdir(parents=True, exist_ok=True)
+ _PROXY_PORT_FILE.write_text(str(port))
+
+ bgp_ep = {
+ "name": pool["name"],
+ "backend_type": "openai-compat",
+ "base_url": "http://bgp.placeholder",
+ "api_key": "",
+ "default_model": model,
+ "models": list(dict.fromkeys(r.get("model", model) for r in pool.get("routes", []))),
+ }
+ pcfg = {
+ "port": port,
+ "backend_type": "openai-compat",
+ "target_url": "http://bgp.placeholder",
+ "api_key": "",
+ "bgp_routes": pool.get("routes", []),
+ "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]],
+ }
+ pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}-{port}.json"
+ pcfg_path.parent.mkdir(parents=True, exist_ok=True)
+ pcfg_path.write_text(json.dumps(pcfg, indent=2))
+ _start_proxy_with_config(pcfg_path, port, logfn)
+ return port, bgp_ep
+
+# ═══════════════════════════════════════════════════════════════════════
+# Codex detection
+# ═══════════════════════════════════════════════════════════════════════
+
+def detect_codex_cli():
+ try:
+ path = shutil.which("codex")
+ if not path:
+ return None
+ out = subprocess.run(["codex", "--version"], capture_output=True, text=True, timeout=5)
+ ver = (out.stdout or "").strip() or (out.stderr or "").strip() or "unknown"
+ return (path, ver)
+ except Exception:
+ return None
+
+
+def detect_codex_desktop():
+ if IS_WINDOWS:
+ la = os.environ.get("LOCALAPPDATA", "")
+ pf = os.environ.get("PROGRAMFILES", "")
+ pf86 = os.environ.get("PROGRAMFILES(X86)", "")
+ desktop_paths = [
+ Path(la) / "Programs" / "Codex Desktop" / "Codex Desktop.exe",
+ Path(pf) / "Codex Desktop" / "Codex Desktop.exe",
+ Path(pf86) / "Codex Desktop" / "Codex Desktop.exe",
+ Path(la) / "OpenAI" / "Codex Desktop" / "Codex Desktop.exe",
+ ]
+ for p in desktop_paths:
+ if p.exists():
+ return str(p)
+ # MSIX / Microsoft Store install: locate via Get-AppxPackage
+ try:
+ r = subprocess.run(
+ ["powershell", "-NoProfile", "-Command",
+ "(Get-AppxPackage *OpenAI.Codex*).InstallLocation"],
+ capture_output=True, text=True, timeout=10,
+ )
+ loc = r.stdout.strip() if r.returncode == 0 else ""
+ if loc:
+ msix_exe = Path(loc) / "app" / "Codex.exe"
+ if msix_exe.exists():
+ return str(msix_exe)
+ except Exception:
+ pass
+ return None
+ if START_SH and START_SH.exists():
+ return str(START_SH)
+ return None
+
+
+def check_codex_auth():
+ try:
+ out = subprocess.run(
+ ["codex", "login", "status"],
+ capture_output=True, text=True, timeout=10,
+ )
+ text = (out.stdout or "").strip()
+ if not text:
+ text = (out.stderr or "").strip()
+ if out.returncode == 0 and text:
+ return ("logged_in", text)
+ if text:
+ return ("error", text)
+ return ("unknown", "No output from codex login status")
+ except FileNotFoundError:
+ return ("not_installed", "codex not found")
+ except Exception as e:
+ return ("error", str(e))
+
+# ═══════════════════════════════════════════════════════════════════════
+# Log helpers
+# ═══════════════════════════════════════════════════════════════════════
+
+def last_log_lines(n=15):
+ try:
+ t = LAUNCH_LOG.read_text()
+ return "\n".join(t.splitlines()[-n:])
+ except Exception:
+ return "(no log file)"
+
+# ═══════════════════════════════════════════════════════════════════════
+# Process helpers (desktop kill etc.)
+# ═══════════════════════════════════════════════════════════════════════
+
+def kill_existing_desktop(logfn=None):
+ if IS_WINDOWS:
+ try:
+ out = subprocess.run(
+ ["tasklist", "/FI", "IMAGENAME eq Codex Desktop.exe", "/FO", "CSV", "/NH"],
+ capture_output=True, text=True, timeout=5,
+ )
+ for line in out.stdout.strip().splitlines():
+ parts = line.split(",")
+ if len(parts) >= 2:
+ pid_str = parts[1].strip('"')
+ if pid_str.isdigit():
+ pid = int(pid_str)
+ _kill_process_group(pid)
+ if logfn:
+ logfn(f"Killed existing Codex Desktop (pid {pid})")
+ time.sleep(2)
+ except Exception as e:
+ if logfn:
+ logfn(f"Note: could not kill existing Desktop: {e}")
+ else:
+ try:
+ out = subprocess.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5)
+ pids = [p for p in out.stdout.strip().splitlines() if p.strip().isdigit()]
+ if not pids:
+ return
+ main_pid = int(pids[0])
+ pgid = os.getpgid(main_pid)
+ if pgid > 0:
+ os.killpg(pgid, signal.SIGTERM)
+ if logfn:
+ logfn(f"Killed existing Codex Desktop (pid {main_pid}, pgid {pgid})")
+ time.sleep(2)
+ try:
+ os.killpg(pgid, signal.SIGKILL)
+ except (ProcessLookupError, PermissionError):
+ pass
+ except Exception as e:
+ if logfn:
+ logfn(f"Note: could not kill existing Desktop: {e}")
+
+# ═══════════════════════════════════════════════════════════════════════
+# AI Monitoring — Self-Healing Watchdog
+# ═══════════════════════════════════════════════════════════════════════
+
+_TIER1_RULES = [
+ ("proxy_health_fail", "restart_proxy", 30),
+ ("proxy_port_conflict", "kill_stale_restart", 60),
+ ("upstream_429", "wait_retry", 0),
+ ("upstream_502_503", "retry_backoff", 30),
+ ("upstream_500_repeat", "switch_provider", 60),
+ ("upstream_timeout", "retry_increase_timeout",30),
+ ("upstream_401_403", "alert_bad_key", 0),
+ ("stream_broken_pipe", "restart_proxy", 30),
+ ("stream_reset", "restart_proxy", 30),
+ ("parsed_tool_calls_0_x3", "clear_schema_cache", 300),
+ ("sanitizer_suspicious_5x","alert_model_issue", 0),
+ ("stuck_recovery_x5", "suggest_switch_model", 0),
+ ("codex_process_dead", "alert_restart", 0),
+ ("schema_corrupt", "delete_provider_caps", 0),
+]
+
+_FAILURE_SIGNALS = {
+ "parsed_tool_calls=0": ("C1", "parser_empty"),
+ "[STUCK-RECOVERY]": ("C3", "stuck_recovery"),
+ "suspicious cmd": ("C4", "sanitizer_flag"),
+ "empty cmd recovered": ("C6", "empty_cmd"),
+ "HTTP 429": ("B1", "rate_limited"),
+ "HTTP 500": ("B2", "server_error"),
+ "HTTP 502": ("B2", "server_error"),
+ "HTTP 503": ("B2", "server_error"),
+ "HTTP 401": ("B3", "auth_failure"),
+ "HTTP 403": ("B4", "forbidden"),
+ "Connection refused": ("A1", "proxy_dead"),
+ "Address already in use": ("A2", "port_conflict"),
+ "Broken pipe": ("B7", "broken_pipe"),
+ "Connection reset": ("B6", "connection_reset"),
+ "timed out": ("B5", "timeout"),
+ "SELF-REVIVE CRASH": ("A5", "proxy_crash"),
+ "stream error": ("B6", "stream_error"),
+ "content_type.*array": ("E1", "schema_corrupt"),
+}
+
+_DIAGNOSTIC_SYSTEM_PROMPT = (
+ 'You are a diagnostic agent for "Codex Launcher" — a desktop app that runs a local '
+ 'translation proxy between OpenAI Codex CLI/Desktop and AI providers.\n\n'
+ 'Analyze the incident and respond with ONLY a JSON object:\n'
+ '{"action": "...", "reason": "...", "confidence": 0.0-1.0}\n\n'
+ 'Available actions: restart_proxy, kill_stale_processes, clear_schema_cache, '
+ 'switch_provider, increase_timeout, regenerate_config, cleanup_stale, '
+ 'alert_user, ignore, retry_now\n\n'
+ 'Rules:\n'
+ '- upstream 401/403 with auth error -> alert_user\n'
+ '- proxy dead -> restart_proxy\n'
+ '- same error 5+ times -> switch_provider or alert_user\n'
+ '- schema/content_type error -> clear_schema_cache\n'
+ '- "Address already in use" -> kill_stale_processes then restart_proxy\n'
+ '- timeout on slow upstream -> increase_timeout\n'
+ '- single transient 429/502/503 -> ignore\n'
+ '- "stream disconnected" + proxy healthy -> ignore\n'
+ '- no extra text, no markdown, just the JSON object'
+)
+
+
+def load_monitoring_config():
+ if MONITORING_FILE.exists():
+ try:
+ return json.loads(MONITORING_FILE.read_text())
+ except Exception:
+ pass
+ return {
+ "enabled": False,
+ "provider_url": "",
+ "model": "",
+ "api_key": "",
+ "health_check_interval_s": 5,
+ "auto_restart_proxy": True,
+ "auto_switch_provider": False,
+ }
+
+
+def save_monitoring_config(cfg):
+ MONITORING_FILE.parent.mkdir(parents=True, exist_ok=True)
+ MONITORING_FILE.write_text(json.dumps(cfg, indent=2))
+
+
+def load_incident_store():
+ if INCIDENT_STORE_FILE.exists():
+ try:
+ return json.loads(INCIDENT_STORE_FILE.read_text())
+ except Exception:
+ pass
+ return {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}
+
+
+def save_incident_store(store):
+ INCIDENT_STORE_FILE.parent.mkdir(parents=True, exist_ok=True)
+ INCIDENT_STORE_FILE.write_text(json.dumps(store, indent=2))
+
+
+def monitoring_log(msg):
+ try:
+ with open(str(MONITORING_LOG), "a") as f:
+ f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
+ except Exception:
+ pass
+
+
+class IncidentStore:
+ def __init__(self):
+ self._store = load_incident_store()
+ self._dirty = False
+
+ def lookup(self, pattern):
+ inc = self._store.get("incidents", {}).get(pattern)
+ if inc and inc.get("success_count", 0) > 0:
+ rate = inc["success_count"] / max(inc["success_count"] + inc.get("fail_count", 0), 1)
+ if rate > 0.5:
+ return inc
+ return None
+
+ def record(self, pattern, fix, success=True):
+ incs = self._store.setdefault("incidents", {})
+ inc = incs.setdefault(pattern, {
+ "fix": fix, "success_count": 0, "fail_count": 0,
+ "last_seen": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "occurrences": 0,
+ })
+ inc["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ inc["occurrences"] = inc.get("occurrences", 0) + 1
+ if success:
+ inc["success_count"] = inc.get("success_count", 0) + 1
+ else:
+ inc["fail_count"] = inc.get("fail_count", 0) + 1
+ self._dirty = True
+
+ def record_ai_call(self, tokens=0):
+ stats = self._store.setdefault("stats", {"ai_calls": 0, "tokens_used": 0})
+ stats["ai_calls"] = stats.get("ai_calls", 0) + 1
+ stats["tokens_used"] = stats.get("tokens_used", 0) + tokens
+ self._dirty = True
+
+ def flush(self):
+ if self._dirty:
+ save_incident_store(self._store)
+ self._dirty = False
+
+ @property
+ def stats(self):
+ return self._store.get("stats", {"ai_calls": 0, "tokens_used": 0})
+
+
+class AIDiagnosticAgent:
+ def __init__(self, provider_url, model, api_key):
+ self.provider_url = provider_url
+ self.model = model
+ self.api_key = api_key
+ self.incident_store = IncidentStore()
+
+ def diagnose(self, context):
+ pattern = self._extract_pattern(context)
+ known = self.incident_store.lookup(pattern)
+ if known:
+ monitoring_log(f"Tier 2 HIT: pattern={pattern} fix={known['fix']}")
+ return {"action": known["fix"], "reason": "known_pattern", "confidence": 0.9, "tier": 2}
+ action = self._call_model(context)
+ if action:
+ self.incident_store.record(pattern, action.get("action", "unknown"))
+ self.incident_store.flush()
+ return action
+
+ def _extract_pattern(self, context):
+ parts = []
+ for k in sorted(context.get("signals", [])):
+ parts.append(k)
+ if context.get("http_code"):
+ parts.append(f"http_{context['http_code']}")
+ return "+".join(parts[:3]) or "unknown"
+
+ def _call_model(self, context):
+ prompt = (
+ f"INCIDENT REPORT:\n"
+ f"Time: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n"
+ f"Proxy health: {context.get('proxy_alive', 'unknown')}\n"
+ f"Upstream: {context.get('upstream_url', 'unknown')}\n"
+ f"Model: {context.get('model', 'unknown')}\n"
+ f"Last HTTP code: {context.get('http_code', 'n/a')}\n"
+ f"Recent signals: {context.get('signals', [])}\n"
+ f"Recent log tail:\n{context.get('log_tail', '')[:1500]}\n"
+ )
+ body = {
+ "model": self.model,
+ "messages": [
+ {"role": "system", "content": _DIAGNOSTIC_SYSTEM_PROMPT},
+ {"role": "user", "content": prompt},
+ ],
+ "max_tokens": 200,
+ "temperature": 0.1,
+ }
+ try:
+ req = urllib.request.Request(
+ self.provider_url,
+ data=json.dumps(body).encode(),
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {self.api_key}",
+ },
+ )
+ resp = urllib.request.urlopen(req, timeout=15)
+ result = json.loads(resp.read())
+ text = result["choices"][0]["message"]["content"].strip()
+ self.incident_store.record_ai_call(tokens=800)
+ action = json.loads(text)
+ action["tier"] = 3
+ monitoring_log(f"Tier 3 AI: action={action.get('action')} reason={action.get('reason')}")
+ return action
+ except Exception as e:
+ monitoring_log(f"Tier 3 AI FAILED: {e}")
+ return {"action": "alert_user", "reason": f"ai_diag_failed: {e}", "confidence": 0.0, "tier": 3}
+
+
+class HealthWatcher(threading.Thread):
+ def __init__(self, on_failure, on_recovery, on_signal, on_action):
+ super().__init__(daemon=True)
+ self.cfg = load_monitoring_config()
+ self.on_failure = on_failure
+ self.on_recovery = on_recovery
+ self.on_signal = on_signal
+ self.on_action = on_action
+ self.failures = 0
+ self.running = False
+ self._signal_counts = collections.defaultdict(int)
+ self._last_actions = {}
+ self._restart_count = 0
+ self._last_restart_time = 0
+
+ def run(self):
+ self.running = True
+ self.incident_store = IncidentStore()
+ self._log_analyzer = _LogAnalyzerThread(self._on_log_signal)
+ self._log_analyzer.start()
+ while self.running:
+ self.cfg = load_monitoring_config()
+ if not self.cfg.get("enabled"):
+ time.sleep(5)
+ continue
+ port = self._get_proxy_port()
+ if port:
+ healthy = self._check_health(port)
+ if healthy:
+ if self.failures > 0:
+ self.failures = 0
+ self.on_recovery()
+ else:
+ self.failures += 1
+ if self.failures >= 3:
+ self._handle_failure("proxy_health_fail")
+ self.incident_store.flush()
+ interval = self.cfg.get("health_check_interval_s", 5)
+ time.sleep(interval)
+
+ def stop(self):
+ self.running = False
+ if hasattr(self, '_log_analyzer'):
+ self._log_analyzer.running = False
+
+ def _get_proxy_port(self):
+ try:
+ cfg_path = PROXY_CONFIG_DIR / "proxy-config.json"
+ if cfg_path.exists():
+ d = json.loads(cfg_path.read_text())
+ return d.get("port")
+ except Exception:
+ pass
+ return None
+
+ def _check_health(self, port):
+ try:
+ req = urllib.request.Request(f"http://localhost:{port}/health")
+ resp = urllib.request.urlopen(req, timeout=5)
+ return resp.status == 200
+ except Exception:
+ return False
+
+ def _on_log_signal(self, fault_id, category, line):
+ self._signal_counts[category] += 1
+ self.on_signal(fault_id, category, line[:200])
+ count = self._signal_counts[category]
+ if category in ("proxy_dead", "port_conflict") and count >= 2:
+ self._handle_failure(category)
+ elif category in ("server_error", "timeout") and count >= 3:
+ self._handle_failure(category + "_repeat")
+ elif category in ("sanitizer_flag",) and count >= 5:
+ self._handle_failure("sanitizer_suspicious_5x")
+ elif category in ("stuck_recovery",) and count >= 5:
+ self._handle_failure("stuck_recovery_x5")
+ elif category in ("parser_empty",) and count >= 3:
+ self._handle_failure("parsed_tool_calls_0_x3")
+ elif category in ("schema_corrupt",):
+ self._handle_failure("schema_corrupt")
+
+ def _handle_failure(self, trigger):
+ now = time.time()
+ for rule_trigger, action, cooldown in _TIER1_RULES:
+ if rule_trigger == trigger:
+ last_t = self._last_actions.get(action, 0)
+ if now - last_t < cooldown:
+ return
+ self._last_actions[action] = now
+ monitoring_log(f"Tier 1: trigger={trigger} action={action}")
+ self.on_action(action, trigger)
+ self.incident_store.record(trigger, action, success=True)
+ return
+ self._try_tier2_3(trigger)
+
+ def _try_tier2_3(self, trigger):
+ cfg = self.cfg
+ if not cfg.get("provider_url") or not cfg.get("model") or not cfg.get("api_key"):
+ monitoring_log(f"No AI configured for Tier 2/3 — alerting user for trigger={trigger}")
+ self.on_action("alert_user", trigger)
+ return
+ agent = AIDiagnosticAgent(cfg["provider_url"], cfg["model"], cfg["api_key"])
+ context = {
+ "signals": [trigger],
+ "proxy_alive": self.failures == 0,
+ "log_tail": self._get_recent_log(),
+ }
+ result = agent.diagnose(context)
+ if result:
+ action = result.get("action", "alert_user")
+ monitoring_log(f"Tier {result.get('tier', '?')}: action={action}")
+ self.on_action(action, trigger)
+
+ def _get_recent_log(self):
+ lines = []
+ for log_name in ["cc-debug.log", "proxy.log"]:
+ log_path = PROXY_CONFIG_DIR / log_name
+ try:
+ text = log_path.read_text()
+ lines.extend(text.splitlines()[-20:])
+ except Exception:
+ pass
+ return "\n".join(lines[-30:])
+
+
+class _LogAnalyzerThread(threading.Thread):
+ def __init__(self, on_signal):
+ super().__init__(daemon=True)
+ self.on_signal = on_signal
+ self.running = False
+
+ def run(self):
+ self.running = True
+ log_paths = [
+ str(PROXY_CONFIG_DIR / "cc-debug.log"),
+ str(PROXY_CONFIG_DIR / "proxy.log"),
+ ]
+ fhs = {}
+ for p in log_paths:
+ try:
+ f = open(p, "r")
+ f.seek(0, 2)
+ fhs[p] = f
+ except Exception:
+ pass
+ while self.running:
+ activity = False
+ for p, fh in list(fhs.items()):
+ try:
+ line = fh.readline()
+ if line:
+ activity = True
+ for pattern, (fault_id, category) in _FAILURE_SIGNALS.items():
+ if re.search(pattern, line):
+ self.on_signal(fault_id, category, line.strip())
+ break
+ except Exception:
+ pass
+ if not activity:
+ time.sleep(0.5)
+
+# ═══════════════════════════════════════════════════════════════════════
+# Usage stats
+# ═══════════════════════════════════════════════════════════════════════
+
+def load_usage_stats():
+ try:
+ if _USAGE_STATS_FILE.exists():
+ return json.loads(_USAGE_STATS_FILE.read_text())
+ except Exception:
+ pass
+ return {"providers": {}, "updated": None}
+
+# ═══════════════════════════════════════════════════════════════════════
+# Default endpoints creation
+# ═══════════════════════════════════════════════════════════════════════
+
+def create_default_endpoints():
+ if not ENDPOINTS_FILE.exists():
+ save_endpoints({
+ "default": "OpenAI",
+ "endpoints": [
+ {"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1",
+ "api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"],
+ "provider_preset": "OpenAI"},
+ {"name": "Z.AI", "backend_type": "openai-compat",
+ "base_url": "https://api.z.ai/api/coding/paas/v4",
+ "api_key": "", "default_model": "glm-5.1",
+ "models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"],
+ "provider_preset": "Custom"},
+ ],
+ })
+
+
+def ensure_dirs():
+ for d in [LOG_DIR, PROXY_CONFIG_DIR]:
+ d.mkdir(parents=True, exist_ok=True)
diff --git a/install.sh b/install.sh
index ae99fff..a98fe33 100755
--- a/install.sh
+++ b/install.sh
@@ -3,11 +3,11 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-if [ -f "$SCRIPT_DIR/codex-launcher_3.10.7_all.deb" ]; then
- echo "Installing codex-launcher_3.10.7_all.deb ..."
- sudo dpkg -i "$SCRIPT_DIR/codex-launcher_3.10.7_all.deb"
+if [ -f "$SCRIPT_DIR/codex-launcher_3.10.8_all.deb" ]; then
+ echo "Installing codex-launcher_3.10.8_all.deb ..."
+ sudo dpkg -i "$SCRIPT_DIR/codex-launcher_3.10.8_all.deb"
echo ""
- echo "Installed v3.10.7 via .deb package."
+ echo "Installed v3.10.8 via .deb package."
echo " translate-proxy.py -> /usr/bin/translate-proxy.py"
echo " codex-launcher-gui -> /usr/bin/codex-launcher-gui"
echo " cleanup-codex-stale -> /usr/bin/cleanup-codex-stale.sh"
diff --git a/src/codex-launcher-gui.py b/src/codex-launcher-gui.py
index a2a87fd..21c75c2 100644
--- a/src/codex-launcher-gui.py
+++ b/src/codex-launcher-gui.py
@@ -1,441 +1,2915 @@
#!/usr/bin/env python3
-"""Codex Launcher GUI (tkinter) — manage endpoints, launch Desktop or CLI with any provider.
+"""Codex Launcher GUI — manage endpoints, launch Desktop or CLI with any provider."""
-Windows-native tkinter GUI mirroring all features of the GTK version.
-Imports process management, config engine, proxy lifecycle from codex_launcher_lib.
-"""
-
-import tkinter as tk
-from tkinter import ttk, filedialog, messagebox, scrolledtext
-import json
-import os
-import shutil
-import socket
-import ssl
-import subprocess
-import sys
-import threading
-import time
-import urllib.error
-import urllib.parse
-import urllib.request
-import base64
-import hashlib
-import secrets
-import http.server
-import collections
+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
-from codex_launcher_lib import (
- IS_WINDOWS, HOME, CONFIG, CONFIG_BAK, CONFIG_TXN,
- ENDPOINTS_FILE, BGP_POOLS_FILE, LAUNCH_LOG, LOG_DIR,
- PROXY_CONFIG_DIR, BIN_DIR, PROXY, CLEANUP, PID_REGISTRY,
- PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH,
- ANTIGRAVITY_MODELS,
- safe_name, label_for_backend, normalize_model_id, normalize_base_url,
- parse_model_list, now_utc_iso, apply_provider_preset,
- load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools,
- get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle,
- backup_config, restore_config, begin_config_transaction, end_config_transaction,
- recover_config_if_needed, write_config_for_native, write_config_for_translated,
- endpoint_models_url, endpoint_model_headers, fetch_models_for_endpoint,
- refresh_endpoint_models, run_endpoint_doctor,
- detect_codex_cli, detect_codex_desktop, check_codex_auth,
- last_log_lines, kill_existing_desktop, safe_cleanup_owned,
- start_proxy_for, stop_proxy, start_bgp_proxy, get_proxy_state, set_proxy_state,
- detect_terminal, open_url, open_file, write_secure_text,
- ensure_dirs, create_default_endpoints,
- load_monitoring_config, save_monitoring_config,
- load_incident_store, save_incident_store, load_usage_stats,
- monitoring_log,
- IncidentStore, AIDiagnosticAgent, HealthWatcher,
- load_oauth_secrets, save_oauth_secrets,
- _usage_theme, UA,
-)
+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.10.4", "2026-05-25", [
+ "OAuth Secrets editor in GUI — update client ID/secret without editing files",
+ "Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
+ ]),
+ ("3.10.3", "2026-05-25", [
+ "Fix Antigravity 404: map display names to verified REST API model IDs",
+ "REST API uses slugs (gemini-3-flash) not display names (Gemini 3.5 Flash)",
+ "Match agy CLI model list: Gemini 3.5 Flash (H/M/L), 3.1 Pro (H/L), Claude 4.6, GPT-OSS",
+ ]),
+ ("3.10.2", "2026-05-25", [
+ "Fetch from API now works for Antigravity — returns current model list",
+ ]),
+ ("3.10.0", "2026-05-25", [
+ "Provider editor: Remove Selected, Clear All, Sync from Preset buttons for model list",
+ "Sync from Preset replaces model list with current preset models",
+ "Stale saved Antigravity models auto-refreshed on preset sync",
+ ]),
+ ("3.9.9", "2026-05-25", [
+ "Refresh Antigravity preset: Gemini 3.5 Flash, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6, GPT-OSS 120B",
+ "Fix Antigravity alias map for new tiered model IDs (high/medium/low/thinking)",
+ "Add model context sizes for Gemini 3.5 Flash, Gemini 3.1 Pro, Claude 4.6, GPT-OSS 120B",
+ ]),
+ ("3.9.8", "2026-05-25", [
+ "Fix Codex Desktop sending wrong model (gpt-5.4-mini) instead of selected model",
+ "Proxy remaps Desktop forced models to user-selected model via CODEX_LAUNCHER_MODEL",
+ "Write review_model + wire_api + retries to config.toml for Desktop compatibility",
+ "send_json() globally catches BrokenPipeError — no more crashes on disconnect",
+ ]),
+ ("3.9.7", "2026-05-25", [
+ "Forward real Codebuff error messages to user (not generic 429)",
+ "Return HTTP 200 with Responses API format for rate limits so Codex displays message",
+ "Extract retryAfterMs from Codebuff 429 responses for accurate cooldown",
+ "RateLimitError carries upstream message through session + chat error paths",
+ "BrokenPipeError crash fix on 'all accounts exhausted' response",
+ "Fix 3 SyntaxWarnings for invalid escape sequences in docstrings",
+ "_codebuff_start_run returns actual error body instead of None",
+ ]),
+ ("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 codebuff, Google OAuth, API keys",
+ "/v1/accounts endpoint for account pool status",
+ ]),
+ ("3.9.0", "2026-05-24", [
+ "Multi-account rotation for OAuth providers (codebuff, Google, API keys)",
+ "Automatic failover: when one account hits rate limit, next is used",
+ "Codebuff: 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-codebuff-model and x-codebuff-instance-id headers",
+ ]),
+ ("3.8.4", "2026-05-24", [
+ "FIXED: Codebuff streaming — SSE events now reach Codex client",
+ "Root cause: stream_buffered_events was never called for codebuff",
+ "Codebuff stream uses buffered flushing (30ms / 4KB / urgent)",
+ "Codebuff OAuth — built-in login flow (no external CLI needed)",
+ "Codebuff API: reverse-engineered www.codebuff.com endpoints",
+ "Codebuff session management with instance ID (waiting room)",
+ "Codebuff agent run lifecycle (start/finish) with model routing",
+ "Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
+ "Reasoning mode works with codebuff (thinking tokens supported)",
+ "GUI: Sandbox mode selector (Read-only / Workspace / Full Access)",
+ "GUI: Approval mode selector (Untrusted / On Request / Full Auto)",
+ "GUI: Codebuff Login button in endpoint editor",
+ "Fixed _STATS undefined error in /health endpoint",
+ "Fixed codebuff credential path (reads default account)",
+ ]),
+ ("3.8.1", "2026-05-24", [
+ "Codebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
+ "Codebuff 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",
+ ]),
+]
-# ═══════════════════════════════════════════════════════════════════════
-# Helpers
-# ═══════════════════════════════════════════════════════════════════════
+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://cloudcode-pa.googleapis.com",
+ "oauth_provider": "google-antigravity",
+ "models": [
+ "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)",
+ "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)",
+ "Claude Sonnet 4.6 (Thinking)",
+ "Claude Opus 4.6 (Thinking)",
+ "GPT-OSS 120B (Medium)",
+ ],
+ },
+ "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",
+ ],
+ },
+ "Codebuff (Free DeepSeek/Kimi)": {
+ "backend_type": "codebuff",
+ "base_url": "https://www.codebuff.com",
+ "oauth_provider": "codebuff",
+ "models": [
+ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
+ "moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
+ ],
+ },
+ "Freebuff (Free DeepSeek/Kimi)": {
+ "backend_type": "codebuff",
+ "base_url": "https://www.codebuff.com",
+ "oauth_provider": "codebuff",
+ "models": [
+ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
+ "moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
+ ],
+ },
+ "FreeBuff": {
+ "backend_type": "codebuff",
+ "base_url": "https://www.codebuff.com",
+ "oauth_provider": "codebuff",
+ "models": [
+ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
+ "moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
+ ],
+ },
+}
-def _fmt_tok(n):
- if n >= 1_000_000:
- return f"{n/1_000_000:.1f}M"
- if n >= 1_000:
- return f"{n/1_000:.1f}K"
- return str(n)
+def 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",
+ "codebuff": "Codebuff (Free AI)",
+ "native": "Native",
+ }.get(backend_type, backend_type)
-def _fmt_dur(s):
- if s >= 3600:
- return f"{s/3600:.1f}h"
- if s >= 60:
- return f"{s/60:.1f}m"
- return f"{s:.1f}s"
+def 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 _status_pill(success_rate, fail_pct):
- U = _usage_theme()
- if fail_pct > 0.15:
- return ("ERR", U["red"])
- if fail_pct > 0.05:
- return ("WARN", U["yellow"])
- return ("OK", U["green"])
+def 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 _show_doctor_results_tk(parent, ep_name, checks):
- dlg = tk.Toplevel(parent)
- dlg.title(f"Doctor: {ep_name}")
- dlg.geometry("520x420")
- dlg.transient(parent)
- dlg.grab_set()
+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 = tk.Label(dlg, text=f"{ep_name} {passed} passed {failed} failed {warned} warnings",
- font=("Segoe UI", 10, "bold"))
- hdr.pack(padx=12, pady=(12, 4), anchor="w")
-
- ttk.Separator(dlg).pack(fill="x", padx=12)
-
- canvas = tk.Canvas(dlg)
- scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview)
- inner = tk.Frame(canvas)
- inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
- canvas.create_window((0, 0), window=inner, anchor="nw")
- canvas.configure(yscrollcommand=scrollbar.set)
-
+ 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 = tk.Frame(inner)
- row.pack(fill="x", padx=12, pady=1)
+ row = Gtk.Box(spacing=6)
if ok is True:
- color, sym = "#27ae60", "✓"
+ color, sym = "#27ae60", "\u2713"
elif ok is False:
- color, sym = "#e74c3c", "✗"
+ color, sym = "#e74c3c", "\u2717"
else:
- color, sym = "#f39c12", "○"
- tk.Label(row, text=sym, fg=color, font=("Segoe UI", 11, "bold")).pack(side="left")
- tk.Label(row, text=name, font=("Segoe UI", 9, "bold")).pack(side="left", padx=(4, 0))
+ 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:
- tk.Label(row, text=detail, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="right")
+ 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()
- canvas.pack(side="left", fill="both", expand=True, padx=(12, 0), pady=6)
- scrollbar.pack(side="right", fill="y", pady=6)
+def endpoint_models_url(endpoint):
+ base = normalize_base_url(endpoint.get("base_url") or "")
+ if not base:
+ return ""
+ return f"{base}/models"
- btn_frame = tk.Frame(dlg)
- btn_frame.pack(pady=(0, 10))
- ttk.Button(btn_frame, text="Close", command=dlg.destroy).pack()
+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
+
+_ANTIGRAVITY_MODELS = [
+ "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)",
+ "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)",
+ "Claude Sonnet 4.6 (Thinking)",
+ "Claude Opus 4.6 (Thinking)",
+ "GPT-OSS 120B (Medium)",
+]
+
+def fetch_models_for_endpoint(endpoint, timeout=10):
+ bt = endpoint.get("backend_type", "")
+ if bt == "gemini-oauth-antigravity":
+ return list(_ANTIGRAVITY_MODELS), None
+ url = endpoint_models_url(endpoint)
+ if not url:
+ return None, "Base URL is empty"
+ try:
+ req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint))
+ raw = urllib.request.urlopen(req, timeout=timeout).read()
+ payload = json.loads(raw)
+ items = payload.get("data") or payload.get("models") or []
+ ids = []
+ seen = set()
+ for item in items:
+ mid = item.get("id") if isinstance(item, dict) else None
+ if mid and mid not in seen:
+ seen.add(mid)
+ ids.append(mid)
+ if not ids:
+ return None, "No models returned"
+ return ids, None
+ except Exception as e:
+ return None, str(e)
+
+def refresh_endpoint_models(endpoint):
+ ids, err = fetch_models_for_endpoint(endpoint)
+ if not ids:
+ return None, err
+ updated = dict(endpoint)
+ updated["models"] = ids
+ if updated.get("default_model") not in ids:
+ updated["default_model"] = ids[0]
+ return updated, None
+
+# ═══════════════════════════════════════════════════════════════════
+# 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'review_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'wire_api = "responses"\n',
+ f'request_max_retries = 1\n',
+ f'stream_max_retries = 0\n',
+ f'stream_idle_timeout_ms = 600000\n',
+ f'\n[profiles."{endpoint["name"]}"]\n',
+ f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
+ f'model = "{_toml_safe(selected_model)}"\n',
+ f'review_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
-# ═══════════════════════════════════════════════════════════════════════
-# EditEndpointDialog
-# ═══════════════════════════════════════════════════════════════════════
+class IncidentStore:
+ def __init__(self):
+ self._store = _load_incident_store()
+ self._dirty = False
-class EditEndpointDialog:
- def __init__(self, parent, existing_name=None):
- self.result = False
- self._existing_name = existing_name
- self._parent_mgr = parent
+ 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
- if existing_name:
- self._data = get_endpoint(existing_name) or {}
+ 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:
- self._data = {
- "name": "", "backend_type": "openai-compat",
- "base_url": "", "api_key": "", "default_model": "",
- "models": [], "provider_preset": "Custom",
- }
+ inc["fail_count"] = inc.get("fail_count", 0) + 1
+ self._dirty = True
- self._dlg = tk.Toplevel(parent)
- title = "Edit Endpoint" if existing_name else "Add Endpoint"
- self._dlg.title(title)
- self._dlg.geometry("520x600")
- self._dlg.transient(parent)
- self._dlg.grab_set()
+ 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
- main = ttk.Frame(self._dlg, padding=12)
- main.pack(fill="both", expand=True)
+ def flush(self):
+ if self._dirty:
+ _save_incident_store(self._store)
+ self._dirty = False
- grid = ttk.Frame(main)
- grid.pack(fill="x")
+ @property
+ def stats(self):
+ return self._store.get("stats", {"ai_calls": 0, "tokens_used": 0})
- row_idx = [0]
- def add_field(label, widget_factory):
- ttk.Label(grid, text=label).grid(row=row_idx[0], column=0, sticky="e", padx=(0, 6), pady=2)
- w = widget_factory()
- w.grid(row=row_idx[0], column=1, sticky="ew", pady=2)
- row_idx[0] += 1
- return w
+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()
- self._entry_name = add_field("Name:", lambda: ttk.Entry(grid))
- self._entry_name.insert(0, self._data.get("name", ""))
+ 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
- self._combo_preset = ttk.Combobox(grid, values=list(PROVIDER_PRESETS.keys()), state="readonly")
- preset = self._data.get("provider_preset", "Custom")
- self._combo_preset.set(preset)
- add_field("Preset:", lambda: self._combo_preset)
- self._combo_preset.bind("<>", lambda e: self._apply_selected_preset(initial=False))
+ 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"
- backend_types = [
- ("openai-compat", "OpenAI-compatible (needs proxy)"),
- ("anthropic", "Anthropic (needs proxy)"),
- ("command-code", "Command Code (needs proxy)"),
- ("freebuff", "Freebuff - Free DeepSeek/Kimi (needs proxy)"),
- ("gemini-oauth-cli", "Gemini CLI OAuth (needs proxy)"),
- ("gemini-oauth-antigravity", "Antigravity OAuth (needs proxy)"),
- ("native", "Native OpenAI (no proxy)"),
- ]
- self._combo_type = ttk.Combobox(grid, values=[f"{v} - {l}" for v, l in backend_types], state="readonly")
- bt = self._data.get("backend_type", "openai-compat")
- bt_display = next((f"{v} - {l}" for v, l in backend_types if v == bt), backend_types[0][0] + " - " + backend_types[0][1])
- self._combo_type.set(bt_display)
- add_field("Type:", lambda: self._combo_type)
- self._bt_map = {f"{v} - {l}": v for v, l in backend_types}
-
- self._entry_url = add_field("Base URL:", lambda: ttk.Entry(grid))
- self._entry_url.insert(0, self._data.get("base_url", ""))
-
- key_frame = ttk.Frame(grid)
- self._entry_key = ttk.Entry(key_frame, show="*")
- self._entry_key.pack(side="left", fill="x", expand=True)
- self._entry_key.insert(0, self._data.get("api_key", ""))
- self._reveal_var = tk.BooleanVar(value=False)
- ttk.Checkbutton(key_frame, text="Show", variable=self._reveal_var,
- command=lambda: self._entry_key.configure(show="" if self._reveal_var.get() else "*")).pack(side="left", padx=(4, 0))
- self._oauth_btn = ttk.Button(key_frame, text="OAuth Login", command=self._do_oauth_login)
- self._oauth_btn.pack(side="left", padx=(4, 0))
- add_field("API Key:", lambda: key_frame)
-
- self._entry_cc_ver = add_field("CC Version:", lambda: ttk.Entry(grid))
- self._entry_cc_ver.insert(0, self._data.get("cc_version", ""))
-
- reason_frame = ttk.Frame(grid)
- self._reason_var = tk.BooleanVar(value=self._data.get("reasoning_enabled", True))
- self._reason_cb = ttk.Checkbutton(reason_frame, text="Reasoning ON", variable=self._reason_var,
- command=self._on_reasoning_toggled)
- self._reason_cb.pack(side="left")
- self._combo_effort = ttk.Combobox(reason_frame, values=["none", "minimal", "low", "medium", "high", "max"],
- state="readonly", width=10)
- self._combo_effort.set(self._data.get("reasoning_effort", "medium"))
- self._combo_effort.pack(side="left", padx=(8, 0))
- ttk.Label(reason_frame, text="Effort").pack(side="left", padx=(4, 0))
- add_field("Reasoning:", lambda: reason_frame)
- self._on_reasoning_toggled()
-
- enhancer_frame = ttk.Frame(grid)
- self._enhancer_var = tk.BooleanVar(value=self._data.get("prompt_enhancer", False))
- self._enhancer_cb = ttk.Checkbutton(enhancer_frame, text="Prompt Enhancer", variable=self._enhancer_var, command=self._on_enhancer_toggled)
- self._enhancer_cb.pack(side="left")
- self._enhancer_status_lbl = ttk.Label(enhancer_frame, text="", foreground="gray")
- self._enhancer_status_lbl.pack(side="left", padx=(6, 0))
- self._enhancer_mode = ttk.Combobox(enhancer_frame, values=["offline", "ai-powered"], state="readonly", width=10)
- self._enhancer_mode.set(self._data.get("prompt_enhancer_mode", "offline"))
- self._enhancer_mode.pack(side="left", padx=(8, 0))
- add_field("Prompt Enhancer:", lambda: enhancer_frame)
- self._on_enhancer_toggled()
-
- self._entry_enhancer_model = ttk.Entry(grid)
- self._entry_enhancer_model.insert(0, self._data.get("prompt_enhancer_model", ""))
- add_field("Enhancer Model:", lambda: self._entry_enhancer_model)
-
- self._entry_enhancer_url = ttk.Entry(grid)
- self._entry_enhancer_url.insert(0, self._data.get("prompt_enhancer_url", ""))
- add_field("Enhancer URL:", lambda: self._entry_enhancer_url)
-
- self._entry_enhancer_key = ttk.Entry(grid, show="*")
- self._entry_enhancer_key.insert(0, self._data.get("prompt_enhancer_key", ""))
- add_field("Enhancer Key:", lambda: self._entry_enhancer_key)
-
- grid.columnconfigure(1, weight=1)
-
- ttk.Label(main, text="Models:").pack(anchor="w", pady=(8, 2))
-
- model_input_frame = ttk.Frame(main)
- model_input_frame.pack(fill="x")
- self._entry_model = ttk.Entry(model_input_frame)
- self._entry_model.pack(side="left", fill="x", expand=True)
- ttk.Button(model_input_frame, text="Add", command=self._add_model).pack(side="left", padx=(4, 0))
- ttk.Button(model_input_frame, text="Bulk Add", command=self._add_models_from_text).pack(side="left", padx=(4, 0))
- ttk.Button(model_input_frame, text="Fetch from API", command=self._fetch_models).pack(side="left", padx=(4, 0))
- ttk.Button(model_input_frame, text="Sync from Preset", command=lambda: self._apply_selected_preset_force()).pack(side="left", padx=(4, 0))
- ttk.Button(model_input_frame, text="Test Endpoint", command=self._diagnose_endpoint).pack(side="left", padx=(4, 0))
-
- ttk.Label(main, text="Bulk add (one per line or comma-separated):").pack(anchor="w", pady=(4, 0))
- self._bulk_text = tk.Text(main, height=3, wrap="word")
- self._bulk_text.pack(fill="x", pady=(2, 4))
-
- list_frame = ttk.Frame(main)
- list_frame.pack(fill="both", expand=True)
- self._model_listbox = tk.Listbox(list_frame, height=6)
- sb = ttk.Scrollbar(list_frame, orient="vertical", command=self._model_listbox.yview)
- self._model_listbox.configure(yscrollcommand=sb.set)
- self._model_listbox.pack(side="left", fill="both", expand=True)
- sb.pack(side="right", fill="y")
- self._model_listbox.bind("", lambda e: self._remove_selected_model())
- for m in self._data.get("models", []):
- self._model_listbox.insert("end", m)
-
- default_frame = ttk.Frame(main)
- default_frame.pack(fill="x", pady=(4, 0))
- ttk.Label(default_frame, text="Default Model:").pack(side="left")
- self._combo_default = ttk.Combobox(default_frame, state="readonly")
- self._combo_default.pack(side="left", fill="x", expand=True, padx=(6, 0))
- self._refresh_default_combo()
- dm = self._data.get("default_model", "")
- if dm:
- self._combo_default.set(dm)
-
- self._apply_selected_preset(initial=True)
-
- btn_frame = ttk.Frame(main)
- btn_frame.pack(fill="x", pady=(8, 0))
- ttk.Button(btn_frame, text="Cancel", command=self._cancel).pack(side="right")
- ttk.Button(btn_frame, text="Save", command=self._save).pack(side="right", padx=(8, 0))
-
- def _on_reasoning_toggled(self):
- state = "readonly" if self._reason_var.get() else "disabled"
- self._combo_effort.configure(state=state)
-
- def _on_enhancer_toggled(self):
- if self._enhancer_var.get():
- self._enhancer_status_lbl.configure(text="ON", foreground="#2ea043")
- else:
- self._enhancer_status_lbl.configure(text="OFF", foreground="#888888")
-
- def _apply_selected_preset(self, initial=False):
- preset_name = self._combo_preset.get() or "Custom"
- preset = PROVIDER_PRESETS.get(preset_name, {})
- is_oauth = bool(preset.get("oauth_provider"))
- self._oauth_btn.configure(state="normal" if is_oauth else "disabled")
-
- if not initial or self._existing_name is None:
- bt = preset.get("backend_type", "openai-compat")
- bt_display = next((k for k, v in self._bt_map.items() if v == bt), list(self._bt_map.keys())[0])
- self._combo_type.set(bt_display)
- self._entry_url.delete(0, "end")
- self._entry_url.insert(0, preset.get("base_url", ""))
- cc_ver = preset.get("cc_version", "")
- if cc_ver and not self._entry_cc_ver.get().strip():
- self._entry_cc_ver.delete(0, "end")
- self._entry_cc_ver.insert(0, cc_ver)
- if preset.get("models") and self._model_listbox.size() == 0:
- self._model_listbox.delete(0, "end")
- for mid in preset["models"]:
- self._model_listbox.insert("end", mid)
- self._refresh_default_combo()
- if preset["models"]:
- self._combo_default.set(preset["models"][0])
-
- def _apply_selected_preset_force(self):
- preset_name = self._combo_preset.get() or "Custom"
- preset = PROVIDER_PRESETS.get(preset_name, {})
- bt = preset.get("backend_type", "openai-compat")
- bt_display = next((k for k, v in self._bt_map.items() if v == bt), list(self._bt_map.keys())[0])
- self._combo_type.set(bt_display)
- self._entry_url.delete(0, "end")
- self._entry_url.insert(0, preset.get("base_url", ""))
- cc_ver = preset.get("cc_version", "")
- if cc_ver:
- self._entry_cc_ver.delete(0, "end")
- self._entry_cc_ver.insert(0, cc_ver)
- if preset.get("models"):
- self._model_listbox.delete(0, "end")
- for mid in preset["models"]:
- self._model_listbox.insert("end", mid)
- self._refresh_default_combo()
- if preset["models"]:
- self._combo_default.set(preset["models"][0])
-
- def _add_model(self):
- m = normalize_model_id(self._entry_model.get())
- if m:
- self._model_listbox.insert("end", m)
- self._refresh_default_combo()
- self._entry_model.delete(0, "end")
-
- def _add_models_from_text(self):
- text = self._bulk_text.get("1.0", "end")
- models = parse_model_list(text)
- existing = set(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
- for mid in models:
- if mid not in existing:
- self._model_listbox.insert("end", mid)
- self._bulk_text.delete("1.0", "end")
- self._refresh_default_combo()
-
- def _remove_selected_model(self):
- sel = self._model_listbox.curselection()
- if sel:
- self._model_listbox.delete(sel[0])
- self._refresh_default_combo()
-
- def _refresh_default_combo(self):
- models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
- current = self._combo_default.get()
- self._combo_default["values"] = models
- if current in models:
- self._combo_default.set(current)
- elif models:
- self._combo_default.set(models[0])
- else:
- self._combo_default.set("")
-
- def _fetch_models(self):
- ep = self._make_endpoint_snapshot()
- ids, err = fetch_models_for_endpoint(ep)
- if ids:
- existing = set(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
- for mid in ids:
- if mid not in existing:
- self._model_listbox.insert("end", mid)
- self._refresh_default_combo()
- else:
- messagebox.showerror("Fetch Models", f"Failed:\n{err}", parent=self._dlg)
-
- def _diagnose_endpoint(self):
- ep = self._make_endpoint_snapshot()
- wait = tk.Toplevel(self._dlg)
- wait.title("Running Doctor...")
- wait.geometry("280x80")
- wait.transient(self._dlg)
- wait.grab_set()
- tk.Label(wait, text="Running endpoint diagnostics...").pack(expand=True)
-
- def _run():
- checks = run_endpoint_doctor(ep)
- self._dlg.after(0, lambda: (wait.destroy(), _show_doctor_results_tk(self._dlg, ep.get("default_model", "endpoint"), checks)))
-
- threading.Thread(target=_run, daemon=True).start()
-
- def _make_endpoint_snapshot(self):
- bt_display = self._combo_type.get()
- bt = self._bt_map.get(bt_display, "openai-compat")
- return {
- "base_url": self._entry_url.get().strip(),
- "api_key": self._entry_key.get().strip(),
- "backend_type": bt,
- "default_model": self._combo_default.get() or "",
+ def _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}
- def _do_oauth_login(self):
- preset_name = self._combo_preset.get() or "Custom"
- preset = PROVIDER_PRESETS.get(preset_name, {})
- provider = preset.get("oauth_provider", "")
- if provider == "codebuff":
- self._codebuff_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 = str(PROXY_CONFIG_DIR / ("google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json"))
+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
- _sec = load_oauth_secrets().get("antigravity" if is_antigravity else "gemini_cli", {})
- CLIENT_ID = _sec.get("client_id", "")
- CLIENT_SECRET = _sec.get("client_secret", "")
+ 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
+# ═══════════════════════════════════════════════════════════════════
+
+def _oauth_discover_project(access_token, token_path, tokens):
+ project_id = ""
+ try:
+ lr = urllib.request.Request(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ data=json.dumps({}).encode(),
+ headers={"Content-Type": "application/json",
+ "Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ lresp = urllib.request.urlopen(lr, timeout=15)
+ ldata = json.loads(lresp.read())
+ p = ldata.get("cloudaicompanionProject", "")
+ if isinstance(p, dict):
+ project_id = p.get("id", "")
+ elif isinstance(p, str):
+ project_id = p
+ except Exception:
+ pass
+ if not project_id:
+ return ""
+ try:
+ test_url = f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={project_id}"
+ test_req = urllib.request.Request(test_url,
+ headers={"Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ urllib.request.urlopen(test_req, timeout=10)
+ except urllib.error.HTTPError as e:
+ if e.code == 403 and "SERVICE_DISABLED" in (e.read().decode()[:500]):
+ print(f"[oauth] project {project_id} has API disabled, searching for valid project...", file=sys.stderr)
+ try:
+ list_req = urllib.request.Request(
+ "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE",
+ headers={"Authorization": f"Bearer {access_token}"})
+ list_resp = urllib.request.urlopen(list_req, timeout=15)
+ projects = json.loads(list_resp.read()).get("projects", [])
+ for proj in projects:
+ pid = proj.get("projectId", "")
+ if not pid or pid == project_id:
+ continue
+ try:
+ t2 = urllib.request.Request(
+ f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={pid}",
+ headers={"Authorization": f"Bearer {access_token}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ urllib.request.urlopen(t2, timeout=10)
+ project_id = pid
+ print(f"[oauth] found working project: {pid}", file=sys.stderr)
+ break
+ except Exception:
+ continue
+ except Exception:
+ pass
+ tokens["project_id"] = project_id
+ with open(token_path, "w") as f:
+ json.dump(tokens, f, indent=2)
+ os.chmod(token_path, 0o600)
+ return project_id
+
+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.10.7")
+ 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)
+ oauth_btn = Gtk.Button(label="OAuth Secrets")
+ oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
+ hdr.pack_end(oauth_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._clear_log_btn = Gtk.Button(label="Clear Log")
+ self._clear_log_btn.connect("clicked", lambda b: self._buf.set_text(""))
+ bb.pack_start(self._clear_log_btn, False, False, 0)
+ self._restart_btn = Gtk.Button(label="Restart Proxy")
+ self._restart_btn.connect("clicked", lambda b: self._manual_restart_proxy())
+ self._restart_btn.set_sensitive(False)
+ bb.pack_start(self._restart_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)
+ self._restart_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 _manual_restart_proxy(self):
+ self._kill()
+ time.sleep(1)
+ try:
+ ep_name = load_endpoints().get("default")
+ if not ep_name:
+ self.log("No default endpoint set")
+ return
+ for ep in load_endpoints().get("endpoints", []):
+ if ep.get("name") == ep_name:
+ self._start_proxy(ep)
+ self.log("Proxy restarted")
+ break
+ except Exception as e:
+ self.log(f"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…")
+ os.environ["CODEX_LAUNCHER_MODEL"] = model
+ try:
+ proxy_port = _start_proxy_for(ep, self.log)
+ except RuntimeError as e:
+ GLib.idle_add(self._show_error_dialog, "Proxy startup failed", str(e))
+ return
+ self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})…")
+ begin_config_transaction(f"launch:{ep['name']}")
+ write_config_for_translated(ep, model, proxy_port)
+ else:
+ self.log(f"Configuring Codex for {ep['name']} (native)…")
+ begin_config_transaction(f"launch:{ep['name']}")
+ write_config_for_native(ep, model)
+
+ if target == "desktop":
+ 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()
+
+ def _google_reoauth(self, provider, parent_dlg=None):
+ import http.server
+ is_antigravity = provider == "google-antigravity"
+ sec_key = "antigravity" if is_antigravity else "gemini_cli"
+ _sp = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json")
+ try:
+ with open(_sp) as _f:
+ _secrets_data = json.load(_f)
+ except Exception:
+ _secrets_data = {}
+ sec = _secrets_data.get(sec_key, {})
+ CLIENT_ID = sec.get("client_id", "")
+ CLIENT_SECRET = sec.get("client_secret", "")
+ if not CLIENT_ID or not CLIENT_SECRET:
+ self._show_error_dialog("Missing OAuth secrets",
+ f"No client_id/client_secret for {sec_key}.\nSet them in OAuth Secrets first.")
+ return
+ token_file = "google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json"
+ token_path = os.path.expanduser(f"~/.cache/codex-proxy/{token_file}")
+ provider_kind = "antigravity" if is_antigravity else "cli"
if is_antigravity:
SCOPES = [
@@ -448,8 +2922,962 @@ class EditEndpointDialog:
port = 51121
redirect_uri = f"http://localhost:{port}/oauth-callback"
callback_path = "/oauth-callback"
+ else:
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ ]
+ 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"
+ callback_path = "/oauth2callback"
+
+ state = secrets.token_hex(32)
+ verifier = secrets.token_urlsafe(64)
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+
+ scope_str = " ".join(SCOPES)
+ auth_url = (
+ f"https://accounts.google.com/o/oauth2/v2/auth?"
+ f"client_id={CLIENT_ID}"
+ f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
+ f"&response_type=code"
+ f"&scope={urllib.parse.quote(scope_str)}"
+ f"&access_type=offline"
+ f"&prompt=select_account%20consent"
+ f"&state={state}"
+ f"&code_challenge={challenge}"
+ f"&code_challenge_method=S256"
+ )
+
+ oauth_dlg = Gtk.Dialog(title=f"Re-OAuth: {'Antigravity' if is_antigravity else 'Gemini CLI'}", parent=parent_dlg or self, modal=True)
+ oauth_dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ oauth_dlg.set_default_size(520, 200)
+ ca = oauth_dlg.get_content_area()
+ ca.set_margin_start(12)
+ ca.set_margin_end(12)
+ ca.set_spacing(6)
+ ca.pack_start(Gtk.Label(label=f"Re-authenticating {'Antigravity' if is_antigravity else 'Gemini CLI'}", use_markup=True, xalign=0), False, False, 0)
+ link_lbl = Gtk.Label(label="Click here to open Google authorization", use_markup=True, xalign=0)
+ link_lbl.set_markup(f'Click here to open Google authorization')
+ ca.pack_start(link_lbl, False, False, 4)
+ status_lbl = Gtk.Label(label="Waiting for browser callback...", xalign=0)
+ ca.pack_start(status_lbl, False, False, 4)
+ ca.show_all()
+
+ code_holder = [None]
+ error_holder = [None]
+
+ class OAuthHandler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self2):
+ qs = urllib.parse.urlparse(self2.path).query
+ params = urllib.parse.parse_qs(qs)
+ if "code" in params:
+ if params.get("state", [None])[0] != state:
+ self2.send_response(400)
+ self2.end_headers()
+ self2.wfile.write(b"CSRF state mismatch")
+ error_holder[0] = "CSRF state mismatch"
+ return
+ code_holder[0] = params["code"][0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini")
+ self2.end_headers()
+ else:
+ error_holder[0] = params.get("error", ["unknown"])[0]
+ self2.send_response(302)
+ self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
+ self2.end_headers()
+ def log_message(self2, fmt, *args):
+ pass
+
+ try:
+ bind_host = "localhost" if is_antigravity else "127.0.0.1"
+ server = http.server.HTTPServer((bind_host, port), OAuthHandler)
+ except OSError:
+ status_lbl.set_text(f"Port {port} in use — close other apps and retry.")
+ oauth_dlg.run()
+ oauth_dlg.destroy()
+ return
+
+ def _wait():
+ deadline = time.time() + 120
+ while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
+ server.handle_request()
+ server.server_close()
+ if code_holder[0]:
+ try:
+ tok_data = urllib.parse.urlencode({
+ "code": code_holder[0], "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
+ "redirect_uri": redirect_uri, "grant_type": "authorization_code",
+ "code_verifier": verifier,
+ }).encode()
+ req = urllib.request.Request("https://oauth2.googleapis.com/token", data=tok_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
+ resp = urllib.request.urlopen(req, timeout=30)
+ tokens = json.loads(resp.read())
+ tokens["client_id"] = CLIENT_ID
+ tokens["client_secret"] = CLIENT_SECRET
+ tokens["provider_kind"] = provider_kind
+ tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
+ os.makedirs(os.path.dirname(token_path), exist_ok=True)
+ with open(token_path, "w") as f:
+ json.dump(tokens, f, indent=2)
+ os.chmod(token_path, 0o600)
+ project_id = _oauth_discover_project(tokens["access_token"], token_path, tokens)
+ def _on_success():
+ status_lbl.set_text(f"Authorization successful! Project: {project_id or 'none'}")
+ GLib.timeout_add_seconds(2, lambda: oauth_dlg.destroy())
+ return False
+ GLib.idle_add(_on_success)
+ except Exception as e:
+ def _on_err(exc=str(e)):
+ status_lbl.set_text(f"Token exchange failed: {exc[:200]}")
+ return False
+ GLib.idle_add(_on_err)
+ else:
+ def _on_fail(err=error_holder[0]):
+ status_lbl.set_text(f"Failed: {err or 'No code received'}")
+ return False
+ GLib.idle_add(_on_fail)
+
+ webbrowser.open(auth_url)
+ threading.Thread(target=_wait, daemon=True).start()
+ oauth_dlg.run()
+ oauth_dlg.destroy()
+
+ def _codebuff_reoauth(self):
+ self._codebuff_oauth_standalone()
+
+ def _codebuff_oauth_standalone(self):
+ import uuid
+ dlg = Gtk.Dialog(title="Freebuff / Codebuff Login", parent=self, modal=True)
+ dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ dlg.set_default_size(500, 240)
+ 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 GitHub via Codebuff", use_markup=True, xalign=0), False, False, 0)
+ status_lbl = Gtk.Label(label="Requesting login URL…", xalign=0)
+ status_lbl.set_line_wrap(True)
+ status_lbl.set_max_width_chars(60)
+ area.pack_start(status_lbl, False, False, 4)
+ link_lbl = Gtk.Label(xalign=0)
+ link_lbl.set_line_wrap(True)
+ link_lbl.set_max_width_chars(60)
+ area.pack_start(link_lbl, False, False, 4)
+ spinner = Gtk.Spinner()
+ spinner.start()
+ area.pack_start(spinner, False, False, 8)
+ area.show_all()
+ link_lbl.set_visible(False)
+ result = {"success": False, "user": None, "error": None}
+
+ def _thread():
+ try:
+ fp_id = str(uuid.uuid4())
+ body = json.dumps({"fingerprintId": fp_id}).encode()
+ req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code",
+ data=body, headers={"Content-Type": "application/json", "User-Agent": "codex-launcher/3.10.7"})
+ resp = urllib.request.urlopen(req, timeout=30)
+ rdata = json.loads(resp.read())
+ login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "")
+ fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "")
+ expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0)
+ if not login_url:
+ result["error"] = "No login URL"
+ GLib.idle_add(_done)
+ return
+ GLib.idle_add(lambda: (status_lbl.set_text("Open this URL in your browser:"),
+ link_lbl.set_markup(f'{login_url}'),
+ link_lbl.set_visible(True)))
+ webbrowser.open(login_url)
+ poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}"
+ deadline = time.time() + 300
+ while time.time() < deadline:
+ time.sleep(2)
+ try:
+ pr = urllib.request.Request(poll, headers={"User-Agent": "codex-launcher/3.10.7"})
+ pd = json.loads(urllib.request.urlopen(pr, timeout=10).read())
+ if pd.get("user", {}).get("authToken"):
+ result["success"] = True
+ result["user"] = pd["user"]
+ GLib.idle_add(_done)
+ return
+ except Exception:
+ pass
+ result["error"] = "Timed out"
+ except Exception as e:
+ result["error"] = str(e)[:200]
+ GLib.idle_add(_done)
+
+ def _done():
+ spinner.stop()
+ if result["success"] and result["user"]:
+ u = result["user"]
+ cp = os.path.expanduser("~/.config/manicode/credentials.json")
+ os.makedirs(os.path.dirname(cp), exist_ok=True)
+ creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""),
+ "email": u.get("email", ""), "authToken": u.get("authToken", ""),
+ "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}}
+ with open(cp, "w") as f:
+ json.dump(creds, f, indent=2)
+ os.chmod(cp, 0o600)
+ status_lbl.set_text(f"Logged in as {u.get('email', 'OK')}")
+ link_lbl.set_visible(False)
+ GLib.timeout_add_seconds(2, dlg.destroy)
+ else:
+ status_lbl.set_text(f"Failed: {result.get('error', 'unknown')}")
+
+ threading.Thread(target=_thread, daemon=True).start()
+ dlg.connect("response", lambda d, r: d.destroy())
+ dlg.run()
+
+ def _edit_oauth_secrets(self):
+ secrets_path = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json")
+ try:
+ with open(secrets_path) as f:
+ data = json.load(f)
+ except Exception:
+ data = {"antigravity": {"client_id": "", "client_secret": ""},
+ "gemini_cli": {"client_id": "", "client_secret": ""}}
+
+ dlg = Gtk.Dialog(title="OAuth Secrets & Credentials", parent=self, modal=True)
+ dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ dlg.add_button("Save", Gtk.ResponseType.OK)
+ dlg.set_default_size(580, 650)
+ 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(6)
+
+ sw = Gtk.ScrolledWindow()
+ sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ sw.add(vbox)
+ area.pack_start(sw, True, True, 0)
+
+ vbox.pack_start(Gtk.Label(label="Google OAuth 2.0 Client Credentials\n~/.config/codex-launcher/oauth-secrets.json", use_markup=True, xalign=0), False, False, 4)
+
+ google_token_dir = os.path.expanduser("~/.cache/codex-proxy")
+ fields = {}
+ for section_key, section_label, oauth_prov, token_file in [
+ ("antigravity", "Antigravity (CloudCode)", "google-antigravity", "google-antigravity-oauth-token.json"),
+ ("gemini_cli", "Gemini CLI", "google-cli", "google-cli-oauth-token.json"),
+ ]:
+ section_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ hdr_row = Gtk.Box(spacing=6)
+ hdr_row.pack_start(Gtk.Label(label=f"\n{section_label}", use_markup=True, xalign=0), True, True, 0)
+ reauth_btn = Gtk.Button(label="Re-OAuth")
+ reauth_btn.set_size_request(80, -1)
+ reauth_btn.connect("clicked", lambda b, p=oauth_prov: self._google_reoauth(p, dlg))
+ hdr_row.pack_end(reauth_btn, False, False, 0)
+ import_btn = Gtk.Button(label="Import JSON")
+ import_btn.set_size_request(100, -1)
+ hdr_row.pack_end(import_btn, False, False, 0)
+ section_box.pack_start(hdr_row, False, False, 2)
+
+ token_path = os.path.join(google_token_dir, token_file)
+ has_token = os.path.exists(token_path)
+ try:
+ with open(token_path) as tf:
+ td = json.load(tf)
+ has_token = bool(td.get("refresh_token") or td.get("access_token"))
+ except Exception:
+ pass
+ tok_status = "Token: valid" if has_token else "Token: missing"
+ section_box.pack_start(Gtk.Label(label=tok_status, use_markup=True, xalign=0), False, False, 0)
+
+ sec = data.get(section_key, {})
+ for fk, fl in [("client_id", "Client ID"), ("client_secret", "Client Secret")]:
+ row = Gtk.Box(spacing=6)
+ lbl = Gtk.Label(label=fl + ":", xalign=0)
+ lbl.set_size_request(100, -1)
+ entry = Gtk.Entry()
+ entry.set_text(sec.get(fk, ""))
+ entry.set_size_request(360, -1)
+ if fk == "client_secret":
+ entry.set_visibility(False)
+ entry.set_invisible_char("*")
+ row.pack_start(lbl, False, False, 0)
+ row.pack_start(entry, True, True, 0)
+ section_box.pack_start(row, False, False, 2)
+ fields[(section_key, fk)] = entry
+ import_btn.connect("clicked", lambda b, sk=section_key: self._import_oauth_json(fields, sk))
+ vbox.pack_start(section_box, False, False, 0)
+
+ vbox.pack_start(Gtk.Label(label="Import client_secret_*.json from Google Cloud Console → Credentials", use_markup=True, xalign=0), False, False, 4)
+
+ sep = Gtk.Separator()
+ vbox.pack_start(sep, False, False, 8)
+
+ vbox.pack_start(Gtk.Label(label="\nFreebuff / Codebuff Credentials\n~/.config/manicode/credentials.json", use_markup=True, xalign=0), False, False, 4)
+
+ cb_creds_path = os.path.expanduser("~/.config/manicode/credentials.json")
+ cb_fields = {}
+ try:
+ with open(cb_creds_path) as f:
+ cb_data = json.load(f)
+ except Exception:
+ cb_data = {}
+ cb_default = cb_data.get("default", {})
+ cb_status_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+
+ cb_info = f"Email: {cb_default.get('email', 'not logged in')}"
+ cb_name = cb_default.get("name", "")
+ if cb_name:
+ cb_info = f"{cb_name} — {cb_info}"
+ has_cb_token = bool(cb_default.get("authToken", ""))
+ status_text = "Logged in" if has_cb_token else "Not logged in"
+ status_color = "#27ae60" if has_cb_token else "#e67e22"
+ cb_info_lbl = Gtk.Label(label=f"{cb_info}\nStatus: {status_text}", use_markup=True, xalign=0)
+ cb_status_box.pack_start(cb_info_lbl, False, False, 2)
+
+ for fk, fl in [("authToken", "Auth Token"), ("fingerprintId", "Fingerprint ID")]:
+ row = Gtk.Box(spacing=6)
+ lbl = Gtk.Label(label=fl + ":", xalign=0)
+ lbl.set_size_request(110, -1)
+ entry = Gtk.Entry()
+ entry.set_text(cb_default.get(fk, ""))
+ entry.set_size_request(360, -1)
+ entry.set_visibility(False)
+ entry.set_invisible_char("*")
+ row.pack_start(lbl, False, False, 0)
+ row.pack_start(entry, True, True, 0)
+ cb_status_box.pack_start(row, False, False, 2)
+ cb_fields[fk] = entry
+
+ cb_btn_row = Gtk.Box(spacing=6)
+ cb_login_btn = Gtk.Button(label="Re-OAuth (GitHub Login)")
+ cb_login_btn.connect("clicked", lambda b: self._codebuff_reoauth())
+ cb_btn_row.pack_start(cb_login_btn, False, False, 0)
+ cb_status_box.pack_start(cb_btn_row, False, False, 4)
+
+ vbox.pack_start(cb_status_box, False, False, 0)
+
+ cb_accounts = cb_data.get("accounts", [])
+ if cb_accounts:
+ vbox.pack_start(Gtk.Label(label=f"\nAdditional accounts: {len(cb_accounts)} (edit credentials.json manually)", use_markup=True, xalign=0), False, False, 2)
+
+ vbox.show_all()
+ sw.show_all()
+
+ if dlg.run() == Gtk.ResponseType.OK:
+ for (sk, fk), entry in fields.items():
+ if sk not in data:
+ data[sk] = {}
+ data[sk][fk] = entry.get_text().strip()
+ try:
+ os.makedirs(os.path.dirname(secrets_path), exist_ok=True)
+ with open(secrets_path, "w") as f:
+ json.dump(data, f, indent=2)
+ os.chmod(secrets_path, 0o600)
+ except Exception as e:
+ self._show_error_dialog("Save failed", str(e))
+ cb_updated = dict(cb_default)
+ for fk, entry in cb_fields.items():
+ val = entry.get_text().strip()
+ if val:
+ cb_updated[fk] = val
+ if cb_updated:
+ cb_data["default"] = cb_updated
+ try:
+ os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True)
+ with open(cb_creds_path, "w") as f:
+ json.dump(cb_data, f, indent=2)
+ os.chmod(cb_creds_path, 0o600)
+ except Exception as e:
+ self._show_error_dialog("Save failed", str(e))
+ dlg.destroy()
+
+ def _import_oauth_json(self, fields, section_key):
+ chooser = Gtk.FileChooserDialog(
+ title="Import Google OAuth Client Secret JSON",
+ parent=self, action=Gtk.FileChooserAction.OPEN)
+ chooser.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ chooser.add_button("Open", Gtk.ResponseType.OK)
+ filt = Gtk.FileFilter()
+ filt.set_name("JSON files")
+ filt.add_pattern("*.json")
+ chooser.add_filter(filt)
+ if chooser.run() == Gtk.ResponseType.OK:
+ path = chooser.get_filename()
+ try:
+ with open(path) as f:
+ raw = json.load(f)
+ creds = raw.get("installed") or raw.get("web") or raw
+ cid = creds.get("client_id", "")
+ csec = creds.get("client_secret", "")
+ if not cid or not csec:
+ raise ValueError("JSON does not contain client_id and client_secret")
+ fields[(section_key, "client_id")].set_text(cid)
+ fields[(section_key, "client_secret")].set_text(csec)
+ except Exception as e:
+ self._show_error_dialog("Import failed", str(e))
+ chooser.destroy()
+
+# ═══════════════════════════════════════════════════════════════════
+# 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)"),
+ ("codebuff", "Codebuff - 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()
+
+ enhancer_box = Gtk.Box(spacing=6)
+ self._switch_enhancer = Gtk.Switch()
+ self._switch_enhancer.set_active(self._data.get("prompt_enhancer", False))
+ enhancer_box.pack_start(self._switch_enhancer, False, False, 0)
+ self._enhancer_status_lbl = Gtk.Label()
+ enhancer_box.pack_start(self._enhancer_status_lbl, False, False, 0)
+ self._switch_enhancer.connect("notify::active", lambda *a: self._on_enhancer_toggled())
+ self._combo_enhancer_mode = Gtk.ComboBoxText()
+ for mode in ["offline", "ai-powered"]:
+ self._combo_enhancer_mode.append(mode, mode.capitalize())
+ self._combo_enhancer_mode.set_active_id(self._data.get("prompt_enhancer_mode", "offline"))
+ enhancer_box.pack_start(self._combo_enhancer_mode, False, False, 6)
+ add_row(8, "Prompt Enhancer:", enhancer_box)
+ self._on_enhancer_toggled()
+
+ self._entry_enhancer_model = Gtk.Entry()
+ self._entry_enhancer_model.set_placeholder_text("e.g. deepseek/deepseek-v4-flash (ai-powered mode only)")
+ self._entry_enhancer_model.set_text(self._data.get("prompt_enhancer_model", ""))
+ add_row(9, "Enhancer Model:", self._entry_enhancer_model)
+
+ self._entry_enhancer_url = Gtk.Entry()
+ self._entry_enhancer_url.set_placeholder_text("e.g. https://www.codebuff.com/api/v1 (ai-powered mode only)")
+ self._entry_enhancer_url.set_text(self._data.get("prompt_enhancer_url", ""))
+ add_row(10, "Enhancer URL:", self._entry_enhancer_url)
+
+ self._entry_enhancer_key = Gtk.Entry()
+ self._entry_enhancer_key.set_placeholder_text("API key for enhancer model (ai-powered mode only)")
+ self._entry_enhancer_key.set_text(self._data.get("prompt_enhancer_key", ""))
+ self._entry_enhancer_key.set_visibility(False)
+ self._entry_enhancer_key.set_invisible_char("*")
+ add_row(11, "Enhancer Key:", self._entry_enhancer_key)
+
+ # 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))
+
+ model_btn_box = Gtk.Box(spacing=6)
+ area.pack_start(model_btn_box, False, False, 0)
+ self._remove_model_btn = Gtk.Button(label="Remove Selected")
+ self._remove_model_btn.connect("clicked", lambda b: self._remove_selected_model())
+ model_btn_box.pack_start(self._remove_model_btn, False, False, 0)
+ self._clear_models_btn = Gtk.Button(label="Clear All")
+ self._clear_models_btn.connect("clicked", lambda b: self._clear_all_models())
+ model_btn_box.pack_start(self._clear_models_btn, False, False, 0)
+ self._sync_preset_btn = Gtk.Button(label="Sync from Preset")
+ self._sync_preset_btn.connect("clicked", lambda b: self._apply_selected_preset())
+ model_btn_box.pack_start(self._sync_preset_btn, False, False, 0)
+
+ 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 == "codebuff":
+ self._oauth_btn.set_label("Codebuff Login")
+ self._entry_key.set_placeholder_text("Auto-filled by codebuff 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 (not initial or len(self._model_store) == 0):
+ current = self._combo_default.get_active_text()
+ self._model_store.clear()
+ for mid in preset["models"]:
+ self._model_store.append([mid])
+ self._refresh_default_combo(current or 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 _on_enhancer_toggled(self, *_):
+ active = self._switch_enhancer.get_active()
+ if active:
+ self._enhancer_status_lbl.set_markup('ON')
+ else:
+ self._enhancer_status_lbl.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 == "codebuff":
+ self._codebuff_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")
+
+ _oauth_secrets_path = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json")
+ try:
+ with open(_oauth_secrets_path) as _f:
+ _oauth_secrets = json.load(_f)
+ except Exception:
+ _oauth_secrets = {}
+
+ if is_antigravity:
+ _sec = _oauth_secrets.get("antigravity", {})
+ CLIENT_ID = _sec.get("client_id", "")
+ CLIENT_SECRET = _sec.get("client_secret", "")
+ SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+ ]
+ port = 51121
+ redirect_uri = f"http://localhost:{port}/oauth-callback"
+ callback_path = "/oauth-callback"
provider_kind = "antigravity"
else:
+ _sec = _oauth_secrets.get("gemini_cli", {})
+ CLIENT_ID = _sec.get("client_id", "")
+ CLIENT_SECRET = _sec.get("client_secret", "")
SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
@@ -460,6 +3888,8 @@ class EditEndpointDialog:
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()
@@ -484,21 +3914,32 @@ class EditEndpointDialog:
f"&code_challenge_method=S256"
)
- oauth_dlg = tk.Toplevel(self._dlg)
- oauth_dlg.title("Google OAuth (Gemini Mode)")
- oauth_dlg.geometry("520x280")
- oauth_dlg.transient(self._dlg)
- oauth_dlg.grab_set()
+ 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)
- tk.Label(oauth_dlg, text="Sign in with Google", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w")
- tk.Label(oauth_dlg, text=f"Using OAuth credentials from {OAUTH_SECRETS_PATH}").pack(padx=16, anchor="w")
+ 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 = tk.Label(oauth_dlg, text="Click here to open Google authorization", fg="blue", cursor="hand2")
- link_lbl.pack(padx=16, pady=(8, 0), anchor="w")
- link_lbl.bind("", lambda e: open_url(auth_url))
+ 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_var = tk.StringVar(value="Opening browser...")
- tk.Label(oauth_dlg, textvariable=self._oauth_status_var).pack(padx=16, pady=(8, 0), anchor="w")
+ 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]
@@ -509,6 +3950,8 @@ class EditEndpointDialog:
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")
@@ -520,7 +3963,8 @@ class EditEndpointDialog:
self2.send_response(400)
self2.send_header("Content-Type", "text/html")
self2.end_headers()
- self2.wfile.write(b"CSRF state mismatch.
")
+ self2.wfile.write(b""
+ b"CSRF state mismatch.
")
error_holder[0] = "CSRF state mismatch"
return
code_holder[0] = params["code"][0]
@@ -533,26 +3977,41 @@ class EditEndpointDialog:
self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
self2.end_headers()
def log_message(self2, fmt, *args):
- pass
+ with open("/tmp/codex-oauth-debug.log", "a") as _dbg:
+ _dbg.write(f"[{time.strftime('%H:%M:%S')}] {fmt % args}\n")
try:
bind_host = "localhost" if is_antigravity else "127.0.0.1"
server = http.server.HTTPServer((bind_host, port), OAuthHandler)
except OSError:
- self._oauth_status_var.set(f"Port {port} already in use -- close other apps and retry.")
+ self._oauth_status.set_text(f"Port {port} already in use — close other apps and retry.")
+ spinner.stop()
+ dlg.run(); dlg.destroy()
return
+ def _oauth_log(msg):
+ with open("/tmp/codex-oauth-debug.log", "a") as _f:
+ _f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
+
+ _oauth_log(f"Starting OAuth: port={port} redirect_uri={redirect_uri}")
+
def wait_for_code():
+ _oauth_log("wait_for_code thread started")
deadline = time.time() + 120
while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
server.handle_request()
server.server_close()
+ _oauth_log(f"Server closed. code={'yes' if code_holder[0] else 'no'} error={'yes' if error_holder[0] else 'no'}")
if code_holder[0]:
try:
+ _oauth_log("Exchanging code for token...")
token_data = urllib.parse.urlencode({
- "code": code_holder[0], "client_id": CLIENT_ID,
- "client_secret": CLIENT_SECRET, "redirect_uri": redirect_uri,
- "grant_type": "authorization_code", "code_verifier": verifier,
+ "code": code_holder[0],
+ "client_id": CLIENT_ID,
+ "client_secret": CLIENT_SECRET,
+ "redirect_uri": redirect_uri,
+ "grant_type": "authorization_code",
+ "code_verifier": verifier,
}).encode()
req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
@@ -565,204 +4024,368 @@ class EditEndpointDialog:
os.makedirs(os.path.dirname(token_path), exist_ok=True)
with open(token_path, "w") as f:
json.dump(tokens, f, indent=2)
-
- project_id = ""
- try:
- lr = urllib.request.Request(
- "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
- data=json.dumps({}).encode(),
- headers={"Content-Type": "application/json",
- "Authorization": f"Bearer {tokens['access_token']}",
- "User-Agent": "google-api-nodejs-client/9.15.1"})
- lresp = urllib.request.urlopen(lr, timeout=15)
- ldata = json.loads(lresp.read())
- p = ldata.get("cloudaicompanionProject", "")
- if isinstance(p, dict):
- project_id = p.get("id", "")
- elif isinstance(p, str):
- project_id = p
- if project_id:
- tokens["project_id"] = project_id
- with open(token_path, "w") as f2:
- json.dump(tokens, f2, indent=2)
- except Exception:
- pass
-
- found_models = []
+ os.chmod(token_path, 0o600)
+ _oauth_log(f"Token saved to {token_path}")
+ project_id = _oauth_discover_project(tokens["access_token"], token_path, tokens)
+ _oauth_log(f"Project ID: {project_id or '(none)'}")
if is_antigravity:
- found_models = list(ANTIGRAVITY_MODELS)
+ found_models = [
+ "gemini-2.5-flash", "gemini-2.5-pro",
+ "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
+ "gemini-3-pro-low", "gemini-3-pro-high",
+ "gemini-3.1-pro-low", "gemini-3.1-pro-high",
+ "gemini-3-flash-low", "gemini-3-flash-medium", "gemini-3-flash-high",
+ "claude-sonnet-4-6", "claude-opus-4-6-thinking",
+ "claude-opus-4-6-thinking-low", "claude-opus-4-6-thinking-medium", "claude-opus-4-6-thinking-high",
+ "gemini-claude-sonnet-4-6",
+ "gemini-claude-opus-4-6-thinking-low", "gemini-claude-opus-4-6-thinking-medium", "gemini-claude-opus-4-6-thinking-high",
+ "gemini-3-pro-image",
+ ]
+ probe_candidates = [
+ "gemini-2.5-flash", "gemini-2.5-pro",
+ "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
+ ]
+ _oauth_log(f"Probing {len(probe_candidates)} model candidates...")
+ for mc in probe_candidates:
+ try:
+ pr = urllib.request.Request(
+ "https://cloudcode-pa.googleapis.com/v1internal:generateContent",
+ data=json.dumps({
+ "project": project_id,
+ "model": mc,
+ "request": {"contents": [{"role": "user", "parts": [{"text": "x"}]}],
+ "generationConfig": {"maxOutputTokens": 1}},
+ }).encode(),
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {tokens['access_token']}",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
+ })
+ pr.get_method = lambda: "POST"
+ resp = urllib.request.urlopen(pr, timeout=10)
+ resp.read()
+ found_models.append(mc)
+ _oauth_log(f" {mc} → available")
+ except urllib.error.HTTPError as e:
+ if e.code == 429:
+ found_models.append(mc)
+ _oauth_log(f" {mc} → available (rate limited)")
+ else:
+ e.read()
+ _oauth_log(f" {mc} → HTTP {e.code}")
+ except Exception as e:
+ _oauth_log(f" {mc} → error: {e}")
else:
found_models = ["gemini-2.5-flash", "gemini-2.5-pro"]
if found_models:
tokens["available_models"] = found_models
with open(token_path, "w") as f3:
json.dump(tokens, f3, indent=2)
-
- self._dlg.after(0, lambda: self._oauth_success(oauth_dlg, tokens.get("access_token", "")))
+ os.chmod(token_path, 0o600)
+ _oauth_log(f"Discovered {len(found_models)} models: {found_models}")
+ else:
+ _oauth_log("No models discovered (will use defaults)")
+ GLib.idle_add(self._oauth_success, dlg, tokens.get("access_token", ""), spinner)
+ return
+ except urllib.error.HTTPError as e:
+ body = e.read().decode(errors='replace')
+ _oauth_log(f"Token exchange HTTP {e.code}: {body}")
+ GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed ({e.code}): {body[:200]}", spinner)
+ return
except Exception as e:
- self._dlg.after(0, lambda: self._oauth_failed(oauth_dlg, str(e)))
- else:
- self._dlg.after(0, lambda: self._oauth_failed(oauth_dlg, error_holder[0] or "No authorization code received."))
+ _oauth_log(f"Token exchange FAILED: {e}")
+ GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed: {e}", spinner)
+ return
+ _oauth_log(f"OAuth failed: {error_holder[0] or 'timeout'}")
+ GLib.idle_add(self._oauth_failed, dlg,
+ error_holder[0] or "No authorization code received.", spinner)
threading.Thread(target=wait_for_code, daemon=True).start()
- open_url(auth_url)
-
- def _oauth_success(self, dlg, access_token):
- self._entry_key.delete(0, "end")
- self._entry_key.insert(0, access_token)
- self._oauth_status_var.set("Authorization successful! Token saved.")
- self._dlg.after(1500, dlg.destroy)
-
- def _oauth_failed(self, dlg, msg):
- self._oauth_status_var.set(f"Failed: {msg}")
- self._dlg.after(3000, dlg.destroy)
+ subprocess.Popen(["xdg-open", auth_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ dlg.connect("response", lambda d, r: d.destroy())
+ dlg.run()
def _codebuff_oauth_flow(self):
- import uuid
- oauth_dlg = tk.Toplevel(self._dlg)
- oauth_dlg.title("Codebuff / Freebuff Login")
- oauth_dlg.geometry("520x240")
- oauth_dlg.transient(self._dlg)
- oauth_dlg.grab_set()
- tk.Label(oauth_dlg, text="Sign in with GitHub via Codebuff", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w")
- self._cb_status_var = tk.StringVar(value="Requesting login URL...")
- tk.Label(oauth_dlg, textvariable=self._cb_status_var).pack(padx=16, pady=(8, 0), anchor="w")
- self._cb_link_lbl = tk.Label(oauth_dlg, text="", fg="blue", cursor="hand2")
- self._cb_link_lbl.pack(padx=16, anchor="w")
- self._cb_oauth_result = {"success": False, "user": None, "error": None}
- self._cb_oauth_dlg = oauth_dlg
+ dlg = Gtk.Dialog(title="Codebuff Login", parent=self, modal=True)
+ dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ dlg.set_default_size(500, 240)
+ 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)
- def _thread():
+ area.pack_start(Gtk.Label(label="Sign in with GitHub via Codebuff", use_markup=True, xalign=0), False, False, 0)
+
+ self._oauth_status = Gtk.Label(label="Requesting login URL…", xalign=0)
+ self._oauth_status.set_line_wrap(True)
+ self._oauth_status.set_max_width_chars(60)
+ area.pack_start(self._oauth_status, False, False, 4)
+
+ link_lbl = Gtk.Label(xalign=0)
+ link_lbl.set_line_wrap(True)
+ link_lbl.set_max_width_chars(60)
+ area.pack_start(link_lbl, False, False, 4)
+
+ spinner = Gtk.Spinner()
+ spinner.start()
+ area.pack_start(spinner, False, False, 8)
+
+ area.show_all()
+ link_lbl.set_visible(False)
+
+ self._fb_oauth_result = {"success": False, "user": None, "error": None}
+
+ def _codebuff_auth_thread():
try:
- fp_id = str(uuid.uuid4())
- body = json.dumps({"fingerprintId": fp_id}).encode()
- req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code",
- data=body, headers={"Content-Type": "application/json", "User-Agent": UA})
+ fingerprint_id = str(uuid.uuid4())
+ auth_url = "https://www.codebuff.com/api/auth/cli/code"
+ body = json.dumps({"fingerprintId": fingerprint_id}).encode()
+ req = urllib.request.Request(auth_url, data=body,
+ headers={"Content-Type": "application/json", "User-Agent": "codex-launcher/3.10.7"})
resp = urllib.request.urlopen(req, timeout=30)
- rdata = json.loads(resp.read())
- login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "")
- fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "")
- expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0)
+ data = json.loads(resp.read())
+ login_url = data.get("loginUrl", "") or data.get("login_url", "")
+ fingerprint_hash = data.get("fingerprintHash", "") or data.get("fingerprint_hash", "")
+ expires_at = data.get("expiresAt", 0) or data.get("expires_at", 0)
if not login_url:
- self._cb_oauth_result["error"] = "No login URL"
- self._dlg.after(0, self._codebuff_oauth_done)
+ self._fb_oauth_result["error"] = "Server returned no login URL"
+ GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
return
+
def _set_link():
- self._cb_status_var.set("Open this URL in your browser to log in:")
- self._cb_link_lbl.configure(text=login_url)
- self._cb_link_lbl.bind("", lambda e: open_url(login_url))
- self._dlg.after(0, _set_link)
- open_url(login_url)
- poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}"
+ self._oauth_status.set_text("Open this URL in your browser to log in:")
+ link_lbl.set_markup(f'{login_url}')
+ link_lbl.set_visible(True)
+ GLib.idle_add(_set_link)
+
+ webbrowser.open(login_url)
+
+ poll_url = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fingerprint_id)}&fingerprintHash={urllib.parse.quote(fingerprint_hash)}&expiresAt={expires_at}"
deadline = time.time() + 300
while time.time() < deadline:
time.sleep(2)
try:
- pr = urllib.request.Request(poll, headers={"User-Agent": UA})
- pd = json.loads(urllib.request.urlopen(pr, timeout=10).read())
- if pd.get("user", {}).get("authToken"):
- self._cb_oauth_result["success"] = True
- self._cb_oauth_result["user"] = pd["user"]
- self._dlg.after(0, self._codebuff_oauth_done)
+ poll_req = urllib.request.Request(poll_url,
+ headers={"User-Agent": "codex-launcher/3.10.7"})
+ poll_resp = urllib.request.urlopen(poll_req, timeout=10)
+ poll_data = json.loads(poll_resp.read())
+ user = poll_data.get("user")
+ if user and user.get("authToken"):
+ self._fb_oauth_result["success"] = True
+ self._fb_oauth_result["user"] = user
+ GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
return
+ except urllib.error.HTTPError:
+ pass
except Exception:
pass
- self._cb_oauth_result["error"] = "Timed out"
+ self._fb_oauth_result["error"] = "Login timed out after 5 minutes."
+ GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
except Exception as e:
- self._cb_oauth_result["error"] = str(e)[:200]
- self._dlg.after(0, self._codebuff_oauth_done)
+ self._fb_oauth_result["error"] = str(e)[:200]
+ GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
- threading.Thread(target=_thread, daemon=True).start()
+ threading.Thread(target=_codebuff_auth_thread, daemon=True).start()
+ dlg.connect("response", lambda d, r: d.destroy())
+ dlg.run()
- def _codebuff_oauth_done(self):
- if self._cb_oauth_result["success"] and self._cb_oauth_result["user"]:
- u = self._cb_oauth_result["user"]
- cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json")
- os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True)
- creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""),
- "email": u.get("email", ""), "authToken": u.get("authToken", ""),
- "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}}
- with open(cb_creds_path, "w") as f:
+ def _codebuff_oauth_done(self, dlg, spinner):
+ spinner.stop()
+ if self._fb_oauth_result["success"] and self._fb_oauth_result["user"]:
+ user = self._fb_oauth_result["user"]
+ creds_path = os.path.expanduser("~/.config/manicode/credentials.json")
+ os.makedirs(os.path.dirname(creds_path), exist_ok=True)
+ creds = {"default": {
+ "id": user.get("id", ""),
+ "name": user.get("name", ""),
+ "email": user.get("email", ""),
+ "authToken": user.get("authToken", ""),
+ "fingerprintId": user.get("fingerprintId", ""),
+ "fingerprintHash": user.get("fingerprintHash", ""),
+ }}
+ with open(creds_path, "w") as f:
json.dump(creds, f, indent=2)
- self._cb_status_var.set(f"Logged in as {u.get('email', 'OK')}")
- self._cb_link_lbl.configure(text="")
- self._entry_key.delete(0, "end")
- self._entry_key.insert(0, u.get("authToken", ""))
- self._dlg.after(2000, self._cb_oauth_dlg.destroy)
+ os.chmod(creds_path, 0o600)
+ self._entry_key.set_text(user.get("authToken", ""))
+ self._oauth_status.set_markup('Authorization successful! Credentials saved.')
+ dlg.set_title("Codebuff Login – Success")
+ GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK))
else:
- self._cb_status_var.set(f"Failed: {self._cb_oauth_result.get('error', 'unknown')}")
+ self._oauth_status.set_markup(f'{self._fb_oauth_result["error"] or "Login failed."}')
+ GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL))
- def _cancel(self):
- self._dlg.destroy()
+ def _oauth_success(self, dlg, access_token, spinner):
+ spinner.stop()
+ self._entry_key.set_text(access_token)
+ self._oauth_status.set_markup('Authorization successful! Token saved.')
+ dlg.set_title("Google OAuth — Success")
+ GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK))
- def _save(self):
- name = self._entry_name.get().strip()
- if not name:
- messagebox.showerror("Error", "Name is required", parent=self._dlg)
+ def _oauth_failed(self, dlg, msg, spinner):
+ spinner.stop()
+ self._oauth_status.set_markup(f'{msg}')
+ GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL))
+
+ def _remove_model(self, path):
+ current = self._combo_default.get_active_text()
+ self._model_store.remove(self._model_store.get_iter(path))
+ self._refresh_default_combo(current)
+
+ def _remove_selected_model(self):
+ sel = self._model_tree.get_selection()
+ model, paths = sel.get_selected_rows()
+ if not paths:
return
- bt_display = self._combo_type.get()
- bt = self._bt_map.get(bt_display, "openai-compat")
- url = self._entry_url.get().strip()
- key = self._entry_key.get().strip()
- models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
+ current = self._combo_default.get_active_text()
+ for p in reversed(paths):
+ self._model_store.remove(self._model_store.get_iter(p))
+ self._refresh_default_combo(current)
+ def _clear_all_models(self):
+ current = self._combo_default.get_active_text()
+ self._model_store.clear()
+ self._refresh_default_combo(current)
+
+ def _refresh_default_combo(self, active=None):
+ if active is None:
+ active = self._combo_default.get_active_text()
+ self._combo_default.remove_all()
+ for row in self._model_store:
+ self._combo_default.append(row[0], row[0])
+ if active and any(row[0] == active for row in self._model_store):
+ self._combo_default.set_active_id(active)
+ elif len(self._model_store) > 0:
+ self._combo_default.set_active(0)
+
+ def _fetch_models(self):
+ ok, err = self._try_fetch_models()
+ if not ok:
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK,
+ f"Failed to fetch models:\n{err}")
+ d.run()
+ d.destroy()
+
+ def _try_fetch_models(self):
+ endpoint = {
+ "base_url": self._entry_url.get_text().strip(),
+ "api_key": self._entry_key.get_text().strip(),
+ "backend_type": self._combo_type.get_active_id() or "openai-compat",
+ }
+ ids, err = fetch_models_for_endpoint(endpoint)
+ if ids:
+ current = self._combo_default.get_active_text()
+ added = 0
+ for mid in ids:
+ # check dupes
+ found = any(self._model_store[i][0] == mid for i in range(len(self._model_store)))
+ if not found:
+ self._model_store.append([mid])
+ added += 1
+ self._refresh_default_combo(current)
+ return True, None
+ return False, err or "No models returned by endpoint"
+
+ def _diagnose_endpoint(self):
+ ep = {
+ "base_url": self._entry_url.get_text().strip(),
+ "api_key": self._entry_key.get_text().strip(),
+ "backend_type": self._combo_type.get_active_id() or "openai-compat",
+ "default_model": self._combo_default.get_active_text() or "",
+ }
+ name = ep.get("default_model") or "endpoint"
+ wait_dlg = Gtk.Dialog(title="Running Doctor…", parent=self, modal=True)
+ wait_dlg.set_default_size(280, 80)
+ lbl = Gtk.Label(label="Running endpoint diagnostics…")
+ 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 _on_response(self, dialog, response):
+ if response != Gtk.ResponseType.OK:
+ self.destroy()
+ return
+
+ name = self._entry_name.get_text().strip()
+ if not name:
+ self._show_error("Name is required")
+ return
+ bt = self._combo_type.get_active_id() or PROVIDER_PRESETS.get(self._combo_preset.get_active_text() or "", {}).get("backend_type") or "openai-compat"
+ url = self._entry_url.get_text().strip()
+ key = self._entry_key.get_text().strip()
+ models = [self._model_store[i][0] for i in range(len(self._model_store))]
if not models:
- ep_snap = self._make_endpoint_snapshot()
- ids, err = fetch_models_for_endpoint(ep_snap)
- if ids:
- for mid in ids:
- self._model_listbox.insert("end", mid)
- self._refresh_default_combo()
- models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size()))
+ ok, err = self._try_fetch_models()
+ if ok:
+ models = [self._model_store[i][0] for i in range(len(self._model_store))]
else:
- r = messagebox.askyesno("No Models", f"Auto-fetch failed ({err}).\n\nAdd models manually now?", parent=self._dlg)
- if r:
- self._entry_model.focus_set()
+ d = Gtk.MessageDialog(
+ self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
+ f"Auto-fetch failed ({err}).\n\nAdd models manually now?"
+ )
+ r = d.run()
+ d.destroy()
+ if r == Gtk.ResponseType.YES:
+ self._entry_model.grab_focus()
return
- self._dlg.destroy()
+ self.destroy()
return
if not models:
- messagebox.showerror("Error", "At least one model is required", parent=self._dlg)
+ self._show_error("At least one model is required")
+ self._entry_model.grab_focus()
return
+ default = self._combo_default.get_active_text() or models[0]
- default = self._combo_default.get() or models[0]
data = load_endpoints()
+ # If renaming, remove old entry
if self._existing_name and self._existing_name != name:
data["endpoints"] = [e for e in data["endpoints"] if e["name"] != self._existing_name]
- existing = [e for e in data["endpoints"] if e["name"] == name]
+ # Check for duplicate name
+ existing = [e for e in data["endpoints"] if e["name"] == name and e != self._data]
if existing:
- messagebox.showerror("Error", f'Endpoint "{name}" already exists', parent=self._dlg)
+ self._show_error(f'Endpoint "{name}" already exists')
return
- new_ep = {
- "name": name, "backend_type": bt, "base_url": normalize_base_url(url),
- "api_key": key, "default_model": default, "models": models,
- "provider_preset": self._combo_preset.get() or "Custom",
- "reasoning_enabled": self._reason_var.get(),
- "reasoning_effort": self._combo_effort.get() or "medium",
- "prompt_enhancer": self._enhancer_var.get(),
- "prompt_enhancer_mode": self._enhancer_mode.get() or "offline",
- }
- cc_ver = self._entry_cc_ver.get().strip()
+ new_ep = {"name": name, "backend_type": bt, "base_url": url,
+ "api_key": key, "default_model": default, "models": models,
+ "provider_preset": self._combo_preset.get_active_text() or "Custom"}
+ cc_ver = self._entry_cc_ver.get_text().strip()
if cc_ver:
new_ep["cc_version"] = cc_ver
- enh_model = self._entry_enhancer_model.get().strip()
- enh_url = self._entry_enhancer_url.get().strip()
- enh_key = self._entry_enhancer_key.get().strip()
+ new_ep["reasoning_enabled"] = self._switch_reasoning.get_active()
+ new_ep["reasoning_effort"] = self._combo_effort.get_active_id() or "medium"
+ new_ep["prompt_enhancer"] = self._switch_enhancer.get_active()
+ new_ep["prompt_enhancer_mode"] = self._combo_enhancer_mode.get_active_id() or "offline"
+ enh_model = self._entry_enhancer_model.get_text().strip()
+ enh_url = self._entry_enhancer_url.get_text().strip()
+ enh_key = self._entry_enhancer_key.get_text().strip()
if enh_model:
new_ep["prompt_enhancer_model"] = enh_model
if enh_url:
new_ep["prompt_enhancer_url"] = enh_url
if enh_key:
new_ep["prompt_enhancer_key"] = enh_key
- preset_name = self._combo_preset.get() or "Custom"
+ preset_name = self._combo_preset.get_active_text() or "Custom"
preset = PROVIDER_PRESETS.get(preset_name, {})
if preset.get("oauth_provider"):
new_ep["oauth_provider"] = preset["oauth_provider"]
+ new_ep["base_url"] = normalize_base_url(new_ep["base_url"])
+ # Update or append
found = False
for i, e in enumerate(data["endpoints"]):
if e["name"] == name:
@@ -775,321 +4398,128 @@ class EditEndpointDialog:
data["default"] = name
save_endpoints(data)
- self.result = True
- self._dlg.destroy()
+ self._parent_mgr._rebuild()
+ self._parent_mgr._parent._on_endpoints_updated()
+ self.destroy()
+ def _show_error(self, msg):
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
+ d.run(); d.destroy()
-# ═══════════════════════════════════════════════════════════════════════
-# EndpointMgr
-# ═══════════════════════════════════════════════════════════════════════
+# ═══════════════════════════════════════════════════════════════════
+# Entry point
+# ═══════════════════════════════════════════════════════════════════
-class EndpointMgr:
- def __init__(self, parent, on_update=None):
+# ═══════════════════════════════════════════════════════════════════
+# BGP Pool Manager
+# ═══════════════════════════════════════════════════════════════════
+
+class BGPPoolMgr(Gtk.Window):
+ def __init__(self, parent):
+ super().__init__(title="AI BGP — Pool Manager")
+ self.set_transient_for(parent)
+ self.set_default_size(620, 440)
self._parent = parent
- self._on_update = on_update
- self._dlg = tk.Toplevel(parent)
- self._dlg.title("Manage Endpoints")
- self._dlg.geometry("600x400")
- self._dlg.transient(parent)
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ vbox.set_margin_start(12)
+ vbox.set_margin_end(12)
+ vbox.set_margin_top(12)
+ vbox.set_margin_bottom(12)
+ self.add(vbox)
- main = ttk.Frame(self._dlg, padding=12)
- main.pack(fill="both", expand=True)
+ hdr = Gtk.Box(spacing=8)
+ vbox.pack_start(hdr, False, False, 0)
+ hdr.pack_start(Gtk.Label(label="AI BGP Pools — multi-provider routing with automatic failover", use_markup=True), False, False, 0)
- ttk.Label(main, text="Endpoints", font=("Segoe UI", 11, "bold")).pack(anchor="w")
+ self._store = Gtk.ListStore(str, str, str)
+ self._tree = Gtk.TreeView(model=self._store)
+ for i, (title, w) in enumerate([("Pool Name", 200), ("Routes", 250), ("Strategy", 100)]):
+ r = Gtk.CellRendererText()
+ c = Gtk.TreeViewColumn(title, r, text=i)
+ c.set_min_width(w)
+ self._tree.append_column(c)
+ self._tree.set_headers_visible(True)
+ sw = Gtk.ScrolledWindow()
+ sw.add(self._tree)
+ vbox.pack_start(sw, True, True, 0)
- tree_frame = ttk.Frame(main)
- tree_frame.pack(fill="both", expand=True, pady=(4, 0))
- cols = ("name", "provider", "backend", "default_model")
- self._tree = ttk.Treeview(tree_frame, columns=cols, show="headings", selectmode="browse")
- for col, heading, width in [("name", "Name", 140), ("provider", "Provider", 160),
- ("backend", "Type", 140), ("default_model", "Default Model", 140)]:
- self._tree.heading(col, text=heading)
- self._tree.column(col, width=width, minwidth=80)
- sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview)
- self._tree.configure(yscrollcommand=sb.set)
- self._tree.pack(side="left", fill="both", expand=True)
- sb.pack(side="right", fill="y")
+ sel = self._tree.get_selection()
+ sel.connect("changed", lambda *_: self._on_select())
- btn_frame = ttk.Frame(main)
- btn_frame.pack(fill="x", pady=(8, 0))
- ttk.Button(btn_frame, text="Add", command=self._add).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Edit", command=self._edit).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Delete", command=self._delete).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Set Default", command=self._set_default).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Doctor", command=self._doctor_selected).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Doctor All", command=self._doctor_all).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right")
+ bbox = Gtk.Box(spacing=8)
+ vbox.pack_start(bbox, False, False, 0)
+ self._add_btn = Gtk.Button(label="Create Pool")
+ self._add_btn.connect("clicked", lambda b: self._add_pool())
+ bbox.pack_start(self._add_btn, True, True, 0)
+ self._edit_btn = Gtk.Button(label="Edit Pool")
+ self._edit_btn.connect("clicked", lambda b: self._edit_pool())
+ self._edit_btn.set_sensitive(False)
+ bbox.pack_start(self._edit_btn, True, True, 0)
+ self._del_btn = Gtk.Button(label="Delete Pool")
+ self._del_btn.connect("clicked", lambda b: self._del_pool())
+ self._del_btn.set_sensitive(False)
+ bbox.pack_start(self._del_btn, True, True, 0)
+ close_btn = Gtk.Button(label="Close")
+ close_btn.connect("clicked", lambda b: self.destroy())
+ bbox.pack_start(close_btn, True, True, 0)
self._rebuild()
+ self.show_all()
def _rebuild(self):
- for item in self._tree.get_children():
- self._tree.delete(item)
- data = load_endpoints()
- for ep in data["endpoints"]:
- provider = ep.get("provider_preset", "Custom")
- bt = label_for_backend(ep["backend_type"])
- self._tree.insert("", "end", values=(ep["name"], provider, bt, ep.get("default_model", "")))
+ self._store.clear()
+ for pool in load_bgp_pools().get("pools", []):
+ routes_str = " → ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", []))
+ self._store.append([pool["name"], routes_str, pool.get("strategy", "failover")])
def _selected_name(self):
- sel = self._tree.selection()
- if not sel:
- return None
- return self._tree.item(sel[0])["values"][0]
+ sel = self._tree.get_selection()
+ m, i = sel.get_selected()
+ return self._store[i][0] if i else None
- def _add(self):
- d = EditEndpointDialog(self._dlg, None)
- self._dlg.wait_window(d._dlg)
- if d.result:
- self._rebuild()
- if self._on_update:
- self._on_update()
+ def _on_select(self):
+ name = self._selected_name()
+ self._edit_btn.set_sensitive(bool(name))
+ self._del_btn.set_sensitive(bool(name))
- def _edit(self):
+ def _add_pool(self):
+ d = BGPPoolEditDialog(self, None)
+ d.connect("response", lambda *_: self._rebuild())
+
+ def _edit_pool(self):
+ name = self._selected_name()
+ if name:
+ d = BGPPoolEditDialog(self, name)
+ d.connect("response", lambda *_: self._rebuild())
+
+ def _del_pool(self):
name = self._selected_name()
if not name:
return
- d = EditEndpointDialog(self._dlg, name)
- self._dlg.wait_window(d._dlg)
- if d.result:
- self._rebuild()
- if self._on_update:
- self._on_update()
-
- def _delete(self):
- name = self._selected_name()
- if not name:
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
+ f'Delete BGP pool "{name}"?')
+ r = d.run(); d.destroy()
+ if r != Gtk.ResponseType.YES:
return
- if not messagebox.askyesno("Delete", f'Delete endpoint "{name}"?', parent=self._dlg):
- return
- data = load_endpoints()
- data["endpoints"] = [e for e in data["endpoints"] if e["name"] != name]
- if data.get("default") == name:
- data["default"] = data["endpoints"][0]["name"] if data["endpoints"] else None
- save_endpoints(data)
+ data = load_bgp_pools()
+ data["pools"] = [p for p in data["pools"] if p["name"] != name]
+ save_bgp_pools(data)
self._rebuild()
- if self._on_update:
- self._on_update()
-
- def _set_default(self):
- name = self._selected_name()
- if not name:
- return
- data = load_endpoints()
- data["default"] = name
- save_endpoints(data)
- self._rebuild()
- if self._on_update:
- self._on_update()
-
- def _doctor_selected(self):
- name = self._selected_name()
- if not name:
- return
- ep = get_endpoint(name)
- if not ep:
- return
- wait = tk.Toplevel(self._dlg)
- wait.title(f"Doctor: {name}...")
- wait.geometry("280x80")
- wait.transient(self._dlg)
- wait.grab_set()
- tk.Label(wait, text=f"Running diagnostics for {name}...").pack(expand=True)
-
- def _run():
- checks = run_endpoint_doctor(ep)
- self._dlg.after(0, lambda: (wait.destroy(), _show_doctor_results_tk(self._dlg, name, checks)))
-
- threading.Thread(target=_run, daemon=True).start()
-
- def _doctor_all(self):
- data = load_endpoints()
- endpoints = data.get("endpoints", [])
- if not endpoints:
- messagebox.showinfo("Doctor All", "No endpoints configured.", parent=self._dlg)
- return
-
- wait = tk.Toplevel(self._dlg)
- wait.title("Doctor All...")
- wait.geometry("320x80")
- wait.transient(self._dlg)
- wait.grab_set()
- tk.Label(wait, text=f"Testing {len(endpoints)} endpoints...").pack(expand=True)
-
- all_results = {}
-
- def _run():
- for ep in endpoints:
- try:
- all_results[ep["name"]] = run_endpoint_doctor(ep)
- except Exception as e:
- all_results[ep["name"]] = [("Doctor run", False, str(e)[:100])]
-
- def _show():
- wait.destroy()
- dlg = tk.Toplevel(self._dlg)
- dlg.title("Doctor All Results")
- dlg.geometry("580x480")
- dlg.transient(self._dlg)
-
- canvas = tk.Canvas(dlg)
- scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview)
- inner = tk.Frame(canvas)
- inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
- canvas.create_window((0, 0), window=inner, anchor="nw")
- canvas.configure(yscrollcommand=scrollbar.set)
-
- for ep_name, checks in all_results.items():
- passed = sum(1 for _, ok, _ in checks if ok is True)
- failed = sum(1 for _, ok, _ in checks if ok is False)
- color = "#e74c3c" if failed else "#27ae60"
- status = f"{failed} failed" if failed else f"{passed} passed"
- tk.Label(inner, text=f"{ep_name} {status}", fg=color,
- font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=12, pady=(8, 2))
- for name, ok, detail in checks:
- if ok is True:
- sym, sc = "✓", "#27ae60"
- elif ok is False:
- sym, sc = "✗", "#e74c3c"
- else:
- sym, sc = "○", "#f39c12"
- row = tk.Frame(inner)
- row.pack(anchor="w", padx=24, pady=0)
- tk.Label(row, text=sym, fg=sc, font=("Segoe UI", 9, "bold")).pack(side="left")
- txt = name
- if detail:
- txt += f" {detail}"
- tk.Label(row, text=txt, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="left")
- ttk.Separator(inner).pack(fill="x", padx=12, pady=4)
-
- canvas.pack(side="left", fill="both", expand=True, padx=(12, 0))
- scrollbar.pack(side="right", fill="y")
- ttk.Button(dlg, text="Close", command=dlg.destroy).pack(pady=8)
-
- self._dlg.after(0, _show)
-
- threading.Thread(target=_run, daemon=True).start()
+ self._parent._on_endpoints_updated()
-# ═══════════════════════════════════════════════════════════════════════
-# BGP Pool Manager
-# ═══════════════════════════════════════════════════════════════════════
+class BGPPoolEditDialog(Gtk.Dialog):
+ def __init__(self, parent, existing_name):
+ title = "Edit BGP Pool" if existing_name else "Create BGP Pool"
+ Gtk.Dialog.__init__(self, title=title, parent=parent, modal=True)
+ self.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ self.add_button("Save", Gtk.ResponseType.OK)
+ self.set_default_size(580, 480)
-class BGPRouteDialog:
- def __init__(self, parent, endpoints, existing=None):
- self.result = None
- self._dlg = tk.Toplevel(parent)
- self._dlg.title("BGP Route")
- self._dlg.geometry("440x300")
- self._dlg.transient(parent)
- self._dlg.grab_set()
-
- main = ttk.Frame(self._dlg, padding=12)
- main.pack(fill="both", expand=True)
-
- ttk.Label(main, text="Route Name:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2)
- self._entry_name = ttk.Entry(main)
- self._entry_name.grid(row=0, column=1, sticky="ew", pady=2)
- if existing:
- self._entry_name.insert(0, existing.get("name", ""))
-
- ttk.Label(main, text="Endpoint:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2)
- ep_names = [e["name"] for e in endpoints]
- self._combo_ep = ttk.Combobox(main, values=ep_names, state="readonly")
- self._combo_ep.grid(row=1, column=1, sticky="ew", pady=2)
- if existing and existing.get("endpoint_name") in ep_names:
- self._combo_ep.set(existing["endpoint_name"])
- elif ep_names:
- self._combo_ep.set(ep_names[0])
-
- ttk.Label(main, text="URL:").grid(row=2, column=0, sticky="e", padx=(0, 6), pady=2)
- self._entry_url = ttk.Entry(main)
- self._entry_url.grid(row=2, column=1, sticky="ew", pady=2)
-
- ttk.Label(main, text="API Key:").grid(row=3, column=0, sticky="e", padx=(0, 6), pady=2)
- self._entry_key = ttk.Entry(main, show="*")
- self._entry_key.grid(row=3, column=1, sticky="ew", pady=2)
-
- ttk.Label(main, text="Model:").grid(row=4, column=0, sticky="e", padx=(0, 6), pady=2)
- self._combo_model = ttk.Combobox(main, state="readonly")
- self._combo_model.grid(row=4, column=1, sticky="ew", pady=2)
-
- main.columnconfigure(1, weight=1)
-
- self._endpoints = endpoints
- self._combo_ep.bind("<>", lambda e: self._on_ep_changed())
- self._on_ep_changed()
-
- if existing:
- self._entry_url.delete(0, "end")
- self._entry_url.insert(0, existing.get("target_url", ""))
- self._entry_key.delete(0, "end")
- self._entry_key.insert(0, existing.get("api_key", ""))
- if existing.get("model"):
- self._combo_model.set(existing["model"])
-
- btn_frame = ttk.Frame(main)
- btn_frame.grid(row=5, column=0, columnspan=2, pady=(12, 0))
- ttk.Button(btn_frame, text="Cancel", command=self._dlg.destroy).pack(side="right")
- ttk.Button(btn_frame, text="OK", command=self._ok).pack(side="right", padx=(8, 0))
-
- self._dlg.wait_window()
-
- def _on_ep_changed(self):
- ep_name = self._combo_ep.get()
- ep = None
- for e in self._endpoints:
- if e["name"] == ep_name:
- ep = e
- break
- if ep:
- self._entry_url.delete(0, "end")
- self._entry_url.insert(0, normalize_base_url(ep.get("base_url", "")))
- self._entry_key.delete(0, "end")
- self._entry_key.insert(0, ep.get("api_key", ""))
- models = ep.get("models", [])
- self._combo_model["values"] = models
- if ep.get("default_model") and ep["default_model"] in models:
- self._combo_model.set(ep["default_model"])
- elif models:
- self._combo_model.set(models[0])
-
- def _ok(self):
- ep_name = self._combo_ep.get()
- ep = None
- for e in self._endpoints:
- if e["name"] == ep_name:
- ep = e
- break
- self.result = {
- "name": self._entry_name.get().strip() or ep_name,
- "endpoint_name": ep_name,
- "target_url": self._entry_url.get().strip(),
- "api_key": self._entry_key.get().strip(),
- "model": self._combo_model.get() or "",
- "priority": 99,
- }
- if ep:
- self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True)
- self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium")
- self.result["oauth_provider"] = ep.get("oauth_provider", "")
- self._dlg.destroy()
-
-
-class BGPPoolEditDialog:
- def __init__(self, parent, existing_name=None):
- self.result = False
self._existing_name = existing_name
self._parent_mgr = parent
- self._dlg = tk.Toplevel(parent._dlg if hasattr(parent, "_dlg") else parent)
- title = "Edit BGP Pool" if existing_name else "Create BGP Pool"
- self._dlg.title(title)
- self._dlg.geometry("620x500")
- self._dlg.transient(parent._dlg if hasattr(parent, "_dlg") else parent)
- self._dlg.grab_set()
-
data = load_bgp_pools()
pool = None
if existing_name:
@@ -1100,131 +4530,95 @@ class BGPPoolEditDialog:
if not pool:
pool = {"name": "", "strategy": "failover", "routes": []}
- main = ttk.Frame(self._dlg, padding=12)
- main.pack(fill="both", expand=True)
+ area = self.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(8)
- grid = ttk.Frame(main)
- grid.pack(fill="x")
- ttk.Label(grid, text="Pool Name:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2)
- self._entry_name = ttk.Entry(grid)
- self._entry_name.grid(row=0, column=1, sticky="ew", pady=2)
- self._entry_name.insert(0, pool["name"])
+ grid = Gtk.Grid(column_spacing=8, row_spacing=6)
+ area.pack_start(grid, False, False, 0)
- ttk.Label(grid, text="Strategy:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2)
- self._combo_strategy = ttk.Combobox(grid, values=["failover", "race"], state="readonly")
- self._combo_strategy.grid(row=1, column=1, sticky="ew", pady=2)
- self._combo_strategy.set(pool.get("strategy", "failover"))
- grid.columnconfigure(1, weight=1)
+ grid.attach(Gtk.Label(label="Pool Name:", xalign=1), 0, 0, 1, 1)
+ self._entry_name = Gtk.Entry(text=pool["name"])
+ grid.attach(self._entry_name, 1, 0, 1, 1)
- ttk.Label(main, text="Routes (double-click to remove):", font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(8, 2))
+ grid.attach(Gtk.Label(label="Strategy:", xalign=1), 0, 1, 1, 1)
+ self._combo_strategy = Gtk.ComboBoxText()
+ self._combo_strategy.append("failover", "Failover (try primary, fall back on error)")
+ self._combo_strategy.append("race", "Race (send to all, return fastest)")
+ self._combo_strategy.set_active_id(pool.get("strategy", "failover"))
+ grid.attach(self._combo_strategy, 1, 1, 1, 1)
- tree_frame = ttk.Frame(main)
- tree_frame.pack(fill="both", expand=True)
- cols = ("name", "endpoint", "url", "model", "priority")
- self._route_tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=8)
- for col, heading, w in [("name", "Route Name", 100), ("endpoint", "Endpoint", 120),
- ("url", "URL", 160), ("model", "Model", 120), ("priority", "Priority", 60)]:
- self._route_tree.heading(col, text=heading)
- self._route_tree.column(col, width=w, minwidth=50)
- rsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._route_tree.yview)
- self._route_tree.configure(yscrollcommand=rsb.set)
- self._route_tree.pack(side="left", fill="both", expand=True)
- rsb.pack(side="right", fill="y")
+ area.pack_start(Gtk.Label(label="Routes (drag to reorder priority)", use_markup=True, xalign=0), False, False, 8)
- self._routes = []
+ self._route_store = Gtk.ListStore(str, str, str, str, str, str)
for r in pool.get("routes", []):
- self._routes.append(dict(r))
- self._route_tree.insert("", "end", values=(
+ self._route_store.append([
r.get("name", ""), r.get("endpoint_name", ""),
- r.get("target_url", ""), r.get("model", ""), r.get("priority", 99)))
+ r.get("target_url", ""), r.get("api_key", ""),
+ r.get("model", ""), str(r.get("priority", 99))
+ ])
- btn_frame = ttk.Frame(main)
- btn_frame.pack(fill="x", pady=(6, 0))
- ttk.Button(btn_frame, text="Add Route", command=self._add_route).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Edit Route", command=self._edit_route).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Remove Route", command=self._remove_route).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Up", command=lambda: self._move_route(-1)).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Down", command=lambda: self._move_route(1)).pack(side="left", padx=(0, 4))
+ self._route_tree = Gtk.TreeView(model=self._route_store)
+ for i, (title, w) in enumerate([
+ ("Route Name", 120), ("Endpoint", 120), ("URL", 150),
+ ("API Key", 80), ("Model", 120), ("Priority", 60)
+ ]):
+ renderer = Gtk.CellRendererText()
+ renderer.set_property("editable", False)
+ col = Gtk.TreeViewColumn(title, renderer, text=i)
+ col.set_min_width(w)
+ col.set_resizable(True)
+ self._route_tree.append_column(col)
+ self._route_tree.set_headers_visible(True)
+ sw = Gtk.ScrolledWindow()
+ sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ sw.add(self._route_tree)
+ sw.set_min_content_height(200)
+ area.pack_start(sw, True, True, 0)
- save_frame = ttk.Frame(main)
- save_frame.pack(fill="x", pady=(8, 0))
- ttk.Button(save_frame, text="Cancel", command=self._dlg.destroy).pack(side="right")
- ttk.Button(save_frame, text="Save", command=self._save).pack(side="right", padx=(8, 0))
+ bbox = Gtk.Box(spacing=6)
+ area.pack_start(bbox, False, False, 0)
+ add_r = Gtk.Button(label="Add Route")
+ add_r.connect("clicked", lambda b: self._add_route())
+ bbox.pack_start(add_r, True, True, 0)
+ edit_r = Gtk.Button(label="Edit Route")
+ edit_r.connect("clicked", lambda b: self._edit_route())
+ bbox.pack_start(edit_r, True, True, 0)
+ rm_r = Gtk.Button(label="Remove Route")
+ rm_r.connect("clicked", lambda b: self._remove_route())
+ bbox.pack_start(rm_r, True, True, 0)
+ up_r = Gtk.Button(label="↑ Up")
+ up_r.connect("clicked", lambda b: self._move_route(-1))
+ bbox.pack_start(up_r, True, True, 0)
+ down_r = Gtk.Button(label="↓ Down")
+ down_r.connect("clicked", lambda b: self._move_route(1))
+ bbox.pack_start(down_r, True, True, 0)
- def _add_route(self):
- endpoints = load_endpoints().get("endpoints", [])
- if not endpoints:
- messagebox.showinfo("Info", "No endpoints configured. Add endpoints first.", parent=self._dlg)
- return
- d = BGPRouteDialog(self._dlg, endpoints, None)
- if d.result:
- r = d.result
- self._routes.append(r)
- self._route_tree.insert("", "end", values=(
- r.get("name", ""), r.get("endpoint_name", ""),
- r.get("target_url", ""), r.get("model", ""), r.get("priority", 99)))
+ self.show_all()
- def _edit_route(self):
- sel = self._route_tree.selection()
- if not sel:
- return
- idx = self._route_tree.index(sel[0])
- endpoints = load_endpoints().get("endpoints", [])
- d = BGPRouteDialog(self._dlg, endpoints, self._routes[idx])
- if d.result:
- r = d.result
- self._routes[idx] = r
- self._route_tree.item(sel[0], values=(
- r.get("name", ""), r.get("endpoint_name", ""),
- r.get("target_url", ""), r.get("model", ""), r.get("priority", 99)))
+ if self.run() == Gtk.ResponseType.OK:
+ self._save()
- def _remove_route(self):
- sel = self._route_tree.selection()
- if not sel:
- return
- idx = self._route_tree.index(sel[0])
- self._route_tree.delete(sel[0])
- del self._routes[idx]
-
- def _move_route(self, direction):
- sel = self._route_tree.selection()
- if not sel:
- return
- idx = self._route_tree.index(sel[0])
- new_idx = idx + direction
- if new_idx < 0 or new_idx >= len(self._routes):
- return
- route = self._routes.pop(idx)
- self._routes.insert(new_idx, route)
- self._rebuild_routes_tree(new_idx)
-
- def _rebuild_routes_tree(self, select_idx=None):
- for item in self._route_tree.get_children():
- self._route_tree.delete(item)
- for r in self._routes:
- self._route_tree.insert("", "end", values=(
- r.get("name", ""), r.get("endpoint_name", ""),
- r.get("target_url", ""), r.get("model", ""), r.get("priority", 99)))
- if select_idx is not None:
- children = self._route_tree.get_children()
- if select_idx < len(children):
- self._route_tree.selection_set(children[select_idx])
+ self.destroy()
def _save(self):
- name = self._entry_name.get().strip()
+ name = self._entry_name.get_text().strip()
if not name:
return
- strategy = self._combo_strategy.get() or "failover"
+ strategy = self._combo_strategy.get_active_id() or "failover"
routes = []
- for i, r in enumerate(self._routes):
- if not r.get("target_url"):
+ for i, row in enumerate(self._route_store):
+ if not row[2]:
continue
routes.append({
- "name": r.get("name") or f"Route {i+1}",
- "endpoint_name": r.get("endpoint_name", ""),
- "target_url": r.get("target_url", ""),
- "api_key": r.get("api_key", ""),
- "model": r.get("model", ""),
+ "name": row[0] or f"Route {i+1}",
+ "endpoint_name": row[1],
+ "target_url": row[2],
+ "api_key": row[3],
+ "model": row[4],
"priority": i + 1,
"reasoning_enabled": True,
"reasoning_effort": "medium",
@@ -1234,309 +4628,331 @@ class BGPPoolEditDialog:
data["pools"] = [p for p in data["pools"] if p["name"] != self._existing_name]
data["pools"].append({"name": name, "strategy": strategy, "routes": routes})
save_bgp_pools(data)
- self.result = True
- self._dlg.destroy()
+ self._parent_mgr._parent._on_endpoints_updated()
+
+ def _add_route(self):
+ endpoints = load_endpoints().get("endpoints", [])
+ if not endpoints:
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK,
+ "No endpoints configured. Add endpoints in Manage Endpoints first.")
+ d.run(); d.destroy()
+ return
+ d = BGPRouteDialog(self, endpoints, None)
+ if d.result:
+ r = d.result
+ self._route_store.append([
+ r.get("name", ""), r.get("endpoint_name", ""),
+ r.get("target_url", ""), r.get("api_key", ""),
+ r.get("model", ""), str(r.get("priority", 99))
+ ])
+
+ def _edit_route(self):
+ sel = self._route_tree.get_selection()
+ m, i = sel.get_selected()
+ if not i:
+ return
+ endpoints = load_endpoints().get("endpoints", [])
+ existing = {
+ "name": m[i][0], "endpoint_name": m[i][1],
+ "target_url": m[i][2], "api_key": m[i][3],
+ "model": m[i][4], "priority": int(m[i][5]) if m[i][5] else 99,
+ }
+ d = BGPRouteDialog(self, endpoints, existing)
+ if d.result:
+ r = d.result
+ m[i][0] = r.get("name", "")
+ m[i][1] = r.get("endpoint_name", "")
+ m[i][2] = r.get("target_url", "")
+ m[i][3] = r.get("api_key", "")
+ m[i][4] = r.get("model", "")
+ m[i][5] = str(r.get("priority", 99))
+
+ def _remove_route(self):
+ sel = self._route_tree.get_selection()
+ m, i = sel.get_selected()
+ if i:
+ self._route_store.remove(i)
+
+ def _move_route(self, direction):
+ sel = self._route_tree.get_selection()
+ m, i = sel.get_selected()
+ if not i:
+ return
+ path = m.get_path(i)
+ idx = path.get_indices()[0]
+ new_idx = idx + direction
+ if new_idx < 0 or new_idx >= len(self._route_store):
+ return
+ row_data = [m[idx][c] for c in range(6)]
+ self._route_store.remove(m.get_iter(Gtk.TreePath(idx)))
+ new_iter = self._route_store.insert(new_idx)
+ for c, v in enumerate(row_data):
+ self._route_store.set_value(new_iter, c, v)
-class BGPPoolMgr:
- def __init__(self, parent, on_update=None):
+class BGPRouteDialog(Gtk.Dialog):
+ def __init__(self, parent, endpoints, existing):
+ Gtk.Dialog.__init__(self, title="BGP Route", parent=parent, modal=True)
+ self.add_button("Cancel", Gtk.ResponseType.CANCEL)
+ self.add_button("OK", Gtk.ResponseType.OK)
+ self.set_default_size(440, 300)
+ self.result = None
+
+ area = self.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(6)
+
+ 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=existing.get("name", "") if existing else "")
+ add_row(0, "Route Name:", self._entry_name)
+
+ self._combo_ep = Gtk.ComboBoxText()
+ ep_names = [e["name"] for e in endpoints]
+ for en in ep_names:
+ self._combo_ep.append(en, en)
+ if existing and existing.get("endpoint_name") in ep_names:
+ self._combo_ep.set_active_id(existing["endpoint_name"])
+ elif ep_names:
+ self._combo_ep.set_active(0)
+ self._combo_ep.connect("changed", lambda b: self._on_ep_changed(endpoints))
+ add_row(1, "Endpoint:", self._combo_ep)
+
+ self._entry_url = Gtk.Entry()
+ add_row(2, "URL:", self._entry_url)
+
+ self._entry_key = Gtk.Entry()
+ self._entry_key.set_visibility(False)
+ add_row(3, "API Key:", self._entry_key)
+
+ self._combo_model = Gtk.ComboBoxText()
+ add_row(4, "Model:", self._combo_model)
+
+ if existing:
+ self._entry_url.set_text(existing.get("target_url", ""))
+ self._entry_key.set_text(existing.get("api_key", ""))
+ self._on_ep_changed(endpoints)
+ if existing and existing.get("model"):
+ self._combo_model.set_active_id(existing["model"])
+
+ self.show_all()
+ if self.run() == Gtk.ResponseType.OK:
+ ep_name = self._combo_ep.get_active_text() or ""
+ ep = None
+ for e in endpoints:
+ if e["name"] == ep_name:
+ ep = e
+ break
+ self.result = {
+ "name": self._entry_name.get_text().strip() or ep_name,
+ "endpoint_name": ep_name,
+ "target_url": self._entry_url.get_text().strip(),
+ "api_key": self._entry_key.get_text().strip(),
+ "model": self._combo_model.get_active_text() or "",
+ "priority": 99,
+ }
+ if ep:
+ self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True)
+ self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium")
+ self.result["oauth_provider"] = ep.get("oauth_provider", "")
+ self.destroy()
+
+ def _on_ep_changed(self, endpoints):
+ ep_name = self._combo_ep.get_active_text()
+ ep = None
+ for e in endpoints:
+ if e["name"] == ep_name:
+ ep = e
+ break
+ if ep:
+ self._entry_url.set_text(normalize_base_url(ep.get("base_url", "")))
+ self._entry_key.set_text(ep.get("api_key", ""))
+ self._combo_model.remove_all()
+ for m in ep.get("models", []):
+ mid = normalize_model_id(m) if m else ""
+ self._combo_model.append(mid, m)
+ if ep.get("default_model"):
+ self._combo_model.set_active_id(normalize_model_id(ep["default_model"]))
+ elif len(ep.get("models", [])) > 0:
+ self._combo_model.set_active(0)
+
+
+_U = {
+ "base": "#0C0E16", "surface0": "#161928", "surface1": "#1E2235",
+ "surface2": "#2A2F47", "text": "#E4E6F0", "subtext": "#B0B4C8",
+ "dim": "#5C6180", "accent": "#7EB8F7", "blue": "#5DA4E8",
+ "sapphire": "#4EC5C1", "green": "#59D4A0", "yellow": "#F0C75E",
+ "red": "#F06A77", "peach": "#F09860", "teal": "#4EC5C1",
+ "lavender": "#A899F0", "sky": "#70C8E8", "maroon": "#C44B5C",
+ "flamingo": "#E878B0", "rosewater": "#F0D0C0",
+ "model_palette": ["#F09860", "#4EC5C1", "#5DA4E8", "#59D4A0",
+ "#F0C75E", "#A899F0", "#70C8E8", "#E878B0",
+ "#C44B5C", "#F0D0C0", "#7EB8F7", "#F06A77"],
+}
+
+_USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json"
+
+def _load_usage_stats():
+ try:
+ if _USAGE_STATS_FILE.exists():
+ return json.loads(_USAGE_STATS_FILE.read_text())
+ except Exception:
+ pass
+ return {"providers": {}, "updated": None}
+
+def _fmt_tok(n):
+ if n >= 1_000_000:
+ return f"{n/1_000_000:.1f}M"
+ if n >= 1_000:
+ return f"{n/1_000:.1f}K"
+ return str(n)
+
+def _fmt_dur(s):
+ if s >= 3600:
+ return f"{s/3600:.1f}h"
+ if s >= 60:
+ return f"{s/60:.1f}m"
+ return f"{s:.1f}s"
+
+def _status_pill(success_rate, fail_pct):
+ if fail_pct > 0.15:
+ return ("ERR", _U["red"])
+ if fail_pct > 0.05:
+ return ("WARN", _U["yellow"])
+ return ("OK", _U["green"])
+
+def _make_css_widget(css_str):
+ p = Gtk.CssProvider()
+ p.load_from_data(css_str.encode())
+ return p
+
+def _apply_css(widget, css_str):
+ ctx = widget.get_style_context()
+ ctx.add_provider(_make_css_widget(css_str), Gtk.STYLE_PROVIDER_PRIORITY_USER)
+
+
+class UsageWindow(Gtk.Window):
+ def __init__(self, parent):
+ super().__init__(title="Usage Dashboard")
+ self.set_transient_for(parent)
+ self.set_default_size(720, 640)
+ self.set_position(Gtk.WindowPosition.CENTER)
self._parent = parent
- self._on_update = on_update
- self._dlg = tk.Toplevel(parent)
- self._dlg.title("AI BGP -- Pool Manager")
- self._dlg.geometry("660x440")
- self._dlg.transient(parent)
+ _apply_css(self, f"""
+ window {{ background-color: {_U["base"]}; }}
+ separator {{ background-color: {_U["surface1"]}; }}
+ """)
- main = ttk.Frame(self._dlg, padding=12)
- main.pack(fill="both", expand=True)
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ self.add(vbox)
- ttk.Label(main, text="AI BGP Pools -- multi-provider routing with automatic failover",
- font=("Segoe UI", 10, "bold")).pack(anchor="w")
+ self._build_header(vbox)
+ self._build_summary_strip(vbox)
+ sep = Gtk.Separator()
+ vbox.pack_start(sep, False, False, 0)
- tree_frame = ttk.Frame(main)
- tree_frame.pack(fill="both", expand=True, pady=(8, 0))
- cols = ("name", "routes", "strategy")
- self._tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=10)
- for col, heading, w in [("name", "Pool Name", 180), ("routes", "Routes", 280), ("strategy", "Strategy", 100)]:
- self._tree.heading(col, text=heading)
- self._tree.column(col, width=w, minwidth=60)
- sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview)
- self._tree.configure(yscrollcommand=sb.set)
- self._tree.pack(side="left", fill="both", expand=True)
- sb.pack(side="right", fill="y")
-
- btn_frame = ttk.Frame(main)
- btn_frame.pack(fill="x", pady=(8, 0))
- ttk.Button(btn_frame, text="Create Pool", command=self._add_pool).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Edit Pool", command=self._edit_pool).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Delete Pool", command=self._del_pool).pack(side="left", padx=(0, 4))
- ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right")
-
- self._rebuild()
-
- def _rebuild(self):
- for item in self._tree.get_children():
- self._tree.delete(item)
- for pool in load_bgp_pools().get("pools", []):
- routes_str = " -> ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", []))
- self._tree.insert("", "end", values=(pool["name"], routes_str, pool.get("strategy", "failover")))
-
- def _selected_name(self):
- sel = self._tree.selection()
- if not sel:
- return None
- return self._tree.item(sel[0])["values"][0]
-
- def _add_pool(self):
- d = BGPPoolEditDialog(self, None)
- self._dlg.wait_window(d._dlg)
- if d.result:
- self._rebuild()
- if self._on_update:
- self._on_update()
-
- def _edit_pool(self):
- name = self._selected_name()
- if not name:
- return
- d = BGPPoolEditDialog(self, name)
- self._dlg.wait_window(d._dlg)
- if d.result:
- self._rebuild()
- if self._on_update:
- self._on_update()
-
- def _del_pool(self):
- name = self._selected_name()
- if not name:
- return
- if not messagebox.askyesno("Delete", f'Delete BGP pool "{name}"?', parent=self._dlg):
- return
- data = load_bgp_pools()
- data["pools"] = [p for p in data["pools"] if p["name"] != name]
- save_bgp_pools(data)
- self._rebuild()
- if self._on_update:
- self._on_update()
-
-
-# ═══════════════════════════════════════════════════════════════════════
-# AI Monitoring Window
-# ═══════════════════════════════════════════════════════════════════════
-
-class AIMonitoringWindow:
- def __init__(self, parent):
- self._dlg = tk.Toplevel(parent)
- self._dlg.title("AI Monitoring")
- self._dlg.geometry("580x520")
- self._dlg.transient(parent)
-
- self._cfg = load_monitoring_config()
- self._store = load_incident_store()
-
- main = ttk.Frame(self._dlg, padding=12)
- main.pack(fill="both", expand=True)
-
- hdr = ttk.Frame(main)
- hdr.pack(fill="x")
- ttk.Label(hdr, text="AI Monitoring", font=("Segoe UI", 11, "bold")).pack(side="left")
- self._toggle_var = tk.BooleanVar(value=self._cfg.get("enabled", False))
- ttk.Checkbutton(hdr, text="Enabled", variable=self._toggle_var,
- command=self._on_toggle).pack(side="right")
-
- frame = ttk.LabelFrame(main, text="Diagnostic Agent", padding=8)
- frame.pack(fill="x", pady=(8, 0))
-
- grid = ttk.Frame(frame)
- grid.pack(fill="x")
- grid.columnconfigure(1, weight=1)
-
- ttk.Label(grid, text="Provider URL:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2)
- self._url_entry = ttk.Entry(grid)
- self._url_entry.grid(row=0, column=1, sticky="ew", pady=2)
- self._url_entry.insert(0, self._cfg.get("provider_url", ""))
-
- ttk.Label(grid, text="Model:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2)
- self._model_entry = ttk.Entry(grid)
- self._model_entry.grid(row=1, column=1, sticky="ew", pady=2)
- self._model_entry.insert(0, self._cfg.get("model", ""))
-
- ttk.Label(grid, text="API Key:").grid(row=2, column=0, sticky="e", padx=(0, 6), pady=2)
- key_frame = ttk.Frame(grid)
- key_frame.grid(row=2, column=1, sticky="ew", pady=2)
- self._key_entry = ttk.Entry(key_frame, show="*")
- self._key_entry.pack(side="left", fill="x", expand=True)
- self._key_entry.insert(0, self._cfg.get("api_key", ""))
- self._reveal_key = tk.BooleanVar(value=False)
- ttk.Checkbutton(key_frame, text="Show", variable=self._reveal_key,
- command=lambda: self._key_entry.configure(show="" if self._reveal_key.get() else "*")).pack(side="left", padx=(4, 0))
-
- ttk.Label(grid, text="Health Check:").grid(row=3, column=0, sticky="e", padx=(0, 6), pady=2)
- spin_frame = ttk.Frame(grid)
- spin_frame.grid(row=3, column=1, sticky="w", pady=2)
- self._interval_spin = ttk.Spinbox(spin_frame, from_=2, to=30, width=5)
- self._interval_spin.set(self._cfg.get("health_check_interval_s", 5))
- self._interval_spin.pack(side="left")
- ttk.Label(spin_frame, text="seconds").pack(side="left", padx=(4, 0))
-
- opts_frame = ttk.Frame(frame)
- opts_frame.pack(fill="x", pady=(4, 0))
- self._auto_restart_var = tk.BooleanVar(value=self._cfg.get("auto_restart_proxy", True))
- ttk.Checkbutton(opts_frame, text="Auto-restart proxy on crash",
- variable=self._auto_restart_var).pack(side="left")
- self._auto_switch_var = tk.BooleanVar(value=self._cfg.get("auto_switch_provider", False))
- ttk.Checkbutton(opts_frame, text="Auto-switch provider on repeated failure",
- variable=self._auto_switch_var).pack(side="left", padx=(12, 0))
-
- ttk.Button(frame, text="Save Configuration", command=self._on_save).pack(pady=(8, 0))
-
- stats = self._store.get("stats", {"ai_calls": 0, "tokens_used": 0})
- stats_text = (f"AI diagnostic calls: {stats.get('ai_calls', 0)} | "
- f"Tokens used: {stats.get('tokens_used', 0):,} | "
- f"Known patterns: {len(self._store.get('incidents', {}))}")
- ttk.Label(main, text=stats_text, font=("Segoe UI", 8)).pack(anchor="w", pady=(8, 0))
-
- inc_frame = ttk.LabelFrame(main, text="Recent Incidents", padding=4)
- inc_frame.pack(fill="both", expand=True, pady=(4, 0))
- self._inc_text = tk.Text(inc_frame, height=8, wrap="word", state="disabled")
- inc_sb = ttk.Scrollbar(inc_frame, orient="vertical", command=self._inc_text.yview)
- self._inc_text.configure(yscrollcommand=inc_sb.set)
- self._inc_text.pack(side="left", fill="both", expand=True)
- inc_sb.pack(side="right", fill="y")
- self._refresh_incidents()
-
- btn_frame = ttk.Frame(main)
- btn_frame.pack(fill="x", pady=(8, 0))
- ttk.Button(btn_frame, text="View Monitoring Log",
- command=lambda: open_file(str(PROXY_CONFIG_DIR / "monitoring.log"))).pack(side="left")
- ttk.Button(btn_frame, text="Clear Incident Store", command=self._on_clear_store).pack(side="left", padx=(8, 0))
- ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right")
-
- def _on_toggle(self):
- self._cfg["enabled"] = self._toggle_var.get()
- save_monitoring_config(self._cfg)
-
- def _on_save(self):
- self._cfg["provider_url"] = self._url_entry.get().strip()
- self._cfg["model"] = self._model_entry.get().strip()
- self._cfg["api_key"] = self._key_entry.get().strip()
- try:
- self._cfg["health_check_interval_s"] = int(self._interval_spin.get())
- except ValueError:
- pass
- self._cfg["auto_restart_proxy"] = self._auto_restart_var.get()
- self._cfg["auto_switch_provider"] = self._auto_switch_var.get()
- save_monitoring_config(self._cfg)
- self._inc_text.configure(state="normal")
- self._inc_text.delete("1.0", "end")
- self._inc_text.insert("end", "Configuration saved.\n")
- self._inc_text.configure(state="disabled")
-
- def _on_clear_store(self):
- save_incident_store({"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}})
- self._store = {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}
- self._refresh_incidents()
-
- def _refresh_incidents(self):
- lines = []
- for pattern, inc in sorted(self._store.get("incidents", {}).items(),
- key=lambda x: x[1].get("last_seen", ""), reverse=True):
- sc = inc.get("success_count", 0)
- fc = inc.get("fail_count", 0)
- rate = sc / max(sc + fc, 1)
- lines.append(
- f"[{inc.get('last_seen', '?')[:16]}] {pattern}\n"
- f" fix={inc.get('fix', '?')} success_rate={rate:.0%} seen={inc.get('occurrences', 0)}x\n"
- )
- if not lines:
- lines.append("No incidents recorded yet.\n\nEnable AI Monitoring and use Codex to populate the store.\n")
- self._inc_text.configure(state="normal")
- self._inc_text.delete("1.0", "end")
- self._inc_text.insert("end", "\n".join(lines))
- self._inc_text.configure(state="disabled")
-
-
-# ═══════════════════════════════════════════════════════════════════════
-# Usage Dashboard
-# ═══════════════════════════════════════════════════════════════════════
-
-class UsageWindow:
- def __init__(self, parent):
- self._U = _usage_theme()
- self._dlg = tk.Toplevel(parent)
- self._dlg.title("Usage Dashboard")
- self._dlg.geometry("720x640")
- self._dlg.transient(parent)
- self._dlg.configure(bg=self._U["base"])
-
- self._build_header()
- self._build_summary_strip()
- ttk.Separator(self._dlg).pack(fill="x", padx=16)
-
- self._cards_frame = tk.Frame(self._dlg, bg=self._U["base"])
- canvas = tk.Canvas(self._cards_frame, bg=self._U["base"], highlightthickness=0)
- scrollbar = ttk.Scrollbar(self._cards_frame, orient="vertical", command=canvas.yview)
- self._cards_inner = tk.Frame(canvas, bg=self._U["base"])
- self._cards_inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
- canvas.create_window((0, 0), window=self._cards_inner, anchor="nw")
- canvas.configure(yscrollcommand=scrollbar.set)
- canvas.pack(side="left", fill="both", expand=True, padx=(16, 0))
- scrollbar.pack(side="right", fill="y")
- self._cards_frame.pack(fill="both", expand=True, pady=(8, 0))
+ self._cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ self._cards_box.set_margin_top(8)
+ sw = Gtk.ScrolledWindow()
+ sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ sw.add(self._cards_box)
+ vbox.pack_start(sw, True, True, 0)
self._refresh()
+ self.show_all()
- def _build_header(self):
- U = self._U
- hdr = tk.Frame(self._dlg, bg=U["base"])
- hdr.pack(fill="x", padx=16, pady=(12, 6))
- tk.Label(hdr, text="⚡", fg=U["accent"], bg=U["base"], font=("Segoe UI", 14)).pack(side="left")
- tk.Label(hdr, text="Usage Dashboard", fg=U["text"], bg=U["base"],
- font=("Segoe UI", 14, "bold")).pack(side="left", padx=(4, 0))
- self._status_dots = tk.Label(hdr, text="", fg=U["text"], bg=U["base"], font=("Segoe UI", 9))
- self._status_dots.pack(side="left", padx=(8, 0))
- self._updated_lbl = tk.Label(hdr, text="Never", fg=U["dim"], bg=U["base"], font=("Segoe UI", 8))
- self._updated_lbl.pack(side="right")
- refresh_btn = tk.Button(hdr, text="Refresh", fg=U["text"], bg=U["surface0"],
- activebackground=U["surface1"], relief="flat", bd=0,
- command=self._refresh, padx=12, pady=2)
- refresh_btn.pack(side="right", padx=(8, 0))
+ def _build_header(self, parent):
+ hdr = Gtk.Box(spacing=8)
+ hdr.set_margin_start(16)
+ hdr.set_margin_end(16)
+ hdr.set_margin_top(12)
+ hdr.set_margin_bottom(6)
+ parent.pack_start(hdr, False, False, 0)
- def _build_summary_strip(self):
- U = self._U
- strip = tk.Frame(self._dlg, bg=U["surface0"], padx=12, pady=8)
- strip.pack(fill="x", padx=16, pady=(0, 6))
- self._kpi_labels = {}
- for key, label, icon in [("providers", "Providers", "\U0001F4CA"),
- ("requests", "Requests", "⚡"),
- ("tokens", "Tokens", "\U0001F9E0"),
- ("latency", "Avg Latency", "⏱")]:
- box = tk.Frame(strip, bg=U["surface0"])
- box.pack(side="left", padx=(0, 20))
- tk.Label(box, text=f"{icon} {label}", fg=U["dim"], bg=U["surface0"],
- font=("Segoe UI", 8), anchor="w").pack(anchor="w")
- val = tk.Label(box, text="-", fg=U["text"], bg=U["surface0"],
- font=("Segoe UI", 9, "bold"), anchor="w")
- val.pack(anchor="w")
- self._kpi_labels[key] = val
+ bolt = Gtk.Label()
+ bolt.set_markup(f'\u26A1')
+ hdr.pack_start(bolt, False, False, 0)
+
+ title = Gtk.Label()
+ title.set_markup(f'Usage Dashboard')
+ hdr.pack_start(title, False, False, 0)
+
+ self._status_dots = Gtk.Label()
+ hdr.pack_start(self._status_dots, False, False, 8)
+
+ self._updated_lbl = Gtk.Label()
+ self._updated_lbl.set_markup(f'Never')
+ hdr.pack_end(self._updated_lbl, False, False, 4)
+
+ refresh_btn = Gtk.Button(label="Refresh")
+ _apply_css(refresh_btn, f"""
+ button {{ color: {_U["text"]}; background-color: {_U["surface0"]};
+ border: 1px solid {_U["surface1"]}; border-radius: 6px; padding: 4px 12px; }}
+ button:hover {{ background-color: {_U["surface1"]}; }}
+ """)
+ refresh_btn.connect("clicked", lambda b: self._refresh())
+ hdr.pack_end(refresh_btn, False, False, 0)
+
+ def _build_summary_strip(self, parent):
+ strip = Gtk.Box(spacing=0)
+ strip.set_margin_start(16)
+ strip.set_margin_end(16)
+ strip.set_margin_bottom(6)
+ _apply_css(strip, f"box {{ background-color: {_U["surface0"]}; border-radius: 8px; padding: 8px 12px; }}")
+ parent.pack_start(strip, False, False, 0)
+
+ self._kpi_boxes = {}
+ for key, label, icon in [
+ ("providers", "Providers", "\U0001F4CA"),
+ ("requests", "Requests", "\u26A1"),
+ ("tokens", "Tokens", "\U0001F9E0"),
+ ("latency", "Avg Latency", "\u23F1"),
+ ]:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
+ lbl = Gtk.Label()
+ lbl.set_markup(f'{icon} {label}')
+ lbl.set_xalign(0)
+ box.pack_start(lbl, False, False, 0)
+ val = Gtk.Label()
+ val.set_markup(f'-')
+ val.set_xalign(0)
+ box.pack_start(val, False, False, 0)
+ box.set_margin_end(20)
+ strip.pack_start(box, False, False, 0)
+ self._kpi_boxes[key] = val
def _refresh(self):
- for w in self._cards_inner.winfo_children():
- w.destroy()
- stats = load_usage_stats()
+ for c in self._cards_box.get_children():
+ self._cards_box.remove(c)
+ stats = _load_usage_stats()
updated = stats.get("updated")
if updated:
- self._updated_lbl.configure(text=updated)
+ self._updated_lbl.set_markup(f'{updated}')
providers = stats.get("providers", {})
if not providers:
- tk.Label(self._cards_inner, text="No usage data yet.\nLaunch a session to start tracking.",
- fg=self._U["dim"], bg=self._U["base"], font=("Segoe UI", 11)).pack(pady=60)
+ empty = Gtk.Label()
+ empty.set_markup(f'No usage data yet.\nLaunch a session to start tracking.')
+ empty.set_margin_top(60)
+ self._cards_box.pack_start(empty, False, False, 0)
+ self._cards_box.show_all()
return
- total_req = total_tok_in = total_tok_out = 0
+ total_req = 0
+ total_tok_in = 0
+ total_tok_out = 0
total_dur = 0.0
- n_ok = n_warn = n_err = 0
+ n_ok = 0
+ n_warn = 0
+ n_err = 0
sorted_providers = sorted(providers.items(), key=lambda x: x[1].get("total_requests", 0), reverse=True)
for prov_name, prov_data in sorted_providers:
@@ -1547,6 +4963,7 @@ class UsageWindow:
total_dur += prov_data.get("total_duration_s", 0.0)
fail = prov_data.get("failures", 0)
fail_pct = fail / t if t > 0 else 0
+ _, sc = _status_pill(0, fail_pct)
if fail_pct > 0.15:
n_err += 1
elif fail_pct > 0.05:
@@ -1554,28 +4971,42 @@ class UsageWindow:
else:
n_ok += 1
- self._kpi_labels["providers"].configure(text=str(len(providers)))
- self._kpi_labels["requests"].configure(text=f"{total_req:,}")
+ self._kpi_boxes["providers"].set_markup(
+ f'{len(providers)}')
+ self._kpi_boxes["requests"].set_markup(
+ f'{total_req:,}')
tok_sum = total_tok_in + total_tok_out
tok_str = f"{_fmt_tok(tok_sum)} in:{_fmt_tok(total_tok_in)} out:{_fmt_tok(total_tok_out)}" if tok_sum else "N/A"
- self._kpi_labels["tokens"].configure(text=tok_str)
+ self._kpi_boxes["tokens"].set_markup(
+ f'{tok_str}')
avg_lat = total_dur / total_req if total_req > 0 else 0
- self._kpi_labels["latency"].configure(text=_fmt_dur(avg_lat))
+ self._kpi_boxes["latency"].set_markup(
+ f'{_fmt_dur(avg_lat)}')
- dots = ""
+ dots_parts = []
if n_ok:
- dots += f"●{n_ok} "
+ dots_parts.append(f'\u25CF{n_ok}')
if n_warn:
- dots += f"◐{n_warn} "
+ dots_parts.append(f'\u25D0{n_warn}')
if n_err:
- dots += f"✗{n_err}"
- self._status_dots.configure(text=dots)
+ dots_parts.append(f'\u2717{n_err}')
+ if dots_parts:
+ self._status_dots.set_markup(" ".join(dots_parts))
for prov_name, prov_data in sorted_providers:
- self._build_card(prov_name, prov_data)
+ card = self._build_card(prov_name, prov_data)
+ self._cards_box.pack_start(card, False, False, 0)
+ self._cards_box.show_all()
def _build_card(self, name, data):
- U = self._U
+ card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ card.set_margin_start(12)
+ card.set_margin_end(12)
+ _apply_css(card, f"""
+ box {{ background-color: {_U["surface0"]}; border-radius: 10px;
+ border: 1px solid {_U["surface1"]}; }}
+ """)
+
total = data.get("total_requests", 0)
ok = data.get("successes", 0)
fail = data.get("failures", 0)
@@ -1583,138 +5014,284 @@ class UsageWindow:
fail_pct = fail / total if total > 0 else 0
status_text, status_color = _status_pill(success_rate, fail_pct)
- card = tk.Frame(self._cards_inner, bg=U["surface0"], padx=14, pady=10,
- highlightbackground=status_color, highlightthickness=1)
- card.pack(fill="x", pady=(0, 6))
+ border_color = status_color
+ _apply_css(card, f"""
+ box {{ background-color: {_U["surface0"]}; border-radius: 10px;
+ border: 1px solid {border_color}; }}
+ """)
- top = tk.Frame(card, bg=U["surface0"])
- top.pack(fill="x")
- tk.Label(top, text="●", fg=status_color, bg=U["surface0"], font=("Segoe UI", 10)).pack(side="left")
+ inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
+ inner.set_margin_start(14)
+ inner.set_margin_end(14)
+ inner.set_margin_top(10)
+ inner.set_margin_bottom(10)
+ card.pack_start(inner, False, False, 0)
+
+ top = Gtk.Box(spacing=6)
+ inner.pack_start(top, False, False, 0)
+
+ dot = Gtk.Label()
+ dot.set_markup(f'\u25CF')
+ top.pack_start(dot, False, False, 0)
+
+ name_lbl = Gtk.Label()
short = name.replace("https://", "").replace("http://", "").split("/")[0]
- tk.Label(top, text=short, fg=U["text"], bg=U["surface0"],
- font=("Segoe UI", 10, "bold")).pack(side="left", padx=(4, 0))
- tk.Label(top, text=f" {status_text} ", fg=U["base"], bg=status_color,
- font=("Segoe UI", 8, "bold")).pack(side="left", padx=(4, 0))
- tk.Label(top, text=f"{total} req", fg=U["subtext"], bg=U["surface0"],
- font=("Segoe UI", 8)).pack(side="left", padx=(6, 0))
+ name_lbl.set_markup(f'{short}')
+ top.pack_start(name_lbl, False, False, 0)
+
+ pill = Gtk.Label()
+ pill.set_markup(f' {status_text} ')
+ top.pack_start(pill, False, False, 4)
+
+ req_lbl = Gtk.Label()
+ req_lbl.set_markup(f'{total} req')
+ top.pack_start(req_lbl, False, False, 6)
+
last_used = data.get("last_used", "")
if last_used:
- tk.Label(top, text=last_used, fg=U["dim"], bg=U["surface0"],
- font=("Segoe UI", 7)).pack(side="right")
+ lu_lbl = Gtk.Label()
+ lu_lbl.set_markup(f'{last_used}')
+ top.pack_end(lu_lbl, False, False, 0)
+
+ sep1 = Gtk.Separator()
+ _apply_css(sep1, f"separator {{ background-color: {status_color}; margin-top: 4px; }}")
+ inner.pack_start(sep1, False, False, 0)
+
+ gauge_box = Gtk.Box(spacing=4)
+ gauge_box.set_margin_top(4)
+ inner.pack_start(gauge_box, False, False, 0)
+
+ gauge_label = Gtk.Label()
+ gauge_label.set_markup(f'\u26A1')
+ gauge_box.pack_start(gauge_label, False, False, 0)
+
+ bar = Gtk.ProgressBar()
+ bar.set_fraction(success_rate)
+ bar_pct = int(success_rate * 100)
+ bar.set_text(f"{bar_pct}%")
+ bar.set_show_text(True)
+ bar_css = f"""
+ progress {{ background-color: {status_color}; border-radius: 6px; }}
+ trough {{ background-color: {_U["surface1"]}; border-radius: 6px; min-height: 12px; }}
+ """
+ _apply_css(bar, bar_css)
+ bar.set_hexpand(True)
+ gauge_box.pack_start(bar, True, True, 0)
- gauge = tk.Frame(card, bg=U["surface0"])
- gauge.pack(fill="x", pady=(4, 0))
- bar_frame = tk.Frame(gauge, bg=U["surface1"], height=12)
- bar_frame.pack(fill="x", side="left", expand=True)
- bar_frame.pack_propagate(False)
- fill_pct = int(success_rate * 100)
- fill_frame = tk.Frame(bar_frame, bg=status_color, height=12)
- fill_frame.place(relwidth=success_rate, relheight=1.0)
- tk.Label(gauge, text=f"{fill_pct}%", fg=U["subtext"], bg=U["surface0"],
- font=("Segoe UI", 8)).pack(side="left", padx=(4, 0))
if fail > 0:
- tk.Label(gauge, text=f"{fail} fail", fg=U["red"], bg=U["surface0"],
- font=("Segoe UI", 8)).pack(side="right")
+ fail_lbl = Gtk.Label()
+ fail_lbl.set_markup(f'{fail} fail')
+ gauge_box.pack_end(fail_lbl, False, False, 0)
+
+ metrics_box = Gtk.Box(spacing=0)
+ metrics_box.set_margin_top(4)
+ inner.pack_start(metrics_box, False, False, 0)
- metrics = tk.Frame(card, bg=U["surface0"])
- metrics.pack(fill="x", pady=(4, 0))
t_in = data.get("total_tokens_in", 0)
t_out = data.get("total_tokens_out", 0)
dur = data.get("total_duration_s", 0.0)
avg_dur = dur / total if total > 0 else 0
- for label, value, color in [("Tokens In", _fmt_tok(t_in), U["sapphire"]),
- ("Tokens Out", _fmt_tok(t_out), U["peach"]),
- ("Avg Latency", _fmt_dur(avg_dur), U["sky"]),
- ("Duration", _fmt_dur(dur), U["lavender"])]:
- box = tk.Frame(metrics, bg=U["surface0"])
- box.pack(side="left", padx=(0, 16))
- tk.Label(box, text=label, fg=U["dim"], bg=U["surface0"], font=("Segoe UI", 7)).pack(anchor="w")
- tk.Label(box, text=value, fg=color, bg=U["surface0"],
- font=("Segoe UI", 9, "bold")).pack(anchor="w")
+
+ for label, value, color in [
+ ("Tokens In", f"{_fmt_tok(t_in)}", _U["sapphire"]),
+ ("Tokens Out", f"{_fmt_tok(t_out)}", _U["peach"]),
+ ("Avg Latency", _fmt_dur(avg_dur), _U["sky"]),
+ ("Duration", _fmt_dur(dur), _U["lavender"]),
+ ]:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ l = Gtk.Label()
+ l.set_markup(f'{label}')
+ l.set_xalign(0)
+ box.pack_start(l, False, False, 0)
+ v = Gtk.Label()
+ v.set_markup(f'{value}')
+ v.set_xalign(0)
+ box.pack_start(v, False, False, 0)
+ box.set_margin_end(16)
+ metrics_box.pack_start(box, False, False, 0)
models = data.get("models", {})
if models:
- models_frame = tk.Frame(card, bg=U["surface0"])
- models_frame.pack(fill="x", pady=(4, 0))
- tk.Label(models_frame, text="Models:", fg=U["lavender"], bg=U["surface0"],
- font=("Segoe UI", 8, "bold")).pack(anchor="w")
- sorted_models = sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True)
- for i, (mname, mdata) in enumerate(sorted_models[:6]):
- m_req = mdata.get("requests", 0)
- pct = m_req / total * 100 if total > 0 else 0
- color = U["model_palette"][i % len(U["model_palette"])]
- row = tk.Frame(models_frame, bg=U["surface0"])
- row.pack(fill="x")
- tk.Label(row, text=f"● {mname}", fg=color, bg=U["surface0"],
- font=("Segoe UI", 7)).pack(side="left")
- tk.Label(row, text=f"{pct:.0f}% ({m_req})", fg=U["dim"], bg=U["surface0"],
- font=("Segoe UI", 7)).pack(side="left", padx=(8, 0))
+ self._build_models_section(inner, models, total)
last_err = data.get("last_error")
if last_err:
- err_frame = tk.Frame(card, bg=U["surface0"])
- err_frame.pack(fill="x", pady=(4, 0))
- tk.Label(err_frame, text=f"⚠ {last_err}", fg=U["red"], bg=U["surface0"],
- font=("Segoe UI", 7)).pack(anchor="w")
+ err_box = Gtk.Box(spacing=4)
+ err_box.set_margin_top(4)
+ inner.pack_start(err_box, False, False, 0)
+ icon = Gtk.Label()
+ icon.set_markup(f'\u26A0')
+ err_box.pack_start(icon, False, False, 0)
+ err_lbl = Gtk.Label()
+ err_lbl.set_markup(f'{last_err}')
+ err_lbl.set_xalign(0)
+ err_lbl.set_line_wrap(True)
+ err_box.pack_start(err_lbl, False, False, 0)
+
+ return card
+
+ def _build_models_section(self, parent, models, total_req):
+ sep_m = Gtk.Separator()
+ _apply_css(sep_m, f"separator {{ background-color: {_U["lavender"]}; margin-top: 4px; margin-bottom: 2px; }}")
+ parent.pack_start(sep_m, False, False, 0)
+
+ header = Gtk.Box(spacing=4)
+ header.set_margin_top(2)
+ parent.pack_start(header, False, False, 0)
+ icon = Gtk.Label()
+ icon.set_markup(f'\U0001F916')
+ header.pack_start(icon, False, False, 0)
+ lbl = Gtk.Label()
+ lbl.set_markup(f'Models')
+ header.pack_start(lbl, False, False, 0)
+
+ sorted_models = sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True)
+
+ if total_req > 0:
+ comp_bar = Gtk.Box(spacing=0)
+ _apply_css(comp_bar, f"box {{ background-color: {_U["surface1"]}; border-radius: 4px; min-height: 8px; margin-top: 2px; }}")
+ parent.pack_start(comp_bar, False, False, 0)
+ for i, (mname, mdata) in enumerate(sorted_models):
+ m_req = mdata.get("requests", 0)
+ pct = m_req / total_req
+ if pct < 0.01:
+ continue
+ seg = Gtk.Box()
+ color = _U["model_palette"][i % len(_U["model_palette"])]
+ _apply_css(seg, f"box {{ background-color: {color}; min-height: 8px; }}")
+ seg.set_size_request(max(int(pct * 400), 4), 8)
+ comp_bar.pack_start(seg, False, False, 0)
+
+ models_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
+ models_box.set_margin_top(2)
+ parent.pack_start(models_box, False, False, 0)
+
+ for i, (mname, mdata) in enumerate(sorted_models[:6]):
+ row = Gtk.Box(spacing=6)
+ models_box.pack_start(row, False, False, 0)
+ color = _U["model_palette"][i % len(_U["model_palette"])]
+ dot = Gtk.Label()
+ dot.set_markup(f'\u25CF')
+ row.pack_start(dot, False, False, 0)
+ m_lbl = Gtk.Label()
+ m_lbl.set_markup(f'{mname}')
+ m_lbl.set_xalign(0)
+ m_lbl.set_size_request(120, -1)
+ row.pack_start(m_lbl, False, False, 0)
+
+ m_req = mdata.get("requests", 0)
+ pct = m_req / total_req * 100 if total_req > 0 else 0
+
+ m_bar = Gtk.ProgressBar()
+ m_bar.set_fraction(m_req / total_req if total_req > 0 else 0)
+ _apply_css(m_bar, f"""
+ progress {{ background-color: {color}; border-radius: 3px; }}
+ trough {{ background-color: {_U["surface1"]}; border-radius: 3px; min-height: 6px; }}
+ """)
+ m_bar.set_size_request(80, -1)
+ row.pack_start(m_bar, False, False, 0)
+
+ pct_lbl = Gtk.Label()
+ pct_lbl.set_markup(f'{pct:.0f}% ({m_req})')
+ row.pack_start(pct_lbl, False, False, 0)
+
+ m_in = mdata.get("tokens_in", 0)
+ m_out = mdata.get("tokens_out", 0)
+ if m_in or m_out:
+ tok_lbl = Gtk.Label()
+ tok_lbl.set_markup(f'in:{_fmt_tok(m_in)} out:{_fmt_tok(m_out)}')
+ row.pack_end(tok_lbl, False, False, 0)
-# ═══════════════════════════════════════════════════════════════════════
-# Request History Window
-# ═══════════════════════════════════════════════════════════════════════
+def main():
+ for d in [LOG_DIR, PROXY_CONFIG_DIR]:
+ d.mkdir(parents=True, exist_ok=True)
+
+ # Create default endpoints if none exist
+ if not ENDPOINTS_FILE.exists():
+ save_endpoints({
+ "default": "OpenAI",
+ "endpoints": [
+ {"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1",
+ "api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"],
+ "provider_preset": "OpenAI"},
+ {"name": "Z.AI", "backend_type": "openai-compat",
+ "base_url": "https://api.z.ai/api/coding/paas/v4",
+ "api_key": "", "default_model": "glm-5.1",
+ "models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"],
+ "provider_preset": "Custom"},
+ ],
+ })
+
+ w = LauncherWin()
+ w.connect("destroy", Gtk.main_quit)
+ Gtk.main()
+
+class RequestHistoryWindow(Gtk.Window):
+ _SNAP_DIR = Path.home() / ".cache/codex-proxy/requests"
-class RequestHistoryWindow:
def __init__(self, parent):
- self._snap_dir = PROXY_CONFIG_DIR / "requests"
- self._dlg = tk.Toplevel(parent)
- self._dlg.title("Request History")
- self._dlg.geometry("720x500")
- self._dlg.transient(parent)
+ Gtk.Window.__init__(self, title="Request History")
+ self.set_transient_for(parent)
+ self.set_default_size(720, 500)
+ self.set_position(Gtk.WindowPosition.CENTER)
- main = ttk.Frame(self._dlg, padding=10)
- main.pack(fill="both", expand=True)
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ vbox.set_margin_start(10)
+ vbox.set_margin_end(10)
+ vbox.set_margin_top(10)
+ vbox.set_margin_bottom(10)
+ self.add(vbox)
- hdr = ttk.Frame(main)
- hdr.pack(fill="x")
- ttk.Label(hdr, text="Request History", font=("Segoe UI", 11, "bold")).pack(side="left")
- ttk.Button(hdr, text="Clear All", command=self._clear_all).pack(side="right")
- ttk.Button(hdr, text="Refresh", command=self._load).pack(side="right", padx=(0, 4))
+ hdr = Gtk.Box(spacing=8)
+ vbox.pack_start(hdr, False, False, 0)
+ lbl = Gtk.Label(label="Request History")
+ lbl.set_use_markup(True)
+ hdr.pack_start(lbl, False, False, 0)
+ refresh_btn = Gtk.Button(label="Refresh")
+ refresh_btn.connect("clicked", lambda b: self._load())
+ hdr.pack_end(refresh_btn, False, False, 0)
+ clear_btn = Gtk.Button(label="Clear All")
+ clear_btn.connect("clicked", lambda b: self._clear_all())
+ hdr.pack_end(clear_btn, False, False, 0)
- paned = ttk.PanedWindow(main, orient="vertical")
- paned.pack(fill="both", expand=True, pady=(6, 0))
+ paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
+ vbox.pack_start(paned, True, True, 0)
- top_frame = ttk.Frame(paned)
- cols = ("time", "model", "status", "duration", "id", "error")
- self._tree = ttk.Treeview(top_frame, columns=cols, show="headings", height=10)
- for col, heading, w in [("time", "Time", 140), ("model", "Model", 140), ("status", "Status", 80),
- ("duration", "Duration", 70), ("id", "ID", 180), ("error", "Error", 120)]:
- self._tree.heading(col, text=heading)
- self._tree.column(col, width=w, minwidth=50)
- tree_sb = ttk.Scrollbar(top_frame, orient="vertical", command=self._tree.yview)
- self._tree.configure(yscrollcommand=tree_sb.set)
- self._tree.pack(side="left", fill="both", expand=True)
- tree_sb.pack(side="right", fill="y")
- paned.add(top_frame, weight=1)
+ top_sw = Gtk.ScrolledWindow()
+ top_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ paned.pack1(top_sw, resize=True, shrink=False)
- bottom_frame = ttk.Frame(paned)
- self._detail = tk.Text(bottom_frame, height=10, wrap="word", font=("Consolas", 9))
- detail_sb = ttk.Scrollbar(bottom_frame, orient="vertical", command=self._detail.yview)
- self._detail.configure(yscrollcommand=detail_sb.set)
- self._detail.pack(side="left", fill="both", expand=True)
- detail_sb.pack(side="right", fill="y")
- paned.add(bottom_frame, weight=1)
+ self._store = Gtk.ListStore(str, str, str, str, str, str)
+ self._tree = Gtk.TreeView(model=self._store)
+ for i, (title, w) in enumerate([("Time", 140), ("Model", 140), ("Status", 80), ("Duration", 70), ("ID", 180), ("Error", 120)]):
+ col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i)
+ col.set_resizable(True)
+ col.set_min_width(w)
+ self._tree.append_column(col)
+ self._tree.connect("row-activated", self._on_row_activated)
+ top_sw.add(self._tree)
- self._tree.bind("<>", self._on_select)
+ self._detail = Gtk.TextView()
+ self._detail.set_editable(False)
+ self._detail.set_monospace(True)
+ self._detail.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+ bottom_sw = Gtk.ScrolledWindow()
+ bottom_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ bottom_sw.add(self._detail)
+ paned.pack2(bottom_sw, resize=True, shrink=False)
self._snapshots = []
self._load()
+ self.show_all()
def _load(self):
- for item in self._tree.get_children():
- self._tree.delete(item)
+ self._store.clear()
self._snapshots = []
- if not self._snap_dir.exists():
+ snap_dir = self._SNAP_DIR
+ if not snap_dir.exists():
return
- files = sorted(self._snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
+ files = sorted(snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
for f in files[:200]:
try:
data = json.loads(f.read_text())
@@ -1726,146 +5303,172 @@ class RequestHistoryWindow:
dur = f"{meta['duration_s']:.1f}s" if meta.get("duration_s") is not None else "-"
rid = meta.get("request_id", "")[:28]
err = (meta.get("error") or "")[:60]
- self._tree.insert("", "end", values=(ts, model, status, dur, rid, err))
+ self._store.append([ts, model, status, dur, rid, err])
except Exception:
pass
- def _on_select(self, event):
- sel = self._tree.selection()
- if not sel:
- return
- idx = self._tree.index(sel[0])
+ def _on_row_activated(self, tree, path, column):
+ idx = path[0]
if idx < len(self._snapshots):
data = self._snapshots[idx]
- self._detail.delete("1.0", "end")
- self._detail.insert("end", json.dumps(data, indent=2, ensure_ascii=False)[:50000])
+ buf = self._detail.get_buffer()
+ buf.set_text(json.dumps(data, indent=2, ensure_ascii=False)[:50000])
def _clear_all(self):
- if not messagebox.askyesno("Clear All", "Delete all request snapshots?", parent=self._dlg):
+ d = Gtk.MessageDialog(self, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO,
+ "Delete all request snapshots?")
+ r = d.run()
+ d.destroy()
+ if r != Gtk.ResponseType.YES:
return
- if self._snap_dir.exists():
- for f in self._snap_dir.glob("*.json"):
+ snap_dir = self._SNAP_DIR
+ if snap_dir.exists():
+ for f in snap_dir.glob("*.json"):
try:
f.unlink()
except Exception:
pass
- for item in self._tree.get_children():
- self._tree.delete(item)
+ self._store.clear()
self._snapshots = []
- self._detail.delete("1.0", "end")
+ self._detail.get_buffer().set_text("")
-
-# ═══════════════════════════════════════════════════════════════════════
-# Benchmark Window
-# ═══════════════════════════════════════════════════════════════════════
-
-class BenchmarkWindow:
+class BenchmarkWindow(Gtk.Window):
_BENCH_PROMPT = "In exactly 3 bullet points, explain why the sky is blue."
_BENCH_TOOLS = [{"type": "function", "function": {"name": "get_weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}}}}]
def __init__(self, parent):
- self._dlg = tk.Toplevel(parent)
- self._dlg.title("Model Benchmark")
- self._dlg.geometry("820x560")
- self._dlg.transient(parent)
+ Gtk.Window.__init__(self, title="Model Benchmark")
+ self.set_transient_for(parent)
+ self.set_default_size(820, 560)
+ self.set_position(Gtk.WindowPosition.CENTER)
self._running = False
self._ep_data = load_endpoints()
- main = ttk.Frame(self._dlg, padding=10)
- main.pack(fill="both", expand=True)
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ vbox.set_margin_start(10)
+ vbox.set_margin_end(10)
+ vbox.set_margin_top(10)
+ vbox.set_margin_bottom(10)
+ self.add(vbox)
- hdr = ttk.Frame(main)
- hdr.pack(fill="x")
- ttk.Label(hdr, text="Multi-Provider Benchmark", font=("Segoe UI", 11, "bold")).pack(side="left")
- self._run_btn = ttk.Button(hdr, text="Run Benchmark", command=self._run)
- self._run_btn.pack(side="right")
+ hdr = Gtk.Box(spacing=8)
+ vbox.pack_start(hdr, False, False, 0)
+ lbl = Gtk.Label(label="Multi-Provider Benchmark")
+ lbl.set_use_markup(True)
+ hdr.pack_start(lbl, False, False, 0)
+ self._run_btn = Gtk.Button(label="Run Benchmark")
+ self._run_btn.connect("clicked", lambda b: self._run())
+ hdr.pack_end(self._run_btn, False, False, 0)
- lanes_frame = ttk.Frame(main)
- lanes_frame.pack(fill="x", pady=(8, 0))
+ lanes_box = Gtk.Box(spacing=6)
+ vbox.pack_start(lanes_box, False, False, 0)
self._lanes = []
- self._c_var = tk.BooleanVar(value=False)
- for i, lane_label in enumerate(["A", "B", "C"]):
+ for i in range(3):
+ frame = Gtk.Frame(label=f"{'A' if i == 0 else 'B' if i == 1 else 'C'}" if i < 2 else None)
if i == 2:
- lf = ttk.LabelFrame(lanes_frame, text="Lane C (optional)")
- cb = ttk.Checkbutton(lanes_frame, text="Enable Lane C", variable=self._c_var,
- command=lambda: lf.configure() if not self._c_var.get() else None)
- else:
- lf = ttk.LabelFrame(lanes_frame, text=f"Lane {lane_label}")
- lf.pack(side="left", fill="both", expand=True, padx=(0, 4 if i < 2 else 0))
+ self._c_frame = frame
+ self._c_check = Gtk.CheckButton(label="Enable Lane C")
+ self._c_check.set_active(False)
+ frame.set_label_widget(self._c_check)
+ frame.set_sensitive(False)
+ self._c_check.connect("toggled", lambda b: frame.set_sensitive(b.get_active()))
+ inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ inner.set_margin_start(6)
+ inner.set_margin_end(6)
+ inner.set_margin_top(4)
+ inner.set_margin_bottom(4)
+ frame.add(inner)
+ lanes_box.pack_start(frame, True, True, 0)
- ep_frame = ttk.Frame(lf, padding=4)
- ep_frame.pack(fill="x")
- ttk.Label(ep_frame, text="Endpoint:").pack(side="left")
- ep_combo = ttk.Combobox(ep_frame, values=[e["name"] for e in self._ep_data.get("endpoints", [])], state="readonly")
- ep_combo.pack(side="left", fill="x", expand=True, padx=(4, 0))
+ row_ep = Gtk.Box(spacing=4)
+ inner.pack_start(row_ep, False, False, 0)
+ row_ep.pack_start(Gtk.Label(label="Endpoint:"), False, False, 0)
+ ep_combo = Gtk.ComboBoxText()
+ for ep in self._ep_data.get("endpoints", []):
+ ep_combo.append(ep["name"], ep["name"])
+ row_ep.pack_start(ep_combo, True, True, 0)
- m_frame = ttk.Frame(lf, padding=4)
- m_frame.pack(fill="x")
- ttk.Label(m_frame, text="Model:").pack(side="left")
- m_combo = ttk.Combobox(m_frame, state="readonly")
- m_combo.pack(side="left", fill="x", expand=True, padx=(4, 0))
+ row_m = Gtk.Box(spacing=4)
+ inner.pack_start(row_m, False, False, 0)
+ row_m.pack_start(Gtk.Label(label="Model:"), False, False, 0)
+ m_combo = Gtk.ComboBoxText()
+ m_combo.set_entry_text_column(0)
+ row_m.pack_start(m_combo, True, True, 0)
+
+ ep_combo.connect("changed", lambda b, mc=m_combo: self._update_lane_models(b, mc))
- ep_combo.bind("<>", lambda e, mc=m_combo: self._update_lane_models(ep_combo, mc))
self._lanes.append({"ep": ep_combo, "model": m_combo})
default_name = self._ep_data.get("default")
- eps = self._ep_data.get("endpoints", [])
if default_name:
- self._lanes[0]["ep"].set(default_name)
+ self._lanes[0]["ep"].set_active_id(default_name)
+ eps = self._ep_data.get("endpoints", [])
if len(eps) > 1:
- self._lanes[1]["ep"].set(eps[1]["name"])
+ self._lanes[1]["ep"].set_active_id(eps[1]["name"])
elif eps:
- self._lanes[1]["ep"].set(eps[0]["name"])
+ self._lanes[1]["ep"].set_active_id(eps[0]["name"])
if len(eps) > 2:
- self._lanes[2]["ep"].set(eps[2]["name"])
+ self._lanes[2]["ep"].set_active_id(eps[2]["name"])
elif len(eps) > 1:
- self._lanes[2]["ep"].set(eps[1]["name"])
+ self._lanes[2]["ep"].set_active_id(eps[1]["name"])
- tests_frame = ttk.Frame(main)
- tests_frame.pack(fill="x", pady=(8, 0))
- self._test_ttft = tk.BooleanVar(value=True)
- self._test_total = tk.BooleanVar(value=True)
- self._test_tools = tk.BooleanVar(value=True)
- self._test_tps = tk.BooleanVar(value=True)
- ttk.Checkbutton(tests_frame, text="Time to First Token", variable=self._test_ttft).pack(side="left")
- ttk.Checkbutton(tests_frame, text="Total Latency", variable=self._test_total).pack(side="left", padx=(8, 0))
- ttk.Checkbutton(tests_frame, text="Tool Call", variable=self._test_tools).pack(side="left", padx=(8, 0))
- ttk.Checkbutton(tests_frame, text="Tokens/sec", variable=self._test_tps).pack(side="left", padx=(8, 0))
+ tests_box = Gtk.Box(spacing=6)
+ vbox.pack_start(tests_box, False, False, 0)
+ self._test_ttft = Gtk.CheckButton(label="Time to First Token")
+ self._test_ttft.set_active(True)
+ tests_box.pack_start(self._test_ttft, False, False, 0)
+ self._test_total = Gtk.CheckButton(label="Total Latency")
+ self._test_total.set_active(True)
+ tests_box.pack_start(self._test_total, False, False, 0)
+ self._test_tools = Gtk.CheckButton(label="Tool Call")
+ self._test_tools.set_active(True)
+ tests_box.pack_start(self._test_tools, False, False, 0)
+ self._test_tps = Gtk.CheckButton(label="Tokens/sec")
+ self._test_tps.set_active(True)
+ tests_box.pack_start(self._test_tps, False, False, 0)
- results_frame = ttk.Frame(main)
- results_frame.pack(fill="both", expand=True, pady=(8, 0))
- cols = ("test", "a", "b", "c", "winner")
- self._results_tree = ttk.Treeview(results_frame, columns=cols, show="headings", height=6)
- for col, heading in [("test", "Test"), ("a", "Lane A"), ("b", "Lane B"), ("c", "Lane C"), ("winner", "Winner")]:
- self._results_tree.heading(col, text=heading)
- self._results_tree.column(col, width=150, minwidth=80)
- rsb = ttk.Scrollbar(results_frame, orient="vertical", command=self._results_tree.yview)
- self._results_tree.configure(yscrollcommand=rsb.set)
- self._results_tree.pack(side="left", fill="both", expand=True)
- rsb.pack(side="right", fill="y")
+ results_sw = Gtk.ScrolledWindow()
+ results_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ vbox.pack_start(results_sw, True, True, 0)
- self._status_var = tk.StringVar(value="Select endpoints and models per lane, then Run Benchmark.")
- ttk.Label(main, textvariable=self._status_var).pack(anchor="w", pady=(4, 0))
+ self._results_store = Gtk.ListStore(str, str, str, str, str)
+ self._results_tree = Gtk.TreeView(model=self._results_store)
+ for i, title in enumerate(["Test", "Lane A", "Lane B", "Lane C", "Winner"]):
+ col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i)
+ col.set_resizable(True)
+ self._results_tree.append_column(col)
+ results_sw.add(self._results_tree)
+
+ self._status = Gtk.Label(label="Select endpoints and models per lane, then Run Benchmark.")
+ self._status.set_xalign(0)
+ vbox.pack_start(self._status, False, False, 0)
+
+ self.show_all()
def _update_lane_models(self, ep_combo, model_combo):
- name = ep_combo.get()
+ name = ep_combo.get_active_text()
if not name:
return
ep = get_endpoint(name)
models = (ep or {}).get("models", [])
- model_combo["values"] = models
- if models:
- model_combo.set(models[0])
+ active = model_combo.get_active_text()
+ model_combo.remove_all()
+ for m in models:
+ model_combo.append(m, m)
+ if active and any(m == active for m in models):
+ model_combo.set_active_id(active)
+ elif models:
+ model_combo.set_active(0)
def _collect_lanes(self):
active = []
for i, lane in enumerate(self._lanes):
- if i == 2 and not self._c_var.get():
+ if i == 2 and not self._c_check.get_active():
continue
- ep_name = lane["ep"].get()
- model = lane["model"].get()
+ ep_name = lane["ep"].get_active_text()
+ model = lane["model"].get_active_text()
if not ep_name or not model:
continue
ep = get_endpoint(ep_name)
@@ -1874,13 +5477,44 @@ class BenchmarkWindow:
active.append({"ep": ep, "model": model, "label": f"{ep_name}/{model}"})
return active
+ def _run(self):
+ if self._running:
+ return
+ lanes = self._collect_lanes()
+ if len(lanes) < 2:
+ self._status.set_text("Need at least 2 lanes with endpoint + model selected.")
+ return
+ self._running = True
+ self._run_btn.set_sensitive(False)
+ self._results_store.clear()
+ self._status.set_text("Running benchmark…")
+ threading.Thread(target=self._run_bench, args=(lanes,), daemon=True).start()
+
def _bench_single(self, ep, model, stream, with_tools=False):
url = normalize_base_url(ep.get("base_url", ""))
key = (ep.get("api_key") or "").strip()
bt = ep.get("backend_type", "openai-compat")
if bt == "anthropic":
test_url = f"{url}/v1/messages"
- headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ body = {"model": model, "max_tokens": 100, "stream": stream,
+ "messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
+ if with_tools:
+ body["tools"] = self._BENCH_TOOLS
+ body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
+ data = json.dumps(body).encode()
+ 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}"
+ oauth_token = ""
+ if token_path.exists():
+ try:
+ td = json.loads(token_path.read_text())
+ oauth_token = td.get("access_token", "")
+ except Exception:
+ pass
+ test_url = f"{url}/v1/chat/completions"
+ headers = {"Authorization": f"Bearer {oauth_token}", "content-type": "application/json"}
body = {"model": model, "max_tokens": 100, "stream": stream,
"messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
if with_tools:
@@ -1889,7 +5523,7 @@ class BenchmarkWindow:
data = json.dumps(body).encode()
else:
test_url = f"{url}/chat/completions"
- headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"}
+ headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
body = {"model": model, "max_tokens": 100, "stream": stream,
"messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
if with_tools:
@@ -1930,7 +5564,7 @@ class BenchmarkWindow:
"detail": f"tools={has_tools}, tok={payload.get('usage', {}).get('total_tokens', '?')}"}
content = msg.get("content", "")[:50]
return {"ttft": ttft or total, "total": total,
- "detail": f"{content[:40]}... tok={payload.get('usage', {}).get('total_tokens', '?')}"}
+ "detail": f"{content[:40]}… tok={payload.get('usage', {}).get('total_tokens', '?')}"}
return {"ttft": ttft or total, "total": total, "detail": result_text[:60]}
except Exception as e:
total = time.time() - t0
@@ -1944,12 +5578,29 @@ class BenchmarkWindow:
max_tok = 512
if bt == "anthropic":
test_url = f"{url}/v1/messages"
- headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
+ "messages": [{"role": "user", "content": prompt}]}).encode()
+ 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}"
+ oauth_token = ""
+ if token_path.exists():
+ try:
+ td = json.loads(token_path.read_text())
+ oauth_token = td.get("access_token", "")
+ except Exception:
+ pass
+ test_url = f"{url}/v1/chat/completions"
+ headers = {"Authorization": f"Bearer {oauth_token}", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
+ "messages": [{"role": "user", "content": prompt}]}).encode()
else:
test_url = f"{url}/chat/completions"
- headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"}
- body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
- "messages": [{"role": "user", "content": prompt}]}).encode()
+ headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
+ body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
+ "messages": [{"role": "user", "content": prompt}]}).encode()
+
req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
t0 = time.time()
first_token_t = None
@@ -1966,17 +5617,30 @@ class BenchmarkWindow:
buf += chunk
total = time.time() - t0
text = buf.decode(errors="replace")
- for line in text.split("\n"):
- if line.startswith("data: ") and line != "data: [DONE]":
- try:
- d = json.loads(line[6:])
- content = d.get("choices", [{}])[0].get("delta", {}).get("content", "")
- if content:
- token_count += max(1, len(content) / 4)
- except Exception:
- pass
- if token_count == 0:
- token_count = max(1, len(text) / 4)
+ if bt == "anthropic":
+ for line in text.split("\n"):
+ if "content_block_delta" in line and "text_delta" in line:
+ try:
+ idx = line.index("{")
+ evt = json.loads(line[idx:])
+ delta = evt.get("delta", {})
+ token_count += len(delta.get("text", "")) / 4
+ except Exception:
+ pass
+ if token_count == 0:
+ token_count = max(1, len(text) / 4)
+ else:
+ for line in text.split("\n"):
+ if line.startswith("data: ") and line != "data: [DONE]":
+ try:
+ d = json.loads(line[6:])
+ content = d.get("choices", [{}])[0].get("delta", {}).get("content", "")
+ if content:
+ token_count += max(1, len(content) / 4)
+ except Exception:
+ pass
+ if token_count == 0:
+ token_count = max(1, len(text) / 4)
gen_time = (time.time() - first_token_t) if first_token_t else total
tps = token_count / gen_time if gen_time > 0 else 0
return {"tps": tps, "tokens": int(token_count), "gen_time": gen_time, "total": total,
@@ -1985,36 +5649,22 @@ class BenchmarkWindow:
total = time.time() - t0
return {"tps": 0, "tokens": 0, "gen_time": total, "total": total, "detail": f"Error: {str(e)[:40]}"}
- def _run(self):
- if self._running:
- return
- lanes = self._collect_lanes()
- if len(lanes) < 2:
- self._status_var.set("Need at least 2 lanes with endpoint + model selected.")
- return
- self._running = True
- self._run_btn.configure(state="disabled")
- for item in self._results_tree.get_children():
- self._results_tree.delete(item)
- self._status_var.set("Running benchmark...")
- threading.Thread(target=self._run_bench, args=(lanes,), daemon=True).start()
-
def _run_bench(self, lanes):
results = []
tests = []
- if self._test_ttft.get():
+ if self._test_ttft.get_active():
tests.append(("TTFT (stream)", True, False))
- if self._test_total.get():
+ if self._test_total.get_active():
tests.append(("Total latency", False, False))
- if self._test_tools.get():
+ if self._test_tools.get_active():
tests.append(("Tool call", False, True))
- run_tps = self._test_tps.get()
+ run_tps = self._test_tps.get_active()
for test_name, stream, tools in tests:
lane_results = []
for lane in lanes:
label = lane["label"]
- self._dlg.after(0, lambda l=label: self._status_var.set(f"Running {test_name}: {l}..."))
+ GLib.idle_add(self._status.set_text, f"{test_name}: {label}…")
r = self._bench_single(lane["ep"], lane["model"], stream, tools)
lane_results.append((label, r))
@@ -2022,7 +5672,7 @@ class BenchmarkWindow:
values = [(lr[0], lr[1][metric]) for lr in lane_results]
sorted_v = sorted(values, key=lambda x: x[1])
best_val = sorted_v[0][1]
- second_val = sorted_v[1][1] if len(sorted_v) > 1 else best_val + 1
+ second_val = sorted_v[1][1]
if best_val < second_val * 0.85:
winner = sorted_v[0][0]
else:
@@ -2033,7 +5683,7 @@ class BenchmarkWindow:
v = lr[1][metric]
cols.append(f"{v:.2f}s ({lr[1]['detail'][:30]})")
while len(cols) < 3:
- cols.append("--")
+ cols.append("—")
cols.append(winner)
results.append(tuple([test_name] + cols))
@@ -2041,7 +5691,7 @@ class BenchmarkWindow:
lane_tps = []
for lane in lanes:
label = lane["label"]
- self._dlg.after(0, lambda l=label: self._status_var.set(f"Tokens/sec: {l}..."))
+ GLib.idle_add(self._status.set_text, f"Tokens/sec: {label}…")
r = self._bench_tps(lane["ep"], lane["model"])
lane_tps.append((label, r))
@@ -2059,1106 +5709,18 @@ class BenchmarkWindow:
tps = lt[1]["tps"]
cols_tps.append(f"{tps:.1f} t/s ({lt[1]['detail'][:25]})")
while len(cols_tps) < 3:
- cols_tps.append("--")
+ cols_tps.append("—")
cols_tps.append(winner_tps)
results.append(tuple(["Tokens/sec"] + cols_tps))
def _show():
for row in results:
- self._results_tree.insert("", "end", values=row)
- self._status_var.set("Benchmark complete.")
+ self._results_store.append(row)
+ self._status.set_text("Benchmark complete.")
self._running = False
- self._run_btn.configure(state="normal")
+ self._run_btn.set_sensitive(True)
- self._dlg.after(0, _show)
-
-
-# ═══════════════════════════════════════════════════════════════════════
-# Main Launcher Window
-# ═══════════════════════════════════════════════════════════════════════
-
-class LauncherWin:
- def __init__(self, root):
- self._root = root
- self._proc = None
- self._endpoints_data = load_endpoints()
- self._refresh_running = False
- recover_config_if_needed()
-
- main = ttk.Frame(root, padding=16)
- main.pack(fill="both", expand=True)
- main.pack_propagate(False)
-
-
- # Title
- hdr = ttk.Frame(main)
- hdr.pack(fill="x")
- ttk.Label(hdr, text=f"Codex Launcher v{CHANGELOG[0][0]}", font=("Segoe UI", 13, "bold")).pack(side="left")
-
- # Toolbar — two rows to fit all buttons
- tb1 = ttk.Frame(main)
- tb1.pack(fill="x", pady=(6, 0))
- ttk.Button(tb1, text="Endpoints...", command=self._open_mgr).pack(side="left")
- ttk.Button(tb1, text="AI Monitor", command=self._open_monitoring).pack(side="left", padx=(6, 0))
- ttk.Button(tb1, text="AI BGP", command=self._open_bgp).pack(side="left", padx=(6, 0))
- ttk.Button(tb1, text="Usage", command=self._open_usage).pack(side="left", padx=(6, 0))
- ttk.Button(tb1, text="Benchmark", command=self._open_benchmark).pack(side="left", padx=(6, 0))
- ttk.Button(tb1, text="History", command=self._open_history).pack(side="left", padx=(6, 0))
- ttk.Button(tb1, text="OAuth Secrets", command=self._edit_oauth_secrets).pack(side="left", padx=(6, 0))
- ttk.Button(tb1, text="Changelog", command=self._show_changelog).pack(side="right")
-
- # Detection status — one row per item so long paths don't truncate
- self._cli_info = detect_codex_cli()
- self._desktop_info = detect_codex_desktop()
-
- cli_row = ttk.Frame(main)
- cli_row.pack(fill="x", pady=(4, 0))
- if self._cli_info:
- cli_path, cli_ver = self._cli_info
- ttk.Label(cli_row, text=f"✓ Codex CLI {cli_ver}", foreground="#2ea043").pack(side="left")
- ttk.Label(cli_row, text=f" ({cli_path})", foreground="gray").pack(side="left")
- else:
- ttk.Label(cli_row, text="✗ Codex CLI -- not found", foreground="#d29922").pack(side="left")
- ttk.Button(cli_row, text="Install", command=lambda: self._show_install_guide("cli")).pack(side="left", padx=(6, 0))
-
- desk_row = ttk.Frame(main)
- desk_row.pack(fill="x", pady=(2, 0))
- if self._desktop_info:
- ttk.Label(desk_row, text="✓ Codex Desktop", foreground="#2ea043").pack(side="left")
- ttk.Label(desk_row, text=f" ({self._desktop_info})", foreground="gray").pack(side="left")
- else:
- ttk.Label(desk_row, text="✗ Codex Desktop -- not found", foreground="#d29922").pack(side="left")
- ttk.Button(desk_row, text="Install", command=lambda: self._show_install_guide("desktop")).pack(side="left", padx=(6, 0))
-
- self._missing = []
- if not self._cli_info:
- self._missing.append("cli")
- if not self._desktop_info:
- self._missing.append("desktop")
-
- # Auth status
- auth_frame = ttk.Frame(main)
- auth_frame.pack(fill="x", pady=(6, 0))
- self._auth_label = ttk.Label(auth_frame, text="Checking auth...")
- self._auth_label.pack(side="left")
- self._relogin_btn = ttk.Button(auth_frame, text="Re-login", command=self._codex_relogin, state="disabled")
- self._relogin_btn.pack(side="right")
- threading.Thread(target=self._check_auth_async, daemon=True).start()
-
- # Ops bar
- ops_frame = ttk.Frame(main)
- ops_frame.pack(fill="x", pady=(6, 0))
- self._refresh_all_btn = ttk.Button(ops_frame, text="Refresh Models", command=self._refresh_all_models)
- self._refresh_all_btn.pack(side="left")
- ttk.Button(ops_frame, text="Backup Profile", command=self._backup_profile).pack(side="left", padx=(8, 0))
- ttk.Button(ops_frame, text="Import Profile", command=self._import_profile).pack(side="left", padx=(8, 0))
-
- # Endpoint + Model selectors
- sel_frame = ttk.Frame(main)
- sel_frame.pack(fill="x", pady=(6, 0))
- ttk.Label(sel_frame, text="Endpoint:").pack(side="left")
- self._combo_ep = ttk.Combobox(sel_frame, state="readonly", width=24)
- self._combo_ep.pack(side="left", padx=(4, 0))
- self._combo_ep.bind("<>", lambda e: self._on_endpoint_changed())
- ttk.Label(sel_frame, text="Model:").pack(side="left", padx=(12, 0))
- self._combo_model = ttk.Combobox(sel_frame, state="readonly", width=24)
- self._combo_model.pack(side="left", padx=(4, 0))
-
- # Launch buttons
- btn_frame1 = ttk.Frame(main)
- btn_frame1.pack(fill="x", pady=(8, 0))
- self._btn_desktop = ttk.Button(btn_frame1, text="Launch Desktop", command=lambda: self._launch("desktop"))
- if "desktop" in self._missing:
- self._btn_desktop.configure(state="disabled")
- self._btn_desktop.pack(side="left", fill="x", expand=True, padx=(0, 4))
- self._btn_cli = ttk.Button(btn_frame1, text="Launch CLI", command=lambda: self._launch("cli"))
- if "cli" in self._missing:
- self._btn_cli.configure(state="disabled")
- self._btn_cli.pack(side="left", fill="x", expand=True)
-
- btn_frame2 = ttk.Frame(main)
- btn_frame2.pack(fill="x", pady=(4, 0))
- self._btn_codex_desktop = ttk.Button(btn_frame2, text="Codex Default (Desktop)",
- command=lambda: self._launch_codex_default("desktop"))
- if "desktop" in self._missing:
- self._btn_codex_desktop.configure(state="disabled")
- self._btn_codex_desktop.pack(side="left", fill="x", expand=True, padx=(0, 4))
- self._btn_codex_cli = ttk.Button(btn_frame2, text="Codex Default (CLI)",
- command=lambda: self._launch_codex_default("cli"))
- if "cli" in self._missing:
- self._btn_codex_cli.configure(state="disabled")
- self._btn_codex_cli.pack(side="left", fill="x", expand=True)
-
- # Log area
- self._log_text = scrolledtext.ScrolledText(main, height=10, state="disabled", wrap="word",
- font=("Consolas", 9))
- self._log_text.pack(fill="both", expand=True, pady=(8, 0))
-
- # Bottom bar
- bb = ttk.Frame(main)
- bb.pack(fill="x", pady=(6, 0))
- ttk.Button(bb, text="Clear Log", command=self._clear_log).pack(side="left")
- self._restart_btn = ttk.Button(bb, text="Restart Proxy", command=self._restart_proxy, state="disabled")
- self._restart_btn.pack(side="left", padx=(4, 0))
- ttk.Button(bb, text="AI Assistant", command=self._open_assistant).pack(side="left", padx=(4, 0))
- self._kill_btn = ttk.Button(bb, text="Kill && Cleanup", command=self._kill, state="disabled")
- self._kill_btn.pack(side="left", fill="x", expand=True, padx=(8, 0))
- ttk.Button(bb, text="View Log", command=self._open_proxy_log_dir).pack(side="left")
- ttk.Button(bb, text="Close", command=self._do_close).pack(side="left", padx=(8, 0))
-
- self._rebuild_combo()
- self._log_dependency_status()
- self._start_watcher()
-
- # ── Logging ──────────────────────────────────────────────────────
-
- def log(self, msg):
- self._root.after(0, self._append_log, msg)
-
- def _append_log(self, msg):
- self._log_text.configure(state="normal")
- self._log_text.insert("end", msg + "\n")
- self._log_text.see("end")
- self._log_text.configure(state="disabled")
-
- def _clear_log(self):
- self._log_text.configure(state="normal")
- self._log_text.delete("1.0", "end")
- self._log_text.configure(state="disabled")
-
- def _restart_proxy(self):
- self._kill()
- ep_name = load_endpoints().get("default")
- if not ep_name:
- self.log("No default endpoint set.")
- return
- for ep in load_endpoints().get("endpoints", []):
- if ep.get("name") == ep_name:
- time.sleep(0.3)
- start_proxy_for(ep, self.log)
- self.log(f"Proxy restarted for {ep_name}")
- return
- self.log(f"Endpoint '{ep_name}' not found.")
-
- def _log_dependency_status(self):
- if self._cli_info:
- _, ver = self._cli_info
- self.log(f"✓ Codex CLI detected ({ver})")
- else:
- self.log("✗ Codex CLI NOT found -- CLI launch disabled.")
- if self._desktop_info:
- self.log(f"✓ Codex Desktop detected ({self._desktop_info})")
- else:
- self.log("✗ Codex Desktop NOT found -- Desktop launch disabled.")
- if self._missing:
- self.log("Install missing tools before using the launcher.")
- else:
- self.log("All dependencies OK.")
-
- # ── Auth ─────────────────────────────────────────────────────────
-
- def _check_auth_async(self):
- status, msg = check_codex_auth()
- self._root.after(0, lambda: self._update_auth_status(status, msg))
-
- def _update_auth_status(self, status, msg):
- if status == "logged_in":
- self._auth_label.configure(text=f"✓ Auth: {msg}", foreground="#2ea043")
- self._relogin_btn.configure(state="normal" if "cli" not in self._missing else "disabled")
- elif status == "not_installed":
- self._auth_label.configure(text="Auth: N/A (CLI not installed)", foreground="#888")
- else:
- self._auth_label.configure(text=f"⚠ Auth: {msg}", foreground="#d29922")
- self._relogin_btn.configure(state="normal" if "cli" not in self._missing else "disabled")
-
- def _codex_relogin(self):
- self.log("Opening codex login in terminal...")
- term = detect_terminal()
- if not term:
- self.log("ERROR: no terminal emulator found for re-login")
- return
- term_name, term_args, term_path = term
- cmd_parts = [term_name] + term_args + ["codex", "login"]
- if IS_WINDOWS:
- subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
- else:
- subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
- self.log("Login flow started in terminal. Re-checking auth in 30s...")
- self._auth_label.configure(text="Auth: waiting for login...")
- threading.Thread(target=lambda: (time.sleep(30), self._check_auth_async()), daemon=True).start()
-
- # ── Combo management ─────────────────────────────────────────────
-
- def _rebuild_combo(self):
- self._endpoints_data = load_endpoints()
- ep_names = [e["name"] for e in self._endpoints_data["endpoints"]]
- bgp_names = [f"\U0001F500 {p['name']}" for p in load_bgp_pools().get("pools", [])]
- all_names = ep_names + bgp_names
- self._combo_ep["values"] = all_names
- if all_names:
- default = self._endpoints_data.get("default")
- if default and default in ep_names:
- self._combo_ep.set(default)
- else:
- self._combo_ep.set(all_names[0])
- self._on_endpoint_changed()
-
- def _on_endpoint_changed(self):
- name = self._combo_ep.get()
- is_bgp = name.startswith("\U0001F500 ")
- bgp_name = name[2:] if is_bgp else None
- ep = get_endpoint(name) if name and not is_bgp else None
- models = []
- if is_bgp:
- for p in load_bgp_pools().get("pools", []):
- if p["name"] == bgp_name:
- seen = set()
- for r in p.get("routes", []):
- m = r.get("model", "")
- if m and m not in seen:
- models.append(m)
- seen.add(m)
- break
- elif ep:
- models = ep.get("models", [])
- self._combo_model["values"] = models
- if ep and ep.get("default_model") in models:
- self._combo_model.set(ep["default_model"])
- elif models:
- self._combo_model.set(models[0])
- else:
- self._combo_model.set("")
-
- # ── Window openers ───────────────────────────────────────────────
-
- def _on_endpoints_updated(self):
- self._rebuild_combo()
-
- def _open_mgr(self):
- EndpointMgr(self._root, on_update=self._on_endpoints_updated)
-
- def _open_bgp(self):
- BGPPoolMgr(self._root, on_update=self._on_endpoints_updated)
-
- def _open_monitoring(self):
- AIMonitoringWindow(self._root)
-
- def _open_usage(self):
- UsageWindow(self._root)
-
- def _open_history(self):
- RequestHistoryWindow(self._root)
-
- def _open_benchmark(self):
- BenchmarkWindow(self._root)
-
- def _open_proxy_log_dir(self):
- log_dir = str(PROXY_CONFIG_DIR)
- req_log = PROXY_CONFIG_DIR / "requests.log"
- if IS_WINDOWS:
- if req_log.exists():
- os.startfile(str(req_log))
- else:
- os.startfile(log_dir)
- else:
- import subprocess as _sp
- _sp.Popen(["xdg-open", log_dir])
-
- def _open_assistant(self):
- assist_path = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
- if Path(assist_path).exists():
- subprocess.Popen([sys.executable, assist_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if IS_WINDOWS else 0)
-
- def _google_reoauth(self, provider, parent_dlg=None):
- is_antigravity = provider == "google-antigravity"
- sec_key = "antigravity" if is_antigravity else "gemini_cli"
- secrets = load_oauth_secrets()
- sec = secrets.get(sec_key, {})
- client_id = sec.get("client_id", "")
- client_secret = sec.get("client_secret", "")
- if not client_id or not client_secret:
- messagebox.showerror("Missing OAuth secrets",
- f"No client_id/client_secret for {sec_key}.\nSet them in OAuth Secrets first.")
- return
- token_file = "google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json"
- token_path = str(PROXY_CONFIG_DIR / token_file)
- redirect = "urn:ietf:wg:oauth:2.0:oob"
- scope_str = "https://www.googleapis.com/auth/cloud-platform"
- auth_url = (f"https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}"
- f"&redirect_uri={urllib.parse.quote(redirect)}"
- f"&response_type=code&scope={urllib.parse.quote(scope_str)}"
- f"&access_type=offline&prompt=consent")
- open_url(auth_url)
- code = tk.simpledialog.askstring("Re-OAuth",
- f"Paste the authorization code for {'Antigravity' if is_antigravity else 'Gemini CLI'}:",
- parent=parent_dlg or self._root)
- if not code:
- return
- try:
- tok_req = urllib.request.Request("https://oauth2.googleapis.com/token",
- data=urllib.parse.urlencode({
- "code": code, "client_id": client_id, "client_secret": client_secret,
- "redirect_uri": redirect, "grant_type": "authorization_code"
- }).encode(),
- headers={"Content-Type": "application/x-www-form-urlencoded"})
- tok_resp = urllib.request.urlopen(tok_req, timeout=30)
- tok_data = json.loads(tok_resp.read())
- tok_data["_updated"] = time.time()
- tok_data["client_id"] = client_id
- tok_data["client_secret"] = client_secret
- tok_data["provider_kind"] = "antigravity" if is_antigravity else "cli"
- os.makedirs(os.path.dirname(token_path), exist_ok=True)
- with open(token_path, "w") as f:
- json.dump(tok_data, f, indent=2)
- self.log(f"[oauth] Refreshed {provider} token")
- except Exception as e:
- messagebox.showerror("Token exchange failed", str(e)[:300])
-
- def _codebuff_reoauth_standalone(self, parent_dlg=None):
- import uuid
- oauth_dlg = tk.Toplevel(parent_dlg or self._root)
- oauth_dlg.title("Freebuff / Codebuff Login")
- oauth_dlg.geometry("520x240")
- if parent_dlg:
- oauth_dlg.transient(parent_dlg)
- else:
- oauth_dlg.transient(self._root)
- oauth_dlg.grab_set()
- tk.Label(oauth_dlg, text="Sign in with GitHub via Codebuff", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w")
- status_var = tk.StringVar(value="Requesting login URL...")
- tk.Label(oauth_dlg, textvariable=status_var).pack(padx=16, pady=(8, 0), anchor="w")
- link_lbl = tk.Label(oauth_dlg, text="", fg="blue", cursor="hand2")
- link_lbl.pack(padx=16, anchor="w")
- result = {"success": False, "user": None, "error": None}
-
- def _thread():
- try:
- fp_id = str(uuid.uuid4())
- body = json.dumps({"fingerprintId": fp_id}).encode()
- req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code",
- data=body, headers={"Content-Type": "application/json", "User-Agent": UA})
- resp = urllib.request.urlopen(req, timeout=30)
- rdata = json.loads(resp.read())
- login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "")
- fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "")
- expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0)
- if not login_url:
- result["error"] = "No login URL"
- self._root.after(0, _done)
- return
- def _set():
- status_var.set("Open this URL in your browser to log in:")
- link_lbl.configure(text=login_url)
- link_lbl.bind("", lambda e: open_url(login_url))
- self._root.after(0, _set)
- open_url(login_url)
- poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}"
- deadline = time.time() + 300
- while time.time() < deadline:
- time.sleep(2)
- try:
- pr = urllib.request.Request(poll, headers={"User-Agent": UA})
- pd = json.loads(urllib.request.urlopen(pr, timeout=10).read())
- if pd.get("user", {}).get("authToken"):
- result["success"] = True
- result["user"] = pd["user"]
- self._root.after(0, _done)
- return
- except Exception:
- pass
- result["error"] = "Timed out"
- except Exception as e:
- result["error"] = str(e)[:200]
- self._root.after(0, _done)
-
- def _done():
- if result["success"] and result["user"]:
- u = result["user"]
- cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json")
- os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True)
- creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""),
- "email": u.get("email", ""), "authToken": u.get("authToken", ""),
- "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}}
- with open(cb_creds_path, "w") as f:
- json.dump(creds, f, indent=2)
- status_var.set(f"Logged in as {u.get('email', 'OK')}")
- link_lbl.configure(text="")
- self._root.after(2000, oauth_dlg.destroy)
- else:
- status_var.set(f"Failed: {result.get('error', 'unknown')}")
-
- threading.Thread(target=_thread, daemon=True).start()
- oauth_dlg.wait_window()
-
- def _edit_oauth_secrets(self):
- import tkinter.simpledialog
- data = load_oauth_secrets()
- if not data:
- data = {"antigravity": {"client_id": "", "client_secret": ""},
- "gemini_cli": {"client_id": "", "client_secret": ""}}
-
- dlg = tk.Toplevel(self._root)
- dlg.title("OAuth Secrets & Credentials")
- dlg.geometry("620x650")
- dlg.transient(self._root)
- dlg.grab_set()
-
- canvas = tk.Canvas(dlg)
- scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview)
- frame = ttk.Frame(canvas, padding=16)
- frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
- canvas.create_window((0, 0), window=frame, anchor="nw")
- canvas.configure(yscrollcommand=scrollbar.set)
- canvas.pack(side="left", fill="both", expand=True)
- scrollbar.pack(side="right", fill="y")
-
- ttk.Label(frame, text="Google OAuth 2.0 Client Credentials", font=("Segoe UI", 10, "bold")).pack(anchor="w")
- ttk.Label(frame, text=str(OAUTH_SECRETS_PATH), foreground="gray").pack(anchor="w", pady=(0, 8))
-
- fields = {}
- nf = ttk.Frame(frame)
- nf.pack(fill="x")
- row = 0
- google_token_dir = str(PROXY_CONFIG_DIR)
- for section_key, section_label, oauth_prov, token_file in [
- ("antigravity", "Antigravity (CloudCode)", "google-antigravity", "google-antigravity-oauth-token.json"),
- ("gemini_cli", "Gemini CLI", "google-cli", "google-cli-oauth-token.json"),
- ]:
- ttk.Label(nf, text=f"\n{section_label}", font=("Segoe UI", 9, "bold")).grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2))
- row += 1
- sec = data.get(section_key, {})
- token_path = os.path.join(google_token_dir, token_file)
- has_token = False
- try:
- with open(token_path) as tf:
- td = json.load(tf)
- has_token = bool(td.get("refresh_token") or td.get("access_token"))
- except Exception:
- pass
- token_status = "Token: valid" if has_token else "Token: missing"
- token_color = "#2ea043" if has_token else "#d29922"
- ttk.Label(nf, text=token_status, foreground=token_color).grid(row=row, column=0, sticky="w", padx=(8, 4), pady=2)
- import_btn = ttk.Button(nf, text="Import JSON",
- command=lambda sk=section_key: self._import_oauth_json(fields, sk))
- import_btn.grid(row=row, column=2, padx=(4, 0), pady=2, sticky="e")
- reauth_btn = ttk.Button(nf, text="Re-OAuth",
- command=lambda p=oauth_prov: self._google_reoauth(p, dlg))
- reauth_btn.grid(row=row, column=3, padx=(4, 0), pady=2, sticky="e")
- row += 1
- for fk, fl in [("client_id", "Client ID"), ("client_secret", "Client Secret")]:
- ttk.Label(nf, text=fl + ":").grid(row=row, column=0, sticky="w", padx=(8, 4), pady=2)
- entry = ttk.Entry(nf, width=55)
- entry.insert(0, sec.get(fk, ""))
- entry.grid(row=row, column=1, columnspan=3, sticky="ew", pady=2)
- if fk == "client_secret":
- entry.configure(show="*")
- fields[(section_key, fk)] = entry
- row += 1
-
- nf.columnconfigure(1, weight=1)
-
- ttk.Label(frame, text="Import client_secret_*.json from Google Cloud Console → Credentials", foreground="gray").pack(anchor="w")
-
- ttk.Separator(frame).pack(fill="x", pady=(12, 8))
-
- ttk.Label(frame, text="Freebuff / Codebuff Credentials", font=("Segoe UI", 10, "bold")).pack(anchor="w")
- ttk.Label(frame, text=str(HOME / ".config" / "manicode" / "credentials.json"), foreground="gray").pack(anchor="w", pady=(0, 8))
-
- cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json")
- cb_fields = {}
- try:
- with open(cb_creds_path) as f:
- cb_data = json.load(f)
- except Exception:
- cb_data = {}
- cb_default = cb_data.get("default", {})
-
- cb_info = f"Email: {cb_default.get('email', 'not logged in')}"
- cb_name = cb_default.get("name", "")
- if cb_name:
- cb_info = f"{cb_name} — {cb_info}"
- has_cb_token = bool(cb_default.get("authToken", ""))
- status_text = "Logged in" if has_cb_token else "Not logged in"
- status_color = "#2ea043" if has_cb_token else "#d29922"
- ttk.Label(frame, text=cb_info).pack(anchor="w")
- ttk.Label(frame, text=f"Status: {status_text}", foreground=status_color, font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(0, 4))
-
- cb_nf = ttk.Frame(frame)
- cb_nf.pack(fill="x")
- cb_row = [0]
- for fk, fl in [("authToken", "Auth Token"), ("fingerprintId", "Fingerprint ID")]:
- ttk.Label(cb_nf, text=fl + ":").grid(row=cb_row[0], column=0, sticky="w", padx=(8, 4), pady=2)
- entry = ttk.Entry(cb_nf, width=55, show="*")
- entry.insert(0, cb_default.get(fk, ""))
- entry.grid(row=cb_row[0], column=1, sticky="ew", pady=2)
- cb_fields[fk] = entry
- cb_row[0] += 1
- cb_nf.columnconfigure(1, weight=1)
-
- ttk.Button(frame, text="Re-OAuth (GitHub Login)",
- command=lambda: self._codebuff_reoauth_standalone(dlg)).pack(anchor="w", pady=(4, 0))
-
- cb_accounts = cb_data.get("accounts", [])
- if cb_accounts:
- ttk.Label(frame, text=f"Additional accounts: {len(cb_accounts)} (edit credentials.json manually)", foreground="gray").pack(anchor="w")
-
- btnf = ttk.Frame(frame)
- btnf.pack(fill="x", pady=(12, 0))
- ttk.Button(btnf, text="Cancel", command=dlg.destroy).pack(side="right", padx=(4, 0))
- save_btn = ttk.Button(btnf, text="Save")
- save_btn.pack(side="right", padx=(4, 0))
-
- def _save():
- for (sk, fk), entry in fields.items():
- if sk not in data:
- data[sk] = {}
- data[sk][fk] = entry.get().strip()
- try:
- save_oauth_secrets(data)
- except Exception as e:
- messagebox.showerror("Save failed", str(e), parent=dlg)
- return
- cb_updated = dict(cb_default)
- for fk, entry in cb_fields.items():
- val = entry.get().strip()
- if val:
- cb_updated[fk] = val
- if cb_updated:
- cb_data["default"] = cb_updated
- try:
- os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True)
- with open(cb_creds_path, "w") as f:
- json.dump(cb_data, f, indent=2)
- except Exception as e:
- messagebox.showerror("Save failed", str(e), parent=dlg)
- return
- dlg.destroy()
-
- save_btn.configure(command=_save)
-
- def _import_oauth_json(self, fields, section_key):
- path = filedialog.askopenfilename(
- title="Import Google OAuth Client Secret JSON",
- filetypes=[("JSON files", "*.json")])
- if not path:
- return
- try:
- with open(path, encoding="utf-8") as f:
- raw = json.load(f)
- creds = raw.get("installed") or raw.get("web") or raw
- cid = creds.get("client_id", "")
- csec = creds.get("client_secret", "")
- if not cid or not csec:
- raise ValueError("JSON does not contain client_id and client_secret")
- if (section_key, "client_id") in fields:
- fields[(section_key, "client_id")].delete(0, "end")
- fields[(section_key, "client_id")].insert(0, cid)
- if (section_key, "client_secret") in fields:
- fields[(section_key, "client_secret")].delete(0, "end")
- fields[(section_key, "client_secret")].insert(0, csec)
- except Exception as e:
- messagebox.showerror("Import failed", str(e))
-
- # ── Watcher ──────────────────────────────────────────────────────
-
- def _start_watcher(self):
- cfg = load_monitoring_config()
- if not cfg.get("enabled"):
- return
- self._watcher = HealthWatcher(
- on_failure=lambda c: self.log(f"[AI Monitor] Proxy unresponsive (failures={c})"),
- on_recovery=lambda: self.log("[AI Monitor] Proxy recovered"),
- on_signal=lambda fid, cat, line: None,
- on_action=self._on_watcher_action,
- )
- self._watcher.start()
- self.log("AI Monitoring: watchdog started")
-
- def _on_watcher_action(self, action, trigger):
- cfg = load_monitoring_config()
- if action == "restart_proxy" and cfg.get("auto_restart_proxy"):
- self.log(f"[AI Monitor] Auto-restarting proxy (trigger: {trigger})")
- self._root.after(0, self._restart_proxy_from_watcher)
- elif action in ("clear_schema_cache", "delete_provider_caps"):
- try:
- cap_file = PROXY_CONFIG_DIR / "provider-caps.json"
- if cap_file.exists():
- cap_file.unlink()
- self.log("[AI Monitor] Cleared corrupt schema cache")
- except Exception as e:
- self.log(f"[AI Monitor] Failed to clear cache: {e}")
- elif action == "kill_stale_restart":
- self.log(f"[AI Monitor] Killing stale processes + restarting (trigger: {trigger})")
- self._kill()
- self._root.after(0, self._restart_proxy_from_watcher)
- else:
- self.log(f"[AI Monitor] Alert: {action} (trigger: {trigger})")
-
- def _restart_proxy_from_watcher(self):
- try:
- ep_name = load_endpoints().get("default")
- if not ep_name:
- return
- for ep in load_endpoints().get("endpoints", []):
- if ep.get("name") == ep_name:
- start_proxy_for(ep, self.log)
- break
- except Exception as e:
- self.log(f"[AI Monitor] Proxy restart failed: {e}")
-
- # ── Profile operations ───────────────────────────────────────────
-
- def _backup_profile(self):
- filename = filedialog.asksaveasfilename(
- title="Backup Codex Profile",
- defaultextension=".json",
- initialfile=f"codex-profile-{time.strftime('%Y%m%d-%H%M%S')}.json",
- filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
- )
- if not filename:
- return
- try:
- save_profile_bundle(filename)
- self.log(f"Profile backed up to {filename}")
- except Exception as e:
- messagebox.showerror("Backup Failed", str(e))
-
- def _refresh_all_models(self):
- if self._refresh_running:
- return
- self._refresh_running = True
- self._refresh_all_btn.configure(state="disabled")
- self.log("Refreshing models for all providers...")
- threading.Thread(target=self._refresh_all_models_worker, daemon=True).start()
-
- def _refresh_all_models_worker(self):
- try:
- data = load_endpoints()
- updated = 0
- failed = []
- for idx, ep in enumerate(list(data["endpoints"])):
- refreshed, err = refresh_endpoint_models(ep)
- if refreshed:
- data["endpoints"][idx] = refreshed
- updated += 1
- else:
- failed.append(f"{ep['name']}: {err}")
- if updated:
- save_endpoints(data)
- self._root.after(0, lambda: self._finish_refresh(updated, failed))
- except Exception as e:
- self._root.after(0, lambda: self._finish_refresh_error(str(e)))
-
- def _finish_refresh(self, updated, failed):
- if updated:
- self._rebuild_combo()
- self.log(f"Refreshed models for {updated} provider(s)")
- if failed:
- messagebox.showwarning("Refresh", "Some providers could not auto-fetch models.\n\n" +
- "\n".join(failed))
- elif updated:
- messagebox.showinfo("Refresh", f"Refreshed models for {updated} provider(s).")
- else:
- messagebox.showinfo("Refresh", "No providers were refreshed.")
- self._refresh_running = False
- self._refresh_all_btn.configure(state="normal")
-
- def _finish_refresh_error(self, err):
- messagebox.showerror("Refresh Failed", err)
- self._refresh_running = False
- self._refresh_all_btn.configure(state="normal")
-
- def _import_profile(self):
- if self._proc and self._proc.poll() is None:
- messagebox.showwarning("Import", "Stop Codex before importing a profile.")
- return
- filename = filedialog.askopenfilename(
- title="Import Codex Profile",
- filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
- )
- if not filename:
- return
- if not messagebox.askyesno("Import",
- "Importing will replace the current endpoints and Codex config. Continue?"):
- return
- try:
- import_profile_bundle(filename)
- self._rebuild_combo()
- self.log(f"Profile imported from {filename}")
- messagebox.showinfo("Import", "Profile imported successfully.")
- except Exception as e:
- messagebox.showerror("Import Failed", str(e))
-
- # ── Dialogs ──────────────────────────────────────────────────────
-
- def _show_changelog(self):
- dlg = tk.Toplevel(self._root)
- dlg.title("Changelog")
- dlg.geometry("540x480")
- dlg.transient(self._root)
- text = scrolledtext.ScrolledText(dlg, wrap="word", font=("Segoe UI", 9))
- text.pack(fill="both", expand=True, padx=12, pady=12)
- for ver, date, items in CHANGELOG:
- text.insert("end", f"v{ver} ({date})\n")
- for item in items:
- text.insert("end", f" • {item}\n")
- text.insert("end", "\n")
- text.configure(state="disabled")
- ttk.Button(dlg, text="Close", command=dlg.destroy).pack(pady=(0, 10))
-
- def _show_install_guide(self, which):
- if which == "cli":
- guide = ("Codex CLI is required to use CLI launch features.\n\n"
- "Install with npm:\n npm install -g @openai/codex\n\n"
- "Or download from:\n https://github.com/openai/codex\n\n"
- "After installing, restart the launcher.")
- else:
- guide = ("Codex Desktop is required to use Desktop launch features.\n\n"
- "Download from:\n https://codex.desktop.openai.com\n\n"
- "After installing, restart the launcher.")
- messagebox.showinfo(f"Install Codex {which.title()}", guide)
-
- # ── Launch ───────────────────────────────────────────────────────
-
- def _set_busy(self, busy):
- has_cli = "cli" not in self._missing
- has_desk = "desktop" not in self._missing
- def _update():
- self._btn_desktop.configure(state="disabled" if busy or not has_desk else "normal")
- self._btn_cli.configure(state="disabled" if busy or not has_cli else "normal")
- self._btn_codex_desktop.configure(state="disabled" if busy or not has_desk else "normal")
- self._btn_codex_cli.configure(state="disabled" if busy or not has_cli else "normal")
- self._kill_btn.configure(state="normal" if busy else "disabled")
- self._restart_btn.configure(state="normal" if busy else "disabled")
- self._root.after(0, _update)
-
- def _launch(self, target):
- name = self._combo_ep.get()
- if not name:
- self.log("ERROR: no endpoint selected")
- return
- model = self._combo_model.get()
- if not model:
- self.log("ERROR: no model selected")
- return
-
- is_bgp = name.startswith("\U0001F500 ")
- if is_bgp:
- pool_name = name[2:]
- pool = None
- for p in load_bgp_pools().get("pools", []):
- if p["name"] == pool_name:
- pool = p
- break
- if not pool:
- self.log(f"ERROR: BGP pool '{pool_name}' not found")
- return
- self._set_busy(True)
- target_name = "Desktop" if target == "desktop" else "CLI"
- self.log(f"=== BGP: {pool_name} / {model} -> {target_name} ===")
- threading.Thread(target=self._run_bgp, args=(pool, model, target), daemon=True).start()
- return
-
- ep = get_endpoint(name)
- if not ep:
- self.log("ERROR: endpoint not found")
- return
- self._set_busy(True)
- target_name = "Desktop" if target == "desktop" else "CLI"
- self.log(f"=== {ep['name']} / {model} -> {target_name} ===")
- threading.Thread(target=self._run, args=(ep, model, target), daemon=True).start()
-
- def _launch_codex_default(self, target):
- if "cli" not in self._missing:
- status, msg = check_codex_auth()
- if status != "logged_in":
- if not messagebox.askyesno("Auth Warning",
- f"Codex auth check: {msg}\n\n"
- "Launch may fail without valid authentication.\nContinue anyway?"):
- self._set_busy(False)
- return
- self._set_busy(True)
- target_name = "Desktop" if target == "desktop" else "CLI"
- self.log(f"=== Codex Default (OAuth) -> {target_name} ===")
- threading.Thread(target=self._run_codex_default, args=(target,), daemon=True).start()
-
- def _run(self, ep, model, target):
- keep_session_alive = False
- try:
- self.log("Cleaning up stale processes...")
- safe_cleanup_owned(self.log)
- recover_config_if_needed(self.log)
-
- needs_proxy = ep["backend_type"] != "native"
- if needs_proxy:
- self.log("Starting translation proxy...")
- try:
- proxy_port = start_proxy_for(ep, self.log)
- except RuntimeError as e:
- self._root.after(0, lambda: messagebox.showerror("Proxy Failed", str(e)))
- return
- self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})...")
- begin_config_transaction(f"launch:{ep['name']}")
- write_config_for_translated(ep, model, proxy_port)
- else:
- self.log(f"Configuring Codex for {ep['name']} (native)...")
- begin_config_transaction(f"launch:{ep['name']}")
- write_config_for_native(ep, model)
-
- if target == "desktop":
- if needs_proxy:
- kill_existing_desktop(self.log)
- keep_session_alive = self._launch_desktop(ep, model)
- else:
- self._launch_cli(ep, model)
- except Exception as e:
- self.log(f"ERROR: {e}")
- finally:
- if keep_session_alive:
- self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.")
- self._set_busy(False)
- self.log("Ready. Use Kill && Cleanup when finished.")
- else:
- stop_proxy()
- restore_config()
- end_config_transaction()
- self._set_busy(False)
- self.log("Ready.")
-
- def _run_bgp(self, pool, model, target):
- keep_session_alive = False
- try:
- self.log("Cleaning up stale processes...")
- safe_cleanup_owned(self.log)
- recover_config_if_needed(self.log)
-
- self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes...")
- port, bgp_ep = start_bgp_proxy(pool, model, self.log)
-
- begin_config_transaction(f"launch:bgp:{pool['name']}")
- write_config_for_translated(bgp_ep, model, port)
-
- if target == "desktop":
- kill_existing_desktop(self.log)
- keep_session_alive = self._launch_desktop(bgp_ep, model)
- else:
- self._launch_cli(bgp_ep, model)
- except Exception as e:
- self.log(f"ERROR: {e}")
- finally:
- if keep_session_alive:
- self.log("Warm-start handoff detected; keeping proxy/config active.")
- self._set_busy(False)
- self.log("Ready. Use Kill && Cleanup when finished.")
- else:
- stop_proxy()
- restore_config()
- end_config_transaction()
- self._set_busy(False)
- self.log("Ready.")
-
- def _run_codex_default(self, target):
- try:
- self.log("Cleaning up stale processes...")
- safe_cleanup_owned(self.log)
- stop_proxy()
- recover_config_if_needed(self.log)
- self.log("Resetting config to Codex defaults (OAuth)...")
- begin_config_transaction("launch:default")
- if CONFIG.exists():
- CONFIG.unlink()
- if target == "desktop":
- self._launch_desktop_direct()
- else:
- self._launch_cli_default()
- except Exception as e:
- self.log(f"ERROR: {e}")
- finally:
- restore_config()
- end_config_transaction()
- self._set_busy(False)
- self.log("Ready.")
-
- def _launch_desktop(self, ep, model):
- desktop_path = self._desktop_info
- if not desktop_path:
- self.log("ERROR: Codex Desktop not found")
- return False
-
- if IS_WINDOWS:
- self._proc = subprocess.Popen(
- [desktop_path],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
- creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
- else:
- self._proc = subprocess.Popen(
- [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
- preexec_fn=os.setsid)
-
- pid = self._proc.pid
- self.log(f"Desktop started (PID {pid})")
- self.log(f"Log: {LAUNCH_LOG}")
-
- t0 = time.time()
- stall_warned = False
- while self._proc and self._proc.poll() is None:
- time.sleep(1.5)
- el = time.time() - t0
- if el > 20 and not stall_warned:
- self.log("Still starting after 20s -- possible stall. Click Kill if window doesn't appear.")
- self.log(f"--- last log lines ---\n{last_log_lines()}")
- stall_warned = True
-
- if self._proc:
- rc = self._proc.poll()
- el = time.time() - t0
- self.log(f"Desktop exited (code {rc}) after {el:.0f}s")
- if el < 12:
- self.log("TIP: Quick exit -- may be warm-start handoff (normal) or crash.")
- last_lines = last_log_lines()
- self.log(f"--- last log lines ---\n{last_lines}")
- if rc == 0 and "warm-start" in last_lines.lower():
- self._proc = None
- return True
- self._proc = None
- return False
-
- def _launch_cli(self, ep, model):
- self.log(f"Launching Codex CLI with {ep['name']}...")
- term = detect_terminal()
- if not term:
- self.log("ERROR: no terminal found")
- return
-
- term_name, term_args, _ = term
- cmd_parts = [term_name] + term_args
- if ep["backend_type"] == "native":
- cmd_parts.extend(["codex", "-c", f"model={model}"])
- else:
- cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"])
-
- self.log(f"Running: {' '.join(cmd_parts)}")
- if IS_WINDOWS:
- self._proc = subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
- else:
- self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
- pid = self._proc.pid
- self.log(f"CLI started in terminal (PID {pid})")
-
- while self._proc and self._proc.poll() is None:
- time.sleep(1.5)
- if self._proc:
- rc = self._proc.poll()
- self.log(f"CLI exited (code {rc})")
- self._proc = None
-
- def _launch_desktop_direct(self):
- self.log("Launching Codex Desktop (default OAuth)...")
- desktop_path = self._desktop_info
- if not desktop_path:
- self.log("ERROR: Codex Desktop not found")
- return
- if IS_WINDOWS:
- self._proc = subprocess.Popen(
- [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
- creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
- else:
- self._proc = subprocess.Popen(
- [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
- preexec_fn=os.setsid)
- pid = self._proc.pid
- self.log(f"Desktop started (PID {pid})")
-
- t0 = time.time()
- stall_warned = False
- while self._proc and self._proc.poll() is None:
- time.sleep(1.5)
- el = time.time() - t0
- if el > 20 and not stall_warned:
- self.log("Still starting after 20s -- possible stall.")
- self.log(f"--- last log lines ---\n{last_log_lines()}")
- stall_warned = True
- if self._proc:
- rc = self._proc.poll()
- el = time.time() - t0
- self.log(f"Desktop exited (code {rc}) after {el:.0f}s")
- self._proc = None
-
- def _launch_cli_default(self):
- self.log("Launching Codex CLI (default OAuth)...")
- term = detect_terminal()
- if not term:
- self.log("ERROR: no terminal found")
- return
- term_name, term_args, _ = term
- cmd_parts = [term_name] + term_args + ["codex"]
- self.log(f"Running: {' '.join(cmd_parts)}")
- if IS_WINDOWS:
- self._proc = subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
- else:
- self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
- pid = self._proc.pid
- self.log(f"CLI started in terminal (PID {pid})")
- while self._proc and self._proc.poll() is None:
- time.sleep(1.5)
- if self._proc:
- rc = self._proc.poll()
- self.log(f"CLI exited (code {rc})")
- self._proc = None
-
- # ── Kill ─────────────────────────────────────────────────────────
-
- def _kill(self):
- self.log("=== Killing ===")
- if self._proc and self._proc.poll() is None:
- try:
- if IS_WINDOWS:
- subprocess.run(["taskkill", "/F", "/T", "/PID", str(self._proc.pid)],
- capture_output=True, timeout=10)
- else:
- import signal as sig
- pgid = os.getpgid(self._proc.pid)
- os.killpg(pgid, sig.SIGTERM)
- time.sleep(1)
- if self._proc.poll() is None:
- os.killpg(pgid, sig.SIGKILL)
- except (ProcessLookupError, PermissionError):
- pass
- self._proc = None
- stop_proxy()
- safe_cleanup_owned(self.log)
- restore_config()
- end_config_transaction()
- LOG_DIR.mkdir(parents=True, exist_ok=True)
- if LAUNCH_LOG.exists():
- try:
- LAUNCH_LOG.unlink()
- except Exception:
- pass
- self.log("Cleanup complete")
- self._set_busy(False)
- self.log("Ready.")
-
- def _do_close(self):
- if self._proc and self._proc.poll() is None:
- if not messagebox.askyesno("Confirm", "Codex is still running. Kill it?"):
- return
- self._kill()
- stop_proxy()
- self._root.destroy()
-
-
-# ═══════════════════════════════════════════════════════════════════════
-# Entry point
-# ═══════════════════════════════════════════════════════════════════════
+ GLib.idle_add(_show)
if __name__ == "__main__":
- ensure_dirs()
- create_default_endpoints()
-
- root = tk.Tk()
- root.title("Codex Launcher")
- root.geometry("800x680")
- root.minsize(640, 520)
- app = LauncherWin(root)
- root.mainloop()
+ main()
diff --git a/src/codex_launcher_lib.py b/src/codex_launcher_lib.py
index 5334593..0768eb1 100644
--- a/src/codex_launcher_lib.py
+++ b/src/codex_launcher_lib.py
@@ -83,6 +83,16 @@ model_catalog_json = ""
"""
CHANGELOG = [
+ ("3.10.8", "2026-05-25", [
+ "Fix Re-OAuth buttons: load_oauth_secrets() was undefined in Linux GUI",
+ "Re-OAuth: replaced deprecated OOB flow with PKCE + localhost callback",
+ "Proxy: prefer production cloudcode-pa over staging/sandbox endpoints",
+ "Proxy: fallthrough 403 SERVICE_DISABLED to next endpoint",
+ "Project discovery: validate against production endpoint, not staging",
+ "Antigravity preset base_url changed to production (was daily-cloudcode-pa.sandbox)",
+ "Fix GLib.idle_add lambda returning truthy tuple (caused repeated calls)",
+ "Windows GUI: project discovery also uses production endpoint",
+ ]),
("3.10.7", "2026-05-25", [
"Prompt Enhancer: per-provider toggle to improve prompt clarity after compaction",
"Two modes: offline (template injection) and ai-powered (external LLM rewrites)",
@@ -439,7 +449,7 @@ PROVIDER_PRESETS = {
},
"Google Antigravity (OAuth)": {
"backend_type": "gemini-oauth-antigravity",
- "base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com",
+ "base_url": "https://cloudcode-pa.googleapis.com",
"oauth_provider": "google-antigravity",
"models": [
"antigravity-gemini-3-flash",
diff --git a/src/translate-proxy.py b/src/translate-proxy.py
index b032b70..be60045 100755
--- a/src/translate-proxy.py
+++ b/src/translate-proxy.py
@@ -4812,9 +4812,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
wrapped["requestId"] = f"agent-{uuid.uuid4().hex[:12]}"
endpoints = ([
+ "https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
- "https://cloudcode-pa.googleapis.com",
] if OAUTH_PROVIDER == "google-antigravity" else [
"https://cloudcode-pa.googleapis.com",
])
@@ -4844,6 +4844,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
except Exception:
pass
+ if OAUTH_PROVIDER == "google-antigravity":
+ print(f"[antigravity-endpoint] endpoints={[e.replace('https://','') for e in endpoints]} project={project_id}", file=sys.stderr)
+
for ep in endpoints:
target = f"{ep}/{url_suffix}"
req = urllib.request.Request(target, data=body_b, headers=headers)
@@ -4860,6 +4863,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
print(f"[{self._session_id}] saved 400 debug request to {debug_path}", file=sys.stderr)
except Exception:
pass
+ if e.code == 403 and "SERVICE_DISABLED" in err_body[:500] and ep != endpoints[-1]:
+ print(f"[{self._session_id}] {ep} SERVICE_DISABLED, trying next endpoint", file=sys.stderr)
+ continue
if e.code == 429 and ep != endpoints[-1]:
print(f"[{self._session_id}] {ep} HTTP 429, trying next endpoint", file=sys.stderr)
continue
diff --git a/translate-proxy.py b/translate-proxy.py
index 7959e76..be60045 100755
--- a/translate-proxy.py
+++ b/translate-proxy.py
@@ -157,6 +157,7 @@ Architecture:
import json, http.server, socketserver, urllib.request, urllib.parse, urllib.error, re
import time, uuid, os, sys, argparse, threading, socket, collections, contextlib, signal
+import secrets, string
import dataclasses
import http.client
import selectors
@@ -246,6 +247,11 @@ REASONING_ENABLED = True
REASONING_EFFORT = "medium"
FORCE_MODEL = ""
BGP_ROUTES = []
+PROMPT_ENHANCER = False
+PROMPT_ENHANCER_MODE = "offline"
+PROMPT_ENHANCER_MODEL = ""
+PROMPT_ENHANCER_URL = ""
+PROMPT_ENHANCER_KEY = ""
SERVER = None
if _IS_WINDOWS:
@@ -310,7 +316,7 @@ _conn_pool = {}
_STREAM_IDLE_TIMEOUT = 300
-_CODEBUFF_AUTH_URL = "https://codebuff.com"
+_CODEBUFF_AUTH_URL = "https://www.codebuff.com"
_CODEBUFF_API_URL = "https://www.codebuff.com"
_CODEBUFF_AGENT_MAP = {
"deepseek/deepseek-v4-pro": "base2-free-deepseek",
@@ -350,11 +356,11 @@ def _codebuff_get_session(token, model):
return sc["instance_id"]
try:
url = f"{_CODEBUFF_API_URL}/api/v1/freebuff/session"
- body = json.dumps({"model": model}).encode()
+ body = json.dumps({}).encode()
req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
- "User-Agent": "codex-launcher/3.10.4",
+ "User-Agent": "ai-sdk/openai-compatible/1.0.25/codebuff",
"x-codebuff-model": model,
})
try:
@@ -402,7 +408,7 @@ def _codebuff_start_run(token, agent_id):
req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
- "User-Agent": "codex-launcher/3.10.4",
+ "User-Agent": "ai-sdk/openai-compatible/1.0.25/codebuff",
})
try:
resp = urllib.request.urlopen(req, timeout=15)
@@ -435,7 +441,7 @@ def _codebuff_finish_run(token, run_id, status="completed"):
req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
- "User-Agent": "codex-launcher/3.10.4",
+ "User-Agent": "ai-sdk/openai-compatible/1.0.25/codebuff",
})
try:
urllib.request.urlopen(req, timeout=10)
@@ -718,7 +724,6 @@ _GEMINI_AGENT_GUARDRAIL = (
"Always emit the actual tool call in the same response."
)
-_LOG_FILE = None
_LOG_FILE_LOCK = threading.Lock()
def _fetch_antigravity_version():
@@ -769,7 +774,7 @@ def _ensure_antigravity_version():
def _init_runtime():
global CONFIG, PORT, BACKEND, TARGET_URL, API_KEY, OAUTH_PROVIDER, _antigravity_version
global MODELS, CC_VERSION, REASONING_ENABLED, REASONING_EFFORT, BGP_ROUTES
- global _api_key_pool
+ global _api_key_pool, PROMPT_ENHANCER
CONFIG = load_config()
PORT = CONFIG["port"]
@@ -782,6 +787,11 @@ def _init_runtime():
REASONING_ENABLED = CONFIG.get("reasoning_enabled", True)
REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium")
FORCE_MODEL = (CONFIG.get("force_model") or "").strip()
+ PROMPT_ENHANCER = CONFIG.get("prompt_enhancer", False)
+ PROMPT_ENHANCER_MODE = CONFIG.get("prompt_enhancer_mode", "offline")
+ PROMPT_ENHANCER_MODEL = CONFIG.get("prompt_enhancer_model", "")
+ PROMPT_ENHANCER_URL = CONFIG.get("prompt_enhancer_url", "")
+ PROMPT_ENHANCER_KEY = CONFIG.get("prompt_enhancer_key", "")
BGP_ROUTES = CONFIG.get("bgp_routes", [])
_api_key_pool = None
if API_KEY and "," in API_KEY and not OAUTH_PROVIDER.startswith("google") and BACKEND not in ("codebuff", "freebuff"):
@@ -1297,8 +1307,8 @@ _COMPACT_KEEP_RECENT = 10
_CROF_ADAPTIVE = {
"fail_history": [],
"model_limits": {},
- "global_item_limit": 30,
- "min_keep_recent": 4,
+ "global_item_limit": 80,
+ "min_keep_recent": 6,
}
_BGP_STATS_PATH = os.path.join(_LOG_DIR, "bgp-route-stats.json")
@@ -1366,6 +1376,8 @@ def _sorted_bgp_routes():
return sorted(BGP_ROUTES, key=lambda r: _score_route(r, stats))
def _crof_record(model, n_items, success):
+ if TARGET_URL and "crof.ai" not in TARGET_URL:
+ return
if not isinstance(n_items, int) or n_items < 1:
return
entry = {"model": model, "items": n_items, "ok": success}
@@ -1391,7 +1403,8 @@ def _crof_record(model, n_items, success):
global_limit = v["limit"]
_CROF_ADAPTIVE["global_item_limit"] = global_limit
- print(f"[crof-adaptive] model={model} items={n_items} {'OK' if success else 'FAIL'} -> limit={ml.get('limit',30)} global={global_limit}", file=sys.stderr)
+ if TARGET_URL and "crof.ai" in TARGET_URL:
+ print(f"[crof-adaptive] model={model} items={n_items} {'OK' if success else 'FAIL'} -> limit={ml.get('limit',30)} global={global_limit}", file=sys.stderr)
def _crof_item_limit(model):
ml = _CROF_ADAPTIVE["model_limits"].get(model, {})
@@ -1436,7 +1449,8 @@ def _crof_compact_for_retry(input_data, model):
summary_lines.append(_item_summary(item, max_len=120))
summary_msg = {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "\n".join(summary_lines)}]}
- print(f"[crof-adaptive] RETRY compact: {len(input_data)} -> {len(head)+1+len(tail)} (limit={limit}, keep={len(tail)})", file=sys.stderr)
+ if TARGET_URL and "crof.ai" in TARGET_URL:
+ print(f"[crof-adaptive] RETRY compact: {len(input_data)} -> {len(head)+1+len(tail)} (limit={limit}, keep={len(tail)})", file=sys.stderr)
return head + [summary_msg] + tail
def _item_summary(item, max_len=200):
@@ -1590,6 +1604,10 @@ _PROVIDER_POLICIES = {
"tool_output_limit": 6000, "max_input_items": 35, "compaction": "balanced"},
"openadapter": {"reasoning_mode": "off", "max_tokens": 32768, "strip_reasoning": True,
"tool_output_limit": 6000, "max_input_items": 30, "compaction": "balanced"},
+ "cloudcode-pa": {"compaction": "aggressive", "context_size": 1000000,
+ "tool_output_limit": 6000, "max_input_items": 60},
+ "googleapis": {"compaction": "balanced", "context_size": 1000000,
+ "tool_output_limit": 6000, "max_input_items": 80},
}
def provider_policy(target_url=None, backend=None):
@@ -1608,12 +1626,14 @@ _MODEL_CONTEXT = {
"claude-sonnet": 200000, "claude-haiku": 200000,
"glm-5.1": 128000, "glm-5": 128000, "glm-4": 128000,
"deepseek": 64000, "gemini-2.5-flash": 1000000, "gemini-2.5-pro": 2000000,
+ "gemini-3-flash": 1000000, "gemini-3.5-flash-low": 1000000,
+ "gemini-3.1-pro-low": 2000000,
"gemini-3.5-flash": 1000000, "gemini-3.1-pro": 2000000,
"Gemini 3.5 Flash": 1000000, "Gemini 3.1 Pro": 2000000,
"Claude Sonnet 4.6": 200000, "Claude Opus 4.6": 200000,
"GPT-OSS 120B": 128000,
- "claude-sonnet-4.6-thinking": 200000, "claude-opus-4.6-thinking": 200000,
- "gpt-oss-120b": 128000,
+ "claude-sonnet-4-6": 200000, "claude-opus-4-6-thinking": 200000,
+ "gpt-oss-120b-medium": 128000,
"mimo": 32768, "minimax": 32768, "kimi": 128000,
"_default": 32768,
}
@@ -1641,7 +1661,7 @@ def _estimate_tokens(obj):
def _adaptive_compact(input_data, model, policy=None):
policy = policy or {}
context_size = int(policy.get("context_size", _context_limit_for_model(model)))
- input_budget = int(context_size * 0.60)
+ input_budget = int(context_size * 0.80)
estimated = _estimate_tokens(input_data)
if estimated <= input_budget:
return input_data, False
@@ -1684,6 +1704,120 @@ def _adaptive_compact(input_data, model, policy=None):
f"items {len(input_data)}->{len(head)+1+len(tail)}", file=sys.stderr)
return head + [summary_msg] + tail, True
+# ═══════════════════════════════════════════════════════════════════
+# Prompt Enhancer
+# ═══════════════════════════════════════════════════════════════════
+
+_PROMPT_ENHANCER_SYSTEM = """You are a prompt enhancement assistant for a coding agent (Codex CLI).
+Your job: rewrite the user's latest message to be clearer, more specific, and more actionable.
+Rules:
+- Preserve the user's EXACT intent — never change what they want done
+- Add explicit action verbs and step-by-step clarity
+- If the message is vague ("fix it", "make it better"), infer context from prior conversation summary and make it specific
+- Keep the enhanced prompt concise — no longer than 2x the original
+- If the original prompt is already clear and specific, return it unchanged
+- Output ONLY the enhanced prompt text, nothing else
+- Never add tasks the user didn't ask for"""
+
+_PROMPT_ENHANCER_OFFLINE = """
+
+You are a coding agent operating inside a context-compacted session. Follow these rules strictly:
+
+1. ACTION CLARITY: Re-read the user's latest message. Identify every explicit and implicit action request. Execute ALL of them — do not skip any.
+
+2. COMPACTED CONTEXT: Previous conversation was summarized. The summary preserves your task history but may lose details. If the user references earlier work ("fix that", "continue", "update it"), infer from the compacted summary what was done and what remains.
+
+3. NO CLARIFICATION ASKING: Never ask "which file?" or "what exactly?" — infer from context. If truly ambiguous, make a reasonable assumption and proceed. The user can correct you.
+
+4. DECISIVE EXECUTION: When the user says "fix", "update", "change", "add", "remove" — do it immediately in the relevant file(s). Do not describe what you would do — actually do it.
+
+5. COMPLETE EDITS: When editing files, make the FULL change requested. Do not partially apply edits or leave placeholders.
+
+6. PRESERVE WORKING STATE: Never break existing functionality. If changing code, keep all surrounding logic intact.
+
+7. MULTI-STEP REQUESTS: If the user asks for multiple things, do ALL of them in sequence. Do not stop after the first one.
+
+
+
+"""
+
+def _enhance_prompt_llm(text, compaction_summary=""):
+ global PROMPT_ENHANCER_MODEL, PROMPT_ENHANCER_URL, PROMPT_ENHANCER_KEY
+ if not PROMPT_ENHANCER_MODEL or not PROMPT_ENHANCER_URL:
+ return text
+ try:
+ messages = [
+ {"role": "system", "content": _PROMPT_ENHANCER_SYSTEM},
+ ]
+ if compaction_summary:
+ messages.append({"role": "user", "content": f"Context from earlier conversation (compacted):\n{compaction_summary[:2000]}"})
+ messages.append({"role": "user", "content": f"Enhance this prompt:\n{text}"})
+ body = json.dumps({"model": PROMPT_ENHANCER_MODEL, "messages": messages, "max_tokens": 2000, "temperature": 0.3}).encode()
+ headers = {"Content-Type": "application/json"}
+ if PROMPT_ENHANCER_KEY:
+ headers["Authorization"] = f"Bearer {PROMPT_ENHANCER_KEY}"
+ req = urllib.request.Request(f"{PROMPT_ENHANCER_URL.rstrip('/')}/chat/completions", data=body, headers=headers)
+ resp = urllib.request.urlopen(req, timeout=15)
+ data = json.loads(resp.read())
+ enhanced = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
+ if enhanced and len(enhanced) >= len(text) * 0.5:
+ print(f"[prompt-enhancer] AI enhanced: {text[:80]}... -> {enhanced[:80]}...", file=sys.stderr)
+ return enhanced
+ except Exception as e:
+ print(f"[prompt-enhancer] AI enhancement failed: {e}", file=sys.stderr)
+ return text
+
+def _apply_prompt_enhancer(input_data):
+ global PROMPT_ENHANCER_MODE
+ if not isinstance(input_data, list) or len(input_data) == 0:
+ return input_data
+ last_user_idx = None
+ for i in range(len(input_data) - 1, -1, -1):
+ item = input_data[i]
+ if isinstance(item, dict) and item.get("type") == "message" and item.get("role") == "user":
+ last_user_idx = i
+ break
+ if last_user_idx is None:
+ return input_data
+ item = input_data[last_user_idx]
+ content = item.get("content", "")
+ if isinstance(content, list):
+ text = content[0].get("text", "") if content else ""
+ elif isinstance(content, str):
+ text = content
+ else:
+ return input_data
+ if not text or len(text) < 5:
+ return input_data
+ if text.startswith(""):
+ return input_data
+ compaction_summary = ""
+ for it in input_data:
+ if isinstance(it, dict) and it.get("type") == "message" and it.get("role") == "user":
+ c = it.get("content", "")
+ t = ""
+ if isinstance(c, list):
+ t = c[0].get("text", "") if c else ""
+ elif isinstance(c, str):
+ t = c
+ if "[Auto-compacted:" in t:
+ compaction_summary = t[:3000]
+ break
+ if PROMPT_ENHANCER_MODE == "ai-powered" and PROMPT_ENHANCER_MODEL and PROMPT_ENHANCER_URL:
+ enhanced = _enhance_prompt_llm(text, compaction_summary)
+ else:
+ enhanced = text
+ enhanced = _PROMPT_ENHANCER_OFFLINE + enhanced
+ new_item = dict(item)
+ if isinstance(item.get("content"), list):
+ new_item["content"] = [{"type": "input_text", "text": enhanced}]
+ else:
+ new_item["content"] = enhanced
+ result = list(input_data)
+ result[last_user_idx] = new_item
+ print(f"[prompt-enhancer] mode={PROMPT_ENHANCER_MODE} enhanced last user message ({len(text)}->{len(enhanced)} chars)", file=sys.stderr)
+ return result
+
# ═══════════════════════════════════════════════════════════════════
# Tool-call pairing validator
# ═══════════════════════════════════════════════════════════════════
@@ -4284,8 +4418,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
body = dict(body)
body["input"] = input_data
+ if PROMPT_ENHANCER and isinstance(input_data, list):
+ input_data = _apply_prompt_enhancer(input_data)
+ body = dict(body)
+ body["input"] = input_data
+
crof_limit = _crof_item_limit(model)
- if not compacted and isinstance(input_data, list) and len(input_data) > crof_limit:
+ _crof_eligible = TARGET_URL and "crof.ai" in TARGET_URL
+ if _crof_eligible and not compacted and isinstance(input_data, list) and len(input_data) > crof_limit:
print(f"[crof-adaptive] proactive compact: {len(input_data)} items > limit {crof_limit}", file=sys.stderr)
input_data = _crof_compact_for_retry(input_data, model)
body = dict(body)
@@ -4456,6 +4596,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
body = dict(body)
body["input"] = input_data
+ if PROMPT_ENHANCER and isinstance(input_data, list):
+ input_data = _apply_prompt_enhancer(input_data)
+ body = dict(body)
+ body["input"] = input_data
+
access_token = _refresh_oauth_token()
token_name = "google-antigravity-oauth-token.json" if OAUTH_PROVIDER == "google-antigravity" else "google-cli-oauth-token.json"
token_path = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy", token_name)
@@ -4667,9 +4812,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
wrapped["requestId"] = f"agent-{uuid.uuid4().hex[:12]}"
endpoints = ([
+ "https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
- "https://cloudcode-pa.googleapis.com",
] if OAUTH_PROVIDER == "google-antigravity" else [
"https://cloudcode-pa.googleapis.com",
])
@@ -4699,6 +4844,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
except Exception:
pass
+ if OAUTH_PROVIDER == "google-antigravity":
+ print(f"[antigravity-endpoint] endpoints={[e.replace('https://','') for e in endpoints]} project={project_id}", file=sys.stderr)
+
for ep in endpoints:
target = f"{ep}/{url_suffix}"
req = urllib.request.Request(target, data=body_b, headers=headers)
@@ -4715,6 +4863,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
print(f"[{self._session_id}] saved 400 debug request to {debug_path}", file=sys.stderr)
except Exception:
pass
+ if e.code == 403 and "SERVICE_DISABLED" in err_body[:500] and ep != endpoints[-1]:
+ print(f"[{self._session_id}] {ep} SERVICE_DISABLED, trying next endpoint", file=sys.stderr)
+ continue
if e.code == 429 and ep != endpoints[-1]:
print(f"[{self._session_id}] {ep} HTTP 429, trying next endpoint", file=sys.stderr)
continue
@@ -5079,7 +5230,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
print(f"[provider-sensor] synthetic retry failed: {e}", file=sys.stderr)
# Auto-retry on finish_reason=length with no content due to too much context.
- if finish_reason == "length" and not has_content and isinstance(input_data, list) and len(input_data) > 5:
+ if finish_reason == "length" and not has_content and isinstance(input_data, list) and len(input_data) > 5 and TARGET_URL and "crof.ai" in TARGET_URL:
print(f"[crof-adaptive] RETRY: finish_reason=length with no content, compacting {n_items} items", file=sys.stderr)
new_input = _crof_compact_for_retry(input_data, model)
if len(new_input) < len(input_data):
@@ -5417,9 +5568,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
metadata = {
"run_id": run_id,
"cost_mode": "free",
+ "client_id": "".join(secrets.choice(string.digits + string.ascii_lowercase) for _ in range(13)),
}
if instance_id:
- metadata["codebuff_instance_id"] = instance_id
+ metadata["freebuff_instance_id"] = instance_id
chat_body = {
"model": model,
@@ -5441,7 +5593,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
- "User-Agent": "codex-launcher/3.10.4",
+ "User-Agent": "ai-sdk/openai-compatible/1.0.25/codebuff",
"x-codebuff-model": model,
}
if instance_id:
@@ -5589,9 +5741,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
instance_id = _codebuff_get_session(token, model)
messages = _cb_input_to_messages(input_data, instructions)
_codebuff_hard_disable_reasoning(messages)
- metadata = {"run_id": run_id, "cost_mode": "free"}
+ metadata = {"run_id": run_id, "cost_mode": "free", "client_id": secrets.token_hex(7)[:13]}
if instance_id:
- metadata["codebuff_instance_id"] = instance_id
+ metadata["freebuff_instance_id"] = instance_id
chat_body = {
"model": model, "messages": messages, "stream": stream,
"max_tokens": max(body.get("max_output_tokens", 0), 64000),
@@ -5607,7 +5759,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
if body.get("tool_choice"):
chat_body["tool_choice"] = body["tool_choice"]
target = f"{_CODEBUFF_API_URL}/api/v1/chat/completions"
- headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "codex-launcher/3.10.4", "x-codebuff-model": model}
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "ai-sdk/openai-compatible/1.0.25/codebuff", "x-codebuff-model": model}
if instance_id:
headers["x-codebuff-instance-id"] = instance_id
print(f"[codebuff] retry POST {target} model={model} stream={stream} run={run_id} (thinking disabled via DeepSeek native)", file=sys.stderr)
@@ -6017,6 +6169,15 @@ def main():
global SERVER, _START_TIME
_START_TIME = time.time()
_init_runtime()
+ try:
+ _current_cfg = os.path.basename(args.config) if args.config else ""
+ for _f in os.listdir(_LOG_DIR):
+ if _f.startswith("proxy-") and _f.endswith(".json") and _f != _current_cfg:
+ os.remove(os.path.join(_LOG_DIR, _f))
+ if _f.startswith("models-") and _f.endswith(".json"):
+ os.remove(os.path.join(_LOG_DIR, _f))
+ except Exception:
+ pass
signal.signal(signal.SIGINT, _handle_shutdown_signal)
if _IS_WINDOWS:
if hasattr(signal, "SIGBREAK"):