diff --git a/codex-launcher-gui b/codex-launcher-gui
index 21c75c2..5783e0a 100755
--- a/codex-launcher-gui
+++ b/codex-launcher-gui
@@ -1856,7 +1856,7 @@ class LauncherWin(Gtk.Window):
# header row
hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0)
- lbl = Gtk.Label(label="Codex Launcher v3.10.7")
+ lbl = Gtk.Label(label="Codex Launcher v3.10.9")
lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0)
changelog_btn = Gtk.Button(label="Changelog")
diff --git a/codex-launcher_3.10.9_all.deb b/codex-launcher_3.10.9_all.deb
index 4f6c405..0687184 100644
Binary files a/codex-launcher_3.10.9_all.deb and b/codex-launcher_3.10.9_all.deb differ
diff --git a/src/codex-launcher-gui.py b/src/codex-launcher-gui.py
index 21c75c2..af7a9c4 100644
--- a/src/codex-launcher-gui.py
+++ b/src/codex-launcher-gui.py
@@ -1,3846 +1,427 @@
#!/usr/bin/env python3
-"""Codex Launcher GUI — manage endpoints, launch Desktop or CLI with any provider."""
+"""Codex Launcher GUI (tkinter) — 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 = ""
+Windows-native tkinter GUI mirroring all features of the GTK version.
+Imports process management, config engine, proxy lifecycle from codex_launcher_lib.
"""
-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",
- ]),
-]
+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
-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",
- ],
- },
-}
+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,
+)
-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)
+# ═══════════════════════════════════════════════════════════════════════
+# Helpers
+# ═══════════════════════════════════════════════════════════════════════
-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 _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 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 _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 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 _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 _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))
+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()
- 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)
+
+ 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 = Gtk.Box(spacing=6)
+ row = tk.Frame(inner)
+ row.pack(fill="x", padx=12, pady=1)
if ok is True:
- color, sym = "#27ae60", "\u2713"
+ color, sym = "#27ae60", "✓"
elif ok is False:
- color, sym = "#e74c3c", "\u2717"
+ color, sym = "#e74c3c", "✗"
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)
+ 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:
- 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()
+ tk.Label(row, text=detail, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="right")
-def endpoint_models_url(endpoint):
- base = normalize_base_url(endpoint.get("base_url") or "")
- if not base:
- return ""
- return f"{base}/models"
+ canvas.pack(side="left", fill="both", expand=True, padx=(12, 0), pady=6)
+ scrollbar.pack(side="right", fill="y", pady=6)
-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
+ btn_frame = tk.Frame(dlg)
+ btn_frame.pack(pady=(0, 10))
+ ttk.Button(btn_frame, text="Close", command=dlg.destroy).pack()
-_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)
+# ═══════════════════════════════════════════════════════════════════════
+# EditEndpointDialog
+# ═══════════════════════════════════════════════════════════════════════
-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
+class EditEndpointDialog:
+ def __init__(self, parent, existing_name=None):
+ self.result = False
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)
+ self._parent_mgr = parent
- 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)
+ 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",
+ }
- grid = Gtk.Grid(column_spacing=8, row_spacing=6)
- area.pack_start(grid, False, False, 0)
+ 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 add_row(row, label, widget):
- grid.attach(Gtk.Label(label=label, xalign=1), 0, row, 1, 1)
- grid.attach(widget, 1, row, 1, 1)
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
- self._entry_name = Gtk.Entry(text=self._data.get("name", ""))
- add_row(0, "Name:", self._entry_name)
+ grid = ttk.Frame(main)
+ grid.pack(fill="x")
- 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)
+ row_idx = [0]
- 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)
+ 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")
- self._combo_type.set_active_id(bt)
- add_row(2, "Type:", self._combo_type)
+ 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 = Gtk.Entry(text=self._data.get("base_url", ""))
- add_row(3, "Base URL:", self._entry_url)
+ self._entry_url = add_field("Base URL:", lambda: ttk.Entry(grid))
+ self._entry_url.insert(0, self._data.get("base_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)
+ 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 = 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)
+ self._entry_cc_ver = add_field("CC Version:", lambda: ttk.Entry(grid))
+ self._entry_cc_ver.insert(0, self._data.get("cc_version", ""))
- 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)
+ 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_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)
+ 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 = 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_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 = 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_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 = 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)
+ 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)
- # Models
- mlbl = Gtk.Label(label="Models:", xalign=0)
- area.pack_start(mlbl, False, False, 4)
+ grid.columnconfigure(1, weight=1)
- 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)
+ ttk.Label(main, text="Models:").pack(anchor="w", pady=(8, 2))
- 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)
+ 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))
- 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)
+ 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_store.append([m])
+ self._model_listbox.insert("end", 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()
+ 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()
- 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._combo_default.set(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()
+ 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 _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 _on_reasoning_toggled(self):
+ state = "readonly" if self._reason_var.get() else "disabled"
+ self._combo_effort.configure(state=state)
- 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 _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_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("")
+ 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:
- 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("")
+ 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_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()
+ 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_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", ""))
+ self._model_listbox.insert("end", mid)
+ self._refresh_default_combo()
+ if preset["models"]:
+ self._combo_default.set(preset["models"][0])
- 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 _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 _on_enhancer_toggled(self, *_):
- active = self._switch_enhancer.get_active()
- if active:
- self._enhancer_status_lbl.set_markup('ON')
+ 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._enhancer_status_lbl.set_markup('OFF')
+ 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_active_text() or "Custom"
+ preset_name = self._combo_preset.get() or "Custom"
preset = PROVIDER_PRESETS.get(preset_name, {})
provider = preset.get("oauth_provider", "")
if provider == "codebuff":
@@ -3850,19 +431,13 @@ class EditEndpointDialog(Gtk.Dialog):
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")
+ token_path = str(PROXY_CONFIG_DIR / ("google-antigravity-oauth-token.json" if is_antigravity else "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 = {}
+ _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:
- _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",
@@ -3875,9 +450,6 @@ class EditEndpointDialog(Gtk.Dialog):
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",
@@ -3888,8 +460,6 @@ class EditEndpointDialog(Gtk.Dialog):
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()
@@ -3914,32 +484,21 @@ class EditEndpointDialog(Gtk.Dialog):
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)
+ 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()
- 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)
+ 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 = 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)
+ 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 = 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()
+ 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]
@@ -3950,8 +509,6 @@ class EditEndpointDialog(Gtk.Dialog):
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")
@@ -3963,8 +520,7 @@ class EditEndpointDialog(Gtk.Dialog):
self2.send_response(400)
self2.send_header("Content-Type", "text/html")
self2.end_headers()
- self2.wfile.write(b""
- b"CSRF state mismatch.
")
+ self2.wfile.write(b"CSRF state mismatch.
")
error_holder[0] = "CSRF state mismatch"
return
code_holder[0] = params["code"][0]
@@ -3977,41 +533,26 @@ class EditEndpointDialog(Gtk.Dialog):
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")
+ 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.set_text(f"Port {port} already in use — close other apps and retry.")
- spinner.stop()
- dlg.run(); dlg.destroy()
+ self._oauth_status_var.set(f"Port {port} already in use -- close other apps and retry.")
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"})
@@ -4024,368 +565,204 @@ class EditEndpointDialog(Gtk.Dialog):
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)'}")
+
+ project_id = ""
+ try:
+ lr = urllib.request.Request(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ data=json.dumps({}).encode(),
+ headers={"Content-Type": "application/json",
+ "Authorization": f"Bearer {tokens['access_token']}",
+ "User-Agent": "google-api-nodejs-client/9.15.1"})
+ lresp = urllib.request.urlopen(lr, timeout=15)
+ ldata = json.loads(lresp.read())
+ p = ldata.get("cloudaicompanionProject", "")
+ if isinstance(p, dict):
+ project_id = p.get("id", "")
+ elif isinstance(p, str):
+ project_id = p
+ if project_id:
+ tokens["project_id"] = project_id
+ with open(token_path, "w") as f2:
+ json.dump(tokens, f2, indent=2)
+ except Exception:
+ pass
+
+ found_models = []
if is_antigravity:
- found_models = [
- "gemini-2.5-flash", "gemini-2.5-pro",
- "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
- "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}")
+ 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)
- 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
+
+ self._dlg.after(0, lambda: self._oauth_success(oauth_dlg, tokens.get("access_token", "")))
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)
+ 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()
- subprocess.Popen(["xdg-open", auth_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- dlg.connect("response", lambda d, r: d.destroy())
- dlg.run()
+ 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):
- 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)
+ 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
- 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():
+ def _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"})
+ 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)
- 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)
+ 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._fb_oauth_result["error"] = "Server returned no login URL"
- GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
+ self._cb_oauth_result["error"] = "No login URL"
+ self._dlg.after(0, self._codebuff_oauth_done)
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}"
+ 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:
- 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)
+ 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 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)
+ self._cb_oauth_result["error"] = "Timed out"
except Exception as e:
- self._fb_oauth_result["error"] = str(e)[:200]
- GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
+ self._cb_oauth_result["error"] = str(e)[:200]
+ self._dlg.after(0, self._codebuff_oauth_done)
- threading.Thread(target=_codebuff_auth_thread, daemon=True).start()
- dlg.connect("response", lambda d, r: d.destroy())
- dlg.run()
+ threading.Thread(target=_thread, daemon=True).start()
- 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:
+ 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)
- 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))
+ 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._oauth_status.set_markup(f'{self._fb_oauth_result["error"] or "Login failed."}')
- GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL))
+ self._cb_status_var.set(f"Failed: {self._cb_oauth_result.get('error', 'unknown')}")
- 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 _cancel(self):
+ self._dlg.destroy()
- 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()
+ def _save(self):
+ name = self._entry_name.get().strip()
if not name:
- self._show_error("Name is required")
+ messagebox.showerror("Error", "Name is required", parent=self._dlg)
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))]
+ 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:
- ok, err = self._try_fetch_models()
- if ok:
- models = [self._model_store[i][0] for i in range(len(self._model_store))]
+ 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:
- 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()
+ 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.destroy()
+ self._dlg.destroy()
return
if not models:
- self._show_error("At least one model is required")
- self._entry_model.grab_focus()
+ messagebox.showerror("Error", "At least one model is required", parent=self._dlg)
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]
- # Check for duplicate name
- existing = [e for e in data["endpoints"] if e["name"] == name and e != self._data]
+ existing = [e for e in data["endpoints"] if e["name"] == name]
if existing:
- self._show_error(f'Endpoint "{name}" already exists')
+ messagebox.showerror("Error", f'Endpoint "{name}" already exists', parent=self._dlg)
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()
+ 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
- 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()
+ 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_active_text() or "Custom"
+ 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"]
- 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:
@@ -4398,128 +775,321 @@ class EditEndpointDialog(Gtk.Dialog):
data["default"] = name
save_endpoints(data)
- self._parent_mgr._rebuild()
- self._parent_mgr._parent._on_endpoints_updated()
- self.destroy()
+ self.result = True
+ self._dlg.destroy()
- def _show_error(self, msg):
- d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
- d.run(); d.destroy()
-# ═══════════════════════════════════════════════════════════════════
-# Entry point
-# ═══════════════════════════════════════════════════════════════════
+# ═══════════════════════════════════════════════════════════════════════
+# EndpointMgr
+# ═══════════════════════════════════════════════════════════════════════
-# ═══════════════════════════════════════════════════════════════════
-# 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)
+class EndpointMgr:
+ def __init__(self, parent, on_update=None):
self._parent = parent
+ self._on_update = on_update
- 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)
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("Manage Endpoints")
+ self._dlg.geometry("600x400")
+ self._dlg.transient(parent)
- 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)
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
- 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)
+ ttk.Label(main, text="Endpoints", font=("Segoe UI", 11, "bold")).pack(anchor="w")
- sel = self._tree.get_selection()
- sel.connect("changed", lambda *_: self._on_select())
+ 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")
- 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)
+ 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()
- 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")])
+ 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.get_selection()
- m, i = sel.get_selected()
- return self._store[i][0] if i else None
+ sel = self._tree.selection()
+ if not sel:
+ return None
+ return self._tree.item(sel[0])["values"][0]
- def _on_select(self):
- name = self._selected_name()
- self._edit_btn.set_sensitive(bool(name))
- self._del_btn.set_sensitive(bool(name))
+ 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 _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):
+ def _edit(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:
+ 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
- data = load_bgp_pools()
- data["pools"] = [p for p in data["pools"] if p["name"] != name]
- save_bgp_pools(data)
+ 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()
- self._parent._on_endpoints_updated()
+ 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()
-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)
+# ═══════════════════════════════════════════════════════════════════════
+# 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:
@@ -4530,95 +1100,131 @@ class BGPPoolEditDialog(Gtk.Dialog):
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)
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
- grid = Gtk.Grid(column_spacing=8, row_spacing=6)
- area.pack_start(grid, False, False, 0)
+ 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.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(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="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)
+ ttk.Label(main, text="Routes (double-click to remove):", font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(8, 2))
- area.pack_start(Gtk.Label(label="Routes (drag to reorder priority)", use_markup=True, xalign=0), False, False, 8)
+ 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._route_store = Gtk.ListStore(str, str, str, str, str, str)
+ self._routes = []
for r in pool.get("routes", []):
- self._route_store.append([
+ self._routes.append(dict(r))
+ self._route_tree.insert("", "end", values=(
r.get("name", ""), r.get("endpoint_name", ""),
- r.get("target_url", ""), r.get("api_key", ""),
- r.get("model", ""), str(r.get("priority", 99))
- ])
+ r.get("target_url", ""), r.get("model", ""), 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)
+ 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))
- 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)
+ 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))
- self.show_all()
+ 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)))
- if self.run() == Gtk.ResponseType.OK:
- self._save()
+ 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)))
- self.destroy()
+ 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_text().strip()
+ name = self._entry_name.get().strip()
if not name:
return
- strategy = self._combo_strategy.get_active_id() or "failover"
+ strategy = self._combo_strategy.get() or "failover"
routes = []
- for i, row in enumerate(self._route_store):
- if not row[2]:
+ for i, r in enumerate(self._routes):
+ if not r.get("target_url"):
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],
+ "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",
@@ -4628,331 +1234,309 @@ class BGPPoolEditDialog(Gtk.Dialog):
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)
+ self.result = True
+ self._dlg.destroy()
-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)
+class BGPPoolMgr:
+ def __init__(self, parent, on_update=None):
self._parent = parent
+ self._on_update = on_update
- _apply_css(self, f"""
- window {{ background-color: {_U["base"]}; }}
- separator {{ background-color: {_U["surface1"]}; }}
- """)
+ self._dlg = tk.Toplevel(parent)
+ self._dlg.title("AI BGP -- Pool Manager")
+ self._dlg.geometry("660x440")
+ self._dlg.transient(parent)
- vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
- self.add(vbox)
+ main = ttk.Frame(self._dlg, padding=12)
+ main.pack(fill="both", expand=True)
- self._build_header(vbox)
- self._build_summary_strip(vbox)
- sep = Gtk.Separator()
- vbox.pack_start(sep, False, False, 0)
+ ttk.Label(main, text="AI BGP Pools -- multi-provider routing with automatic failover",
+ font=("Segoe UI", 10, "bold")).pack(anchor="w")
- 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)
+ 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()
- 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)
+ 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))
- 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 _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 c in self._cards_box.get_children():
- self._cards_box.remove(c)
- stats = _load_usage_stats()
+ for w in self._cards_inner.winfo_children():
+ w.destroy()
+ stats = load_usage_stats()
updated = stats.get("updated")
if updated:
- self._updated_lbl.set_markup(f'{updated}')
+ self._updated_lbl.configure(text=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()
+ 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 = 0
- total_tok_in = 0
- total_tok_out = 0
+ total_req = total_tok_in = total_tok_out = 0
total_dur = 0.0
- n_ok = 0
- n_warn = 0
- n_err = 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:
@@ -4963,7 +1547,6 @@ class UsageWindow(Gtk.Window):
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:
@@ -4971,42 +1554,28 @@ class UsageWindow(Gtk.Window):
else:
n_ok += 1
- self._kpi_boxes["providers"].set_markup(
- f'{len(providers)}')
- self._kpi_boxes["requests"].set_markup(
- f'{total_req:,}')
+ 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_boxes["tokens"].set_markup(
- f'{tok_str}')
+ self._kpi_labels["tokens"].configure(text=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)}')
+ self._kpi_labels["latency"].configure(text=_fmt_dur(avg_lat))
- dots_parts = []
+ dots = ""
if n_ok:
- dots_parts.append(f'\u25CF{n_ok}')
+ dots += f"●{n_ok} "
if n_warn:
- dots_parts.append(f'\u25D0{n_warn}')
+ dots += f"◐{n_warn} "
if n_err:
- dots_parts.append(f'\u2717{n_err}')
- if dots_parts:
- self._status_dots.set_markup(" ".join(dots_parts))
+ dots += f"✗{n_err}"
+ self._status_dots.configure(text=dots)
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()
+ self._build_card(prov_name, prov_data)
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"]}; }}
- """)
-
+ U = self._U
total = data.get("total_requests", 0)
ok = data.get("successes", 0)
fail = data.get("failures", 0)
@@ -5014,284 +1583,138 @@ class UsageWindow(Gtk.Window):
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}; }}
- """)
+ 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))
- 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()
+ 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]
- 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)
-
+ 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:
- 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)
+ 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:
- 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)
+ 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", 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)
+ 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:
- self._build_models_section(inner, models, total)
+ 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_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)
+ 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")
-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"
+# ═══════════════════════════════════════════════════════════════════════
+# Request History Window
+# ═══════════════════════════════════════════════════════════════════════
+class RequestHistoryWindow:
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)
+ 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)
- 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)
+ main = ttk.Frame(self._dlg, padding=10)
+ main.pack(fill="both", expand=True)
- 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)
+ 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 = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
- vbox.pack_start(paned, True, True, 0)
+ paned = ttk.PanedWindow(main, orient="vertical")
+ paned.pack(fill="both", expand=True, pady=(6, 0))
- top_sw = Gtk.ScrolledWindow()
- top_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
- paned.pack1(top_sw, resize=True, shrink=False)
+ 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)
- 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)
+ 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._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._tree.bind("<>", self._on_select)
self._snapshots = []
self._load()
- self.show_all()
def _load(self):
- self._store.clear()
+ for item in self._tree.get_children():
+ self._tree.delete(item)
self._snapshots = []
- snap_dir = self._SNAP_DIR
- if not snap_dir.exists():
+ if not self._snap_dir.exists():
return
- files = sorted(snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
+ 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())
@@ -5303,172 +1726,146 @@ class RequestHistoryWindow(Gtk.Window):
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])
+ self._tree.insert("", "end", values=(ts, model, status, dur, rid, err))
except Exception:
pass
- def _on_row_activated(self, tree, path, column):
- idx = path[0]
+ 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]
- buf = self._detail.get_buffer()
- buf.set_text(json.dumps(data, indent=2, ensure_ascii=False)[:50000])
+ self._detail.delete("1.0", "end")
+ self._detail.insert("end", 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:
+ if not messagebox.askyesno("Clear All", "Delete all request snapshots?", parent=self._dlg):
return
- snap_dir = self._SNAP_DIR
- if snap_dir.exists():
- for f in snap_dir.glob("*.json"):
+ if self._snap_dir.exists():
+ for f in self._snap_dir.glob("*.json"):
try:
f.unlink()
except Exception:
pass
- self._store.clear()
+ for item in self._tree.get_children():
+ self._tree.delete(item)
self._snapshots = []
- self._detail.get_buffer().set_text("")
+ self._detail.delete("1.0", "end")
-class BenchmarkWindow(Gtk.Window):
+
+# ═══════════════════════════════════════════════════════════════════════
+# 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):
- 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._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()
- 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)
+ main = ttk.Frame(self._dlg, padding=10)
+ main.pack(fill="both", expand=True)
- 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)
+ 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_box = Gtk.Box(spacing=6)
- vbox.pack_start(lanes_box, False, False, 0)
+ lanes_frame = ttk.Frame(main)
+ lanes_frame.pack(fill="x", pady=(8, 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)
+ self._c_var = tk.BooleanVar(value=False)
+ for i, lane_label in enumerate(["A", "B", "C"]):
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)
+ 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))
- 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)
+ 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_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))
+ 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")
- if default_name:
- self._lanes[0]["ep"].set_active_id(default_name)
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_active_id(eps[1]["name"])
+ self._lanes[1]["ep"].set(eps[1]["name"])
elif eps:
- self._lanes[1]["ep"].set_active_id(eps[0]["name"])
+ self._lanes[1]["ep"].set(eps[0]["name"])
if len(eps) > 2:
- self._lanes[2]["ep"].set_active_id(eps[2]["name"])
+ self._lanes[2]["ep"].set(eps[2]["name"])
elif len(eps) > 1:
- self._lanes[2]["ep"].set_active_id(eps[1]["name"])
+ self._lanes[2]["ep"].set(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)
+ 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_sw = Gtk.ScrolledWindow()
- results_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
- vbox.pack_start(results_sw, True, True, 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._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()
+ 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_active_text()
+ name = ep_combo.get()
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)
+ 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_check.get_active():
+ if i == 2 and not self._c_var.get():
continue
- ep_name = lane["ep"].get_active_text()
- model = lane["model"].get_active_text()
+ ep_name = lane["ep"].get()
+ model = lane["model"].get()
if not ep_name or not model:
continue
ep = get_endpoint(ep_name)
@@ -5477,44 +1874,13 @@ class BenchmarkWindow(Gtk.Window):
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"}
+ 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:
@@ -5523,7 +1889,7 @@ class BenchmarkWindow(Gtk.Window):
data = json.dumps(body).encode()
else:
test_url = f"{url}/chat/completions"
- headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
+ 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:
@@ -5564,7 +1930,7 @@ class BenchmarkWindow(Gtk.Window):
"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
@@ -5578,29 +1944,12 @@ class BenchmarkWindow(Gtk.Window):
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()
+ 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 = {"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 = {"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
@@ -5617,30 +1966,17 @@ class BenchmarkWindow(Gtk.Window):
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)
+ 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,
@@ -5649,22 +1985,36 @@ class BenchmarkWindow(Gtk.Window):
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_active():
+ if self._test_ttft.get():
tests.append(("TTFT (stream)", True, False))
- if self._test_total.get_active():
+ if self._test_total.get():
tests.append(("Total latency", False, False))
- if self._test_tools.get_active():
+ if self._test_tools.get():
tests.append(("Tool call", False, True))
- run_tps = self._test_tps.get_active()
+ run_tps = self._test_tps.get()
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}…")
+ 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))
@@ -5672,7 +2022,7 @@ class BenchmarkWindow(Gtk.Window):
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]
+ 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:
@@ -5683,7 +2033,7 @@ class BenchmarkWindow(Gtk.Window):
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))
@@ -5691,7 +2041,7 @@ class BenchmarkWindow(Gtk.Window):
lane_tps = []
for lane in lanes:
label = lane["label"]
- GLib.idle_add(self._status.set_text, f"Tokens/sec: {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))
@@ -5709,18 +2059,1255 @@ class BenchmarkWindow(Gtk.Window):
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_store.append(row)
- self._status.set_text("Benchmark complete.")
+ self._results_tree.insert("", "end", values=row)
+ self._status_var.set("Benchmark complete.")
self._running = False
- self._run_btn.set_sensitive(True)
+ self._run_btn.configure(state="normal")
- GLib.idle_add(_show)
+ 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://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]):
+ 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
+ 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
+ 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"
+ 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"
+
+ 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")
+ if parent_dlg:
+ oauth_dlg.transient(parent_dlg)
+ else:
+ oauth_dlg.transient(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__":
- 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()