From 4fa74a4ebe6b69d79795f7f6e3361f889b90e9d8 Mon Sep 17 00:00:00 2001 From: Roman | RyzenAdvanced Date: Mon, 25 May 2026 22:34:52 +0400 Subject: [PATCH] v3.10.9: restore Windows GUI, version labels v3.10.9 in both GUIs --- codex-launcher-gui | 2 +- codex-launcher_3.10.9_all.deb | Bin 105478 -> 105484 bytes src/codex-launcher-gui.py | 7747 ++++++++++++--------------------- 3 files changed, 2668 insertions(+), 5081 deletions(-) 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 4f6c4057bb552d5b208e4b5e10f1cff865ad2b29..0687184658ffd8d28f8a2f69490d9f67d2010f2f 100644 GIT binary patch delta 99344 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%0DIc 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 z%0D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%0Daj0Wg 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%0AdTEDXUE>Kw$3hu9HK%;+& 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 z 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 HP;7n*9o5%7 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()