diff --git a/codex-launcher-gui b/codex-launcher-gui index 21c75c2..5783e0a 100755 --- a/codex-launcher-gui +++ b/codex-launcher-gui @@ -1856,7 +1856,7 @@ class LauncherWin(Gtk.Window): # header row hdr = Gtk.Box(spacing=8) vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Codex Launcher v3.10.7") + lbl = Gtk.Label(label="Codex Launcher v3.10.9") lbl.set_use_markup(True) hdr.pack_start(lbl, False, False, 0) changelog_btn = Gtk.Button(label="Changelog") diff --git a/codex-launcher_3.10.9_all.deb b/codex-launcher_3.10.9_all.deb index 4f6c405..0687184 100644 Binary files a/codex-launcher_3.10.9_all.deb and b/codex-launcher_3.10.9_all.deb differ diff --git a/src/codex-launcher-gui.py b/src/codex-launcher-gui.py index 21c75c2..af7a9c4 100644 --- a/src/codex-launcher-gui.py +++ b/src/codex-launcher-gui.py @@ -1,3846 +1,427 @@ #!/usr/bin/env python3 -"""Codex Launcher GUI — manage endpoints, launch Desktop or CLI with any provider.""" +"""Codex Launcher GUI (tkinter) — manage endpoints, launch Desktop or CLI with any provider. -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib -import subprocess, os, signal, sys, threading, time, json, urllib.request, urllib.parse, urllib.error, tempfile, shutil -import hashlib, socket, ssl, contextlib, re, collections -import base64, secrets, uuid, webbrowser -from pathlib import Path - -HOME = Path.home() -START_SH = Path("/opt/codex-desktop/start.sh") -CONFIG = HOME / ".codex/config.toml" -CONFIG_BAK = HOME / ".codex/config.toml.launcher-bak" -CLEANUP = HOME / ".local/bin/cleanup-codex-stale.sh" -PROXY = HOME / ".local/bin/translate-proxy.py" -ENDPOINTS_FILE = HOME / ".codex/endpoints.json" -BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json" -LOG_DIR = HOME / ".cache/codex-desktop" -LAUNCH_LOG = LOG_DIR / "launcher.log" -PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy" -DEFAULT_CONFIG = """model = "" -model_provider = "" -model_catalog_json = "" +Windows-native tkinter GUI mirroring all features of the GTK version. +Imports process management, config engine, proxy lifecycle from codex_launcher_lib. """ -CHANGELOG = [ - ("3.10.4", "2026-05-25", [ - "OAuth Secrets editor in GUI — update client ID/secret without editing files", - "Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)", - ]), - ("3.10.3", "2026-05-25", [ - "Fix Antigravity 404: map display names to verified REST API model IDs", - "REST API uses slugs (gemini-3-flash) not display names (Gemini 3.5 Flash)", - "Match agy CLI model list: Gemini 3.5 Flash (H/M/L), 3.1 Pro (H/L), Claude 4.6, GPT-OSS", - ]), - ("3.10.2", "2026-05-25", [ - "Fetch from API now works for Antigravity — returns current model list", - ]), - ("3.10.0", "2026-05-25", [ - "Provider editor: Remove Selected, Clear All, Sync from Preset buttons for model list", - "Sync from Preset replaces model list with current preset models", - "Stale saved Antigravity models auto-refreshed on preset sync", - ]), - ("3.9.9", "2026-05-25", [ - "Refresh Antigravity preset: Gemini 3.5 Flash, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6, GPT-OSS 120B", - "Fix Antigravity alias map for new tiered model IDs (high/medium/low/thinking)", - "Add model context sizes for Gemini 3.5 Flash, Gemini 3.1 Pro, Claude 4.6, GPT-OSS 120B", - ]), - ("3.9.8", "2026-05-25", [ - "Fix Codex Desktop sending wrong model (gpt-5.4-mini) instead of selected model", - "Proxy remaps Desktop forced models to user-selected model via CODEX_LAUNCHER_MODEL", - "Write review_model + wire_api + retries to config.toml for Desktop compatibility", - "send_json() globally catches BrokenPipeError — no more crashes on disconnect", - ]), - ("3.9.7", "2026-05-25", [ - "Forward real Codebuff error messages to user (not generic 429)", - "Return HTTP 200 with Responses API format for rate limits so Codex displays message", - "Extract retryAfterMs from Codebuff 429 responses for accurate cooldown", - "RateLimitError carries upstream message through session + chat error paths", - "BrokenPipeError crash fix on 'all accounts exhausted' response", - "Fix 3 SyntaxWarnings for invalid escape sequences in docstrings", - "_codebuff_start_run returns actual error body instead of None", - ]), - ("3.9.6", "2026-05-25", [ - "Fix Gemini follow-up turns returning text-only instead of tool calls", - "Enforce latest user instruction as final Gemini content turn", - "Edit-intent detection with tool-use nudge for file modification requests", - "Debug logging: contents count, latest user text, final content preview", - "Thought signature preservation for Gemini 3 tool-call continuity", - "thought_signature field on all functionCall parts (snake_case)", - "Smart tool output compaction: old=3000, recent=20000 chars", - "Follow-through guardrail system instruction for autonomous agent behavior", - "Stream hang fix for function-call-only responses", - "Multi-account rotation for codebuff, Google OAuth, API keys", - "/v1/accounts endpoint for account pool status", - ]), - ("3.9.0", "2026-05-24", [ - "Multi-account rotation for OAuth providers (codebuff, Google, API keys)", - "Automatic failover: when one account hits rate limit, next is used", - "Codebuff: supports accounts[] array in credentials.json", - "Google OAuth: supports multiple token files (google-*-oauth-token-N.json)", - "API keys: comma-separated keys rotate on 429 errors", - "New /v1/accounts endpoint shows account pool status", - "Added x-codebuff-model and x-codebuff-instance-id headers", - ]), - ("3.8.4", "2026-05-24", [ - "FIXED: Codebuff streaming — SSE events now reach Codex client", - "Root cause: stream_buffered_events was never called for codebuff", - "Codebuff stream uses buffered flushing (30ms / 4KB / urgent)", - "Codebuff OAuth — built-in login flow (no external CLI needed)", - "Codebuff API: reverse-engineered www.codebuff.com endpoints", - "Codebuff session management with instance ID (waiting room)", - "Codebuff agent run lifecycle (start/finish) with model routing", - "Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7", - "Reasoning mode works with codebuff (thinking tokens supported)", - "GUI: Sandbox mode selector (Read-only / Workspace / Full Access)", - "GUI: Approval mode selector (Untrusted / On Request / Full Auto)", - "GUI: Codebuff Login button in endpoint editor", - "Fixed _STATS undefined error in /health endpoint", - "Fixed codebuff credential path (reads default account)", - ]), - ("3.8.1", "2026-05-24", [ - "Codebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7", - "Codebuff backend: auto agent-run lifecycle, credential detection, model routing", - "Restored all provider presets (Command Code, Crof, OpenAdapter, OpenRouter, etc.)", - "AI Monitoring — self-healing watchdog with 3-tier response system", - "HealthWatcher: monitors proxy health every 5s, auto-restarts on crash", - "LogAnalyzer: tails debug logs for 18 failure signal patterns", - "Tier 1: 14 rule-based auto-recovery rules (< 1 s response)", - "Tier 2: Incident pattern store with success rate tracking", - "Tier 3: AI diagnostic agent — configurable provider/model for novel failures", - "30 fault types catalogued across 5 categories (A-E)", - "GUI: AI Monitor panel with ON/OFF, provider selector, incident log", - "Enhanced /health endpoint with memory and uptime metrics", - ]), - ("3.7.0", "2026-05-22", [ - "Intelligence Routing — self-healing parser system for Command Code", - "Layer 1: Deep URL extraction from nested JSON in explore_agent blocks", - "Layer 2: Auto-proceed on require_escalation / request_escalation_permission blocks", - "Layer 3: Intent-based command synthesis when all parsers fail (5 heuristics)", - "Module-level _build_explore_cmd() — reuses URL extraction across parser + stream", - "54 self-test patterns covering all three Intelligence Routing layers", - ]), - ("3.6.0", "2026-05-22", [ - "Connection pooling — persistent HTTPS connections per host", - "Stream idle timeout (300s) — kills silent streams instead of hanging", - "Retry-After header support on all retry paths", - "Bounded stream buffers (8MB) — prevents OOM", - "Dual logging to proxy.log + stderr", - ]), - ("3.5.0", "2026-05-22", [ - "Command Code adapter overhaul — 17 patches for multi-format tool-call parsing", - "DSML, XML, explore_agent, bash blocks, raw JSON parser chain", - "Self-revive watchdog — auto-restarts proxy on crash", - "Debug-to-file logging in cc-debug.log", - "Inline self-test (19 patterns)", - ]), - ("3.3.0", "2026-05-20", [ - "Antigravity + Gemini CLI OAuth — full Codex agent loop working", - "Auto-continue on MAX_TOKENS for Gemini/Antigravity", - "BGP++ route scoring and provider policy layer", - ]), - ("3.0.0", "2026-05-20", [ - "Major overhaul — ThreadingHTTPServer, thread-safe state, graceful shutdown", - "Dynamic port allocation, proxy health gating, atomic config", - "Usage Dashboard v2 with dark theme", - ]), - ("2.7.0", "2026-05-20", [ - "Usage Dashboard redesigned (OpenUsage-inspired dark theme)", - "TCP_NODELAY streaming, Anthropic prompt caching", - ]), - ("2.6.1", "2026-05-20", [ - "Google OAuth rebuilt to emulate Gemini CLI — no client_secret.json needed", - "Uses Google's public OAuth client_id (same as gemini-cli)", - "PKCE + CSRF state protection for secure auth", - "Just click OAuth Login → browser opens → authorize → done", - "Includes cloud-platform scope for Gemini Code Assist compatibility", - ]), - ("2.6.0", "2026-05-20", [ - "Usage Dashboard — per-provider request/token/latency tracking", - "Visual cards with success rate bars, model breakdown, error tracking", - "Google OAuth: browse for client_secret.json instead of fixed path", - ]), - ("2.5.1", "2026-05-20", [ - "Adaptive retry for 429/502/503 errors with exponential backoff", - "BGP routes also retry transient errors before failing over", - "Proxy socket reuse — no more 'Address already in use' crashes", - "BGP route count shown at proxy startup", - ]), - ("2.5.0", "2026-05-20", [ - "AI BGP — multi-provider routing with automatic failover", - "Create BGP pools with ordered routes from any configured endpoint", - "Each route uses its own endpoint URL, API key, and model", - "Failover strategy: tries primary, falls back on error/timeout", - "BGP pools appear in endpoint dropdown with shuffle icon", - "Up/down reordering for route priority in pool editor", - "Fixed TOML config breakage from multi-line paste in fields", - ]), - ("2.4.0", "2026-05-20", [ - "Added OpenAdapter provider preset (api.openadapter.in)", - "One API key access to 40+ models — GLM, DeepSeek, Kimi, Qwen, Claude, GPT, Gemini", - "Fixed Add/Edit dialog crash (missing _on_reasoning_toggled method)", - "Redesigned Google OAuth flow with live status dialog", - ]), - ("2.3.2", "2026-05-20", [ - "Added Google Gemini provider with OAuth support", - "Two presets: 'Google Gemini (API Key)' and 'Google Gemini (OAuth)'", - "OAuth Login button in endpoint editor — full Google OAuth2 flow with auto-refresh", - "Auto-refreshes OAuth access tokens when expired (no manual re-login needed)", - "Supports gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, and more", - "Uses Gemini's OpenAI-compatible endpoint — works with existing proxy", - ]), - ("2.3.0", "2026-05-20", [ - "Adaptive Crof self-healing system — auto-adjusts to Crof model limits", - "Tracks per-model success/failure history, learns item count limits dynamically", - "Proactively compacts input when above learned limit before sending to Crof", - "Auto-retries on finish_reason=length — aggressively compacts and resends", - "Prevents 'stream disconnected' and 'incomplete' errors on long conversations", - ]), - ("2.2.1", "2026-05-20", [ - "Fixed compaction orphaning function_call_output items — root cause of Crof incomplete responses", - "Compaction now respects function_call/function_call_output pairs — no more dangling tool results", - "Fixed reasoning control: reasoning_effort=none now always sends enable_thinking=false too", - ]), - ("2.2.0", "2026-05-20", [ - "Added per-provider Reasoning On/Off toggle in endpoint editor", - "Added Reasoning Effort level per provider: None, Minimal, Low, Medium, High, Max", - "When reasoning is OFF: sends enable_thinking=false + reasoning_effort=none to upstream API", - "When reasoning is ON: sends user-selected effort level (default: Medium)", - "Fixes Crof mimo-v2.5-pro and similar reasoning models exhausting output tokens", - "Strip reasoning_content from proxy output — Codex doesn't use it", - "Force max_tokens=64000 minimum for openai-compat providers", - ]), - ("2.1.3", "2026-05-19", [ - "Fixed Crof mimo-v2.5-pro stopping: reasoning_content exhausted all output tokens", - "Strip reasoning_content from proxy output — Codex doesn't use it, avoids token waste", - "Force max_tokens=64000 minimum for openai-compat providers — gives models room for both reasoning and content", - ]), - ("2.1.2", "2026-05-19", [ - "Fixed Crof.ai and providers stopping after first tool call (root cause: None tool IDs)", - "Codex sends function_call items with id=None — proxy now matches tool results to calls by position", - "Fixed orphan message output item when response has only tool calls (no text)", - "Auto-trims long conversations (>30 items) to prevent context overflow on providers like Crof", - "Added request/response logging to ~/.cache/codex-proxy/requests.log", - ]), - ("2.1.1", "2026-05-19", [ - "Fixed proxy: map 'developer' role to 'system' for Chat Completions providers", - "Fixed proxy: map 'developer' role to 'user' for Anthropic providers", - "Forward 'instructions' field from Responses API as system message/param", - "Fixes DeepSeek and other providers rejecting unknown 'developer' role", - ]), - ("2.1.0", "2026-05-19", [ - "Added Codex auth status detection (codex login status)", - "Added Re-login button to re-authenticate via codex login", - "Auto-checks auth before launching Codex Default mode", - "Warns if OAuth token expired or missing before launch", - ]), - ("2.0.1", "2026-05-19", [ - "Added Codex CLI/Desktop installation verifier to main page", - "Disables Desktop/CLI launch buttons when corresponding tool is missing", - "Shows install instructions in status area on startup", - ]), - ("2.0.0", "2026-05-19", [ - "Initial release: multi-provider Codex Launcher", - "Translation proxy: Responses API to Chat Completions + Anthropic Messages", - "GTK endpoint manager with 10+ provider presets", - "Codex Default mode (built-in OAuth, zero config)", - "Browser UA injection for Cloudflare-protected providers (OpenCode)", - "Streaming SSE, tool calls, reasoning content support", - "Profile backup/import, model auto-fetch, bulk import", - "Refresh Models in background thread", - "URL normalization to prevent double-path bugs", - "Config backup/restore around sessions", - ".deb installer package", - ]), -] +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext +import json +import os +import shutil +import socket +import ssl +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import base64 +import hashlib +import secrets +import http.server +import collections +from pathlib import Path -PROVIDER_PRESETS = { - "Custom": { - "backend_type": "openai-compat", - "base_url": "", - "models": [], - }, - "OpenAI": { - "backend_type": "native", - "base_url": "https://api.openai.com/v1", - "models": ["gpt-4o", "gpt-4o-mini"], - }, - "Anthropic": { - "backend_type": "anthropic", - "base_url": "https://api.anthropic.com/v1", - "models": ["claude-sonnet-4-5", "claude-3-5-haiku-latest"], - }, - "OpenCode Zen (OpenAI-compatible)": { - "backend_type": "openai-compat", - "base_url": "https://opencode.ai/zen/v1", - "models": [ - "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6", - "minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free", - "deepseek-v4-flash-free", "nemotron-3-super-free", - "qwen3.6-plus", "qwen3.5-plus", "big-pickle", - ], - }, - "OpenCode Zen (Anthropic)": { - "backend_type": "anthropic", - "base_url": "https://opencode.ai/zen/v1", - "models": [ - "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", - "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", - "claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku", - ], - }, - "OpenCode Go (OpenAI-compatible)": { - "backend_type": "openai-compat", - "base_url": "https://opencode.ai/zen/go/v1", - "models": [ - "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6", - "mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m2.5", - "qwen3.6-plus", "qwen3.5-plus", "deepseek-v4-pro", "deepseek-v4-flash", - ], - }, - "OpenCode Go (Anthropic)": { - "backend_type": "anthropic", - "base_url": "https://opencode.ai/zen/go/v1", - "models": ["minimax-m2.7", "minimax-m2.5"], - }, - "Crof.ai": { - "backend_type": "openai-compat", - "base_url": "https://crof.ai/v1", - "models": [], - }, - "NVIDIA NIM": { - "backend_type": "openai-compat", - "base_url": "https://integrate.api.nvidia.com/v1", - "models": [], - }, - "Kilo.ai Gateway": { - "backend_type": "openai-compat", - "base_url": "https://api.kilo.ai/api/gateway", - "models": [], - }, - "Command Code": { - "backend_type": "command-code", - "base_url": "https://api.commandcode.ai", - "cc_version": "0.26.8", - "models": [ - "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", - "anthropic:claude-sonnet-4-6", "anthropic:claude-haiku-4-5-20251001", - "anthropic:claude-opus-4-7", "anthropic:claude-opus-4-6", - "openai:gpt-5.5", "openai:gpt-5.4", "openai:gpt-5.4-mini", "openai:gpt-5.3-codex", - "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5", - "zai-org/GLM-5.1", "zai-org/GLM-5", - "MiniMaxAI/MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.5", - "Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus", - "stepfun/Step-3.5-Flash", "google/gemini-3.1-flash-lite", - ], - }, - "OpenRouter": { - "backend_type": "openai-compat", - "base_url": "https://openrouter.ai/api/v1", - "models": [], - }, - "Google Gemini (API Key)": { - "backend_type": "openai-compat", - "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", - "models": [ - "gemini-2.5-flash", "gemini-2.5-pro", - "gemini-2.0-flash", "gemini-2.0-flash-lite", - "gemini-2.5-flash-preview-native-audio-dialog", - ], - }, - "Google Gemini (OAuth)": { - "backend_type": "gemini-oauth-cli", - "base_url": "https://cloudcode-pa.googleapis.com", - "oauth_provider": "google-cli", - "models": [ - "gemini-2.5-flash", "gemini-2.5-pro", - ], - }, - "Google Antigravity (OAuth)": { - "backend_type": "gemini-oauth-antigravity", - "base_url": "https://cloudcode-pa.googleapis.com", - "oauth_provider": "google-antigravity", - "models": [ - "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)", - "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)", - "Claude Sonnet 4.6 (Thinking)", - "Claude Opus 4.6 (Thinking)", - "GPT-OSS 120B (Medium)", - ], - }, - "OpenAdapter": { - "backend_type": "openai-compat", - "base_url": "https://api.openadapter.in/v1", - "models": [ - "0G-DeepSeek-V3", - "0G-DeepSeek-v4-Pro", - "0G-GLM-5", - "0G-GLM-5.1", - "0G-Qwen3.6", - "0G-Qwen-VL", - ], - }, - "Z.ai Coding": { - "backend_type": "openai-compat", - "base_url": "https://api.z.ai/api/coding/paas/v4", - "models": [ - "glm-5.1", "glm-4.7", "GLM-4-Plus", "GLM-4-Long", - "GLM-4-Flash", "GLM-4-FlashX", "GLM-Z1-Flash", - ], - }, - "Codebuff (Free DeepSeek/Kimi)": { - "backend_type": "codebuff", - "base_url": "https://www.codebuff.com", - "oauth_provider": "codebuff", - "models": [ - "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", - "moonshotai/kimi-k2.6", "minimax/minimax-m2.7", - ], - }, - "Freebuff (Free DeepSeek/Kimi)": { - "backend_type": "codebuff", - "base_url": "https://www.codebuff.com", - "oauth_provider": "codebuff", - "models": [ - "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", - "moonshotai/kimi-k2.6", "minimax/minimax-m2.7", - ], - }, - "FreeBuff": { - "backend_type": "codebuff", - "base_url": "https://www.codebuff.com", - "oauth_provider": "codebuff", - "models": [ - "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", - "moonshotai/kimi-k2.6", "minimax/minimax-m2.7", - ], - }, -} +from codex_launcher_lib import ( + IS_WINDOWS, HOME, CONFIG, CONFIG_BAK, CONFIG_TXN, + ENDPOINTS_FILE, BGP_POOLS_FILE, LAUNCH_LOG, LOG_DIR, + PROXY_CONFIG_DIR, BIN_DIR, PROXY, CLEANUP, PID_REGISTRY, + PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH, + ANTIGRAVITY_MODELS, + safe_name, label_for_backend, normalize_model_id, normalize_base_url, + parse_model_list, now_utc_iso, apply_provider_preset, + load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools, + get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle, + backup_config, restore_config, begin_config_transaction, end_config_transaction, + recover_config_if_needed, write_config_for_native, write_config_for_translated, + endpoint_models_url, endpoint_model_headers, fetch_models_for_endpoint, + refresh_endpoint_models, run_endpoint_doctor, + detect_codex_cli, detect_codex_desktop, check_codex_auth, + last_log_lines, kill_existing_desktop, safe_cleanup_owned, + start_proxy_for, stop_proxy, start_bgp_proxy, get_proxy_state, set_proxy_state, + detect_terminal, open_url, open_file, write_secure_text, + ensure_dirs, create_default_endpoints, + load_monitoring_config, save_monitoring_config, + load_incident_store, save_incident_store, load_usage_stats, + monitoring_log, + IncidentStore, AIDiagnosticAgent, HealthWatcher, + load_oauth_secrets, save_oauth_secrets, + _usage_theme, UA, +) -def safe_name(name): - base = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name).strip("._-") or "endpoint" - digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] - return f"{base}-{digest}" -def label_for_backend(backend_type): - return { - "openai-compat": "OpenAI-compatible", - "anthropic": "Anthropic", - "command-code": "Command Code", - "codebuff": "Codebuff (Free AI)", - "native": "Native", - }.get(backend_type, backend_type) +# ═══════════════════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════════════════ -def normalize_model_id(text): - value = text.strip().lower() - if not value: - return "" - value = value.replace("/", "-") - value = value.replace("+", "plus") - value = "".join(ch if ch.isalnum() or ch in ".-" else "-" for ch in value) - while "--" in value: - value = value.replace("--", "-") - return value.strip("-.") +def _fmt_tok(n): + if n >= 1_000_000: + return f"{n/1_000_000:.1f}M" + if n >= 1_000: + return f"{n/1_000:.1f}K" + return str(n) -def normalize_base_url(url): - base = (url or "").strip().rstrip("/") - for suffix in ("/chat/completions", "/responses", "/messages"): - if base.endswith(suffix): - base = base[: -len(suffix)] - break - return base.rstrip("/") -def parse_model_list(text): - out = [] - seen = set() - for raw in text.replace(",", "\n").splitlines(): - mid = normalize_model_id(raw) - if mid and mid not in seen: - seen.add(mid) - out.append(mid) - return out +def _fmt_dur(s): + if s >= 3600: + return f"{s/3600:.1f}h" + if s >= 60: + return f"{s/60:.1f}m" + return f"{s:.1f}s" -def apply_provider_preset(endpoint, preset_name): - preset = PROVIDER_PRESETS.get(preset_name) - if not preset: - return endpoint - updated = dict(endpoint) - updated["provider_preset"] = preset_name - updated["backend_type"] = preset["backend_type"] - updated["base_url"] = normalize_base_url(preset["base_url"]) - if preset.get("cc_version") and not updated.get("cc_version"): - updated["cc_version"] = preset["cc_version"] - if not updated.get("models") or (preset.get("backend_type") or "").startswith("gemini-oauth"): - updated["models"] = list(preset.get("models", [])) - if preset.get("oauth_provider"): - updated["oauth_provider"] = preset["oauth_provider"] - if not updated.get("default_model") and updated.get("models"): - updated["default_model"] = updated["models"][0] - return updated -def _doctor_check_streaming(base_url, key, bt, model, add): - if bt == "anthropic": - test_url = f"{base_url}/v1/messages" - headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} - body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1, "stream": True, - "messages": [{"role": "user", "content": "hi"}]}).encode() - else: - test_url = f"{base_url}/chat/completions" - headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"} - body = json.dumps({"model": model, "max_tokens": 1, "stream": True, - "messages": [{"role": "user", "content": "hi"}]}).encode() - try: - req = urllib.request.Request(test_url, data=body, headers=headers, method="POST") - t0 = time.time() - resp = urllib.request.urlopen(req, timeout=20) - content_type = resp.headers.get("content-type", "") - first_chunk = resp.read(512) - lat = (time.time() - t0) * 1000 - is_sse = "text/event-stream" in content_type or first_chunk.startswith(b"data:") - if is_sse: - add("Streaming support", True, f"SSE OK in {lat:.0f}ms") - else: - add("Streaming support", False, f"Expected SSE, got {content_type[:60]}") - except urllib.error.HTTPError as e: - body_text = "" - try: - body_text = e.read(200).decode(errors="replace") - except Exception: - pass - if e.code == 429: - add("Streaming support", None, "Rate limited (skipped)") - elif e.code in (400, 404, 422): - add("Streaming support", False, f"HTTP {e.code}: {body_text[:80]}") - else: - add("Streaming support", False, f"HTTP {e.code}") - except Exception as e: - add("Streaming support", False, str(e)[:100]) +def _status_pill(success_rate, fail_pct): + U = _usage_theme() + if fail_pct > 0.15: + return ("ERR", U["red"]) + if fail_pct > 0.05: + return ("WARN", U["yellow"]) + return ("OK", U["green"]) -def _doctor_check_toolcall(base_url, key, bt, model, add): - tool = {"type": "function", "function": {"name": "test_tool", "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}}} - if bt == "anthropic": - test_url = f"{base_url}/v1/messages" - headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} - body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 50, "stream": False, - "tools": [tool], "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode() - else: - test_url = f"{base_url}/chat/completions" - headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"} - body = json.dumps({"model": model, "max_tokens": 50, "stream": False, "tools": [tool], - "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode() - try: - req = urllib.request.Request(test_url, data=body, headers=headers, method="POST") - t0 = time.time() - resp = urllib.request.urlopen(req, timeout=30) - raw = resp.read() - lat = (time.time() - t0) * 1000 - payload = json.loads(raw) - has_tools = False - if bt == "anthropic": - for block in (payload.get("content") or []): - if block.get("type") == "tool_use": - has_tools = True - break - else: - choices = payload.get("choices") or [] - for ch in choices: - if (ch.get("message", {}).get("tool_calls")): - has_tools = True - break - if has_tools: - add("Tool-call support", True, f"Tool call received in {lat:.0f}ms") - else: - add("Tool-call support", None, f"Responded but no tool_call ({lat:.0f}ms)") - except urllib.error.HTTPError as e: - if e.code == 429: - add("Tool-call support", None, "Rate limited (skipped)") - elif e.code in (400, 404, 422): - err_body = "" - try: - err_body = e.read(200).decode(errors="replace") - except Exception: - pass - add("Tool-call support", False, f"HTTP {e.code}: {err_body[:80]}") - else: - add("Tool-call support", False, f"HTTP {e.code}") - except Exception as e: - add("Tool-call support", False, str(e)[:100]) -def run_endpoint_doctor(endpoint): - """Comprehensive health checks for an endpoint. Returns [(name, ok, detail), ...]. - ok: True=pass, False=fail, None=warn/skip.""" - checks = [] - def add(name, ok, detail=""): - checks.append((name, ok, detail)) +def _show_doctor_results_tk(parent, ep_name, checks): + dlg = tk.Toplevel(parent) + dlg.title(f"Doctor: {ep_name}") + dlg.geometry("520x420") + dlg.transient(parent) + dlg.grab_set() - url = normalize_base_url(endpoint.get("base_url") or "") - key = (endpoint.get("api_key") or "").strip() - bt = endpoint.get("backend_type", "openai-compat") - model = endpoint.get("default_model") or endpoint.get("models", [""])[0] if endpoint.get("models") else "" - - # 1. URL format - parsed = urllib.parse.urlparse(url) - has_url = bool(parsed.scheme and parsed.netloc) - add("URL format", has_url, url if has_url else "Missing scheme or host") - if not has_url: - return checks - - host = parsed.hostname - port = parsed.port or (443 if parsed.scheme == "https" else 80) - - # 2. DNS resolution - try: - t0 = time.time() - addrs = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM) - dns_ms = (time.time() - t0) * 1000 - add("DNS resolution", True, f"{addrs[0][4][0]} ({dns_ms:.0f}ms)") - except socket.gaierror as e: - add("DNS resolution", False, str(e)) - return checks - - # 3. TCP/TLS connection - try: - t0 = time.time() - sock = socket.create_connection((host, port), timeout=10) - tcp_ms = (time.time() - t0) * 1000 - if parsed.scheme == "https": - ctx = ssl.create_default_context() - try: - ssock = ctx.wrap_socket(sock, server_hostname=host) - tls_ms = (time.time() - t0) * 1000 - add("TLS connection", True, f"TCP {tcp_ms:.0f}ms + handshake {tls_ms:.0f}ms") - ssock.close() - except ssl.SSLError as e: - add("TLS certificate", False, str(e)[:120]) - sock.close() - return checks - else: - add("TCP connection", True, f"{tcp_ms:.0f}ms") - sock.close() - except (socket.timeout, ConnectionRefusedError, OSError) as e: - add("TCP connection", False, str(e)[:100]) - return checks - - # 4. Auth + /models (backend-aware) - if bt == "anthropic": - add("/models endpoint", None, "Anthropic has no /models endpoint — testing via /messages") - try: - t0 = time.time() - msg_url = f"{url}/v1/messages" - body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1, - "messages": [{"role": "user", "content": "hi"}]}).encode() - req = urllib.request.Request(msg_url, data=body, headers={ - "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json", - }, method="POST") - urllib.request.urlopen(req, timeout=15) - lat = (time.time() - t0) * 1000 - add("Auth valid", True, f"Responded in {lat:.0f}ms") - except urllib.error.HTTPError as e: - if e.code in (401, 403): - add("Auth valid", False, f"HTTP {e.code} — check API key") - elif e.code == 400: - add("Auth valid", True, "Authenticated (model or param error)") - else: - add("Auth valid", False, f"HTTP {e.code}") - except Exception as e: - add("Auth valid", False, str(e)[:100]) - elif bt.startswith("gemini-oauth"): - token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json" - token_path = Path.home() / f".cache/codex-proxy/{token_name}" - if token_path.exists(): - try: - td = json.loads(token_path.read_text()) - exp = td.get("expires_at", 0) - if exp > time.time(): - remaining = exp - time.time() - add("OAuth token", True, f"Valid ({remaining / 60:.0f} min remaining)") - else: - add("OAuth token", False, "Token expired — re-login required") - except Exception as e: - add("OAuth token", False, str(e)[:80]) - else: - add("OAuth token", False, f"No token file ({token_name})") - try: - t0 = time.time() - ids, err = fetch_models_for_endpoint(endpoint) - lat = (time.time() - t0) * 1000 - if ids: - add("Network reachable", True, f"{lat:.0f}ms") - add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)") - if model: - add("Selected model exists", model in ids, - model if model in ids else f"'{model}' not in {ids[:5]}...") - elif err and ("401" in str(err) or "403" in str(err)): - add("Network reachable", True, f"{lat:.0f}ms") - add("Auth valid", False, str(err)[:100]) - else: - add("Network reachable", False, str(err or "no response")[:100]) - except Exception as e: - add("Network", False, str(e)[:100]) - else: - try: - t0 = time.time() - ids, err = fetch_models_for_endpoint(endpoint) - lat = (time.time() - t0) * 1000 - if ids: - add("Network reachable", True, f"{lat:.0f}ms") - add("Auth valid", True) - add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)") - if model: - add("Selected model exists", model in ids, - model if model in ids else f"'{model}' not found in {len(ids)} models") - else: - add("Selected model", False, "No model selected") - elif err and ("401" in str(err) or "403" in str(err)): - add("Network reachable", True, f"{lat:.0f}ms") - add("Auth valid", False, f"HTTP 401/403 — check API key") - elif err and "429" in str(err): - add("Network reachable", True, f"{lat:.0f}ms") - add("Auth valid", True, "Authenticated but rate-limited") - add("/models endpoint", None, "Rate limited — skipped") - else: - add("Network reachable", False, str(err or "no response")[:100]) - except Exception as e: - add("Network", False, str(e)[:100]) - - # 5. Streaming smoke test - if bt not in ("native", "command-code"): - _doctor_check_streaming(url, key, bt, model, add) - - # 6. Tool-call support test - if bt not in ("native", "command-code"): - _doctor_check_toolcall(url, key, bt, model, add) - - return checks - -def _show_doctor_results(parent, endpoint_name, checks): - dlg = Gtk.Dialog(title=f"Doctor: {endpoint_name}", parent=parent, modal=True) - dlg.add_button("Close", Gtk.ResponseType.CLOSE) - dlg.set_default_size(480, 400) - area = dlg.get_content_area() - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(4) passed = sum(1 for _, ok, _ in checks if ok is True) failed = sum(1 for _, ok, _ in checks if ok is False) warned = sum(1 for _, ok, _ in checks if ok is None) - hdr = Gtk.Label() - hdr.set_markup(f'{endpoint_name} ' - f'{passed} passed ' - f'{failed} failed ' - f'{warned} warnings') - area.pack_start(hdr, False, False, 6) - sep = Gtk.Separator() - area.pack_start(sep, False, False, 4) + + hdr = tk.Label(dlg, text=f"{ep_name} {passed} passed {failed} failed {warned} warnings", + font=("Segoe UI", 10, "bold")) + hdr.pack(padx=12, pady=(12, 4), anchor="w") + + ttk.Separator(dlg).pack(fill="x", padx=12) + + canvas = tk.Canvas(dlg) + scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview) + inner = tk.Frame(canvas) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + for name, ok, detail in checks: - row = Gtk.Box(spacing=6) + row = tk.Frame(inner) + row.pack(fill="x", padx=12, pady=1) if ok is True: - color, sym = "#27ae60", "\u2713" + color, sym = "#27ae60", "✓" elif ok is False: - color, sym = "#e74c3c", "\u2717" + color, sym = "#e74c3c", "✗" else: - color, sym = "#f39c12", "\u25CB" - icon = Gtk.Label() - icon.set_markup(f'{sym}') - row.pack_start(icon, False, False, 0) - lbl = Gtk.Label() - lbl.set_markup(f'{name}') - row.pack_start(lbl, False, False, 0) + color, sym = "#f39c12", "○" + tk.Label(row, text=sym, fg=color, font=("Segoe UI", 11, "bold")).pack(side="left") + tk.Label(row, text=name, font=("Segoe UI", 9, "bold")).pack(side="left", padx=(4, 0)) if detail: - det = Gtk.Label() - det.set_markup(f'{detail}') - det.set_line_wrap(True) - row.pack_end(det, False, False, 0) - area.pack_start(row, False, False, 2) - dlg.show_all() - dlg.run() - dlg.destroy() + tk.Label(row, text=detail, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="right") -def endpoint_models_url(endpoint): - base = normalize_base_url(endpoint.get("base_url") or "") - if not base: - return "" - return f"{base}/models" + canvas.pack(side="left", fill="both", expand=True, padx=(12, 0), pady=6) + scrollbar.pack(side="right", fill="y", pady=6) -def endpoint_model_headers(endpoint): - key = (endpoint.get("api_key") or "").strip() - backend = endpoint.get("backend_type", "openai-compat") - headers = {} - if backend == "anthropic": - if key: - headers["x-api-key"] = key - headers["anthropic-version"] = "2023-06-01" - elif key: - headers["Authorization"] = f"Bearer {key}" - return headers + btn_frame = tk.Frame(dlg) + btn_frame.pack(pady=(0, 10)) + ttk.Button(btn_frame, text="Close", command=dlg.destroy).pack() -_ANTIGRAVITY_MODELS = [ - "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)", - "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)", - "Claude Sonnet 4.6 (Thinking)", - "Claude Opus 4.6 (Thinking)", - "GPT-OSS 120B (Medium)", -] -def fetch_models_for_endpoint(endpoint, timeout=10): - bt = endpoint.get("backend_type", "") - if bt == "gemini-oauth-antigravity": - return list(_ANTIGRAVITY_MODELS), None - url = endpoint_models_url(endpoint) - if not url: - return None, "Base URL is empty" - try: - req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint)) - raw = urllib.request.urlopen(req, timeout=timeout).read() - payload = json.loads(raw) - items = payload.get("data") or payload.get("models") or [] - ids = [] - seen = set() - for item in items: - mid = item.get("id") if isinstance(item, dict) else None - if mid and mid not in seen: - seen.add(mid) - ids.append(mid) - if not ids: - return None, "No models returned" - return ids, None - except Exception as e: - return None, str(e) +# ═══════════════════════════════════════════════════════════════════════ +# EditEndpointDialog +# ═══════════════════════════════════════════════════════════════════════ -def refresh_endpoint_models(endpoint): - ids, err = fetch_models_for_endpoint(endpoint) - if not ids: - return None, err - updated = dict(endpoint) - updated["models"] = ids - if updated.get("default_model") not in ids: - updated["default_model"] = ids[0] - return updated, None - -# ═══════════════════════════════════════════════════════════════════ -# Endpoint storage -# ═══════════════════════════════════════════════════════════════════ - -def load_endpoints(): - if ENDPOINTS_FILE.exists(): - try: - return json.loads(ENDPOINTS_FILE.read_text()) - except Exception: - pass - return {"default": None, "endpoints": []} - -def save_endpoints(data): - ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True) - ENDPOINTS_FILE.write_text(json.dumps(data, indent=2)) - -def load_bgp_pools(): - if BGP_POOLS_FILE.exists(): - try: - return json.loads(BGP_POOLS_FILE.read_text()) - except Exception: - pass - return {"pools": []} - -def save_bgp_pools(data): - BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True) - BGP_POOLS_FILE.write_text(json.dumps(data, indent=2)) - -def get_endpoint(name): - for e in load_endpoints()["endpoints"]: - if e["name"] == name: - return e - return None - -def now_utc_iso(): - return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - -def build_profile_bundle(): - return { - "version": 1, - "exported_at": now_utc_iso(), - "endpoints": load_endpoints(), - "codex_config_toml": CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "", - } - -def save_profile_bundle(path): - bundle = build_profile_bundle() - Path(path).write_text(json.dumps(bundle, indent=2), encoding="utf-8") - -def import_profile_bundle(path): - data = json.loads(Path(path).read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise ValueError("Invalid profile bundle") - - endpoints = data.get("endpoints") - if not isinstance(endpoints, dict) or "endpoints" not in endpoints: - raise ValueError("Profile bundle missing endpoints") - - # Keep a local rollback point before overwriting the current profile. - if CONFIG.exists(): - shutil.copy2(str(CONFIG), str(CONFIG_BAK)) - if ENDPOINTS_FILE.exists(): - shutil.copy2(str(ENDPOINTS_FILE), str(ENDPOINTS_FILE.with_suffix(".json.import-bak"))) - - save_endpoints(endpoints) - - cfg = data.get("codex_config_toml", "") - if isinstance(cfg, str) and cfg.strip(): - CONFIG.parent.mkdir(parents=True, exist_ok=True) - CONFIG.write_text(cfg, encoding="utf-8") - return endpoints - -# ═══════════════════════════════════════════════════════════════════ -# Config management -# ═══════════════════════════════════════════════════════════════════ - -def backup_config(): - if CONFIG.exists(): - tmp = CONFIG_BAK.with_suffix(".tmp") - shutil.copy2(str(CONFIG), str(tmp)) - os.replace(str(tmp), str(CONFIG_BAK)) - -def restore_config(): - if CONFIG_BAK.exists(): - tmp = CONFIG.with_suffix(".tmp") - shutil.copy2(str(CONFIG_BAK), str(tmp)) - os.replace(str(tmp), str(CONFIG)) - -def write_secure_text(path, text): - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(text, encoding="utf-8") - os.chmod(str(tmp), 0o600) - os.replace(str(tmp), str(path)) - -CONFIG_TXN = HOME / ".codex/config.toml.launcher-txn.json" - -def begin_config_transaction(reason): - txn = {"started_at": time.time(), "reason": reason, - "config_existed": CONFIG.exists(), "backup_path": str(CONFIG_BAK)} - if CONFIG.exists(): - backup_config() - CONFIG_TXN.parent.mkdir(parents=True, exist_ok=True) - CONFIG_TXN.write_text(json.dumps(txn, indent=2)) - -def end_config_transaction(): - CONFIG_TXN.unlink(missing_ok=True) - -def recover_config_if_needed(logfn=None): - if not CONFIG_TXN.exists(): - return - try: - txn = json.loads(CONFIG_TXN.read_text()) - if txn.get("config_existed") and CONFIG_BAK.exists(): - restore_config() - if logfn: - logfn("Recovered Codex config from interrupted session.") - elif CONFIG.exists(): - CONFIG.unlink() - if logfn: - logfn("Removed generated config from interrupted session.") - finally: - CONFIG_TXN.unlink(missing_ok=True) - -def write_config_for_native(endpoint, selected_model): - """Write config for native OpenAI (no proxy needed).""" - backup_config() - model_catalog = _gen_model_catalog(endpoint, selected_model) - mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" - mc_path.parent.mkdir(parents=True, exist_ok=True) - mc_path.write_text(json.dumps(model_catalog, indent=2)) - - lines = [ - f'model = "{_toml_safe(selected_model)}"\n', - f'model_provider = "{_toml_safe(endpoint["name"])}"\n', - f'model_catalog_json = "{mc_path}"\n', - f'\n[model_providers."{endpoint["name"]}"]\n', - f'name = "{_toml_safe(endpoint["name"])}"\n', - f'base_url = "{_toml_safe(endpoint["base_url"])}"\n', - f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n', - f'\n[profiles."{endpoint["name"]}"]\n', - f'model_provider = "{_toml_safe(endpoint["name"])}"\n', - f'model = "{_toml_safe(selected_model)}"\n', - f'model_catalog_json = "{mc_path}"\n', - f'service_tier = "default"\n', - f'approvals_reviewer = "user"\n', - ] - write_secure_text(CONFIG, "".join(lines)) - -def _toml_safe(val): - val = str(val).replace('"', '\\"') - return val.split('\n', 1)[0].strip() - -def _resolve_secret(value): - value = (value or "").strip() - m = re.fullmatch(r"\$\{ENV:([A-Z0-9_]+)\}", value) - if m: - return os.environ.get(m.group(1), "") - return value - -def write_config_for_translated(endpoint, selected_model, proxy_port=8080): - backup_config() - model_catalog = _gen_model_catalog(endpoint, selected_model) - mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" - mc_path.parent.mkdir(parents=True, exist_ok=True) - mc_path.write_text(json.dumps(model_catalog, indent=2)) - - lines = [ - f'model = "{_toml_safe(selected_model)}"\n', - f'review_model = "{_toml_safe(selected_model)}"\n', - f'model_provider = "{_toml_safe(endpoint["name"])}"\n', - f'model_catalog_json = "{mc_path}"\n', - f'\n[model_providers."{endpoint["name"]}"]\n', - f'name = "{_toml_safe(endpoint["name"])}"\n', - f'base_url = "http://127.0.0.1:{proxy_port}"\n', - f'experimental_bearer_token = "codex-launcher-local"\n', - f'wire_api = "responses"\n', - f'request_max_retries = 1\n', - f'stream_max_retries = 0\n', - f'stream_idle_timeout_ms = 600000\n', - f'\n[profiles."{endpoint["name"]}"]\n', - f'model_provider = "{_toml_safe(endpoint["name"])}"\n', - f'model = "{_toml_safe(selected_model)}"\n', - f'review_model = "{_toml_safe(selected_model)}"\n', - f'model_catalog_json = "{mc_path}"\n', - f'service_tier = "fast"\n', - f'approvals_reviewer = "user"\n', - ] - write_secure_text(CONFIG, "".join(lines)) - -def _gen_model_catalog(endpoint, selected_model=None): - default_model = selected_model or endpoint.get("default_model") - models = [] - for mid in endpoint.get("models", []): - models.append({ - "slug": mid, "model": mid, "display_name": mid, - "description": f"{endpoint['name']} {mid}", - "hidden": False, "isDefault": mid == default_model, - "shell_type": "shell_command", "visibility": "list", - "default_reasoning_level": "medium", - "supported_reasoning_levels": [ - {"effort": "low", "description": "Fast"}, - {"effort": "medium", "description": "Balanced"}, - {"effort": "high", "description": "Deep"}, - {"effort": "xhigh", "description": "Extra deep"}, - ], - "supportedReasoningEfforts": [ - {"reasoningEffort": "low", "description": "Fast"}, - {"reasoningEffort": "medium", "description": "Balanced"}, - {"reasoningEffort": "high", "description": "Deep"}, - {"reasoningEffort": "xhigh", "description": "Extra deep"}, - ], - "priority": 30, "context_size": 128000, - "additional_speed_tiers": [], "service_tiers": [], - "supports_reasoning_summaries": True, "support_verbosity": True, - "reasoning": True, "tool_call": True, - "supports_parallel_tool_calls": True, - "experimental_supported_tools": [], "supported_in_api": True, - "truncation_policy": {"mode": "tokens", "limit": 128000}, - "base_instructions": "You are Codex, a coding agent.", - }) - return {"models": models} - -# ═══════════════════════════════════════════════════════════════════ -# Proxy management -# ═══════════════════════════════════════════════════════════════════ - -_proxy_proc = None -_proxy_port = None - -PID_REGISTRY = HOME / ".cache" / "codex-launcher" / "pids.json" - -def _pick_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - -def _load_pid_registry(): - if PID_REGISTRY.exists(): - try: - return json.loads(PID_REGISTRY.read_text()) - except Exception: - pass - return {} - -def _save_pid_registry(data): - PID_REGISTRY.parent.mkdir(parents=True, exist_ok=True) - tmp = PID_REGISTRY.with_suffix(".tmp") - tmp.write_text(json.dumps(data, indent=2)) - os.replace(str(tmp), str(PID_REGISTRY)) - -def _register_pgid(kind, pid): - data = _load_pid_registry() - try: - pgid = os.getpgid(pid) - except ProcessLookupError: - return - data[kind] = {"pid": pid, "pgid": pgid, "ts": time.time()} - _save_pid_registry(data) - -def safe_cleanup_owned(logfn=None): - data = _load_pid_registry() - changed = False - for kind, meta in list(data.items()): - pgid = meta.get("pgid") - if not pgid: - continue - try: - os.killpg(pgid, signal.SIGTERM) - if logfn: - logfn(f"Stopped {kind} (pgid {pgid})") - changed = True - except ProcessLookupError: - changed = True - except Exception as e: - if logfn: - logfn(f"Could not stop {kind}: {e}") - if changed: - _save_pid_registry({}) - -def _start_proxy_for(endpoint, logfn): - global _proxy_proc, _proxy_port - # Clear stale Python bytecode cache so proxy picks up latest source changes - import shutil - pycache = os.path.join(os.path.dirname(os.path.abspath(__file__)), '__pycache__') - if os.path.isdir(pycache): - shutil.rmtree(pycache, ignore_errors=True) - _stop_proxy() - port = _pick_free_port() - _proxy_port = port - - model_list = endpoint.get("models", []) - if (endpoint.get("backend_type") or "").startswith("gemini-oauth") and (endpoint.get("oauth_provider") or "").startswith("google"): - token_name = "google-antigravity-oauth-token.json" if endpoint.get("oauth_provider") == "google-antigravity" else "google-cli-oauth-token.json" - token_path = os.path.expanduser(f"~/.cache/codex-proxy/{token_name}") - try: - with open(token_path) as tf: - td = json.load(tf) - discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", []) - if discovered: - model_list = discovered - except Exception: - pass - pcfg = { - "port": port, - "backend_type": endpoint["backend_type"], - "target_url": normalize_base_url(endpoint["base_url"]), - "api_key": endpoint["api_key"], - "cc_version": endpoint.get("cc_version", ""), - "oauth_provider": endpoint.get("oauth_provider", ""), - "reasoning_enabled": endpoint.get("reasoning_enabled", True), - "reasoning_effort": endpoint.get("reasoning_effort", "medium"), - "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]} - for m in model_list], - } - pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}-{port}.json" - pcfg_path.parent.mkdir(parents=True, exist_ok=True) - pcfg_path.write_text(json.dumps(pcfg, indent=2)) - _start_proxy_with_config(pcfg_path, port, logfn) - return port - -def _start_proxy_with_config(pcfg_path, port, logfn): - global _proxy_proc - _proxy_proc = subprocess.Popen( - ["python3", str(PROXY), "--config", str(pcfg_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - preexec_fn=os.setsid, - text=True, - ) - _register_pgid("proxy", _proxy_proc.pid) - - def _pipe_stderr(): - if not _proxy_proc.stderr: - return - for line in _proxy_proc.stderr: - GLib.idle_add(logfn, f"[proxy] {line.rstrip()}") - threading.Thread(target=_pipe_stderr, daemon=True).start() - - deadline = time.time() + 15 - last_err = None - while time.time() < deadline: - if _proxy_proc.poll() is not None: - raise RuntimeError(f"Proxy exited early with code {_proxy_proc.returncode}") - try: - urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=2) - logfn(f"Proxy ready on port {port}") - return - except Exception as e: - last_err = e - time.sleep(0.3) - try: - os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM) - _proxy_proc.wait(timeout=3) - except Exception: - with contextlib.suppress(Exception): - os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL) - raise RuntimeError(f"Proxy failed health check on port {port}: {last_err}") - -def _stop_proxy(): - global _proxy_proc - if _proxy_proc and _proxy_proc.poll() is None: - try: - os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM) - time.sleep(0.5) - if _proxy_proc.poll() is None: - os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass - _proxy_proc = None - -def _kill_existing_desktop(logfn=None): - import subprocess as _sp - try: - out = _sp.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5) - pids = [p for p in out.stdout.strip().splitlines() if p.strip().isdigit()] - if not pids: - return - main_pid = int(pids[0]) - pgid = os.getpgid(main_pid) - if pgid > 0: - os.killpg(pgid, signal.SIGTERM) - if logfn: - logfn(f"Killed existing Codex Desktop (pid {main_pid}, pgid {pgid})") - time.sleep(2) - try: - os.killpg(pgid, signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass - except Exception as e: - if logfn: - logfn(f"Note: could not kill existing Desktop: {e}") - -def _run_cleanup(logfn=None): - safe_cleanup_owned(logfn) - -def _last_log_lines(n=15): - try: - t = LAUNCH_LOG.read_text() - return "\n".join(t.splitlines()[-n:]) - except Exception: - return "(no log file)" - -def _detect_codex_cli(): - try: - path = shutil.which("codex") - if not path: - return None - out = subprocess.run(["codex", "--version"], capture_output=True, text=True, timeout=5) - ver = (out.stdout or "").strip() or (out.stderr or "").strip() or "unknown" - return (path, ver) - except Exception: - return None - -def _detect_codex_desktop(): - if START_SH.exists(): - return str(START_SH) - return None - -def _check_codex_auth(): - try: - out = subprocess.run( - ["codex", "login", "status"], - capture_output=True, text=True, timeout=10, - ) - text = (out.stdout or "").strip() - if not text: - text = (out.stderr or "").strip() - if out.returncode == 0 and text: - return ("logged_in", text) - if text: - return ("error", text) - return ("unknown", "No output from codex login status") - except FileNotFoundError: - return ("not_installed", "codex not found") - except Exception as e: - return ("error", str(e)) - -# ═══════════════════════════════════════════════════════════════════ -# AI Monitoring — Self-Healing Watchdog -# ═══════════════════════════════════════════════════════════════════ - -MONITORING_FILE = Path.home() / ".cache/codex-proxy/monitoring-config.json" -INCIDENT_STORE_FILE = Path.home() / ".cache/codex-proxy/incident-store.json" -MONITORING_LOG = Path.home() / ".cache/codex-proxy/monitoring.log" - -_TIER1_RULES = [ - ("proxy_health_fail", "restart_proxy", 30), - ("proxy_port_conflict", "kill_stale_restart", 60), - ("upstream_429", "wait_retry", 0), - ("upstream_502_503", "retry_backoff", 30), - ("upstream_500_repeat", "switch_provider", 60), - ("upstream_timeout", "retry_increase_timeout",30), - ("upstream_401_403", "alert_bad_key", 0), - ("stream_broken_pipe", "restart_proxy", 30), - ("stream_reset", "restart_proxy", 30), - ("parsed_tool_calls_0_x3", "clear_schema_cache", 300), - ("sanitizer_suspicious_5x","alert_model_issue", 0), - ("stuck_recovery_x5", "suggest_switch_model", 0), - ("codex_process_dead", "alert_restart", 0), - ("schema_corrupt", "delete_provider_caps", 0), -] - -_FAILURE_SIGNALS = { - "parsed_tool_calls=0": ("C1", "parser_empty"), - "[STUCK-RECOVERY]": ("C3", "stuck_recovery"), - "suspicious cmd": ("C4", "sanitizer_flag"), - "empty cmd recovered": ("C6", "empty_cmd"), - "HTTP 429": ("B1", "rate_limited"), - "HTTP 500": ("B2", "server_error"), - "HTTP 502": ("B2", "server_error"), - "HTTP 503": ("B2", "server_error"), - "HTTP 401": ("B3", "auth_failure"), - "HTTP 403": ("B4", "forbidden"), - "Connection refused": ("A1", "proxy_dead"), - "Address already in use": ("A2", "port_conflict"), - "Broken pipe": ("B7", "broken_pipe"), - "Connection reset": ("B6", "connection_reset"), - "timed out": ("B5", "timeout"), - "SELF-REVIVE CRASH": ("A5", "proxy_crash"), - "stream error": ("B6", "stream_error"), - "content_type.*array": ("E1", "schema_corrupt"), -} - -_DIAGNOSTIC_SYSTEM_PROMPT = ( - 'You are a diagnostic agent for "Codex Launcher" — a desktop app that runs a local ' - 'translation proxy between OpenAI Codex CLI/Desktop and AI providers.\n\n' - 'Analyze the incident and respond with ONLY a JSON object:\n' - '{"action": "...", "reason": "...", "confidence": 0.0-1.0}\n\n' - 'Available actions: restart_proxy, kill_stale_processes, clear_schema_cache, ' - 'switch_provider, increase_timeout, regenerate_config, cleanup_stale, ' - 'alert_user, ignore, retry_now\n\n' - 'Rules:\n' - '- upstream 401/403 with auth error -> alert_user\n' - '- proxy dead -> restart_proxy\n' - '- same error 5+ times -> switch_provider or alert_user\n' - '- schema/content_type error -> clear_schema_cache\n' - '- "Address already in use" -> kill_stale_processes then restart_proxy\n' - '- timeout on slow upstream -> increase_timeout\n' - '- single transient 429/502/503 -> ignore\n' - '- "stream disconnected" + proxy healthy -> ignore\n' - '- no extra text, no markdown, just the JSON object' -) - -def _load_monitoring_config(): - if MONITORING_FILE.exists(): - try: - return json.loads(MONITORING_FILE.read_text()) - except Exception: - pass - return { - "enabled": False, - "provider_url": "", - "model": "", - "api_key": "", - "health_check_interval_s": 5, - "auto_restart_proxy": True, - "auto_switch_provider": False, - } - -def _save_monitoring_config(cfg): - MONITORING_FILE.parent.mkdir(parents=True, exist_ok=True) - MONITORING_FILE.write_text(json.dumps(cfg, indent=2)) - -def _load_incident_store(): - if INCIDENT_STORE_FILE.exists(): - try: - return json.loads(INCIDENT_STORE_FILE.read_text()) - except Exception: - pass - return {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}} - -def _save_incident_store(store): - INCIDENT_STORE_FILE.parent.mkdir(parents=True, exist_ok=True) - INCIDENT_STORE_FILE.write_text(json.dumps(store, indent=2)) - -def _monitoring_log(msg): - try: - with open(str(MONITORING_LOG), "a") as f: - f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n") - except Exception: - pass - - -class IncidentStore: - def __init__(self): - self._store = _load_incident_store() - self._dirty = False - - def lookup(self, pattern): - inc = self._store.get("incidents", {}).get(pattern) - if inc and inc.get("success_count", 0) > 0: - rate = inc["success_count"] / max(inc["success_count"] + inc.get("fail_count", 0), 1) - if rate > 0.5: - return inc - return None - - def record(self, pattern, fix, success=True): - incs = self._store.setdefault("incidents", {}) - inc = incs.setdefault(pattern, { - "fix": fix, "success_count": 0, "fail_count": 0, - "last_seen": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "occurrences": 0, - }) - inc["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - inc["occurrences"] = inc.get("occurrences", 0) + 1 - if success: - inc["success_count"] = inc.get("success_count", 0) + 1 - else: - inc["fail_count"] = inc.get("fail_count", 0) + 1 - self._dirty = True - - def record_ai_call(self, tokens=0): - stats = self._store.setdefault("stats", {"ai_calls": 0, "tokens_used": 0}) - stats["ai_calls"] = stats.get("ai_calls", 0) + 1 - stats["tokens_used"] = stats.get("tokens_used", 0) + tokens - self._dirty = True - - def flush(self): - if self._dirty: - _save_incident_store(self._store) - self._dirty = False - - @property - def stats(self): - return self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}) - - -class AIDiagnosticAgent: - def __init__(self, provider_url, model, api_key): - self.provider_url = provider_url - self.model = model - self.api_key = api_key - self.incident_store = IncidentStore() - - def diagnose(self, context): - pattern = self._extract_pattern(context) - known = self.incident_store.lookup(pattern) - if known: - _monitoring_log(f"Tier 2 HIT: pattern={pattern} fix={known['fix']}") - return {"action": known["fix"], "reason": "known_pattern", "confidence": 0.9, "tier": 2} - action = self._call_model(context) - if action: - self.incident_store.record(pattern, action.get("action", "unknown")) - self.incident_store.flush() - return action - - def _extract_pattern(self, context): - parts = [] - for k in sorted(context.get("signals", [])): - parts.append(k) - if context.get("http_code"): - parts.append(f"http_{context['http_code']}") - return "+".join(parts[:3]) or "unknown" - - def _call_model(self, context): - prompt = ( - f"INCIDENT REPORT:\n" - f"Time: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n" - f"Proxy health: {context.get('proxy_alive', 'unknown')}\n" - f"Upstream: {context.get('upstream_url', 'unknown')}\n" - f"Model: {context.get('model', 'unknown')}\n" - f"Last HTTP code: {context.get('http_code', 'n/a')}\n" - f"Recent signals: {context.get('signals', [])}\n" - f"Recent log tail:\n{context.get('log_tail', '')[:1500]}\n" - ) - body = { - "model": self.model, - "messages": [ - {"role": "system", "content": _DIAGNOSTIC_SYSTEM_PROMPT}, - {"role": "user", "content": prompt}, - ], - "max_tokens": 200, - "temperature": 0.1, - } - try: - req = urllib.request.Request( - self.provider_url, - data=json.dumps(body).encode(), - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - }, - ) - resp = urllib.request.urlopen(req, timeout=15) - result = json.loads(resp.read()) - text = result["choices"][0]["message"]["content"].strip() - self.incident_store.record_ai_call(tokens=800) - action = json.loads(text) - action["tier"] = 3 - _monitoring_log(f"Tier 3 AI: action={action.get('action')} reason={action.get('reason')}") - return action - except Exception as e: - _monitoring_log(f"Tier 3 AI FAILED: {e}") - return {"action": "alert_user", "reason": f"ai_diag_failed: {e}", "confidence": 0.0, "tier": 3} - - -class HealthWatcher(threading.Thread): - def __init__(self, on_failure, on_recovery, on_signal, on_action): - super().__init__(daemon=True) - self.cfg = _load_monitoring_config() - self.on_failure = on_failure - self.on_recovery = on_recovery - self.on_signal = on_signal - self.on_action = on_action - self.failures = 0 - self.running = False - self._signal_counts = collections.defaultdict(int) - self._last_actions = {} - self._restart_count = 0 - self._last_restart_time = 0 - - def run(self): - self.running = True - self.incident_store = IncidentStore() - self._log_analyzer = _LogAnalyzerThread(self._on_log_signal) - self._log_analyzer.start() - while self.running: - self.cfg = _load_monitoring_config() - if not self.cfg.get("enabled"): - time.sleep(5) - continue - port = self._get_proxy_port() - if port: - healthy = self._check_health(port) - if healthy: - if self.failures > 0: - self.failures = 0 - self.on_recovery() - else: - self.failures += 1 - if self.failures >= 3: - self._handle_failure("proxy_health_fail") - self.incident_store.flush() - interval = self.cfg.get("health_check_interval_s", 5) - time.sleep(interval) - - def stop(self): - self.running = False - if hasattr(self, '_log_analyzer'): - self._log_analyzer.running = False - - def _get_proxy_port(self): - try: - cfg_path = Path.home() / ".cache/codex-proxy/proxy-config.json" - if cfg_path.exists(): - d = json.loads(cfg_path.read_text()) - return d.get("port") - except Exception: - pass - return None - - def _check_health(self, port): - try: - req = urllib.request.Request(f"http://localhost:{port}/health") - resp = urllib.request.urlopen(req, timeout=5) - return resp.status == 200 - except Exception: - return False - - def _on_log_signal(self, fault_id, category, line): - self._signal_counts[category] += 1 - self.on_signal(fault_id, category, line[:200]) - count = self._signal_counts[category] - if category in ("proxy_dead", "port_conflict") and count >= 2: - self._handle_failure(category) - elif category in ("server_error", "timeout") and count >= 3: - self._handle_failure(category + "_repeat") - elif category in ("sanitizer_flag",) and count >= 5: - self._handle_failure("sanitizer_suspicious_5x") - elif category in ("stuck_recovery",) and count >= 5: - self._handle_failure("stuck_recovery_x5") - elif category in ("parser_empty",) and count >= 3: - self._handle_failure("parsed_tool_calls_0_x3") - elif category in ("schema_corrupt",): - self._handle_failure("schema_corrupt") - - def _handle_failure(self, trigger): - now = time.time() - for rule_trigger, action, cooldown in _TIER1_RULES: - if rule_trigger == trigger: - last_t = self._last_actions.get(action, 0) - if now - last_t < cooldown: - return - self._last_actions[action] = now - _monitoring_log(f"Tier 1: trigger={trigger} action={action}") - self.on_action(action, trigger) - self.incident_store.record(trigger, action, success=True) - return - self._try_tier2_3(trigger) - - def _try_tier2_3(self, trigger): - cfg = self.cfg - if not cfg.get("provider_url") or not cfg.get("model") or not cfg.get("api_key"): - _monitoring_log(f"No AI configured for Tier 2/3 — alerting user for trigger={trigger}") - self.on_action("alert_user", trigger) - return - agent = AIDiagnosticAgent(cfg["provider_url"], cfg["model"], cfg["api_key"]) - context = { - "signals": [trigger], - "proxy_alive": self.failures == 0, - "log_tail": self._get_recent_log(), - } - result = agent.diagnose(context) - if result: - action = result.get("action", "alert_user") - _monitoring_log(f"Tier {result.get('tier', '?')}: action={action}") - self.on_action(action, trigger) - - -class _LogAnalyzerThread(threading.Thread): - def __init__(self, on_signal): - super().__init__(daemon=True) - self.on_signal = on_signal - self.running = False - - def run(self): - self.running = True - log_paths = [ - str(Path.home() / ".cache/codex-proxy/cc-debug.log"), - str(Path.home() / ".cache/codex-proxy/proxy.log"), - ] - fhs = {} - for p in log_paths: - try: - f = open(p, "r") - f.seek(0, 2) - fhs[p] = f - except Exception: - pass - while self.running: - activity = False - for p, fh in list(fhs.items()): - try: - line = fh.readline() - if line: - activity = True - for pattern, (fault_id, category) in _FAILURE_SIGNALS.items(): - if re.search(pattern, line): - self.on_signal(fault_id, category, line.strip()) - break - except Exception: - pass - if not activity: - time.sleep(0.5) - - -class AIMonitoringWindow(Gtk.Window): - def __init__(self, parent=None): - super().__init__(title="AI Monitoring") - self.set_transient_for(parent) - self.set_default_size(580, 520) - self.set_border_width(12) - self._cfg = _load_monitoring_config() - self._store = _load_incident_store() - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self.add(vbox) - - hdr = Gtk.Box(spacing=8) - vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label() - lbl.set_markup("AI Monitoring") - lbl.set_use_markup(True) - hdr.pack_start(lbl, False, False, 0) - self._toggle = Gtk.Switch() - self._toggle.set_active(self._cfg.get("enabled", False)) - self._toggle.connect("state-set", self._on_toggle) - hdr.pack_end(self._toggle, False, False, 0) - lbl2 = Gtk.Label(label="Enabled") - hdr.pack_end(lbl2, False, False, 0) - - frame = Gtk.Frame(label="Diagnostic Agent") - vbox.pack_start(frame, False, False, 0) - grid = Gtk.Grid(column_spacing=8, row_spacing=6, margin=8) - frame.add(grid) - - grid.attach(Gtk.Label(label="Provider URL:", halign=Gtk.Align.END), 0, 0, 1, 1) - self._url_entry = Gtk.Entry(hexpand=True) - self._url_entry.set_text(self._cfg.get("provider_url", "")) - self._url_entry.set_placeholder_text("https://api.openai.com/v1/chat/completions") - grid.attach(self._url_entry, 1, 0, 2, 1) - - grid.attach(Gtk.Label(label="Model:", halign=Gtk.Align.END), 0, 1, 1, 1) - self._model_entry = Gtk.Entry(hexpand=True) - self._model_entry.set_text(self._cfg.get("model", "")) - self._model_entry.set_placeholder_text("gpt-4o-mini or Qwen/Qwen3-32B") - grid.attach(self._model_entry, 1, 1, 2, 1) - - grid.attach(Gtk.Label(label="API Key:", halign=Gtk.Align.END), 0, 2, 1, 1) - self._key_entry = Gtk.Entry(hexpand=True, visibility=False) - self._key_entry.set_text(self._cfg.get("api_key", "")) - self._key_entry.set_placeholder_text("sk-...") - grid.attach(self._key_entry, 1, 2, 1, 1) - self._reveal_btn = Gtk.ToggleButton(label="Show") - self._reveal_btn.connect("toggled", lambda b: self._key_entry.set_visibility(b.get_active())) - grid.attach(self._reveal_btn, 2, 2, 1, 1) - - grid.attach(Gtk.Label(label="Health Check:", halign=Gtk.Align.END), 0, 3, 1, 1) - adj = Gtk.Adjustment(value=self._cfg.get("health_check_interval_s", 5), lower=2, upper=30, step_increment=1) - self._interval_spin = Gtk.SpinButton(adjustment=adj) - self._interval_spin.set_numeric(True) - grid.attach(self._interval_spin, 1, 3, 1, 1) - grid.attach(Gtk.Label(label="seconds"), 2, 3, 1, 1) - - opts_box = Gtk.Box(spacing=12, margin_top=4) - grid.attach(opts_box, 0, 4, 3, 1) - self._auto_restart_cb = Gtk.CheckButton(label="Auto-restart proxy on crash") - self._auto_restart_cb.set_active(self._cfg.get("auto_restart_proxy", True)) - opts_box.pack_start(self._auto_restart_cb, False, False, 0) - self._auto_switch_cb = Gtk.CheckButton(label="Auto-switch provider on repeated failure") - self._auto_switch_cb.set_active(self._cfg.get("auto_switch_provider", False)) - opts_box.pack_start(self._auto_switch_cb, False, False, 0) - - save_btn = Gtk.Button(label="Save Configuration") - save_btn.get_style_context().add_class("suggested-action") - save_btn.connect("clicked", self._on_save) - grid.attach(save_btn, 0, 5, 3, 1) - - stats_box = Gtk.Box(spacing=16) - vbox.pack_start(stats_box, False, False, 0) - stats = self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}) - self._stats_lbl = Gtk.Label() - self._stats_lbl.set_markup( - f"AI diagnostic calls: {stats.get('ai_calls', 0)} | " - f"Tokens used: {stats.get('tokens_used', 0):,} | " - f"Known patterns: {len(self._store.get('incidents', {}))}" - ) - self._stats_lbl.set_use_markup(True) - stats_box.pack_start(self._stats_lbl, False, False, 0) - - frame2 = Gtk.Frame(label="Recent Incidents") - vbox.pack_start(frame2, True, True, 0) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - frame2.add(sw) - self._inc_buf = Gtk.TextBuffer() - tv = Gtk.TextView(buffer=self._inc_buf) - tv.set_editable(False) - tv.set_cursor_visible(False) - tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - sw.add(tv) - self._refresh_incidents() - - bb = Gtk.Box(spacing=8) - vbox.pack_start(bb, False, False, 0) - view_btn = Gtk.Button(label="View Monitoring Log") - view_btn.connect("clicked", lambda b: subprocess.Popen(["xdg-open", str(MONITORING_LOG)])) - bb.pack_start(view_btn, False, False, 0) - clear_btn = Gtk.Button(label="Clear Incident Store") - clear_btn.connect("clicked", self._on_clear_store) - bb.pack_start(clear_btn, False, False, 0) - close_btn = Gtk.Button(label="Close") - close_btn.connect("clicked", lambda b: self.destroy()) - bb.pack_end(close_btn, False, False, 0) - - self.show_all() - - def _on_toggle(self, switch, state): - self._cfg["enabled"] = state - _save_monitoring_config(self._cfg) - - def _on_save(self, btn): - self._cfg["provider_url"] = self._url_entry.get_text().strip() - self._cfg["model"] = self._model_entry.get_text().strip() - self._cfg["api_key"] = self._key_entry.get_text().strip() - self._cfg["health_check_interval_s"] = int(self._interval_spin.get_value()) - self._cfg["auto_restart_proxy"] = self._auto_restart_cb.get_active() - self._cfg["auto_switch_provider"] = self._auto_switch_cb.get_active() - _save_monitoring_config(self._cfg) - self._inc_buf.set_text("Configuration saved.\n") - - def _on_clear_store(self, btn): - _save_incident_store({"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}) - self._store = {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}} - self._refresh_incidents() - - def _refresh_incidents(self): - lines = [] - for pattern, inc in sorted(self._store.get("incidents", {}).items(), - key=lambda x: x[1].get("last_seen", ""), reverse=True): - sc = inc.get("success_count", 0) - fc = inc.get("fail_count", 0) - rate = sc / max(sc + fc, 1) - bar = "+" * min(int(rate * 10), 10) + "-" * (10 - min(int(rate * 10), 10)) - lines.append( - f"[{inc.get('last_seen', '?')[:16]}] {pattern}\n" - f" fix={inc.get('fix', '?')} success_rate={rate:.0%} [{bar}] " - f"seen={inc.get('occurrences', 0)}x\n" - ) - if not lines: - lines.append("No incidents recorded yet.\n") - lines.append("\nEnable AI Monitoring and use Codex to populate the store.\n") - self._inc_buf.set_text("\n".join(lines)) - - -# ═══════════════════════════════════════════════════════════════════ -# Main window -# ═══════════════════════════════════════════════════════════════════ - -def _oauth_discover_project(access_token, token_path, tokens): - project_id = "" - try: - lr = urllib.request.Request( - "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - data=json.dumps({}).encode(), - headers={"Content-Type": "application/json", - "Authorization": f"Bearer {access_token}", - "User-Agent": "google-api-nodejs-client/9.15.1"}) - lresp = urllib.request.urlopen(lr, timeout=15) - ldata = json.loads(lresp.read()) - p = ldata.get("cloudaicompanionProject", "") - if isinstance(p, dict): - project_id = p.get("id", "") - elif isinstance(p, str): - project_id = p - except Exception: - pass - if not project_id: - return "" - try: - test_url = f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={project_id}" - test_req = urllib.request.Request(test_url, - headers={"Authorization": f"Bearer {access_token}", - "User-Agent": "google-api-nodejs-client/9.15.1"}) - urllib.request.urlopen(test_req, timeout=10) - except urllib.error.HTTPError as e: - if e.code == 403 and "SERVICE_DISABLED" in (e.read().decode()[:500]): - print(f"[oauth] project {project_id} has API disabled, searching for valid project...", file=sys.stderr) - try: - list_req = urllib.request.Request( - "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE", - headers={"Authorization": f"Bearer {access_token}"}) - list_resp = urllib.request.urlopen(list_req, timeout=15) - projects = json.loads(list_resp.read()).get("projects", []) - for proj in projects: - pid = proj.get("projectId", "") - if not pid or pid == project_id: - continue - try: - t2 = urllib.request.Request( - f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={pid}", - headers={"Authorization": f"Bearer {access_token}", - "User-Agent": "google-api-nodejs-client/9.15.1"}) - urllib.request.urlopen(t2, timeout=10) - project_id = pid - print(f"[oauth] found working project: {pid}", file=sys.stderr) - break - except Exception: - continue - except Exception: - pass - tokens["project_id"] = project_id - with open(token_path, "w") as f: - json.dump(tokens, f, indent=2) - os.chmod(token_path, 0o600) - return project_id - -class LauncherWin(Gtk.Window): - def __init__(self): - super().__init__(title="Codex Launcher") - self.set_default_size(560, 460) - self.set_border_width(12) - self.set_position(Gtk.WindowPosition.CENTER) - self._proc = None - self._endpoints_data = load_endpoints() - recover_config_if_needed() - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self.add(vbox) - - # header row - hdr = Gtk.Box(spacing=8) - vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Codex Launcher v3.10.7") - lbl.set_use_markup(True) - hdr.pack_start(lbl, False, False, 0) - changelog_btn = Gtk.Button(label="Changelog") - changelog_btn.connect("clicked", lambda b: self._show_changelog()) - hdr.pack_end(changelog_btn, False, False, 0) - history_btn = Gtk.Button(label="History") - history_btn.connect("clicked", lambda b: self._open_history()) - hdr.pack_end(history_btn, False, False, 0) - bench_btn = Gtk.Button(label="Benchmark") - bench_btn.connect("clicked", lambda b: self._open_benchmark()) - hdr.pack_end(bench_btn, False, False, 0) - usage_btn = Gtk.Button(label="Usage") - usage_btn.connect("clicked", lambda b: self._open_usage()) - hdr.pack_end(usage_btn, False, False, 0) - bgp_btn = Gtk.Button(label="AI BGP") - bgp_btn.connect("clicked", lambda b: self._open_bgp()) - hdr.pack_end(bgp_btn, False, False, 0) - mon_btn = Gtk.Button(label="AI Monitor") - mon_btn.connect("clicked", lambda b: self._open_monitoring()) - hdr.pack_end(mon_btn, False, False, 0) - mgr_btn = Gtk.Button(label="Manage Endpoints") - mgr_btn.connect("clicked", lambda b: self._open_mgr()) - hdr.pack_end(mgr_btn, False, False, 0) - oauth_btn = Gtk.Button(label="OAuth Secrets") - oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets()) - hdr.pack_end(oauth_btn, False, False, 0) - - # verification status bar - self._cli_info = _detect_codex_cli() - self._desktop_info = _detect_codex_desktop() - ver_box = Gtk.Box(spacing=12) - vbox.pack_start(ver_box, False, False, 0) - - if self._cli_info: - cli_path, cli_ver = self._cli_info - cli_lbl = Gtk.Label() - cli_lbl.set_markup(f"✔ Codex CLI {cli_ver} ({cli_path})") - cli_lbl.set_use_markup(True) - ver_box.pack_start(cli_lbl, False, False, 0) - else: - cli_lbl = Gtk.Label() - cli_lbl.set_markup("✘ Codex CLI — not found") - cli_lbl.set_use_markup(True) - ver_box.pack_start(cli_lbl, False, False, 0) - cli_install_btn = Gtk.Button(label="Install") - cli_install_btn.connect("clicked", lambda b: self._show_install_guide("cli")) - ver_box.pack_start(cli_install_btn, False, False, 0) - - ver_box.pack_start(Gtk.Label(label=" "), False, False, 0) - - if self._desktop_info: - desk_lbl = Gtk.Label() - desk_lbl.set_markup(f"✔ Codex Desktop ({self._desktop_info})") - desk_lbl.set_use_markup(True) - ver_box.pack_start(desk_lbl, False, False, 0) - else: - desk_lbl = Gtk.Label() - desk_lbl.set_markup("✘ Codex Desktop — not found") - desk_lbl.set_use_markup(True) - ver_box.pack_start(desk_lbl, False, False, 0) - desk_install_btn = Gtk.Button(label="Install") - desk_install_btn.connect("clicked", lambda b: self._show_install_guide("desktop")) - ver_box.pack_start(desk_install_btn, False, False, 0) - - self._missing = [] - if not self._cli_info: - self._missing.append("cli") - if not self._desktop_info: - self._missing.append("desktop") - - auth_box = Gtk.Box(spacing=12) - vbox.pack_start(auth_box, False, False, 0) - self._auth_label = Gtk.Label() - self._auth_label.set_markup("Checking auth…") - self._auth_label.set_use_markup(True) - self._auth_label.set_ellipsize(3) - auth_box.pack_start(self._auth_label, False, False, 0) - self._relogin_btn = Gtk.Button(label="Re-login") - self._relogin_btn.set_sensitive(False) - self._relogin_btn.connect("clicked", lambda b: self._codex_relogin()) - auth_box.pack_end(self._relogin_btn, False, False, 0) - threading.Thread(target=self._check_auth_async, daemon=True).start() - - ops_box = Gtk.Box(spacing=8) - vbox.pack_start(ops_box, False, False, 0) - self._refresh_all_btn = Gtk.Button(label="Refresh Models") - self._refresh_all_btn.connect("clicked", lambda b: self._refresh_all_models()) - ops_box.pack_start(self._refresh_all_btn, False, False, 0) - self._backup_btn = Gtk.Button(label="Backup Profile") - self._backup_btn.connect("clicked", lambda b: self._backup_profile()) - ops_box.pack_start(self._backup_btn, False, False, 0) - self._import_btn = Gtk.Button(label="Import Profile") - self._import_btn.connect("clicked", lambda b: self._import_profile()) - ops_box.pack_start(self._import_btn, False, False, 0) - - # endpoint selector - sel_box = Gtk.Box(spacing=6) - vbox.pack_start(sel_box, False, False, 4) - sel_box.pack_start(Gtk.Label(label="Endpoint:"), False, False, 0) - self._combo = Gtk.ComboBoxText() - self._combo.connect("changed", lambda c: self._on_endpoint_changed()) - sel_box.pack_start(self._combo, True, True, 0) - - # model selector - sel_box.pack_start(Gtk.Label(label="Model:"), False, False, 0) - self._model_combo = Gtk.ComboBoxText() - sel_box.pack_start(self._model_combo, True, True, 0) - - # sandbox mode selector - sel_box.pack_start(Gtk.Label(label="Sandbox:"), False, False, 0) - self._sandbox_combo = Gtk.ComboBoxText() - for v, l in [("read-only", "Read-only"), - ("workspace-write", "Workspace"), - ("danger-full-access", "Full Access")]: - self._sandbox_combo.append(v, l) - self._sandbox_combo.set_active_id("workspace-write") - sel_box.pack_start(self._sandbox_combo, True, True, 0) - - # approval mode selector - sel_box.pack_start(Gtk.Label(label="Approval:"), False, False, 0) - self._approval_combo = Gtk.ComboBoxText() - for v, l in [("untrusted", "Untrusted"), - ("on-request", "On Request"), - ("never", "Never (Full Auto)")]: - self._approval_combo.append(v, l) - self._approval_combo.set_active_id("on-request") - sel_box.pack_start(self._approval_combo, True, True, 0) - - # launch buttons - btn_box = Gtk.Box(spacing=8, homogeneous=True) - vbox.pack_start(btn_box, False, False, 8) - self._btn_desktop = Gtk.Button(label="Launch Desktop") - self._btn_desktop.connect("clicked", lambda b: self._launch("desktop")) - if "desktop" in self._missing: - self._btn_desktop.set_tooltip_text("Codex Desktop is not installed") - self._btn_desktop.set_sensitive(False) - btn_box.pack_start(self._btn_desktop, True, True, 0) - self._btn_cli = Gtk.Button(label="Launch CLI") - self._btn_cli.connect("clicked", lambda b: self._launch("cli")) - if "cli" in self._missing: - self._btn_cli.set_tooltip_text("Codex CLI is not installed") - self._btn_cli.set_sensitive(False) - btn_box.pack_start(self._btn_cli, True, True, 0) - - btn_box2 = Gtk.Box(spacing=8, homogeneous=True) - vbox.pack_start(btn_box2, False, False, 0) - self._btn_codex_desktop = Gtk.Button(label="Codex Default (Desktop)") - self._btn_codex_desktop.connect("clicked", lambda b: self._launch_codex_default("desktop")) - if "desktop" in self._missing: - self._btn_codex_desktop.set_tooltip_text("Codex Desktop is not installed") - self._btn_codex_desktop.set_sensitive(False) - btn_box2.pack_start(self._btn_codex_desktop, True, True, 0) - self._btn_codex_cli = Gtk.Button(label="Codex Default (CLI)") - self._btn_codex_cli.connect("clicked", lambda b: self._launch_codex_default("cli")) - if "cli" in self._missing: - self._btn_codex_cli.set_tooltip_text("Codex CLI is not installed") - self._btn_codex_cli.set_sensitive(False) - btn_box2.pack_start(self._btn_codex_cli, True, True, 0) - - # status - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - vbox.pack_start(sw, True, True, 0) - self._buf = Gtk.TextBuffer() - self._tv = Gtk.TextView(buffer=self._buf) - self._tv.set_editable(False) - self._tv.set_cursor_visible(False) - self._tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - sw.add(self._tv) - - # bottom bar - bb = Gtk.Box(spacing=8) - vbox.pack_start(bb, False, False, 0) - assist_btn = Gtk.Button(label="AI Assistant") - assist_btn.get_style_context().add_class("suggested-action") - assist_btn.connect("clicked", lambda b: self._open_assistant()) - assist_btn.set_tooltip_text("Open AI coding assistant with streaming, tools, and session management") - bb.pack_start(assist_btn, False, False, 0) - self._clear_log_btn = Gtk.Button(label="Clear Log") - self._clear_log_btn.connect("clicked", lambda b: self._buf.set_text("")) - bb.pack_start(self._clear_log_btn, False, False, 0) - self._restart_btn = Gtk.Button(label="Restart Proxy") - self._restart_btn.connect("clicked", lambda b: self._manual_restart_proxy()) - self._restart_btn.set_sensitive(False) - bb.pack_start(self._restart_btn, False, False, 0) - self._kill_btn = Gtk.Button(label="Kill && Cleanup") - self._kill_btn.connect("clicked", lambda b: self._kill()) - self._kill_btn.set_sensitive(False) - bb.pack_start(self._kill_btn, True, True, 0) - self._view_log_btn = Gtk.Button(label="View Log") - self._view_log_btn.connect("clicked", lambda b: subprocess.Popen(["xdg-open", str(LAUNCH_LOG)])) - bb.pack_start(self._view_log_btn, False, False, 0) - self._close_btn = Gtk.Button(label="Close") - self._close_btn.connect("clicked", lambda b: self._do_close()) - bb.pack_start(self._close_btn, False, False, 0) - - self.show_all() - self._rebuild_combo() - self._log_dependency_status() - self._start_watcher() - - # ── helpers ────────────────────────────────────────────────── - - def log(self, msg): - GLib.idle_add(self._append_log, msg) - - def _append_log(self, msg): - e = self._buf.get_end_iter() - self._buf.insert(e, msg + "\n") - m = self._buf.create_mark(None, e, False) - self._tv.scroll_to_mark(m, 0.0, True, 0.0, 0.5) - self._buf.delete_mark(m) - - def _log_dependency_status(self): - if self._cli_info: - _, ver = self._cli_info - self.log(f"✔ Codex CLI detected ({ver})") - else: - self.log("✘ Codex CLI NOT found — CLI launch disabled. Click 'Install' above.") - if self._desktop_info: - self.log(f"✔ Codex Desktop detected ({self._desktop_info})") - else: - self.log("✘ Codex Desktop NOT found — Desktop launch disabled. Click 'Install' above.") - if self._missing: - self.log("⚠ Install missing tools before using the launcher.") - else: - self.log("All dependencies OK.") - - def _check_auth_async(self): - status, msg = _check_codex_auth() - GLib.idle_add(self._update_auth_status, status, msg) - - def _update_auth_status(self, status, msg): - if status == "logged_in": - self._auth_label.set_markup(f"✔ Auth: {msg}") - self._relogin_btn.set_sensitive("cli" not in self._missing) - elif status == "not_installed": - self._auth_label.set_markup("Auth: N/A (CLI not installed)") - else: - self._auth_label.set_markup(f"⚠ Auth: {msg}") - self._relogin_btn.set_sensitive("cli" not in self._missing) - return False - - def _codex_relogin(self): - self.log("Opening codex login in terminal…") - terms = [ - ("x-terminal-emulator", ["-e"]), - ("kgx", ["--"]), - ("gnome-terminal", ["--"]), - ("konsole", ["-e"]), - ("xterm", ["-e"]), - ] - term = None - term_args = None - for t in terms: - if shutil.which(t[0]): - term = t[0] - term_args = t[1] - break - if not term: - self.log("ERROR: no terminal emulator found for re-login") - return - cmd_parts = [term] + term_args + ["codex", "login"] - subprocess.Popen(cmd_parts, preexec_fn=os.setsid) - self.log("Login flow started in terminal. Re-checking auth in 30s…") - self._auth_label.set_markup("Auth: waiting for login…") - threading.Thread(target=self._delayed_auth_check, daemon=True).start() - - def _delayed_auth_check(self): - time.sleep(30) - self._check_auth_async() - - def _set_busy(self, busy): - def _update(): - has_cli = "cli" not in self._missing - has_desk = "desktop" not in self._missing - self._btn_desktop.set_sensitive(not busy and has_desk) - self._btn_cli.set_sensitive(not busy and has_cli) - self._btn_codex_desktop.set_sensitive(not busy and has_desk) - self._btn_codex_cli.set_sensitive(not busy and has_cli) - self._kill_btn.set_sensitive(busy) - self._restart_btn.set_sensitive(busy) - GLib.idle_add(_update) - - def _rebuild_combo(self): - self._endpoints_data = load_endpoints() - self._combo.remove_all() - names = [e["name"] for e in self._endpoints_data["endpoints"]] - for n in names: - self._combo.append_text(n) - bgp_names = [p["name"] for p in load_bgp_pools().get("pools", [])] - for n in bgp_names: - self._combo.append_text(f"🔀 {n}") - if names or bgp_names: - default = self._endpoints_data.get("default") - if default and default in names: - self._combo.set_active(names.index(default)) - else: - self._combo.set_active(0) - self._on_endpoint_changed() - - def _on_endpoint_changed(self): - name = self._combo.get_active_text() - is_bgp = name and name.startswith("🔀 ") - bgp_name = name[2:] if is_bgp else None - ep = get_endpoint(name) if name and not is_bgp else None - self._model_combo.remove_all() - if is_bgp: - pool = None - for p in load_bgp_pools().get("pools", []): - if p["name"] == bgp_name: - pool = p - break - if pool: - seen = set() - for r in pool.get("routes", []): - m = r.get("model", "") - if m and m not in seen: - self._model_combo.append_text(m) - seen.add(m) - if seen: - self._model_combo.set_active(0) - elif ep: - for m in ep.get("models", []): - self._model_combo.append_text(m) - GLib.idle_add(self._select_default_model, ep) - - def _select_default_model(self, ep): - dm = ep.get("default_model", "") - models = ep.get("models", []) - if dm in models: - self._model_combo.set_active(models.index(dm)) - elif models: - self._model_combo.set_active(0) - - # ── endpoint mgr ───────────────────────────────────────────── - - def _open_mgr(self): - try: - self._mgr_window = EndpointMgr(self) - self._mgr_window.connect("destroy", lambda *_: setattr(self, "_mgr_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_bgp(self): - try: - self._bgp_window = BGPPoolMgr(self) - self._bgp_window.connect("destroy", lambda *_: setattr(self, "_bgp_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_monitoring(self): - try: - self._monitoring_window = AIMonitoringWindow(self) - self._monitoring_window.connect("destroy", lambda *_: setattr(self, "_monitoring_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _start_watcher(self): - cfg = _load_monitoring_config() - if not cfg.get("enabled"): - return - self._watcher = HealthWatcher( - on_failure=self._on_watcher_failure, - on_recovery=self._on_watcher_recovery, - on_signal=self._on_watcher_signal, - on_action=self._on_watcher_action, - ) - self._watcher.start() - self.log("AI Monitoring: watchdog started") - - def _on_watcher_failure(self, count): - GLib.idle_add(self.log, f"[AI Monitor] Proxy unresponsive (failures={count})") - - def _on_watcher_recovery(self): - GLib.idle_add(self.log, "[AI Monitor] Proxy recovered") - - def _on_watcher_signal(self, fault_id, category, line): - pass - - def _on_watcher_action(self, action, trigger): - cfg = _load_monitoring_config() - if action == "restart_proxy" and cfg.get("auto_restart_proxy"): - GLib.idle_add(self.log, f"[AI Monitor] Auto-restarting proxy (trigger: {trigger})") - GLib.idle_add(self._restart_proxy_from_watcher) - elif action == "clear_schema_cache": - try: - cap_file = Path.home() / ".cache/codex-proxy/provider-caps.json" - if cap_file.exists(): - cap_file.unlink() - GLib.idle_add(self.log, "[AI Monitor] Cleared corrupt schema cache") - except Exception as e: - GLib.idle_add(self.log, f"[AI Monitor] Failed to clear cache: {e}") - elif action == "delete_provider_caps": - try: - cap_file = Path.home() / ".cache/codex-proxy/provider-caps.json" - if cap_file.exists(): - cap_file.unlink() - GLib.idle_add(self.log, "[AI Monitor] Deleted corrupted provider-caps.json") - except Exception as e: - GLib.idle_add(self.log, f"[AI Monitor] Failed: {e}") - elif action == "kill_stale_restart": - GLib.idle_add(self.log, f"[AI Monitor] Killing stale processes + restarting (trigger: {trigger})") - self._kill() - GLib.idle_add(self._restart_proxy_from_watcher) - else: - GLib.idle_add(self.log, f"[AI Monitor] Alert: {action} (trigger: {trigger})") - - def _restart_proxy_from_watcher(self): - try: - ep_name = load_endpoints().get("default") - if not ep_name: - return - for ep in load_endpoints().get("endpoints", []): - if ep.get("name") == ep_name: - self._start_proxy(ep) - break - except Exception as e: - self.log(f"[AI Monitor] Proxy restart failed: {e}") - - def _manual_restart_proxy(self): - self._kill() - time.sleep(1) - try: - ep_name = load_endpoints().get("default") - if not ep_name: - self.log("No default endpoint set") - return - for ep in load_endpoints().get("endpoints", []): - if ep.get("name") == ep_name: - self._start_proxy(ep) - self.log("Proxy restarted") - break - except Exception as e: - self.log(f"Proxy restart failed: {e}") - - def _open_usage(self): - try: - self._usage_window = UsageWindow(self) - self._usage_window.connect("destroy", lambda *_: setattr(self, "_usage_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_history(self): - try: - self._history_window = RequestHistoryWindow(self) - self._history_window.connect("destroy", lambda *_: setattr(self, "_history_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_benchmark(self): - try: - self._benchmark_window = BenchmarkWindow(self) - self._benchmark_window.connect("destroy", lambda *_: setattr(self, "_benchmark_window", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _open_assistant(self): - import subprocess, sys - _py = str(Path(__file__).resolve().parent / "flet-codex-assist.py") - subprocess.Popen([sys.executable, _py], start_new_session=True) - - def _backup_profile(self): - chooser = Gtk.FileChooserDialog( - title="Backup Codex Profile", - parent=self, - action=Gtk.FileChooserAction.SAVE, - ) - chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_SAVE, Gtk.ResponseType.OK) - chooser.set_do_overwrite_confirmation(True) - chooser.set_current_name(f"codex-profile-{time.strftime('%Y%m%d-%H%M%S')}.json") - resp = chooser.run() - filename = chooser.get_filename() if resp == Gtk.ResponseType.OK else None - chooser.destroy() - if not filename: - return - try: - save_profile_bundle(filename) - self.log(f"Profile backed up to {filename}") - except Exception as e: - self._show_message(Gtk.MessageType.ERROR, f"Backup failed:\n{e}") - - def _refresh_all_models(self): - if getattr(self, "_refresh_running", False): - return - self._refresh_running = True - self._refresh_all_btn.set_sensitive(False) - self.log("Refreshing models for all providers...") - threading.Thread(target=self._refresh_all_models_worker, daemon=True).start() - - def _refresh_all_models_worker(self): - try: - data = load_endpoints() - updated = 0 - failed = [] - - for idx, ep in enumerate(list(data["endpoints"])): - refreshed, err = refresh_endpoint_models(ep) - if refreshed: - data["endpoints"][idx] = refreshed - updated += 1 - else: - failed.append(f"{ep['name']}: {err}") - - if updated: - save_endpoints(data) - - GLib.idle_add(self._finish_refresh_all_models, updated, failed) - except Exception as e: - GLib.idle_add(self._finish_refresh_all_models_error, str(e)) - - def _finish_refresh_all_models(self, updated, failed): - try: - if updated: - self._rebuild_combo() - if getattr(self, "_mgr_window", None): - try: - self._mgr_window._rebuild() - except Exception: - pass - self.log(f"Refreshed models for {updated} provider(s)") - - if failed: - self._show_message( - Gtk.MessageType.WARNING, - "Some providers could not auto-fetch models.\n\n" - + "\n".join(failed) - + "\n\nThose providers were left unchanged so you can manage them manually." - ) - elif updated: - self._show_message(Gtk.MessageType.INFO, f"Refreshed models for {updated} provider(s).") - else: - self._show_message(Gtk.MessageType.INFO, "No providers were refreshed.") - finally: - self._refresh_running = False - self._refresh_all_btn.set_sensitive(True) - return False - - def _finish_refresh_all_models_error(self, err): - try: - self._show_message(Gtk.MessageType.ERROR, f"Refresh failed:\n{err}") - finally: - self._refresh_running = False - self._refresh_all_btn.set_sensitive(True) - return False - - def _import_profile(self): - if self._proc and self._proc.poll() is None: - self._show_message(Gtk.MessageType.WARNING, "Stop Codex before importing a profile.") - return - - chooser = Gtk.FileChooserDialog( - title="Import Codex Profile", - parent=self, - action=Gtk.FileChooserAction.OPEN, - ) - chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_OPEN, Gtk.ResponseType.OK) - resp = chooser.run() - filename = chooser.get_filename() if resp == Gtk.ResponseType.OK else None - chooser.destroy() - if not filename: - return - - confirm = Gtk.MessageDialog( - self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, - "Importing will replace the current endpoints and Codex config. Continue?" - ) - ok = confirm.run() == Gtk.ResponseType.YES - confirm.destroy() - if not ok: - return - - try: - import_profile_bundle(filename) - self._rebuild_combo() - self.log(f"Profile imported from {filename}") - self._show_message(Gtk.MessageType.INFO, "Profile imported successfully.") - except Exception as e: - self._show_message(Gtk.MessageType.ERROR, f"Import failed:\n{e}") - - def _on_endpoints_updated(self): - self._rebuild_combo() - - def _show_message(self, msg_type, text): - d = Gtk.MessageDialog(self, 0, msg_type, Gtk.ButtonsType.OK, text) - d.run() - d.destroy() - - def _show_changelog(self): - d = Gtk.Dialog(title="Changelog", transient_for=self, modal=True) - d.set_default_size(520, 480) - d.add_button("Close", Gtk.ResponseType.CLOSE) - area = d.get_content_area() - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - area.pack_start(sw, True, True, 0) - buf = Gtk.TextBuffer() - tv = Gtk.TextView(buffer=buf) - tv.set_editable(False) - tv.set_cursor_visible(False) - tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - sw.add(tv) - lines = [] - for ver, date, items in CHANGELOG: - lines.append(f"v{ver} ({date})") - for item in items: - lines.append(f" \u2022 {item}") - lines.append("") - txt = "\n".join(lines).strip() - buf.insert(buf.get_end_iter(), txt) - d.show_all() - d.run() - d.destroy() - - def _show_install_guide(self, which): - if which == "cli": - title = "Install Codex CLI" - guide = ( - "Codex CLI is required to use CLI launch features.\n\n" - "Install with npm:\n" - " npm install -g @openai/codex\n\n" - "Or download from:\n" - " https://github.com/openai/codex\n\n" - "After installing, restart the launcher." - ) - else: - title = "Install Codex Desktop" - guide = ( - "Codex Desktop is required to use Desktop launch features.\n\n" - "Expected location: /opt/codex-desktop/start.sh\n\n" - "Download from:\n" - " https://codex.desktop.openai.com\n\n" - "After installing, restart the launcher." - ) - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, guide) - d.set_title(title) - d.run() - d.destroy() - - # ── launch ─────────────────────────────────────────────────── - - def _launch(self, target): - name = self._combo.get_active_text() - if not name: - self.log("ERROR: no endpoint selected") - return - model = self._model_combo.get_active_text() - if not model: - self.log("ERROR: no model selected") - return - - is_bgp = bool(name and name.startswith("🔀 ")) - if is_bgp: - pool_name = name[2:] - pool = None - for p in load_bgp_pools().get("pools", []): - if p["name"] == pool_name: - pool = p - break - if not pool: - self.log(f"ERROR: BGP pool '{pool_name}' not found") - return - self._set_busy(True) - self.log(f"=== 🔀 BGP: {pool_name} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===") - threading.Thread(target=self._run_bgp, args=(pool, model, target), daemon=True).start() - return - - ep = get_endpoint(name) - if not ep: - self.log("ERROR: endpoint not found") - return - self._set_busy(True) - self.log(f"=== {ep['name']} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===") - threading.Thread(target=self._run, args=(ep, model, target), daemon=True).start() - - def _launch_codex_default(self, target): - if "cli" not in self._missing: - status, msg = _check_codex_auth() - if status != "logged_in": - d = Gtk.MessageDialog( - self, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO, - f"Codex auth check: {msg}\n\n" - "Launch may fail without valid authentication.\n" - "Continue anyway?" - ) - r = d.run() - d.destroy() - if r != Gtk.ResponseType.YES: - self._set_busy(False) - return - self._set_busy(True) - self.log(f"=== Codex Default (OAuth) → {'Desktop' if target == 'desktop' else 'CLI'} ===") - threading.Thread(target=self._run_codex_default, args=(target,), daemon=True).start() - - def _run(self, ep, model, target): - keep_session_alive = False - try: - self.log("Cleaning up stale processes…") - _run_cleanup(self.log) - recover_config_if_needed(self.log) - - needs_proxy = ep["backend_type"] != "native" - - if needs_proxy: - self.log("Starting translation proxy…") - os.environ["CODEX_LAUNCHER_MODEL"] = model - try: - proxy_port = _start_proxy_for(ep, self.log) - except RuntimeError as e: - GLib.idle_add(self._show_error_dialog, "Proxy startup failed", str(e)) - return - self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})…") - begin_config_transaction(f"launch:{ep['name']}") - write_config_for_translated(ep, model, proxy_port) - else: - self.log(f"Configuring Codex for {ep['name']} (native)…") - begin_config_transaction(f"launch:{ep['name']}") - write_config_for_native(ep, model) - - if target == "desktop": - if needs_proxy: - _kill_existing_desktop(self.log) - keep_session_alive = self._launch_desktop(ep, model) - else: - self._launch_cli(ep, model) - - except Exception as e: - self.log(f"ERROR: {e}") - finally: - if keep_session_alive: - self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.") - self._set_busy(False) - self.log("Ready. Use Kill && Cleanup when finished.") - else: - _stop_proxy() - restore_config() - end_config_transaction() - self._set_busy(False) - self.log("Ready.") - - def _run_bgp(self, pool, model, target): - keep_session_alive = False - try: - self.log("Cleaning up stale processes…") - _run_cleanup(self.log) - recover_config_if_needed(self.log) - - port = _pick_free_port() - self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes on :{port}…") - bgp_ep = { - "name": pool["name"], - "backend_type": "openai-compat", - "base_url": "http://bgp.placeholder", - "api_key": "", - "default_model": model, - "models": list(dict.fromkeys(r.get("model", model) for r in pool.get("routes", []))), - } - pcfg = { - "port": port, - "backend_type": "openai-compat", - "target_url": "http://bgp.placeholder", - "api_key": "", - "bgp_routes": pool.get("routes", []), - "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]], - } - pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}-{port}.json" - pcfg_path.parent.mkdir(parents=True, exist_ok=True) - pcfg_path.write_text(json.dumps(pcfg, indent=2)) - try: - _start_proxy_with_config(pcfg_path, port, self.log) - except RuntimeError as e: - GLib.idle_add(self._show_error_dialog, "BGP proxy startup failed", str(e)) - return - - begin_config_transaction(f"launch:bgp:{pool['name']}") - write_config_for_translated(bgp_ep, model, port) - - if target == "desktop": - _kill_existing_desktop(self.log) - keep_session_alive = self._launch_desktop(bgp_ep, model) - else: - self._launch_cli(bgp_ep, model) - - except Exception as e: - self.log(f"ERROR: {e}") - finally: - if keep_session_alive: - self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.") - self._set_busy(False) - self.log("Ready. Use Kill && Cleanup when finished.") - else: - _stop_proxy() - restore_config() - end_config_transaction() - self._set_busy(False) - self.log("Ready.") - - def _run_codex_default(self, target): - try: - self.log("Cleaning up stale processes…") - _run_cleanup(self.log) - _stop_proxy() - recover_config_if_needed(self.log) - - self.log("Resetting config to Codex defaults (OAuth)…") - begin_config_transaction("launch:default") - if CONFIG.exists(): - CONFIG.unlink() - - if target == "desktop": - self._launch_desktop_direct() - else: - self._launch_cli_default() - except Exception as e: - self.log(f"ERROR: {e}") - finally: - restore_config() - end_config_transaction() - self._set_busy(False) - self.log("Ready.") - - def _show_error_dialog(self, title, message): - dialog = Gtk.MessageDialog( - transient_for=self, flags=0, - message_type=Gtk.MessageType.ERROR, - buttons=Gtk.ButtonsType.CLOSE, text=str(title)) - dialog.format_secondary_text(str(message)) - dialog.run() - dialog.destroy() - - def _launch_desktop(self, ep, model): - args = [str(START_SH)] - if ep["backend_type"] != "native": - args += ["--", "--ozone-platform=wayland"] - - self._proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid) - pid = self._proc.pid - self.log(f"Desktop started (PID {pid})") - self.log(f"Log: {LAUNCH_LOG}") - - t0 = time.time() - stall_warned = False - while self._proc and self._proc.poll() is None: - time.sleep(1.5) - el = time.time() - t0 - if el > 20 and not stall_warned: - self.log("⚠ Still starting after 20 s — possible stall. Click Kill if window doesn't appear.") - self.log(f"--- last log lines ---\n{_last_log_lines()}") - stall_warned = True - - if self._proc: - rc = self._proc.poll() - el = time.time() - t0 - self.log(f"Desktop exited (code {rc}) after {el:.0f}s") - if el < 12: - self.log("TIP: Quick exit — may be warm-start handoff (normal) or crash. Kill && retry if needed.") - last_lines = _last_log_lines() - self.log(f"--- last log lines ---\n{last_lines}") - if rc == 0 and "warm-start" in last_lines.lower(): - self._proc = None - return True - self._proc = None - return False - - def _launch_cli(self, ep, model): - """Launch codex CLI in a terminal with the selected endpoint.""" - self.log(f"Launching Codex CLI with {ep['name']}…") - - terms = [ - ("x-terminal-emulator", ["-e"]), - ("kgx", ["--"]), - ("gnome-terminal", ["--"]), - ("konsole", ["-e"]), - ("xterm", ["-e"]), - ] - term = None - term_args = None - for t in terms: - if shutil.which(t[0]): - term = t[0] - term_args = t[1] - break - - if not term: - self.log("ERROR: no terminal emulator found (tried x-terminal-emulator, kgx, gnome-terminal, konsole, xterm)") - return - - sandbox = self._sandbox_combo.get_active_id() or "workspace-write" - approval = self._approval_combo.get_active_id() or "on-request" - - cmd_parts = [term] + term_args - - if ep["backend_type"] == "native": - cmd_parts.extend(["codex", "-c", f"model={model}", - "-s", sandbox, "-a", approval]) - else: - cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}", - "-s", sandbox, "-a", approval]) - - self.log(f"Running: {' '.join(cmd_parts)}") - self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) - pid = self._proc.pid - self.log(f"CLI started in terminal (PID {pid})") - - # Wait for terminal process - while self._proc and self._proc.poll() is None: - time.sleep(1.5) - - if self._proc: - rc = self._proc.poll() - self.log(f"CLI exited (code {rc})") - self._proc = None - - def _launch_desktop_direct(self): - self.log("Launching Codex Desktop (default OAuth)…") - self._proc = subprocess.Popen( - [str(START_SH), "--", "--ozone-platform=wayland"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid, - ) - pid = self._proc.pid - self.log(f"Desktop started (PID {pid})") - self.log(f"Log: {LAUNCH_LOG}") - - t0 = time.time() - stall_warned = False - while self._proc and self._proc.poll() is None: - time.sleep(1.5) - el = time.time() - t0 - if el > 20 and not stall_warned: - self.log("Still starting after 20s — possible stall. Click Kill if window doesn't appear.") - self.log(f"--- last log lines ---\n{_last_log_lines()}") - stall_warned = True - - if self._proc: - rc = self._proc.poll() - el = time.time() - t0 - self.log(f"Desktop exited (code {rc}) after {el:.0f}s") - if el < 12: - self.log("TIP: Quick exit — may be warm-start handoff (normal) or crash.") - self.log(f"--- last log lines ---\n{_last_log_lines()}") - self._proc = None - - def _launch_cli_default(self): - self.log("Launching Codex CLI (default OAuth)…") - terms = [ - ("x-terminal-emulator", ["-e"]), - ("kgx", ["--"]), - ("gnome-terminal", ["--"]), - ("konsole", ["-e"]), - ("xterm", ["-e"]), - ] - term = None - term_args = None - for t in terms: - if shutil.which(t[0]): - term = t[0] - term_args = t[1] - break - - if not term: - self.log("ERROR: no terminal emulator found") - return - - sandbox = self._sandbox_combo.get_active_id() or "workspace-write" - approval = self._approval_combo.get_active_id() or "on-request" - cmd_parts = [term] + term_args + ["codex", "-s", sandbox, "-a", approval] - self.log(f"Running: {' '.join(cmd_parts)}") - self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) - pid = self._proc.pid - self.log(f"CLI started in terminal (PID {pid})") - - while self._proc and self._proc.poll() is None: - time.sleep(1.5) - - if self._proc: - rc = self._proc.poll() - self.log(f"CLI exited (code {rc})") - self._proc = None - - # ── kill ───────────────────────────────────────────────────── - - def _kill(self): - self.log("=== Killing ===") - if self._proc and self._proc.poll() is None: - try: - pgid = os.getpgid(self._proc.pid) - os.killpg(pgid, signal.SIGTERM) - time.sleep(1) - if self._proc.poll() is None: - os.killpg(pgid, signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass - self._proc = None - _stop_proxy() - _run_cleanup(self.log) - restore_config() - end_config_transaction() - LOG_DIR.mkdir(parents=True, exist_ok=True) - LAUNCH_LOG.unlink(missing_ok=True) - self.log("Cleanup complete") - self._set_busy(False) - self.log("Ready.") - - def _do_close(self): - if self._proc and self._proc.poll() is None: - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, - "Codex is still running. Kill it?") - r = d.run() - d.destroy() - if r != Gtk.ResponseType.YES: - return - self._kill() - _stop_proxy() - Gtk.main_quit() - - def _google_reoauth(self, provider, parent_dlg=None): - import http.server - is_antigravity = provider == "google-antigravity" - sec_key = "antigravity" if is_antigravity else "gemini_cli" - _sp = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json") - try: - with open(_sp) as _f: - _secrets_data = json.load(_f) - except Exception: - _secrets_data = {} - sec = _secrets_data.get(sec_key, {}) - CLIENT_ID = sec.get("client_id", "") - CLIENT_SECRET = sec.get("client_secret", "") - if not CLIENT_ID or not CLIENT_SECRET: - self._show_error_dialog("Missing OAuth secrets", - f"No client_id/client_secret for {sec_key}.\nSet them in OAuth Secrets first.") - return - token_file = "google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json" - token_path = os.path.expanduser(f"~/.cache/codex-proxy/{token_file}") - provider_kind = "antigravity" if is_antigravity else "cli" - - if is_antigravity: - SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/cclog", - "https://www.googleapis.com/auth/experimentsandconfigs", - ] - port = 51121 - redirect_uri = f"http://localhost:{port}/oauth-callback" - callback_path = "/oauth-callback" - else: - SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - ] - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - port = s.getsockname()[1] - redirect_uri = f"http://127.0.0.1:{port}/oauth2callback" - callback_path = "/oauth2callback" - - state = secrets.token_hex(32) - verifier = secrets.token_urlsafe(64) - challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() - - scope_str = " ".join(SCOPES) - auth_url = ( - f"https://accounts.google.com/o/oauth2/v2/auth?" - f"client_id={CLIENT_ID}" - f"&redirect_uri={urllib.parse.quote(redirect_uri)}" - f"&response_type=code" - f"&scope={urllib.parse.quote(scope_str)}" - f"&access_type=offline" - f"&prompt=select_account%20consent" - f"&state={state}" - f"&code_challenge={challenge}" - f"&code_challenge_method=S256" - ) - - oauth_dlg = Gtk.Dialog(title=f"Re-OAuth: {'Antigravity' if is_antigravity else 'Gemini CLI'}", parent=parent_dlg or self, modal=True) - oauth_dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) - oauth_dlg.set_default_size(520, 200) - ca = oauth_dlg.get_content_area() - ca.set_margin_start(12) - ca.set_margin_end(12) - ca.set_spacing(6) - ca.pack_start(Gtk.Label(label=f"Re-authenticating {'Antigravity' if is_antigravity else 'Gemini CLI'}", use_markup=True, xalign=0), False, False, 0) - link_lbl = Gtk.Label(label="Click here to open Google authorization", use_markup=True, xalign=0) - link_lbl.set_markup(f'Click here to open Google authorization') - ca.pack_start(link_lbl, False, False, 4) - status_lbl = Gtk.Label(label="Waiting for browser callback...", xalign=0) - ca.pack_start(status_lbl, False, False, 4) - ca.show_all() - - code_holder = [None] - error_holder = [None] - - class OAuthHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self2): - qs = urllib.parse.urlparse(self2.path).query - params = urllib.parse.parse_qs(qs) - if "code" in params: - if params.get("state", [None])[0] != state: - self2.send_response(400) - self2.end_headers() - self2.wfile.write(b"CSRF state mismatch") - error_holder[0] = "CSRF state mismatch" - return - code_holder[0] = params["code"][0] - self2.send_response(302) - self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini") - self2.end_headers() - else: - error_holder[0] = params.get("error", ["unknown"])[0] - self2.send_response(302) - self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini") - self2.end_headers() - def log_message(self2, fmt, *args): - pass - - try: - bind_host = "localhost" if is_antigravity else "127.0.0.1" - server = http.server.HTTPServer((bind_host, port), OAuthHandler) - except OSError: - status_lbl.set_text(f"Port {port} in use — close other apps and retry.") - oauth_dlg.run() - oauth_dlg.destroy() - return - - def _wait(): - deadline = time.time() + 120 - while code_holder[0] is None and error_holder[0] is None and time.time() < deadline: - server.handle_request() - server.server_close() - if code_holder[0]: - try: - tok_data = urllib.parse.urlencode({ - "code": code_holder[0], "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, - "redirect_uri": redirect_uri, "grant_type": "authorization_code", - "code_verifier": verifier, - }).encode() - req = urllib.request.Request("https://oauth2.googleapis.com/token", data=tok_data, - headers={"Content-Type": "application/x-www-form-urlencoded"}) - resp = urllib.request.urlopen(req, timeout=30) - tokens = json.loads(resp.read()) - tokens["client_id"] = CLIENT_ID - tokens["client_secret"] = CLIENT_SECRET - tokens["provider_kind"] = provider_kind - tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600) - os.makedirs(os.path.dirname(token_path), exist_ok=True) - with open(token_path, "w") as f: - json.dump(tokens, f, indent=2) - os.chmod(token_path, 0o600) - project_id = _oauth_discover_project(tokens["access_token"], token_path, tokens) - def _on_success(): - status_lbl.set_text(f"Authorization successful! Project: {project_id or 'none'}") - GLib.timeout_add_seconds(2, lambda: oauth_dlg.destroy()) - return False - GLib.idle_add(_on_success) - except Exception as e: - def _on_err(exc=str(e)): - status_lbl.set_text(f"Token exchange failed: {exc[:200]}") - return False - GLib.idle_add(_on_err) - else: - def _on_fail(err=error_holder[0]): - status_lbl.set_text(f"Failed: {err or 'No code received'}") - return False - GLib.idle_add(_on_fail) - - webbrowser.open(auth_url) - threading.Thread(target=_wait, daemon=True).start() - oauth_dlg.run() - oauth_dlg.destroy() - - def _codebuff_reoauth(self): - self._codebuff_oauth_standalone() - - def _codebuff_oauth_standalone(self): - import uuid - dlg = Gtk.Dialog(title="Freebuff / Codebuff Login", parent=self, modal=True) - dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) - dlg.set_default_size(500, 240) - area = dlg.get_content_area() - area.set_margin_start(16) - area.set_margin_end(16) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(8) - area.pack_start(Gtk.Label(label="Sign in with GitHub via Codebuff", use_markup=True, xalign=0), False, False, 0) - status_lbl = Gtk.Label(label="Requesting login URL…", xalign=0) - status_lbl.set_line_wrap(True) - status_lbl.set_max_width_chars(60) - area.pack_start(status_lbl, False, False, 4) - link_lbl = Gtk.Label(xalign=0) - link_lbl.set_line_wrap(True) - link_lbl.set_max_width_chars(60) - area.pack_start(link_lbl, False, False, 4) - spinner = Gtk.Spinner() - spinner.start() - area.pack_start(spinner, False, False, 8) - area.show_all() - link_lbl.set_visible(False) - result = {"success": False, "user": None, "error": None} - - def _thread(): - try: - fp_id = str(uuid.uuid4()) - body = json.dumps({"fingerprintId": fp_id}).encode() - req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code", - data=body, headers={"Content-Type": "application/json", "User-Agent": "codex-launcher/3.10.7"}) - resp = urllib.request.urlopen(req, timeout=30) - rdata = json.loads(resp.read()) - login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "") - fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "") - expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0) - if not login_url: - result["error"] = "No login URL" - GLib.idle_add(_done) - return - GLib.idle_add(lambda: (status_lbl.set_text("Open this URL in your browser:"), - link_lbl.set_markup(f'{login_url}'), - link_lbl.set_visible(True))) - webbrowser.open(login_url) - poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}" - deadline = time.time() + 300 - while time.time() < deadline: - time.sleep(2) - try: - pr = urllib.request.Request(poll, headers={"User-Agent": "codex-launcher/3.10.7"}) - pd = json.loads(urllib.request.urlopen(pr, timeout=10).read()) - if pd.get("user", {}).get("authToken"): - result["success"] = True - result["user"] = pd["user"] - GLib.idle_add(_done) - return - except Exception: - pass - result["error"] = "Timed out" - except Exception as e: - result["error"] = str(e)[:200] - GLib.idle_add(_done) - - def _done(): - spinner.stop() - if result["success"] and result["user"]: - u = result["user"] - cp = os.path.expanduser("~/.config/manicode/credentials.json") - os.makedirs(os.path.dirname(cp), exist_ok=True) - creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""), - "email": u.get("email", ""), "authToken": u.get("authToken", ""), - "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}} - with open(cp, "w") as f: - json.dump(creds, f, indent=2) - os.chmod(cp, 0o600) - status_lbl.set_text(f"Logged in as {u.get('email', 'OK')}") - link_lbl.set_visible(False) - GLib.timeout_add_seconds(2, dlg.destroy) - else: - status_lbl.set_text(f"Failed: {result.get('error', 'unknown')}") - - threading.Thread(target=_thread, daemon=True).start() - dlg.connect("response", lambda d, r: d.destroy()) - dlg.run() - - def _edit_oauth_secrets(self): - secrets_path = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json") - try: - with open(secrets_path) as f: - data = json.load(f) - except Exception: - data = {"antigravity": {"client_id": "", "client_secret": ""}, - "gemini_cli": {"client_id": "", "client_secret": ""}} - - dlg = Gtk.Dialog(title="OAuth Secrets & Credentials", parent=self, modal=True) - dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) - dlg.add_button("Save", Gtk.ResponseType.OK) - dlg.set_default_size(580, 650) - area = dlg.get_content_area() - area.set_margin_start(16) - area.set_margin_end(16) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(6) - - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - sw.add(vbox) - area.pack_start(sw, True, True, 0) - - vbox.pack_start(Gtk.Label(label="Google OAuth 2.0 Client Credentials\n~/.config/codex-launcher/oauth-secrets.json", use_markup=True, xalign=0), False, False, 4) - - google_token_dir = os.path.expanduser("~/.cache/codex-proxy") - fields = {} - for section_key, section_label, oauth_prov, token_file in [ - ("antigravity", "Antigravity (CloudCode)", "google-antigravity", "google-antigravity-oauth-token.json"), - ("gemini_cli", "Gemini CLI", "google-cli", "google-cli-oauth-token.json"), - ]: - section_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - hdr_row = Gtk.Box(spacing=6) - hdr_row.pack_start(Gtk.Label(label=f"\n{section_label}", use_markup=True, xalign=0), True, True, 0) - reauth_btn = Gtk.Button(label="Re-OAuth") - reauth_btn.set_size_request(80, -1) - reauth_btn.connect("clicked", lambda b, p=oauth_prov: self._google_reoauth(p, dlg)) - hdr_row.pack_end(reauth_btn, False, False, 0) - import_btn = Gtk.Button(label="Import JSON") - import_btn.set_size_request(100, -1) - hdr_row.pack_end(import_btn, False, False, 0) - section_box.pack_start(hdr_row, False, False, 2) - - token_path = os.path.join(google_token_dir, token_file) - has_token = os.path.exists(token_path) - try: - with open(token_path) as tf: - td = json.load(tf) - has_token = bool(td.get("refresh_token") or td.get("access_token")) - except Exception: - pass - tok_status = "Token: valid" if has_token else "Token: missing" - section_box.pack_start(Gtk.Label(label=tok_status, use_markup=True, xalign=0), False, False, 0) - - sec = data.get(section_key, {}) - for fk, fl in [("client_id", "Client ID"), ("client_secret", "Client Secret")]: - row = Gtk.Box(spacing=6) - lbl = Gtk.Label(label=fl + ":", xalign=0) - lbl.set_size_request(100, -1) - entry = Gtk.Entry() - entry.set_text(sec.get(fk, "")) - entry.set_size_request(360, -1) - if fk == "client_secret": - entry.set_visibility(False) - entry.set_invisible_char("*") - row.pack_start(lbl, False, False, 0) - row.pack_start(entry, True, True, 0) - section_box.pack_start(row, False, False, 2) - fields[(section_key, fk)] = entry - import_btn.connect("clicked", lambda b, sk=section_key: self._import_oauth_json(fields, sk)) - vbox.pack_start(section_box, False, False, 0) - - vbox.pack_start(Gtk.Label(label="Import client_secret_*.json from Google Cloud Console → Credentials", use_markup=True, xalign=0), False, False, 4) - - sep = Gtk.Separator() - vbox.pack_start(sep, False, False, 8) - - vbox.pack_start(Gtk.Label(label="\nFreebuff / Codebuff Credentials\n~/.config/manicode/credentials.json", use_markup=True, xalign=0), False, False, 4) - - cb_creds_path = os.path.expanduser("~/.config/manicode/credentials.json") - cb_fields = {} - try: - with open(cb_creds_path) as f: - cb_data = json.load(f) - except Exception: - cb_data = {} - cb_default = cb_data.get("default", {}) - cb_status_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - - cb_info = f"Email: {cb_default.get('email', 'not logged in')}" - cb_name = cb_default.get("name", "") - if cb_name: - cb_info = f"{cb_name} — {cb_info}" - has_cb_token = bool(cb_default.get("authToken", "")) - status_text = "Logged in" if has_cb_token else "Not logged in" - status_color = "#27ae60" if has_cb_token else "#e67e22" - cb_info_lbl = Gtk.Label(label=f"{cb_info}\nStatus: {status_text}", use_markup=True, xalign=0) - cb_status_box.pack_start(cb_info_lbl, False, False, 2) - - for fk, fl in [("authToken", "Auth Token"), ("fingerprintId", "Fingerprint ID")]: - row = Gtk.Box(spacing=6) - lbl = Gtk.Label(label=fl + ":", xalign=0) - lbl.set_size_request(110, -1) - entry = Gtk.Entry() - entry.set_text(cb_default.get(fk, "")) - entry.set_size_request(360, -1) - entry.set_visibility(False) - entry.set_invisible_char("*") - row.pack_start(lbl, False, False, 0) - row.pack_start(entry, True, True, 0) - cb_status_box.pack_start(row, False, False, 2) - cb_fields[fk] = entry - - cb_btn_row = Gtk.Box(spacing=6) - cb_login_btn = Gtk.Button(label="Re-OAuth (GitHub Login)") - cb_login_btn.connect("clicked", lambda b: self._codebuff_reoauth()) - cb_btn_row.pack_start(cb_login_btn, False, False, 0) - cb_status_box.pack_start(cb_btn_row, False, False, 4) - - vbox.pack_start(cb_status_box, False, False, 0) - - cb_accounts = cb_data.get("accounts", []) - if cb_accounts: - vbox.pack_start(Gtk.Label(label=f"\nAdditional accounts: {len(cb_accounts)} (edit credentials.json manually)", use_markup=True, xalign=0), False, False, 2) - - vbox.show_all() - sw.show_all() - - if dlg.run() == Gtk.ResponseType.OK: - for (sk, fk), entry in fields.items(): - if sk not in data: - data[sk] = {} - data[sk][fk] = entry.get_text().strip() - try: - os.makedirs(os.path.dirname(secrets_path), exist_ok=True) - with open(secrets_path, "w") as f: - json.dump(data, f, indent=2) - os.chmod(secrets_path, 0o600) - except Exception as e: - self._show_error_dialog("Save failed", str(e)) - cb_updated = dict(cb_default) - for fk, entry in cb_fields.items(): - val = entry.get_text().strip() - if val: - cb_updated[fk] = val - if cb_updated: - cb_data["default"] = cb_updated - try: - os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True) - with open(cb_creds_path, "w") as f: - json.dump(cb_data, f, indent=2) - os.chmod(cb_creds_path, 0o600) - except Exception as e: - self._show_error_dialog("Save failed", str(e)) - dlg.destroy() - - def _import_oauth_json(self, fields, section_key): - chooser = Gtk.FileChooserDialog( - title="Import Google OAuth Client Secret JSON", - parent=self, action=Gtk.FileChooserAction.OPEN) - chooser.add_button("Cancel", Gtk.ResponseType.CANCEL) - chooser.add_button("Open", Gtk.ResponseType.OK) - filt = Gtk.FileFilter() - filt.set_name("JSON files") - filt.add_pattern("*.json") - chooser.add_filter(filt) - if chooser.run() == Gtk.ResponseType.OK: - path = chooser.get_filename() - try: - with open(path) as f: - raw = json.load(f) - creds = raw.get("installed") or raw.get("web") or raw - cid = creds.get("client_id", "") - csec = creds.get("client_secret", "") - if not cid or not csec: - raise ValueError("JSON does not contain client_id and client_secret") - fields[(section_key, "client_id")].set_text(cid) - fields[(section_key, "client_secret")].set_text(csec) - except Exception as e: - self._show_error_dialog("Import failed", str(e)) - chooser.destroy() - -# ═══════════════════════════════════════════════════════════════════ -# Endpoint manager dialog -# ═══════════════════════════════════════════════════════════════════ - -class EndpointMgr(Gtk.Window): - def __init__(self, parent): - super().__init__(title="Manage Endpoints") - self.set_transient_for(parent) - self.set_modal(True) - self._parent = parent - self.set_default_size(500, 350) - self.set_border_width(12) - self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self.add(vbox) - - title_lbl = Gtk.Label(label="Endpoints") - title_lbl.set_use_markup(True) - vbox.pack_start(title_lbl, False, False, 0) - - sw = Gtk.ScrolledWindow() - vbox.pack_start(sw, True, True, 0) - self._store = Gtk.ListStore(str, str, str, str) # name, provider, backend, default_model - self._tree = Gtk.TreeView(model=self._store) - for i, title in enumerate(["Name", "Provider", "Type", "Default Model"]): - col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i) - col.set_resizable(True) - self._tree.append_column(col) - sw.add(self._tree) - - btn_bar = Gtk.Box(spacing=8) - vbox.pack_start(btn_bar, False, False, 0) - self._add_btn = Gtk.Button(label="Add") - self._add_btn.connect("clicked", lambda b: self._add()) - btn_bar.pack_start(self._add_btn, False, False, 0) - self._edit_btn = Gtk.Button(label="Edit") - self._edit_btn.connect("clicked", lambda b: self._edit()) - btn_bar.pack_start(self._edit_btn, False, False, 0) - self._delete_btn = Gtk.Button(label="Delete") - self._delete_btn.connect("clicked", lambda b: self._delete()) - btn_bar.pack_start(self._delete_btn, False, False, 0) - self._default_btn = Gtk.Button(label="Set Default") - self._default_btn.connect("clicked", lambda b: self._set_default()) - btn_bar.pack_start(self._default_btn, False, False, 0) - self._doctor_btn = Gtk.Button(label="Doctor") - self._doctor_btn.connect("clicked", lambda b: self._doctor_selected()) - btn_bar.pack_start(self._doctor_btn, False, False, 0) - self._doctor_all_btn = Gtk.Button(label="Doctor All") - self._doctor_all_btn.connect("clicked", lambda b: self._doctor_all()) - btn_bar.pack_start(self._doctor_all_btn, False, False, 0) - self._mgr_close_btn = Gtk.Button(label="Close") - self._mgr_close_btn.connect("clicked", lambda b: self.destroy()) - btn_bar.pack_end(self._mgr_close_btn, False, False, 0) - - self._rebuild() - self.show_all() - - def _rebuild(self): - data = load_endpoints() - self._store.clear() - for ep in data["endpoints"]: - provider = ep.get("provider_preset", "Custom") - bt = label_for_backend(ep["backend_type"]) - self._store.append([ep["name"], provider, bt, ep.get("default_model", "")]) - - def _selected(self): - sel = self._tree.get_selection() - m, i = sel.get_selected() - if i is None: - return None - return self._store[i][0] - - def _add(self): - try: - self._dialog = EditEndpointDialog(self, None) - self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _edit(self): - name = self._selected() - if name: - try: - self._dialog = EditEndpointDialog(self, name) - self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None)) - except Exception as e: - import traceback; traceback.print_exc() - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") - d.run(); d.destroy() - - def _delete(self): - name = self._selected() - if not name: - return - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, - f'Delete endpoint "{name}"?') - r = d.run() - d.destroy() - if r != Gtk.ResponseType.YES: - return - data = load_endpoints() - data["endpoints"] = [e for e in data["endpoints"] if e["name"] != name] - if data.get("default") == name: - data["default"] = data["endpoints"][0]["name"] if data["endpoints"] else None - save_endpoints(data) - self._rebuild() - self._parent._on_endpoints_updated() - - def _set_default(self): - name = self._selected() - if not name: - return - data = load_endpoints() - data["default"] = name - save_endpoints(data) - self._rebuild() - self._parent._on_endpoints_updated() - - def _doctor_selected(self): - name = self._selected() - if not name: - return - ep = get_endpoint(name) - if not ep: - return - wait_dlg = Gtk.Dialog(title=f"Doctor: {name}…", parent=self, modal=True) - wait_dlg.set_default_size(280, 80) - lbl = Gtk.Label(label=f"Running diagnostics for {name}…") - lbl.set_margin_top(16) - lbl.set_margin_bottom(16) - wait_dlg.get_content_area().pack_start(lbl, True, True, 0) - wait_dlg.show_all() - - def _run(): - checks = run_endpoint_doctor(ep) - GLib.idle_add(wait_dlg.destroy) - GLib.idle_add(_show_doctor_results, self, name, checks) - - threading.Thread(target=_run, daemon=True).start() - wait_dlg.run() - - def _doctor_all(self): - data = load_endpoints() - endpoints = data.get("endpoints", []) - if not endpoints: - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, "No endpoints configured.") - d.run() - d.destroy() - return - wait_dlg = Gtk.Dialog(title="Doctor All…", parent=self, modal=True) - wait_dlg.set_default_size(320, 80) - lbl = Gtk.Label(label=f"Testing {len(endpoints)} endpoints…") - lbl.set_margin_top(16) - lbl.set_margin_bottom(16) - wait_dlg.get_content_area().pack_start(lbl, True, True, 0) - wait_dlg.show_all() - - all_results = {} - - def _run(): - for ep in endpoints: - try: - all_results[ep["name"]] = run_endpoint_doctor(ep) - except Exception as e: - all_results[ep["name"]] = [("Doctor run", False, str(e)[:100])] - GLib.idle_add(wait_dlg.destroy) - GLib.idle_add(self._show_doctor_all_results, all_results) - - threading.Thread(target=_run, daemon=True).start() - wait_dlg.run() - - def _show_doctor_all_results(self, all_results): - dlg = Gtk.Dialog(title="Doctor All Results", parent=self, modal=True) - dlg.add_button("Close", Gtk.ResponseType.CLOSE) - dlg.set_default_size(560, 450) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - sw.add(area) - for ep_name, checks in all_results.items(): - passed = sum(1 for _, ok, _ in checks if ok is True) - failed = sum(1 for _, ok, _ in checks if ok is False) - if failed: - color, status = "#e74c3c", f"{failed} failed" - else: - color, status = "#27ae60", f"{passed} passed" - hdr = Gtk.Label() - hdr.set_markup(f'{ep_name} {status}') - hdr.set_xalign(0) - area.pack_start(hdr, False, False, 4) - for name, ok, detail in checks: - if ok is True: - sym, sc = "\u2713", "#27ae60" - elif ok is False: - sym, sc = "\u2717", "#e74c3c" - else: - sym, sc = "\u25CB", "#f39c12" - row = Gtk.Box(spacing=4) - row.set_margin_start(12) - icon = Gtk.Label() - icon.set_markup(f'{sym}') - lbl = Gtk.Label() - lbl.set_markup(f'{name}' - + (f' {detail}' if detail else '') - + '') - lbl.set_xalign(0) - row.pack_start(icon, False, False, 0) - row.pack_start(lbl, False, False, 0) - area.pack_start(row, False, False, 1) - sep = Gtk.Separator() - area.pack_start(sep, False, False, 4) - dlg.get_content_area().pack_start(sw, True, True, 0) - dlg.show_all() - dlg.run() - dlg.destroy() - -class EditEndpointDialog(Gtk.Dialog): - def __init__(self, parent, existing_name): - title = "Edit Endpoint" if existing_name else "Add Endpoint" - Gtk.Dialog.__init__(self, title=title) - self.set_transient_for(parent) - self.set_modal(True) - self._parent_mgr = parent +class EditEndpointDialog: + def __init__(self, parent, existing_name=None): + self.result = False self._existing_name = existing_name - self._data = get_endpoint(existing_name) if existing_name else { - "name": "", "backend_type": "openai-compat", - "base_url": "", "api_key": "", "default_model": "", "models": [], - "provider_preset": "Custom", - } - self.set_default_size(480, 520) + self._parent_mgr = parent - area = self.get_content_area() - area.set_spacing(6) - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) + if existing_name: + self._data = get_endpoint(existing_name) or {} + else: + self._data = { + "name": "", "backend_type": "openai-compat", + "base_url": "", "api_key": "", "default_model": "", + "models": [], "provider_preset": "Custom", + } - grid = Gtk.Grid(column_spacing=8, row_spacing=6) - area.pack_start(grid, False, False, 0) + self._dlg = tk.Toplevel(parent) + title = "Edit Endpoint" if existing_name else "Add Endpoint" + self._dlg.title(title) + self._dlg.geometry("520x600") + self._dlg.transient(parent) + self._dlg.grab_set() - def add_row(row, label, widget): - grid.attach(Gtk.Label(label=label, xalign=1), 0, row, 1, 1) - grid.attach(widget, 1, row, 1, 1) + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) - self._entry_name = Gtk.Entry(text=self._data.get("name", "")) - add_row(0, "Name:", self._entry_name) + grid = ttk.Frame(main) + grid.pack(fill="x") - self._combo_preset = Gtk.ComboBoxText() - self._preset_names = list(PROVIDER_PRESETS.keys()) - for preset_name in self._preset_names: - self._combo_preset.append_text(preset_name) - self._combo_preset.set_active(self._preset_names.index(self._data.get("provider_preset", "Custom")) if self._data.get("provider_preset", "Custom") in self._preset_names else 0) - self._combo_preset.connect("changed", lambda c: self._apply_selected_preset()) - add_row(1, "Preset:", self._combo_preset) + row_idx = [0] - self._combo_type = Gtk.ComboBoxText() - for val, lab in [("openai-compat", "OpenAI-compatible (needs proxy)"), - ("anthropic", "Anthropic (needs proxy)"), - ("command-code", "Command Code (needs proxy)"), - ("codebuff", "Codebuff - Free DeepSeek/Kimi (needs proxy)"), - ("gemini-oauth-cli", "Gemini CLI OAuth (needs proxy)"), - ("gemini-oauth-antigravity", "Antigravity OAuth (needs proxy)"), - ("native", "Native OpenAI (no proxy)")]: - self._combo_type.append(val, lab) + def add_field(label, widget_factory): + ttk.Label(grid, text=label).grid(row=row_idx[0], column=0, sticky="e", padx=(0, 6), pady=2) + w = widget_factory() + w.grid(row=row_idx[0], column=1, sticky="ew", pady=2) + row_idx[0] += 1 + return w + + self._entry_name = add_field("Name:", lambda: ttk.Entry(grid)) + self._entry_name.insert(0, self._data.get("name", "")) + + self._combo_preset = ttk.Combobox(grid, values=list(PROVIDER_PRESETS.keys()), state="readonly") + preset = self._data.get("provider_preset", "Custom") + self._combo_preset.set(preset) + add_field("Preset:", lambda: self._combo_preset) + self._combo_preset.bind("<>", lambda e: self._apply_selected_preset(initial=False)) + + backend_types = [ + ("openai-compat", "OpenAI-compatible (needs proxy)"), + ("anthropic", "Anthropic (needs proxy)"), + ("command-code", "Command Code (needs proxy)"), + ("freebuff", "Freebuff - Free DeepSeek/Kimi (needs proxy)"), + ("gemini-oauth-cli", "Gemini CLI OAuth (needs proxy)"), + ("gemini-oauth-antigravity", "Antigravity OAuth (needs proxy)"), + ("native", "Native OpenAI (no proxy)"), + ] + self._combo_type = ttk.Combobox(grid, values=[f"{v} - {l}" for v, l in backend_types], state="readonly") bt = self._data.get("backend_type", "openai-compat") - self._combo_type.set_active_id(bt) - add_row(2, "Type:", self._combo_type) + bt_display = next((f"{v} - {l}" for v, l in backend_types if v == bt), backend_types[0][0] + " - " + backend_types[0][1]) + self._combo_type.set(bt_display) + add_field("Type:", lambda: self._combo_type) + self._bt_map = {f"{v} - {l}": v for v, l in backend_types} - self._entry_url = Gtk.Entry(text=self._data.get("base_url", "")) - add_row(3, "Base URL:", self._entry_url) + self._entry_url = add_field("Base URL:", lambda: ttk.Entry(grid)) + self._entry_url.insert(0, self._data.get("base_url", "")) - self._entry_key = Gtk.Entry(text=self._data.get("api_key", "")) - self._entry_key.set_visibility(False) - key_box = Gtk.Box(spacing=6) - key_box.pack_start(self._entry_key, True, True, 0) - self._oauth_btn = Gtk.Button(label="OAuth Login") - self._oauth_btn.connect("clicked", lambda b: self._do_oauth_login()) - key_box.pack_start(self._oauth_btn, False, False, 0) - add_row(4, "API Key:", key_box) - self._oauth_btn.set_visible(False) + key_frame = ttk.Frame(grid) + self._entry_key = ttk.Entry(key_frame, show="*") + self._entry_key.pack(side="left", fill="x", expand=True) + self._entry_key.insert(0, self._data.get("api_key", "")) + self._reveal_var = tk.BooleanVar(value=False) + ttk.Checkbutton(key_frame, text="Show", variable=self._reveal_var, + command=lambda: self._entry_key.configure(show="" if self._reveal_var.get() else "*")).pack(side="left", padx=(4, 0)) + self._oauth_btn = ttk.Button(key_frame, text="OAuth Login", command=self._do_oauth_login) + self._oauth_btn.pack(side="left", padx=(4, 0)) + add_field("API Key:", lambda: key_frame) - self._entry_cc_ver = Gtk.Entry(text=self._data.get("cc_version", "")) - self._entry_cc_ver.set_placeholder_text("e.g. 0.26.8 (Command Code only)") - add_row(5, "CC Version:", self._entry_cc_ver) + self._entry_cc_ver = add_field("CC Version:", lambda: ttk.Entry(grid)) + self._entry_cc_ver.insert(0, self._data.get("cc_version", "")) - reasoning_css = b""" - switch.reasoning-toggle { - min-width: 56px; min-height: 28px; - border-radius: 14px; - background: #e67e22; - border: 2px solid #cf6d17; - } - switch.reasoning-toggle:checked { - background: #2ecc71; - border: 2px solid #27ae60; - } - switch.reasoning-toggle slider { - min-width: 24px; min-height: 24px; - border-radius: 12px; - background: white; - border: 1px solid #bbb; - } - """ - reasoning_box = Gtk.Box(spacing=10) - self._switch_reasoning = Gtk.Switch() - self._switch_reasoning.set_name("reasoning-toggle") - ctx = self._switch_reasoning.get_style_context() - ctx.add_class("reasoning-toggle") - try: - css_prov = Gtk.CssProvider() - css_prov.load_from_data(reasoning_css) - ctx.add_provider(css_prov, Gtk.STYLE_PROVIDER_PRIORITY_USER) - except Exception: - pass - self._switch_reasoning.set_active(self._data.get("reasoning_enabled", True)) - self._switch_reasoning.connect("notify::active", lambda *a: self._on_reasoning_toggled()) - reasoning_box.pack_start(self._switch_reasoning, False, False, 0) - self._lbl_reasoning = Gtk.Label() - reasoning_box.pack_start(self._lbl_reasoning, False, False, 0) - add_row(6, "Reasoning:", reasoning_box) - - self._combo_effort = Gtk.ComboBoxText() - for ev, el in [("none", "None"), ("minimal", "Minimal"), ("low", "Low"), - ("medium", "Medium"), ("high", "High"), ("max", "Max")]: - self._combo_effort.append(ev, el) - saved_effort = self._data.get("reasoning_effort", "medium") - self._combo_effort.set_active_id(saved_effort if saved_effort in ("none","minimal","low","medium","high","max") else "medium") - add_row(7, "Effort:", self._combo_effort) + reason_frame = ttk.Frame(grid) + self._reason_var = tk.BooleanVar(value=self._data.get("reasoning_enabled", True)) + self._reason_cb = ttk.Checkbutton(reason_frame, text="Reasoning ON", variable=self._reason_var, + command=self._on_reasoning_toggled) + self._reason_cb.pack(side="left") + self._combo_effort = ttk.Combobox(reason_frame, values=["none", "minimal", "low", "medium", "high", "max"], + state="readonly", width=10) + self._combo_effort.set(self._data.get("reasoning_effort", "medium")) + self._combo_effort.pack(side="left", padx=(8, 0)) + ttk.Label(reason_frame, text="Effort").pack(side="left", padx=(4, 0)) + add_field("Reasoning:", lambda: reason_frame) self._on_reasoning_toggled() - enhancer_box = Gtk.Box(spacing=6) - self._switch_enhancer = Gtk.Switch() - self._switch_enhancer.set_active(self._data.get("prompt_enhancer", False)) - enhancer_box.pack_start(self._switch_enhancer, False, False, 0) - self._enhancer_status_lbl = Gtk.Label() - enhancer_box.pack_start(self._enhancer_status_lbl, False, False, 0) - self._switch_enhancer.connect("notify::active", lambda *a: self._on_enhancer_toggled()) - self._combo_enhancer_mode = Gtk.ComboBoxText() - for mode in ["offline", "ai-powered"]: - self._combo_enhancer_mode.append(mode, mode.capitalize()) - self._combo_enhancer_mode.set_active_id(self._data.get("prompt_enhancer_mode", "offline")) - enhancer_box.pack_start(self._combo_enhancer_mode, False, False, 6) - add_row(8, "Prompt Enhancer:", enhancer_box) + enhancer_frame = ttk.Frame(grid) + self._enhancer_var = tk.BooleanVar(value=self._data.get("prompt_enhancer", False)) + self._enhancer_cb = ttk.Checkbutton(enhancer_frame, text="Prompt Enhancer", variable=self._enhancer_var, command=self._on_enhancer_toggled) + self._enhancer_cb.pack(side="left") + self._enhancer_status_lbl = ttk.Label(enhancer_frame, text="", foreground="gray") + self._enhancer_status_lbl.pack(side="left", padx=(6, 0)) + self._enhancer_mode = ttk.Combobox(enhancer_frame, values=["offline", "ai-powered"], state="readonly", width=10) + self._enhancer_mode.set(self._data.get("prompt_enhancer_mode", "offline")) + self._enhancer_mode.pack(side="left", padx=(8, 0)) + add_field("Prompt Enhancer:", lambda: enhancer_frame) self._on_enhancer_toggled() - self._entry_enhancer_model = Gtk.Entry() - self._entry_enhancer_model.set_placeholder_text("e.g. deepseek/deepseek-v4-flash (ai-powered mode only)") - self._entry_enhancer_model.set_text(self._data.get("prompt_enhancer_model", "")) - add_row(9, "Enhancer Model:", self._entry_enhancer_model) + self._entry_enhancer_model = ttk.Entry(grid) + self._entry_enhancer_model.insert(0, self._data.get("prompt_enhancer_model", "")) + add_field("Enhancer Model:", lambda: self._entry_enhancer_model) - self._entry_enhancer_url = Gtk.Entry() - self._entry_enhancer_url.set_placeholder_text("e.g. https://www.codebuff.com/api/v1 (ai-powered mode only)") - self._entry_enhancer_url.set_text(self._data.get("prompt_enhancer_url", "")) - add_row(10, "Enhancer URL:", self._entry_enhancer_url) + self._entry_enhancer_url = ttk.Entry(grid) + self._entry_enhancer_url.insert(0, self._data.get("prompt_enhancer_url", "")) + add_field("Enhancer URL:", lambda: self._entry_enhancer_url) - self._entry_enhancer_key = Gtk.Entry() - self._entry_enhancer_key.set_placeholder_text("API key for enhancer model (ai-powered mode only)") - self._entry_enhancer_key.set_text(self._data.get("prompt_enhancer_key", "")) - self._entry_enhancer_key.set_visibility(False) - self._entry_enhancer_key.set_invisible_char("*") - add_row(11, "Enhancer Key:", self._entry_enhancer_key) + self._entry_enhancer_key = ttk.Entry(grid, show="*") + self._entry_enhancer_key.insert(0, self._data.get("prompt_enhancer_key", "")) + add_field("Enhancer Key:", lambda: self._entry_enhancer_key) - # Models - mlbl = Gtk.Label(label="Models:", xalign=0) - area.pack_start(mlbl, False, False, 4) + grid.columnconfigure(1, weight=1) - mbox = Gtk.Box(spacing=6) - area.pack_start(mbox, False, False, 0) - self._entry_model = Gtk.Entry() - mbox.pack_start(self._entry_model, True, True, 0) - self._add_model_btn = Gtk.Button(label="Add") - self._add_model_btn.connect("clicked", lambda b: self._add_model()) - mbox.pack_start(self._add_model_btn, False, False, 0) - self._add_list_btn = Gtk.Button(label="Add List") - self._add_list_btn.connect("clicked", lambda b: self._add_models_from_text()) - mbox.pack_start(self._add_list_btn, False, False, 0) - self._fetch_models_btn = Gtk.Button(label="Fetch from API") - self._fetch_models_btn.connect("clicked", lambda b: self._fetch_models()) - mbox.pack_start(self._fetch_models_btn, False, False, 0) - self._test_btn = Gtk.Button(label="Test Endpoint") - self._test_btn.connect("clicked", lambda b: self._diagnose_endpoint()) - mbox.pack_start(self._test_btn, False, False, 0) + ttk.Label(main, text="Models:").pack(anchor="w", pady=(8, 2)) - bulk_lbl = Gtk.Label(label="Bulk add models (one per line or comma-separated):", xalign=0) - area.pack_start(bulk_lbl, False, False, 2) - bulk_sw = Gtk.ScrolledWindow() - bulk_sw.set_min_content_height(72) - area.pack_start(bulk_sw, False, False, 0) - self._bulk_buf = Gtk.TextBuffer() - self._bulk_text = Gtk.TextView(buffer=self._bulk_buf) - self._bulk_text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - bulk_sw.add(self._bulk_text) + model_input_frame = ttk.Frame(main) + model_input_frame.pack(fill="x") + self._entry_model = ttk.Entry(model_input_frame) + self._entry_model.pack(side="left", fill="x", expand=True) + ttk.Button(model_input_frame, text="Add", command=self._add_model).pack(side="left", padx=(4, 0)) + ttk.Button(model_input_frame, text="Bulk Add", command=self._add_models_from_text).pack(side="left", padx=(4, 0)) + ttk.Button(model_input_frame, text="Fetch from API", command=self._fetch_models).pack(side="left", padx=(4, 0)) + ttk.Button(model_input_frame, text="Sync from Preset", command=lambda: self._apply_selected_preset_force()).pack(side="left", padx=(4, 0)) + ttk.Button(model_input_frame, text="Test Endpoint", command=self._diagnose_endpoint).pack(side="left", padx=(4, 0)) - sw = Gtk.ScrolledWindow() - sw.set_min_content_height(120) - area.pack_start(sw, True, True, 0) - self._model_store = Gtk.ListStore(str) - self._model_tree = Gtk.TreeView(model=self._model_store) - self._model_tree.append_column(Gtk.TreeViewColumn("Model ID", Gtk.CellRendererText(), text=0)) - self._model_tree.set_rules_hint(True) - sw.add(self._model_tree) - self._model_tree.connect("row-activated", lambda t, p, c: self._remove_model(p)) - - model_btn_box = Gtk.Box(spacing=6) - area.pack_start(model_btn_box, False, False, 0) - self._remove_model_btn = Gtk.Button(label="Remove Selected") - self._remove_model_btn.connect("clicked", lambda b: self._remove_selected_model()) - model_btn_box.pack_start(self._remove_model_btn, False, False, 0) - self._clear_models_btn = Gtk.Button(label="Clear All") - self._clear_models_btn.connect("clicked", lambda b: self._clear_all_models()) - model_btn_box.pack_start(self._clear_models_btn, False, False, 0) - self._sync_preset_btn = Gtk.Button(label="Sync from Preset") - self._sync_preset_btn.connect("clicked", lambda b: self._apply_selected_preset()) - model_btn_box.pack_start(self._sync_preset_btn, False, False, 0) + ttk.Label(main, text="Bulk add (one per line or comma-separated):").pack(anchor="w", pady=(4, 0)) + self._bulk_text = tk.Text(main, height=3, wrap="word") + self._bulk_text.pack(fill="x", pady=(2, 4)) + list_frame = ttk.Frame(main) + list_frame.pack(fill="both", expand=True) + self._model_listbox = tk.Listbox(list_frame, height=6) + sb = ttk.Scrollbar(list_frame, orient="vertical", command=self._model_listbox.yview) + self._model_listbox.configure(yscrollcommand=sb.set) + self._model_listbox.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") + self._model_listbox.bind("", lambda e: self._remove_selected_model()) for m in self._data.get("models", []): - self._model_store.append([m]) + self._model_listbox.insert("end", m) - # Default model combo - dbox = Gtk.Box(spacing=6) - area.pack_start(dbox, False, False, 0) - dbox.pack_start(Gtk.Label(label="Default Model:"), False, False, 0) - self._combo_default = Gtk.ComboBoxText() + default_frame = ttk.Frame(main) + default_frame.pack(fill="x", pady=(4, 0)) + ttk.Label(default_frame, text="Default Model:").pack(side="left") + self._combo_default = ttk.Combobox(default_frame, state="readonly") + self._combo_default.pack(side="left", fill="x", expand=True, padx=(6, 0)) self._refresh_default_combo() - dbox.pack_start(self._combo_default, True, True, 0) dm = self._data.get("default_model", "") if dm: - self._combo_default.set_active_id(dm) + self._combo_default.set(dm) self._apply_selected_preset(initial=True) - # Buttons - self.add_button("Cancel", Gtk.ResponseType.CANCEL) - self.add_button("Save", Gtk.ResponseType.OK) - self.connect("response", self._on_response) - self.show_all() + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(btn_frame, text="Cancel", command=self._cancel).pack(side="right") + ttk.Button(btn_frame, text="Save", command=self._save).pack(side="right", padx=(8, 0)) - def _add_model(self): - m = normalize_model_id(self._entry_model.get_text()) - if m: - current = self._combo_default.get_active_text() - self._model_store.append([m]) - self._refresh_default_combo(current or m) - self._entry_model.set_text("") + def _on_reasoning_toggled(self): + state = "readonly" if self._reason_var.get() else "disabled" + self._combo_effort.configure(state=state) - def _add_models_from_text(self): - buf = self._bulk_buf.get_text(self._bulk_buf.get_start_iter(), self._bulk_buf.get_end_iter(), True) - models = parse_model_list(buf) - if not models: - return - current = self._combo_default.get_active_text() - existing = {self._model_store[i][0] for i in range(len(self._model_store))} - added = False - for mid in models: - if mid not in existing: - self._model_store.append([mid]) - existing.add(mid) - added = True - if added: - self._refresh_default_combo(current or models[0]) - self._bulk_buf.set_text("") + def _on_enhancer_toggled(self): + if self._enhancer_var.get(): + self._enhancer_status_lbl.configure(text="ON", foreground="#2ea043") + else: + self._enhancer_status_lbl.configure(text="OFF", foreground="#888888") def _apply_selected_preset(self, initial=False): - preset_name = self._combo_preset.get_active_text() or "Custom" - preset = PROVIDER_PRESETS.get(preset_name, PROVIDER_PRESETS["Custom"]) - oauth_provider = preset.get("oauth_provider", "") - is_oauth = bool(oauth_provider) - self._oauth_btn.set_visible(is_oauth) - if oauth_provider == "codebuff": - self._oauth_btn.set_label("Codebuff Login") - self._entry_key.set_placeholder_text("Auto-filled by codebuff login") - elif is_oauth: - self._oauth_btn.set_label("OAuth Login") - self._entry_key.set_placeholder_text("Auto-filled by OAuth") - else: - self._entry_key.set_placeholder_text("") + preset_name = self._combo_preset.get() or "Custom" + preset = PROVIDER_PRESETS.get(preset_name, {}) + is_oauth = bool(preset.get("oauth_provider")) + self._oauth_btn.configure(state="normal" if is_oauth else "disabled") + if not initial or self._existing_name is None: - self._combo_type.set_active_id(preset.get("backend_type", "openai-compat")) - self._entry_url.set_text(preset.get("base_url", "")) - if not self._entry_key.get_text().strip(): - self._entry_key.set_text("") + bt = preset.get("backend_type", "openai-compat") + bt_display = next((k for k, v in self._bt_map.items() if v == bt), list(self._bt_map.keys())[0]) + self._combo_type.set(bt_display) + self._entry_url.delete(0, "end") + self._entry_url.insert(0, preset.get("base_url", "")) cc_ver = preset.get("cc_version", "") - if cc_ver and not self._entry_cc_ver.get_text().strip(): - self._entry_cc_ver.set_text(cc_ver) - if preset.get("models") and (not initial or len(self._model_store) == 0): - current = self._combo_default.get_active_text() - self._model_store.clear() + if cc_ver and not self._entry_cc_ver.get().strip(): + self._entry_cc_ver.delete(0, "end") + self._entry_cc_ver.insert(0, cc_ver) + if preset.get("models") and self._model_listbox.size() == 0: + self._model_listbox.delete(0, "end") + for mid in preset["models"]: + self._model_listbox.insert("end", mid) + self._refresh_default_combo() + if preset["models"]: + self._combo_default.set(preset["models"][0]) + + def _apply_selected_preset_force(self): + preset_name = self._combo_preset.get() or "Custom" + preset = PROVIDER_PRESETS.get(preset_name, {}) + bt = preset.get("backend_type", "openai-compat") + bt_display = next((k for k, v in self._bt_map.items() if v == bt), list(self._bt_map.keys())[0]) + self._combo_type.set(bt_display) + self._entry_url.delete(0, "end") + self._entry_url.insert(0, preset.get("base_url", "")) + cc_ver = preset.get("cc_version", "") + if cc_ver: + self._entry_cc_ver.delete(0, "end") + self._entry_cc_ver.insert(0, cc_ver) + if preset.get("models"): + self._model_listbox.delete(0, "end") for mid in preset["models"]: - self._model_store.append([mid]) - self._refresh_default_combo(current or preset["models"][0]) - if initial and self._data.get("models"): - self._refresh_default_combo(self._data.get("default_model", "")) + self._model_listbox.insert("end", mid) + self._refresh_default_combo() + if preset["models"]: + self._combo_default.set(preset["models"][0]) - def _on_reasoning_toggled(self, *_): - active = self._switch_reasoning.get_active() - self._combo_effort.set_sensitive(active) - if active: - self._lbl_reasoning.set_markup('ON') - else: - self._lbl_reasoning.set_markup('OFF') + def _add_model(self): + m = normalize_model_id(self._entry_model.get()) + if m: + self._model_listbox.insert("end", m) + self._refresh_default_combo() + self._entry_model.delete(0, "end") - def _on_enhancer_toggled(self, *_): - active = self._switch_enhancer.get_active() - if active: - self._enhancer_status_lbl.set_markup('ON') + def _add_models_from_text(self): + text = self._bulk_text.get("1.0", "end") + models = parse_model_list(text) + existing = set(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + for mid in models: + if mid not in existing: + self._model_listbox.insert("end", mid) + self._bulk_text.delete("1.0", "end") + self._refresh_default_combo() + + def _remove_selected_model(self): + sel = self._model_listbox.curselection() + if sel: + self._model_listbox.delete(sel[0]) + self._refresh_default_combo() + + def _refresh_default_combo(self): + models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + current = self._combo_default.get() + self._combo_default["values"] = models + if current in models: + self._combo_default.set(current) + elif models: + self._combo_default.set(models[0]) else: - self._enhancer_status_lbl.set_markup('OFF') + self._combo_default.set("") + + def _fetch_models(self): + ep = self._make_endpoint_snapshot() + ids, err = fetch_models_for_endpoint(ep) + if ids: + existing = set(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + for mid in ids: + if mid not in existing: + self._model_listbox.insert("end", mid) + self._refresh_default_combo() + else: + messagebox.showerror("Fetch Models", f"Failed:\n{err}", parent=self._dlg) + + def _diagnose_endpoint(self): + ep = self._make_endpoint_snapshot() + wait = tk.Toplevel(self._dlg) + wait.title("Running Doctor...") + wait.geometry("280x80") + wait.transient(self._dlg) + wait.grab_set() + tk.Label(wait, text="Running endpoint diagnostics...").pack(expand=True) + + def _run(): + checks = run_endpoint_doctor(ep) + self._dlg.after(0, lambda: (wait.destroy(), _show_doctor_results_tk(self._dlg, ep.get("default_model", "endpoint"), checks))) + + threading.Thread(target=_run, daemon=True).start() + + def _make_endpoint_snapshot(self): + bt_display = self._combo_type.get() + bt = self._bt_map.get(bt_display, "openai-compat") + return { + "base_url": self._entry_url.get().strip(), + "api_key": self._entry_key.get().strip(), + "backend_type": bt, + "default_model": self._combo_default.get() or "", + } def _do_oauth_login(self): - preset_name = self._combo_preset.get_active_text() or "Custom" + preset_name = self._combo_preset.get() or "Custom" preset = PROVIDER_PRESETS.get(preset_name, {}) provider = preset.get("oauth_provider", "") if provider == "codebuff": @@ -3850,19 +431,13 @@ class EditEndpointDialog(Gtk.Dialog): def _google_oauth_flow(self, oauth_provider="google-cli"): is_antigravity = oauth_provider == "google-antigravity" - token_path = os.path.expanduser("~/.cache/codex-proxy/google-antigravity-oauth-token.json" if is_antigravity else "~/.cache/codex-proxy/google-cli-oauth-token.json") + token_path = str(PROXY_CONFIG_DIR / ("google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json")) - _oauth_secrets_path = os.path.expanduser("~/.config/codex-launcher/oauth-secrets.json") - try: - with open(_oauth_secrets_path) as _f: - _oauth_secrets = json.load(_f) - except Exception: - _oauth_secrets = {} + _sec = load_oauth_secrets().get("antigravity" if is_antigravity else "gemini_cli", {}) + CLIENT_ID = _sec.get("client_id", "") + CLIENT_SECRET = _sec.get("client_secret", "") if is_antigravity: - _sec = _oauth_secrets.get("antigravity", {}) - CLIENT_ID = _sec.get("client_id", "") - CLIENT_SECRET = _sec.get("client_secret", "") SCOPES = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", @@ -3875,9 +450,6 @@ class EditEndpointDialog(Gtk.Dialog): callback_path = "/oauth-callback" provider_kind = "antigravity" else: - _sec = _oauth_secrets.get("gemini_cli", {}) - CLIENT_ID = _sec.get("client_id", "") - CLIENT_SECRET = _sec.get("client_secret", "") SCOPES = [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/userinfo.email", @@ -3888,8 +460,6 @@ class EditEndpointDialog(Gtk.Dialog): callback_path = "/oauth2callback" provider_kind = "cli" - import http.server - state = secrets.token_hex(32) verifier = secrets.token_urlsafe(64) challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() @@ -3914,32 +484,21 @@ class EditEndpointDialog(Gtk.Dialog): f"&code_challenge_method=S256" ) - dlg = Gtk.Dialog(title="Google OAuth (Gemini Mode)", parent=self, modal=True) - dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) - dlg.set_default_size(520, 280) - area = dlg.get_content_area() - area.set_margin_start(16) - area.set_margin_end(16) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(8) + oauth_dlg = tk.Toplevel(self._dlg) + oauth_dlg.title("Google OAuth (Gemini Mode)") + oauth_dlg.geometry("520x280") + oauth_dlg.transient(self._dlg) + oauth_dlg.grab_set() - area.pack_start(Gtk.Label(label="Sign in with Google", use_markup=True, xalign=0), False, False, 0) - area.pack_start(Gtk.Label(label="Emulating Gemini CLI OAuth — no client_secret.json needed.", xalign=0), False, False, 0) + tk.Label(oauth_dlg, text="Sign in with Google", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w") + tk.Label(oauth_dlg, text=f"Using OAuth credentials from {OAUTH_SECRETS_PATH}").pack(padx=16, anchor="w") - link_lbl = Gtk.Label() - link_lbl.set_markup(f'Click here to open Google authorization') - link_lbl.set_line_wrap(True) - area.pack_start(link_lbl, False, False, 4) + link_lbl = tk.Label(oauth_dlg, text="Click here to open Google authorization", fg="blue", cursor="hand2") + link_lbl.pack(padx=16, pady=(8, 0), anchor="w") + link_lbl.bind("", lambda e: open_url(auth_url)) - self._oauth_status = Gtk.Label(label="Opening browser…", xalign=0) - area.pack_start(self._oauth_status, False, False, 4) - - spinner = Gtk.Spinner() - spinner.start() - area.pack_start(spinner, False, False, 8) - - area.show_all() + self._oauth_status_var = tk.StringVar(value="Opening browser...") + tk.Label(oauth_dlg, textvariable=self._oauth_status_var).pack(padx=16, pady=(8, 0), anchor="w") code_holder = [None] error_holder = [None] @@ -3950,8 +509,6 @@ class EditEndpointDialog(Gtk.Dialog): qs = urllib.parse.urlparse(self2.path).query params = urllib.parse.parse_qs(qs) received_state[0] = params.get("state", [None])[0] - with open("/tmp/codex-oauth-debug.log", "a") as _dbg: - _dbg.write(f"[{time.strftime('%H:%M:%S')}] GET {self2.path} state={received_state[0]} code={'code' in params}\n") if self2.path.find(callback_path) == -1: self2.send_response(302) self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini") @@ -3963,8 +520,7 @@ class EditEndpointDialog(Gtk.Dialog): self2.send_response(400) self2.send_header("Content-Type", "text/html") self2.end_headers() - self2.wfile.write(b"" - b"

CSRF state mismatch.

") + self2.wfile.write(b"

CSRF state mismatch.

") error_holder[0] = "CSRF state mismatch" return code_holder[0] = params["code"][0] @@ -3977,41 +533,26 @@ class EditEndpointDialog(Gtk.Dialog): self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini") self2.end_headers() def log_message(self2, fmt, *args): - with open("/tmp/codex-oauth-debug.log", "a") as _dbg: - _dbg.write(f"[{time.strftime('%H:%M:%S')}] {fmt % args}\n") + pass try: bind_host = "localhost" if is_antigravity else "127.0.0.1" server = http.server.HTTPServer((bind_host, port), OAuthHandler) except OSError: - self._oauth_status.set_text(f"Port {port} already in use — close other apps and retry.") - spinner.stop() - dlg.run(); dlg.destroy() + self._oauth_status_var.set(f"Port {port} already in use -- close other apps and retry.") return - def _oauth_log(msg): - with open("/tmp/codex-oauth-debug.log", "a") as _f: - _f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n") - - _oauth_log(f"Starting OAuth: port={port} redirect_uri={redirect_uri}") - def wait_for_code(): - _oauth_log("wait_for_code thread started") deadline = time.time() + 120 while code_holder[0] is None and error_holder[0] is None and time.time() < deadline: server.handle_request() server.server_close() - _oauth_log(f"Server closed. code={'yes' if code_holder[0] else 'no'} error={'yes' if error_holder[0] else 'no'}") if code_holder[0]: try: - _oauth_log("Exchanging code for token...") token_data = urllib.parse.urlencode({ - "code": code_holder[0], - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "redirect_uri": redirect_uri, - "grant_type": "authorization_code", - "code_verifier": verifier, + "code": code_holder[0], "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, "redirect_uri": redirect_uri, + "grant_type": "authorization_code", "code_verifier": verifier, }).encode() req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}) @@ -4024,368 +565,204 @@ class EditEndpointDialog(Gtk.Dialog): os.makedirs(os.path.dirname(token_path), exist_ok=True) with open(token_path, "w") as f: json.dump(tokens, f, indent=2) - os.chmod(token_path, 0o600) - _oauth_log(f"Token saved to {token_path}") - project_id = _oauth_discover_project(tokens["access_token"], token_path, tokens) - _oauth_log(f"Project ID: {project_id or '(none)'}") + + project_id = "" + try: + lr = urllib.request.Request( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + data=json.dumps({}).encode(), + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {tokens['access_token']}", + "User-Agent": "google-api-nodejs-client/9.15.1"}) + lresp = urllib.request.urlopen(lr, timeout=15) + ldata = json.loads(lresp.read()) + p = ldata.get("cloudaicompanionProject", "") + if isinstance(p, dict): + project_id = p.get("id", "") + elif isinstance(p, str): + project_id = p + if project_id: + tokens["project_id"] = project_id + with open(token_path, "w") as f2: + json.dump(tokens, f2, indent=2) + except Exception: + pass + + found_models = [] if is_antigravity: - found_models = [ - "gemini-2.5-flash", "gemini-2.5-pro", - "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview", - "gemini-3-pro-low", "gemini-3-pro-high", - "gemini-3.1-pro-low", "gemini-3.1-pro-high", - "gemini-3-flash-low", "gemini-3-flash-medium", "gemini-3-flash-high", - "claude-sonnet-4-6", "claude-opus-4-6-thinking", - "claude-opus-4-6-thinking-low", "claude-opus-4-6-thinking-medium", "claude-opus-4-6-thinking-high", - "gemini-claude-sonnet-4-6", - "gemini-claude-opus-4-6-thinking-low", "gemini-claude-opus-4-6-thinking-medium", "gemini-claude-opus-4-6-thinking-high", - "gemini-3-pro-image", - ] - probe_candidates = [ - "gemini-2.5-flash", "gemini-2.5-pro", - "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview", - ] - _oauth_log(f"Probing {len(probe_candidates)} model candidates...") - for mc in probe_candidates: - try: - pr = urllib.request.Request( - "https://cloudcode-pa.googleapis.com/v1internal:generateContent", - data=json.dumps({ - "project": project_id, - "model": mc, - "request": {"contents": [{"role": "user", "parts": [{"text": "x"}]}], - "generationConfig": {"maxOutputTokens": 1}}, - }).encode(), - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {tokens['access_token']}", - "User-Agent": "google-api-nodejs-client/9.15.1", - "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", - }) - pr.get_method = lambda: "POST" - resp = urllib.request.urlopen(pr, timeout=10) - resp.read() - found_models.append(mc) - _oauth_log(f" {mc} → available") - except urllib.error.HTTPError as e: - if e.code == 429: - found_models.append(mc) - _oauth_log(f" {mc} → available (rate limited)") - else: - e.read() - _oauth_log(f" {mc} → HTTP {e.code}") - except Exception as e: - _oauth_log(f" {mc} → error: {e}") + found_models = list(ANTIGRAVITY_MODELS) else: found_models = ["gemini-2.5-flash", "gemini-2.5-pro"] if found_models: tokens["available_models"] = found_models with open(token_path, "w") as f3: json.dump(tokens, f3, indent=2) - os.chmod(token_path, 0o600) - _oauth_log(f"Discovered {len(found_models)} models: {found_models}") - else: - _oauth_log("No models discovered (will use defaults)") - GLib.idle_add(self._oauth_success, dlg, tokens.get("access_token", ""), spinner) - return - except urllib.error.HTTPError as e: - body = e.read().decode(errors='replace') - _oauth_log(f"Token exchange HTTP {e.code}: {body}") - GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed ({e.code}): {body[:200]}", spinner) - return + + self._dlg.after(0, lambda: self._oauth_success(oauth_dlg, tokens.get("access_token", ""))) except Exception as e: - _oauth_log(f"Token exchange FAILED: {e}") - GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed: {e}", spinner) - return - _oauth_log(f"OAuth failed: {error_holder[0] or 'timeout'}") - GLib.idle_add(self._oauth_failed, dlg, - error_holder[0] or "No authorization code received.", spinner) + self._dlg.after(0, lambda: self._oauth_failed(oauth_dlg, str(e))) + else: + self._dlg.after(0, lambda: self._oauth_failed(oauth_dlg, error_holder[0] or "No authorization code received.")) threading.Thread(target=wait_for_code, daemon=True).start() - subprocess.Popen(["xdg-open", auth_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - dlg.connect("response", lambda d, r: d.destroy()) - dlg.run() + open_url(auth_url) + + def _oauth_success(self, dlg, access_token): + self._entry_key.delete(0, "end") + self._entry_key.insert(0, access_token) + self._oauth_status_var.set("Authorization successful! Token saved.") + self._dlg.after(1500, dlg.destroy) + + def _oauth_failed(self, dlg, msg): + self._oauth_status_var.set(f"Failed: {msg}") + self._dlg.after(3000, dlg.destroy) def _codebuff_oauth_flow(self): - dlg = Gtk.Dialog(title="Codebuff Login", parent=self, modal=True) - dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) - dlg.set_default_size(500, 240) - area = dlg.get_content_area() - area.set_margin_start(16) - area.set_margin_end(16) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(8) + import uuid + oauth_dlg = tk.Toplevel(self._dlg) + oauth_dlg.title("Codebuff / Freebuff Login") + oauth_dlg.geometry("520x240") + oauth_dlg.transient(self._dlg) + oauth_dlg.grab_set() + tk.Label(oauth_dlg, text="Sign in with GitHub via Codebuff", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w") + self._cb_status_var = tk.StringVar(value="Requesting login URL...") + tk.Label(oauth_dlg, textvariable=self._cb_status_var).pack(padx=16, pady=(8, 0), anchor="w") + self._cb_link_lbl = tk.Label(oauth_dlg, text="", fg="blue", cursor="hand2") + self._cb_link_lbl.pack(padx=16, anchor="w") + self._cb_oauth_result = {"success": False, "user": None, "error": None} + self._cb_oauth_dlg = oauth_dlg - area.pack_start(Gtk.Label(label="Sign in with GitHub via Codebuff", use_markup=True, xalign=0), False, False, 0) - - self._oauth_status = Gtk.Label(label="Requesting login URL…", xalign=0) - self._oauth_status.set_line_wrap(True) - self._oauth_status.set_max_width_chars(60) - area.pack_start(self._oauth_status, False, False, 4) - - link_lbl = Gtk.Label(xalign=0) - link_lbl.set_line_wrap(True) - link_lbl.set_max_width_chars(60) - area.pack_start(link_lbl, False, False, 4) - - spinner = Gtk.Spinner() - spinner.start() - area.pack_start(spinner, False, False, 8) - - area.show_all() - link_lbl.set_visible(False) - - self._fb_oauth_result = {"success": False, "user": None, "error": None} - - def _codebuff_auth_thread(): + def _thread(): try: - fingerprint_id = str(uuid.uuid4()) - auth_url = "https://www.codebuff.com/api/auth/cli/code" - body = json.dumps({"fingerprintId": fingerprint_id}).encode() - req = urllib.request.Request(auth_url, data=body, - headers={"Content-Type": "application/json", "User-Agent": "codex-launcher/3.10.7"}) + fp_id = str(uuid.uuid4()) + body = json.dumps({"fingerprintId": fp_id}).encode() + req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code", + data=body, headers={"Content-Type": "application/json", "User-Agent": UA}) resp = urllib.request.urlopen(req, timeout=30) - data = json.loads(resp.read()) - login_url = data.get("loginUrl", "") or data.get("login_url", "") - fingerprint_hash = data.get("fingerprintHash", "") or data.get("fingerprint_hash", "") - expires_at = data.get("expiresAt", 0) or data.get("expires_at", 0) + rdata = json.loads(resp.read()) + login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "") + fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "") + expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0) if not login_url: - self._fb_oauth_result["error"] = "Server returned no login URL" - GLib.idle_add(self._codebuff_oauth_done, dlg, spinner) + self._cb_oauth_result["error"] = "No login URL" + self._dlg.after(0, self._codebuff_oauth_done) return - def _set_link(): - self._oauth_status.set_text("Open this URL in your browser to log in:") - link_lbl.set_markup(f'{login_url}') - link_lbl.set_visible(True) - GLib.idle_add(_set_link) - - webbrowser.open(login_url) - - poll_url = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fingerprint_id)}&fingerprintHash={urllib.parse.quote(fingerprint_hash)}&expiresAt={expires_at}" + self._cb_status_var.set("Open this URL in your browser to log in:") + self._cb_link_lbl.configure(text=login_url) + self._cb_link_lbl.bind("", lambda e: open_url(login_url)) + self._dlg.after(0, _set_link) + open_url(login_url) + poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}" deadline = time.time() + 300 while time.time() < deadline: time.sleep(2) try: - poll_req = urllib.request.Request(poll_url, - headers={"User-Agent": "codex-launcher/3.10.7"}) - poll_resp = urllib.request.urlopen(poll_req, timeout=10) - poll_data = json.loads(poll_resp.read()) - user = poll_data.get("user") - if user and user.get("authToken"): - self._fb_oauth_result["success"] = True - self._fb_oauth_result["user"] = user - GLib.idle_add(self._codebuff_oauth_done, dlg, spinner) + pr = urllib.request.Request(poll, headers={"User-Agent": UA}) + pd = json.loads(urllib.request.urlopen(pr, timeout=10).read()) + if pd.get("user", {}).get("authToken"): + self._cb_oauth_result["success"] = True + self._cb_oauth_result["user"] = pd["user"] + self._dlg.after(0, self._codebuff_oauth_done) return - except urllib.error.HTTPError: - pass except Exception: pass - self._fb_oauth_result["error"] = "Login timed out after 5 minutes." - GLib.idle_add(self._codebuff_oauth_done, dlg, spinner) + self._cb_oauth_result["error"] = "Timed out" except Exception as e: - self._fb_oauth_result["error"] = str(e)[:200] - GLib.idle_add(self._codebuff_oauth_done, dlg, spinner) + self._cb_oauth_result["error"] = str(e)[:200] + self._dlg.after(0, self._codebuff_oauth_done) - threading.Thread(target=_codebuff_auth_thread, daemon=True).start() - dlg.connect("response", lambda d, r: d.destroy()) - dlg.run() + threading.Thread(target=_thread, daemon=True).start() - def _codebuff_oauth_done(self, dlg, spinner): - spinner.stop() - if self._fb_oauth_result["success"] and self._fb_oauth_result["user"]: - user = self._fb_oauth_result["user"] - creds_path = os.path.expanduser("~/.config/manicode/credentials.json") - os.makedirs(os.path.dirname(creds_path), exist_ok=True) - creds = {"default": { - "id": user.get("id", ""), - "name": user.get("name", ""), - "email": user.get("email", ""), - "authToken": user.get("authToken", ""), - "fingerprintId": user.get("fingerprintId", ""), - "fingerprintHash": user.get("fingerprintHash", ""), - }} - with open(creds_path, "w") as f: + def _codebuff_oauth_done(self): + if self._cb_oauth_result["success"] and self._cb_oauth_result["user"]: + u = self._cb_oauth_result["user"] + cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json") + os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True) + creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""), + "email": u.get("email", ""), "authToken": u.get("authToken", ""), + "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}} + with open(cb_creds_path, "w") as f: json.dump(creds, f, indent=2) - os.chmod(creds_path, 0o600) - self._entry_key.set_text(user.get("authToken", "")) - self._oauth_status.set_markup('Authorization successful! Credentials saved.') - dlg.set_title("Codebuff Login – Success") - GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK)) + self._cb_status_var.set(f"Logged in as {u.get('email', 'OK')}") + self._cb_link_lbl.configure(text="") + self._entry_key.delete(0, "end") + self._entry_key.insert(0, u.get("authToken", "")) + self._dlg.after(2000, self._cb_oauth_dlg.destroy) else: - self._oauth_status.set_markup(f'{self._fb_oauth_result["error"] or "Login failed."}') - GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL)) + self._cb_status_var.set(f"Failed: {self._cb_oauth_result.get('error', 'unknown')}") - def _oauth_success(self, dlg, access_token, spinner): - spinner.stop() - self._entry_key.set_text(access_token) - self._oauth_status.set_markup('Authorization successful! Token saved.') - dlg.set_title("Google OAuth — Success") - GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK)) + def _cancel(self): + self._dlg.destroy() - def _oauth_failed(self, dlg, msg, spinner): - spinner.stop() - self._oauth_status.set_markup(f'{msg}') - GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL)) - - def _remove_model(self, path): - current = self._combo_default.get_active_text() - self._model_store.remove(self._model_store.get_iter(path)) - self._refresh_default_combo(current) - - def _remove_selected_model(self): - sel = self._model_tree.get_selection() - model, paths = sel.get_selected_rows() - if not paths: - return - current = self._combo_default.get_active_text() - for p in reversed(paths): - self._model_store.remove(self._model_store.get_iter(p)) - self._refresh_default_combo(current) - - def _clear_all_models(self): - current = self._combo_default.get_active_text() - self._model_store.clear() - self._refresh_default_combo(current) - - def _refresh_default_combo(self, active=None): - if active is None: - active = self._combo_default.get_active_text() - self._combo_default.remove_all() - for row in self._model_store: - self._combo_default.append(row[0], row[0]) - if active and any(row[0] == active for row in self._model_store): - self._combo_default.set_active_id(active) - elif len(self._model_store) > 0: - self._combo_default.set_active(0) - - def _fetch_models(self): - ok, err = self._try_fetch_models() - if not ok: - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, - f"Failed to fetch models:\n{err}") - d.run() - d.destroy() - - def _try_fetch_models(self): - endpoint = { - "base_url": self._entry_url.get_text().strip(), - "api_key": self._entry_key.get_text().strip(), - "backend_type": self._combo_type.get_active_id() or "openai-compat", - } - ids, err = fetch_models_for_endpoint(endpoint) - if ids: - current = self._combo_default.get_active_text() - added = 0 - for mid in ids: - # check dupes - found = any(self._model_store[i][0] == mid for i in range(len(self._model_store))) - if not found: - self._model_store.append([mid]) - added += 1 - self._refresh_default_combo(current) - return True, None - return False, err or "No models returned by endpoint" - - def _diagnose_endpoint(self): - ep = { - "base_url": self._entry_url.get_text().strip(), - "api_key": self._entry_key.get_text().strip(), - "backend_type": self._combo_type.get_active_id() or "openai-compat", - "default_model": self._combo_default.get_active_text() or "", - } - name = ep.get("default_model") or "endpoint" - wait_dlg = Gtk.Dialog(title="Running Doctor…", parent=self, modal=True) - wait_dlg.set_default_size(280, 80) - lbl = Gtk.Label(label="Running endpoint diagnostics…") - lbl.set_margin_top(16) - lbl.set_margin_bottom(16) - wait_dlg.get_content_area().pack_start(lbl, True, True, 0) - wait_dlg.show_all() - - def _run(): - checks = run_endpoint_doctor(ep) - GLib.idle_add(wait_dlg.destroy) - GLib.idle_add(_show_doctor_results, self, name, checks) - - threading.Thread(target=_run, daemon=True).start() - wait_dlg.run() - - def _on_response(self, dialog, response): - if response != Gtk.ResponseType.OK: - self.destroy() - return - - name = self._entry_name.get_text().strip() + def _save(self): + name = self._entry_name.get().strip() if not name: - self._show_error("Name is required") + messagebox.showerror("Error", "Name is required", parent=self._dlg) return - bt = self._combo_type.get_active_id() or PROVIDER_PRESETS.get(self._combo_preset.get_active_text() or "", {}).get("backend_type") or "openai-compat" - url = self._entry_url.get_text().strip() - key = self._entry_key.get_text().strip() - models = [self._model_store[i][0] for i in range(len(self._model_store))] + bt_display = self._combo_type.get() + bt = self._bt_map.get(bt_display, "openai-compat") + url = self._entry_url.get().strip() + key = self._entry_key.get().strip() + models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size())) + if not models: - ok, err = self._try_fetch_models() - if ok: - models = [self._model_store[i][0] for i in range(len(self._model_store))] + ep_snap = self._make_endpoint_snapshot() + ids, err = fetch_models_for_endpoint(ep_snap) + if ids: + for mid in ids: + self._model_listbox.insert("end", mid) + self._refresh_default_combo() + models = list(self._model_listbox.get(i) for i in range(self._model_listbox.size())) else: - d = Gtk.MessageDialog( - self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, - f"Auto-fetch failed ({err}).\n\nAdd models manually now?" - ) - r = d.run() - d.destroy() - if r == Gtk.ResponseType.YES: - self._entry_model.grab_focus() + r = messagebox.askyesno("No Models", f"Auto-fetch failed ({err}).\n\nAdd models manually now?", parent=self._dlg) + if r: + self._entry_model.focus_set() return - self.destroy() + self._dlg.destroy() return if not models: - self._show_error("At least one model is required") - self._entry_model.grab_focus() + messagebox.showerror("Error", "At least one model is required", parent=self._dlg) return - default = self._combo_default.get_active_text() or models[0] + default = self._combo_default.get() or models[0] data = load_endpoints() - # If renaming, remove old entry if self._existing_name and self._existing_name != name: data["endpoints"] = [e for e in data["endpoints"] if e["name"] != self._existing_name] - # Check for duplicate name - existing = [e for e in data["endpoints"] if e["name"] == name and e != self._data] + existing = [e for e in data["endpoints"] if e["name"] == name] if existing: - self._show_error(f'Endpoint "{name}" already exists') + messagebox.showerror("Error", f'Endpoint "{name}" already exists', parent=self._dlg) return - new_ep = {"name": name, "backend_type": bt, "base_url": url, - "api_key": key, "default_model": default, "models": models, - "provider_preset": self._combo_preset.get_active_text() or "Custom"} - cc_ver = self._entry_cc_ver.get_text().strip() + new_ep = { + "name": name, "backend_type": bt, "base_url": normalize_base_url(url), + "api_key": key, "default_model": default, "models": models, + "provider_preset": self._combo_preset.get() or "Custom", + "reasoning_enabled": self._reason_var.get(), + "reasoning_effort": self._combo_effort.get() or "medium", + "prompt_enhancer": self._enhancer_var.get(), + "prompt_enhancer_mode": self._enhancer_mode.get() or "offline", + } + cc_ver = self._entry_cc_ver.get().strip() if cc_ver: new_ep["cc_version"] = cc_ver - new_ep["reasoning_enabled"] = self._switch_reasoning.get_active() - new_ep["reasoning_effort"] = self._combo_effort.get_active_id() or "medium" - new_ep["prompt_enhancer"] = self._switch_enhancer.get_active() - new_ep["prompt_enhancer_mode"] = self._combo_enhancer_mode.get_active_id() or "offline" - enh_model = self._entry_enhancer_model.get_text().strip() - enh_url = self._entry_enhancer_url.get_text().strip() - enh_key = self._entry_enhancer_key.get_text().strip() + enh_model = self._entry_enhancer_model.get().strip() + enh_url = self._entry_enhancer_url.get().strip() + enh_key = self._entry_enhancer_key.get().strip() if enh_model: new_ep["prompt_enhancer_model"] = enh_model if enh_url: new_ep["prompt_enhancer_url"] = enh_url if enh_key: new_ep["prompt_enhancer_key"] = enh_key - preset_name = self._combo_preset.get_active_text() or "Custom" + preset_name = self._combo_preset.get() or "Custom" preset = PROVIDER_PRESETS.get(preset_name, {}) if preset.get("oauth_provider"): new_ep["oauth_provider"] = preset["oauth_provider"] - new_ep["base_url"] = normalize_base_url(new_ep["base_url"]) - # Update or append found = False for i, e in enumerate(data["endpoints"]): if e["name"] == name: @@ -4398,128 +775,321 @@ class EditEndpointDialog(Gtk.Dialog): data["default"] = name save_endpoints(data) - self._parent_mgr._rebuild() - self._parent_mgr._parent._on_endpoints_updated() - self.destroy() + self.result = True + self._dlg.destroy() - def _show_error(self, msg): - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg) - d.run(); d.destroy() -# ═══════════════════════════════════════════════════════════════════ -# Entry point -# ═══════════════════════════════════════════════════════════════════ +# ═══════════════════════════════════════════════════════════════════════ +# EndpointMgr +# ═══════════════════════════════════════════════════════════════════════ -# ═══════════════════════════════════════════════════════════════════ -# BGP Pool Manager -# ═══════════════════════════════════════════════════════════════════ - -class BGPPoolMgr(Gtk.Window): - def __init__(self, parent): - super().__init__(title="AI BGP — Pool Manager") - self.set_transient_for(parent) - self.set_default_size(620, 440) +class EndpointMgr: + def __init__(self, parent, on_update=None): self._parent = parent + self._on_update = on_update - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - vbox.set_margin_start(12) - vbox.set_margin_end(12) - vbox.set_margin_top(12) - vbox.set_margin_bottom(12) - self.add(vbox) + self._dlg = tk.Toplevel(parent) + self._dlg.title("Manage Endpoints") + self._dlg.geometry("600x400") + self._dlg.transient(parent) - hdr = Gtk.Box(spacing=8) - vbox.pack_start(hdr, False, False, 0) - hdr.pack_start(Gtk.Label(label="AI BGP Pools — multi-provider routing with automatic failover", use_markup=True), False, False, 0) + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) - self._store = Gtk.ListStore(str, str, str) - self._tree = Gtk.TreeView(model=self._store) - for i, (title, w) in enumerate([("Pool Name", 200), ("Routes", 250), ("Strategy", 100)]): - r = Gtk.CellRendererText() - c = Gtk.TreeViewColumn(title, r, text=i) - c.set_min_width(w) - self._tree.append_column(c) - self._tree.set_headers_visible(True) - sw = Gtk.ScrolledWindow() - sw.add(self._tree) - vbox.pack_start(sw, True, True, 0) + ttk.Label(main, text="Endpoints", font=("Segoe UI", 11, "bold")).pack(anchor="w") - sel = self._tree.get_selection() - sel.connect("changed", lambda *_: self._on_select()) + tree_frame = ttk.Frame(main) + tree_frame.pack(fill="both", expand=True, pady=(4, 0)) + cols = ("name", "provider", "backend", "default_model") + self._tree = ttk.Treeview(tree_frame, columns=cols, show="headings", selectmode="browse") + for col, heading, width in [("name", "Name", 140), ("provider", "Provider", 160), + ("backend", "Type", 140), ("default_model", "Default Model", 140)]: + self._tree.heading(col, text=heading) + self._tree.column(col, width=width, minwidth=80) + sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview) + self._tree.configure(yscrollcommand=sb.set) + self._tree.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") - bbox = Gtk.Box(spacing=8) - vbox.pack_start(bbox, False, False, 0) - self._add_btn = Gtk.Button(label="Create Pool") - self._add_btn.connect("clicked", lambda b: self._add_pool()) - bbox.pack_start(self._add_btn, True, True, 0) - self._edit_btn = Gtk.Button(label="Edit Pool") - self._edit_btn.connect("clicked", lambda b: self._edit_pool()) - self._edit_btn.set_sensitive(False) - bbox.pack_start(self._edit_btn, True, True, 0) - self._del_btn = Gtk.Button(label="Delete Pool") - self._del_btn.connect("clicked", lambda b: self._del_pool()) - self._del_btn.set_sensitive(False) - bbox.pack_start(self._del_btn, True, True, 0) - close_btn = Gtk.Button(label="Close") - close_btn.connect("clicked", lambda b: self.destroy()) - bbox.pack_start(close_btn, True, True, 0) + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(btn_frame, text="Add", command=self._add).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Edit", command=self._edit).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Delete", command=self._delete).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Set Default", command=self._set_default).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Doctor", command=self._doctor_selected).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Doctor All", command=self._doctor_all).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right") self._rebuild() - self.show_all() def _rebuild(self): - self._store.clear() - for pool in load_bgp_pools().get("pools", []): - routes_str = " → ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", [])) - self._store.append([pool["name"], routes_str, pool.get("strategy", "failover")]) + for item in self._tree.get_children(): + self._tree.delete(item) + data = load_endpoints() + for ep in data["endpoints"]: + provider = ep.get("provider_preset", "Custom") + bt = label_for_backend(ep["backend_type"]) + self._tree.insert("", "end", values=(ep["name"], provider, bt, ep.get("default_model", ""))) def _selected_name(self): - sel = self._tree.get_selection() - m, i = sel.get_selected() - return self._store[i][0] if i else None + sel = self._tree.selection() + if not sel: + return None + return self._tree.item(sel[0])["values"][0] - def _on_select(self): - name = self._selected_name() - self._edit_btn.set_sensitive(bool(name)) - self._del_btn.set_sensitive(bool(name)) + def _add(self): + d = EditEndpointDialog(self._dlg, None) + self._dlg.wait_window(d._dlg) + if d.result: + self._rebuild() + if self._on_update: + self._on_update() - def _add_pool(self): - d = BGPPoolEditDialog(self, None) - d.connect("response", lambda *_: self._rebuild()) - - def _edit_pool(self): - name = self._selected_name() - if name: - d = BGPPoolEditDialog(self, name) - d.connect("response", lambda *_: self._rebuild()) - - def _del_pool(self): + def _edit(self): name = self._selected_name() if not name: return - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, - f'Delete BGP pool "{name}"?') - r = d.run(); d.destroy() - if r != Gtk.ResponseType.YES: + d = EditEndpointDialog(self._dlg, name) + self._dlg.wait_window(d._dlg) + if d.result: + self._rebuild() + if self._on_update: + self._on_update() + + def _delete(self): + name = self._selected_name() + if not name: return - data = load_bgp_pools() - data["pools"] = [p for p in data["pools"] if p["name"] != name] - save_bgp_pools(data) + if not messagebox.askyesno("Delete", f'Delete endpoint "{name}"?', parent=self._dlg): + return + data = load_endpoints() + data["endpoints"] = [e for e in data["endpoints"] if e["name"] != name] + if data.get("default") == name: + data["default"] = data["endpoints"][0]["name"] if data["endpoints"] else None + save_endpoints(data) self._rebuild() - self._parent._on_endpoints_updated() + if self._on_update: + self._on_update() + + def _set_default(self): + name = self._selected_name() + if not name: + return + data = load_endpoints() + data["default"] = name + save_endpoints(data) + self._rebuild() + if self._on_update: + self._on_update() + + def _doctor_selected(self): + name = self._selected_name() + if not name: + return + ep = get_endpoint(name) + if not ep: + return + wait = tk.Toplevel(self._dlg) + wait.title(f"Doctor: {name}...") + wait.geometry("280x80") + wait.transient(self._dlg) + wait.grab_set() + tk.Label(wait, text=f"Running diagnostics for {name}...").pack(expand=True) + + def _run(): + checks = run_endpoint_doctor(ep) + self._dlg.after(0, lambda: (wait.destroy(), _show_doctor_results_tk(self._dlg, name, checks))) + + threading.Thread(target=_run, daemon=True).start() + + def _doctor_all(self): + data = load_endpoints() + endpoints = data.get("endpoints", []) + if not endpoints: + messagebox.showinfo("Doctor All", "No endpoints configured.", parent=self._dlg) + return + + wait = tk.Toplevel(self._dlg) + wait.title("Doctor All...") + wait.geometry("320x80") + wait.transient(self._dlg) + wait.grab_set() + tk.Label(wait, text=f"Testing {len(endpoints)} endpoints...").pack(expand=True) + + all_results = {} + + def _run(): + for ep in endpoints: + try: + all_results[ep["name"]] = run_endpoint_doctor(ep) + except Exception as e: + all_results[ep["name"]] = [("Doctor run", False, str(e)[:100])] + + def _show(): + wait.destroy() + dlg = tk.Toplevel(self._dlg) + dlg.title("Doctor All Results") + dlg.geometry("580x480") + dlg.transient(self._dlg) + + canvas = tk.Canvas(dlg) + scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview) + inner = tk.Frame(canvas) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + for ep_name, checks in all_results.items(): + passed = sum(1 for _, ok, _ in checks if ok is True) + failed = sum(1 for _, ok, _ in checks if ok is False) + color = "#e74c3c" if failed else "#27ae60" + status = f"{failed} failed" if failed else f"{passed} passed" + tk.Label(inner, text=f"{ep_name} {status}", fg=color, + font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=12, pady=(8, 2)) + for name, ok, detail in checks: + if ok is True: + sym, sc = "✓", "#27ae60" + elif ok is False: + sym, sc = "✗", "#e74c3c" + else: + sym, sc = "○", "#f39c12" + row = tk.Frame(inner) + row.pack(anchor="w", padx=24, pady=0) + tk.Label(row, text=sym, fg=sc, font=("Segoe UI", 9, "bold")).pack(side="left") + txt = name + if detail: + txt += f" {detail}" + tk.Label(row, text=txt, fg="#7f8c8d", font=("Segoe UI", 8)).pack(side="left") + ttk.Separator(inner).pack(fill="x", padx=12, pady=4) + + canvas.pack(side="left", fill="both", expand=True, padx=(12, 0)) + scrollbar.pack(side="right", fill="y") + ttk.Button(dlg, text="Close", command=dlg.destroy).pack(pady=8) + + self._dlg.after(0, _show) + + threading.Thread(target=_run, daemon=True).start() -class BGPPoolEditDialog(Gtk.Dialog): - def __init__(self, parent, existing_name): - title = "Edit BGP Pool" if existing_name else "Create BGP Pool" - Gtk.Dialog.__init__(self, title=title, parent=parent, modal=True) - self.add_button("Cancel", Gtk.ResponseType.CANCEL) - self.add_button("Save", Gtk.ResponseType.OK) - self.set_default_size(580, 480) +# ═══════════════════════════════════════════════════════════════════════ +# BGP Pool Manager +# ═══════════════════════════════════════════════════════════════════════ +class BGPRouteDialog: + def __init__(self, parent, endpoints, existing=None): + self.result = None + self._dlg = tk.Toplevel(parent) + self._dlg.title("BGP Route") + self._dlg.geometry("440x300") + self._dlg.transient(parent) + self._dlg.grab_set() + + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) + + ttk.Label(main, text="Route Name:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2) + self._entry_name = ttk.Entry(main) + self._entry_name.grid(row=0, column=1, sticky="ew", pady=2) + if existing: + self._entry_name.insert(0, existing.get("name", "")) + + ttk.Label(main, text="Endpoint:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2) + ep_names = [e["name"] for e in endpoints] + self._combo_ep = ttk.Combobox(main, values=ep_names, state="readonly") + self._combo_ep.grid(row=1, column=1, sticky="ew", pady=2) + if existing and existing.get("endpoint_name") in ep_names: + self._combo_ep.set(existing["endpoint_name"]) + elif ep_names: + self._combo_ep.set(ep_names[0]) + + ttk.Label(main, text="URL:").grid(row=2, column=0, sticky="e", padx=(0, 6), pady=2) + self._entry_url = ttk.Entry(main) + self._entry_url.grid(row=2, column=1, sticky="ew", pady=2) + + ttk.Label(main, text="API Key:").grid(row=3, column=0, sticky="e", padx=(0, 6), pady=2) + self._entry_key = ttk.Entry(main, show="*") + self._entry_key.grid(row=3, column=1, sticky="ew", pady=2) + + ttk.Label(main, text="Model:").grid(row=4, column=0, sticky="e", padx=(0, 6), pady=2) + self._combo_model = ttk.Combobox(main, state="readonly") + self._combo_model.grid(row=4, column=1, sticky="ew", pady=2) + + main.columnconfigure(1, weight=1) + + self._endpoints = endpoints + self._combo_ep.bind("<>", lambda e: self._on_ep_changed()) + self._on_ep_changed() + + if existing: + self._entry_url.delete(0, "end") + self._entry_url.insert(0, existing.get("target_url", "")) + self._entry_key.delete(0, "end") + self._entry_key.insert(0, existing.get("api_key", "")) + if existing.get("model"): + self._combo_model.set(existing["model"]) + + btn_frame = ttk.Frame(main) + btn_frame.grid(row=5, column=0, columnspan=2, pady=(12, 0)) + ttk.Button(btn_frame, text="Cancel", command=self._dlg.destroy).pack(side="right") + ttk.Button(btn_frame, text="OK", command=self._ok).pack(side="right", padx=(8, 0)) + + self._dlg.wait_window() + + def _on_ep_changed(self): + ep_name = self._combo_ep.get() + ep = None + for e in self._endpoints: + if e["name"] == ep_name: + ep = e + break + if ep: + self._entry_url.delete(0, "end") + self._entry_url.insert(0, normalize_base_url(ep.get("base_url", ""))) + self._entry_key.delete(0, "end") + self._entry_key.insert(0, ep.get("api_key", "")) + models = ep.get("models", []) + self._combo_model["values"] = models + if ep.get("default_model") and ep["default_model"] in models: + self._combo_model.set(ep["default_model"]) + elif models: + self._combo_model.set(models[0]) + + def _ok(self): + ep_name = self._combo_ep.get() + ep = None + for e in self._endpoints: + if e["name"] == ep_name: + ep = e + break + self.result = { + "name": self._entry_name.get().strip() or ep_name, + "endpoint_name": ep_name, + "target_url": self._entry_url.get().strip(), + "api_key": self._entry_key.get().strip(), + "model": self._combo_model.get() or "", + "priority": 99, + } + if ep: + self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True) + self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium") + self.result["oauth_provider"] = ep.get("oauth_provider", "") + self._dlg.destroy() + + +class BGPPoolEditDialog: + def __init__(self, parent, existing_name=None): + self.result = False self._existing_name = existing_name self._parent_mgr = parent + self._dlg = tk.Toplevel(parent._dlg if hasattr(parent, "_dlg") else parent) + title = "Edit BGP Pool" if existing_name else "Create BGP Pool" + self._dlg.title(title) + self._dlg.geometry("620x500") + self._dlg.transient(parent._dlg if hasattr(parent, "_dlg") else parent) + self._dlg.grab_set() + data = load_bgp_pools() pool = None if existing_name: @@ -4530,95 +1100,131 @@ class BGPPoolEditDialog(Gtk.Dialog): if not pool: pool = {"name": "", "strategy": "failover", "routes": []} - area = self.get_content_area() - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(8) + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) - grid = Gtk.Grid(column_spacing=8, row_spacing=6) - area.pack_start(grid, False, False, 0) + grid = ttk.Frame(main) + grid.pack(fill="x") + ttk.Label(grid, text="Pool Name:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2) + self._entry_name = ttk.Entry(grid) + self._entry_name.grid(row=0, column=1, sticky="ew", pady=2) + self._entry_name.insert(0, pool["name"]) - grid.attach(Gtk.Label(label="Pool Name:", xalign=1), 0, 0, 1, 1) - self._entry_name = Gtk.Entry(text=pool["name"]) - grid.attach(self._entry_name, 1, 0, 1, 1) + ttk.Label(grid, text="Strategy:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2) + self._combo_strategy = ttk.Combobox(grid, values=["failover", "race"], state="readonly") + self._combo_strategy.grid(row=1, column=1, sticky="ew", pady=2) + self._combo_strategy.set(pool.get("strategy", "failover")) + grid.columnconfigure(1, weight=1) - grid.attach(Gtk.Label(label="Strategy:", xalign=1), 0, 1, 1, 1) - self._combo_strategy = Gtk.ComboBoxText() - self._combo_strategy.append("failover", "Failover (try primary, fall back on error)") - self._combo_strategy.append("race", "Race (send to all, return fastest)") - self._combo_strategy.set_active_id(pool.get("strategy", "failover")) - grid.attach(self._combo_strategy, 1, 1, 1, 1) + ttk.Label(main, text="Routes (double-click to remove):", font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(8, 2)) - area.pack_start(Gtk.Label(label="Routes (drag to reorder priority)", use_markup=True, xalign=0), False, False, 8) + tree_frame = ttk.Frame(main) + tree_frame.pack(fill="both", expand=True) + cols = ("name", "endpoint", "url", "model", "priority") + self._route_tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=8) + for col, heading, w in [("name", "Route Name", 100), ("endpoint", "Endpoint", 120), + ("url", "URL", 160), ("model", "Model", 120), ("priority", "Priority", 60)]: + self._route_tree.heading(col, text=heading) + self._route_tree.column(col, width=w, minwidth=50) + rsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._route_tree.yview) + self._route_tree.configure(yscrollcommand=rsb.set) + self._route_tree.pack(side="left", fill="both", expand=True) + rsb.pack(side="right", fill="y") - self._route_store = Gtk.ListStore(str, str, str, str, str, str) + self._routes = [] for r in pool.get("routes", []): - self._route_store.append([ + self._routes.append(dict(r)) + self._route_tree.insert("", "end", values=( r.get("name", ""), r.get("endpoint_name", ""), - r.get("target_url", ""), r.get("api_key", ""), - r.get("model", ""), str(r.get("priority", 99)) - ]) + r.get("target_url", ""), r.get("model", ""), r.get("priority", 99))) - self._route_tree = Gtk.TreeView(model=self._route_store) - for i, (title, w) in enumerate([ - ("Route Name", 120), ("Endpoint", 120), ("URL", 150), - ("API Key", 80), ("Model", 120), ("Priority", 60) - ]): - renderer = Gtk.CellRendererText() - renderer.set_property("editable", False) - col = Gtk.TreeViewColumn(title, renderer, text=i) - col.set_min_width(w) - col.set_resizable(True) - self._route_tree.append_column(col) - self._route_tree.set_headers_visible(True) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - sw.add(self._route_tree) - sw.set_min_content_height(200) - area.pack_start(sw, True, True, 0) + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(6, 0)) + ttk.Button(btn_frame, text="Add Route", command=self._add_route).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Edit Route", command=self._edit_route).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Remove Route", command=self._remove_route).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Up", command=lambda: self._move_route(-1)).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Down", command=lambda: self._move_route(1)).pack(side="left", padx=(0, 4)) - bbox = Gtk.Box(spacing=6) - area.pack_start(bbox, False, False, 0) - add_r = Gtk.Button(label="Add Route") - add_r.connect("clicked", lambda b: self._add_route()) - bbox.pack_start(add_r, True, True, 0) - edit_r = Gtk.Button(label="Edit Route") - edit_r.connect("clicked", lambda b: self._edit_route()) - bbox.pack_start(edit_r, True, True, 0) - rm_r = Gtk.Button(label="Remove Route") - rm_r.connect("clicked", lambda b: self._remove_route()) - bbox.pack_start(rm_r, True, True, 0) - up_r = Gtk.Button(label="↑ Up") - up_r.connect("clicked", lambda b: self._move_route(-1)) - bbox.pack_start(up_r, True, True, 0) - down_r = Gtk.Button(label="↓ Down") - down_r.connect("clicked", lambda b: self._move_route(1)) - bbox.pack_start(down_r, True, True, 0) + save_frame = ttk.Frame(main) + save_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(save_frame, text="Cancel", command=self._dlg.destroy).pack(side="right") + ttk.Button(save_frame, text="Save", command=self._save).pack(side="right", padx=(8, 0)) - self.show_all() + def _add_route(self): + endpoints = load_endpoints().get("endpoints", []) + if not endpoints: + messagebox.showinfo("Info", "No endpoints configured. Add endpoints first.", parent=self._dlg) + return + d = BGPRouteDialog(self._dlg, endpoints, None) + if d.result: + r = d.result + self._routes.append(r) + self._route_tree.insert("", "end", values=( + r.get("name", ""), r.get("endpoint_name", ""), + r.get("target_url", ""), r.get("model", ""), r.get("priority", 99))) - if self.run() == Gtk.ResponseType.OK: - self._save() + def _edit_route(self): + sel = self._route_tree.selection() + if not sel: + return + idx = self._route_tree.index(sel[0]) + endpoints = load_endpoints().get("endpoints", []) + d = BGPRouteDialog(self._dlg, endpoints, self._routes[idx]) + if d.result: + r = d.result + self._routes[idx] = r + self._route_tree.item(sel[0], values=( + r.get("name", ""), r.get("endpoint_name", ""), + r.get("target_url", ""), r.get("model", ""), r.get("priority", 99))) - self.destroy() + def _remove_route(self): + sel = self._route_tree.selection() + if not sel: + return + idx = self._route_tree.index(sel[0]) + self._route_tree.delete(sel[0]) + del self._routes[idx] + + def _move_route(self, direction): + sel = self._route_tree.selection() + if not sel: + return + idx = self._route_tree.index(sel[0]) + new_idx = idx + direction + if new_idx < 0 or new_idx >= len(self._routes): + return + route = self._routes.pop(idx) + self._routes.insert(new_idx, route) + self._rebuild_routes_tree(new_idx) + + def _rebuild_routes_tree(self, select_idx=None): + for item in self._route_tree.get_children(): + self._route_tree.delete(item) + for r in self._routes: + self._route_tree.insert("", "end", values=( + r.get("name", ""), r.get("endpoint_name", ""), + r.get("target_url", ""), r.get("model", ""), r.get("priority", 99))) + if select_idx is not None: + children = self._route_tree.get_children() + if select_idx < len(children): + self._route_tree.selection_set(children[select_idx]) def _save(self): - name = self._entry_name.get_text().strip() + name = self._entry_name.get().strip() if not name: return - strategy = self._combo_strategy.get_active_id() or "failover" + strategy = self._combo_strategy.get() or "failover" routes = [] - for i, row in enumerate(self._route_store): - if not row[2]: + for i, r in enumerate(self._routes): + if not r.get("target_url"): continue routes.append({ - "name": row[0] or f"Route {i+1}", - "endpoint_name": row[1], - "target_url": row[2], - "api_key": row[3], - "model": row[4], + "name": r.get("name") or f"Route {i+1}", + "endpoint_name": r.get("endpoint_name", ""), + "target_url": r.get("target_url", ""), + "api_key": r.get("api_key", ""), + "model": r.get("model", ""), "priority": i + 1, "reasoning_enabled": True, "reasoning_effort": "medium", @@ -4628,331 +1234,309 @@ class BGPPoolEditDialog(Gtk.Dialog): data["pools"] = [p for p in data["pools"] if p["name"] != self._existing_name] data["pools"].append({"name": name, "strategy": strategy, "routes": routes}) save_bgp_pools(data) - self._parent_mgr._parent._on_endpoints_updated() - - def _add_route(self): - endpoints = load_endpoints().get("endpoints", []) - if not endpoints: - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, - "No endpoints configured. Add endpoints in Manage Endpoints first.") - d.run(); d.destroy() - return - d = BGPRouteDialog(self, endpoints, None) - if d.result: - r = d.result - self._route_store.append([ - r.get("name", ""), r.get("endpoint_name", ""), - r.get("target_url", ""), r.get("api_key", ""), - r.get("model", ""), str(r.get("priority", 99)) - ]) - - def _edit_route(self): - sel = self._route_tree.get_selection() - m, i = sel.get_selected() - if not i: - return - endpoints = load_endpoints().get("endpoints", []) - existing = { - "name": m[i][0], "endpoint_name": m[i][1], - "target_url": m[i][2], "api_key": m[i][3], - "model": m[i][4], "priority": int(m[i][5]) if m[i][5] else 99, - } - d = BGPRouteDialog(self, endpoints, existing) - if d.result: - r = d.result - m[i][0] = r.get("name", "") - m[i][1] = r.get("endpoint_name", "") - m[i][2] = r.get("target_url", "") - m[i][3] = r.get("api_key", "") - m[i][4] = r.get("model", "") - m[i][5] = str(r.get("priority", 99)) - - def _remove_route(self): - sel = self._route_tree.get_selection() - m, i = sel.get_selected() - if i: - self._route_store.remove(i) - - def _move_route(self, direction): - sel = self._route_tree.get_selection() - m, i = sel.get_selected() - if not i: - return - path = m.get_path(i) - idx = path.get_indices()[0] - new_idx = idx + direction - if new_idx < 0 or new_idx >= len(self._route_store): - return - row_data = [m[idx][c] for c in range(6)] - self._route_store.remove(m.get_iter(Gtk.TreePath(idx))) - new_iter = self._route_store.insert(new_idx) - for c, v in enumerate(row_data): - self._route_store.set_value(new_iter, c, v) + self.result = True + self._dlg.destroy() -class BGPRouteDialog(Gtk.Dialog): - def __init__(self, parent, endpoints, existing): - Gtk.Dialog.__init__(self, title="BGP Route", parent=parent, modal=True) - self.add_button("Cancel", Gtk.ResponseType.CANCEL) - self.add_button("OK", Gtk.ResponseType.OK) - self.set_default_size(440, 300) - self.result = None - - area = self.get_content_area() - area.set_margin_start(12) - area.set_margin_end(12) - area.set_margin_top(12) - area.set_margin_bottom(12) - area.set_spacing(6) - - grid = Gtk.Grid(column_spacing=8, row_spacing=6) - area.pack_start(grid, False, False, 0) - - def add_row(row, label, widget): - grid.attach(Gtk.Label(label=label, xalign=1), 0, row, 1, 1) - grid.attach(widget, 1, row, 1, 1) - - self._entry_name = Gtk.Entry(text=existing.get("name", "") if existing else "") - add_row(0, "Route Name:", self._entry_name) - - self._combo_ep = Gtk.ComboBoxText() - ep_names = [e["name"] for e in endpoints] - for en in ep_names: - self._combo_ep.append(en, en) - if existing and existing.get("endpoint_name") in ep_names: - self._combo_ep.set_active_id(existing["endpoint_name"]) - elif ep_names: - self._combo_ep.set_active(0) - self._combo_ep.connect("changed", lambda b: self._on_ep_changed(endpoints)) - add_row(1, "Endpoint:", self._combo_ep) - - self._entry_url = Gtk.Entry() - add_row(2, "URL:", self._entry_url) - - self._entry_key = Gtk.Entry() - self._entry_key.set_visibility(False) - add_row(3, "API Key:", self._entry_key) - - self._combo_model = Gtk.ComboBoxText() - add_row(4, "Model:", self._combo_model) - - if existing: - self._entry_url.set_text(existing.get("target_url", "")) - self._entry_key.set_text(existing.get("api_key", "")) - self._on_ep_changed(endpoints) - if existing and existing.get("model"): - self._combo_model.set_active_id(existing["model"]) - - self.show_all() - if self.run() == Gtk.ResponseType.OK: - ep_name = self._combo_ep.get_active_text() or "" - ep = None - for e in endpoints: - if e["name"] == ep_name: - ep = e - break - self.result = { - "name": self._entry_name.get_text().strip() or ep_name, - "endpoint_name": ep_name, - "target_url": self._entry_url.get_text().strip(), - "api_key": self._entry_key.get_text().strip(), - "model": self._combo_model.get_active_text() or "", - "priority": 99, - } - if ep: - self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True) - self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium") - self.result["oauth_provider"] = ep.get("oauth_provider", "") - self.destroy() - - def _on_ep_changed(self, endpoints): - ep_name = self._combo_ep.get_active_text() - ep = None - for e in endpoints: - if e["name"] == ep_name: - ep = e - break - if ep: - self._entry_url.set_text(normalize_base_url(ep.get("base_url", ""))) - self._entry_key.set_text(ep.get("api_key", "")) - self._combo_model.remove_all() - for m in ep.get("models", []): - mid = normalize_model_id(m) if m else "" - self._combo_model.append(mid, m) - if ep.get("default_model"): - self._combo_model.set_active_id(normalize_model_id(ep["default_model"])) - elif len(ep.get("models", [])) > 0: - self._combo_model.set_active(0) - - -_U = { - "base": "#0C0E16", "surface0": "#161928", "surface1": "#1E2235", - "surface2": "#2A2F47", "text": "#E4E6F0", "subtext": "#B0B4C8", - "dim": "#5C6180", "accent": "#7EB8F7", "blue": "#5DA4E8", - "sapphire": "#4EC5C1", "green": "#59D4A0", "yellow": "#F0C75E", - "red": "#F06A77", "peach": "#F09860", "teal": "#4EC5C1", - "lavender": "#A899F0", "sky": "#70C8E8", "maroon": "#C44B5C", - "flamingo": "#E878B0", "rosewater": "#F0D0C0", - "model_palette": ["#F09860", "#4EC5C1", "#5DA4E8", "#59D4A0", - "#F0C75E", "#A899F0", "#70C8E8", "#E878B0", - "#C44B5C", "#F0D0C0", "#7EB8F7", "#F06A77"], -} - -_USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json" - -def _load_usage_stats(): - try: - if _USAGE_STATS_FILE.exists(): - return json.loads(_USAGE_STATS_FILE.read_text()) - except Exception: - pass - return {"providers": {}, "updated": None} - -def _fmt_tok(n): - if n >= 1_000_000: - return f"{n/1_000_000:.1f}M" - if n >= 1_000: - return f"{n/1_000:.1f}K" - return str(n) - -def _fmt_dur(s): - if s >= 3600: - return f"{s/3600:.1f}h" - if s >= 60: - return f"{s/60:.1f}m" - return f"{s:.1f}s" - -def _status_pill(success_rate, fail_pct): - if fail_pct > 0.15: - return ("ERR", _U["red"]) - if fail_pct > 0.05: - return ("WARN", _U["yellow"]) - return ("OK", _U["green"]) - -def _make_css_widget(css_str): - p = Gtk.CssProvider() - p.load_from_data(css_str.encode()) - return p - -def _apply_css(widget, css_str): - ctx = widget.get_style_context() - ctx.add_provider(_make_css_widget(css_str), Gtk.STYLE_PROVIDER_PRIORITY_USER) - - -class UsageWindow(Gtk.Window): - def __init__(self, parent): - super().__init__(title="Usage Dashboard") - self.set_transient_for(parent) - self.set_default_size(720, 640) - self.set_position(Gtk.WindowPosition.CENTER) +class BGPPoolMgr: + def __init__(self, parent, on_update=None): self._parent = parent + self._on_update = on_update - _apply_css(self, f""" - window {{ background-color: {_U["base"]}; }} - separator {{ background-color: {_U["surface1"]}; }} - """) + self._dlg = tk.Toplevel(parent) + self._dlg.title("AI BGP -- Pool Manager") + self._dlg.geometry("660x440") + self._dlg.transient(parent) - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - self.add(vbox) + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) - self._build_header(vbox) - self._build_summary_strip(vbox) - sep = Gtk.Separator() - vbox.pack_start(sep, False, False, 0) + ttk.Label(main, text="AI BGP Pools -- multi-provider routing with automatic failover", + font=("Segoe UI", 10, "bold")).pack(anchor="w") - self._cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - self._cards_box.set_margin_top(8) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - sw.add(self._cards_box) - vbox.pack_start(sw, True, True, 0) + tree_frame = ttk.Frame(main) + tree_frame.pack(fill="both", expand=True, pady=(8, 0)) + cols = ("name", "routes", "strategy") + self._tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=10) + for col, heading, w in [("name", "Pool Name", 180), ("routes", "Routes", 280), ("strategy", "Strategy", 100)]: + self._tree.heading(col, text=heading) + self._tree.column(col, width=w, minwidth=60) + sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview) + self._tree.configure(yscrollcommand=sb.set) + self._tree.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") + + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(btn_frame, text="Create Pool", command=self._add_pool).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Edit Pool", command=self._edit_pool).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Delete Pool", command=self._del_pool).pack(side="left", padx=(0, 4)) + ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right") + + self._rebuild() + + def _rebuild(self): + for item in self._tree.get_children(): + self._tree.delete(item) + for pool in load_bgp_pools().get("pools", []): + routes_str = " -> ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", [])) + self._tree.insert("", "end", values=(pool["name"], routes_str, pool.get("strategy", "failover"))) + + def _selected_name(self): + sel = self._tree.selection() + if not sel: + return None + return self._tree.item(sel[0])["values"][0] + + def _add_pool(self): + d = BGPPoolEditDialog(self, None) + self._dlg.wait_window(d._dlg) + if d.result: + self._rebuild() + if self._on_update: + self._on_update() + + def _edit_pool(self): + name = self._selected_name() + if not name: + return + d = BGPPoolEditDialog(self, name) + self._dlg.wait_window(d._dlg) + if d.result: + self._rebuild() + if self._on_update: + self._on_update() + + def _del_pool(self): + name = self._selected_name() + if not name: + return + if not messagebox.askyesno("Delete", f'Delete BGP pool "{name}"?', parent=self._dlg): + return + data = load_bgp_pools() + data["pools"] = [p for p in data["pools"] if p["name"] != name] + save_bgp_pools(data) + self._rebuild() + if self._on_update: + self._on_update() + + +# ═══════════════════════════════════════════════════════════════════════ +# AI Monitoring Window +# ═══════════════════════════════════════════════════════════════════════ + +class AIMonitoringWindow: + def __init__(self, parent): + self._dlg = tk.Toplevel(parent) + self._dlg.title("AI Monitoring") + self._dlg.geometry("580x520") + self._dlg.transient(parent) + + self._cfg = load_monitoring_config() + self._store = load_incident_store() + + main = ttk.Frame(self._dlg, padding=12) + main.pack(fill="both", expand=True) + + hdr = ttk.Frame(main) + hdr.pack(fill="x") + ttk.Label(hdr, text="AI Monitoring", font=("Segoe UI", 11, "bold")).pack(side="left") + self._toggle_var = tk.BooleanVar(value=self._cfg.get("enabled", False)) + ttk.Checkbutton(hdr, text="Enabled", variable=self._toggle_var, + command=self._on_toggle).pack(side="right") + + frame = ttk.LabelFrame(main, text="Diagnostic Agent", padding=8) + frame.pack(fill="x", pady=(8, 0)) + + grid = ttk.Frame(frame) + grid.pack(fill="x") + grid.columnconfigure(1, weight=1) + + ttk.Label(grid, text="Provider URL:").grid(row=0, column=0, sticky="e", padx=(0, 6), pady=2) + self._url_entry = ttk.Entry(grid) + self._url_entry.grid(row=0, column=1, sticky="ew", pady=2) + self._url_entry.insert(0, self._cfg.get("provider_url", "")) + + ttk.Label(grid, text="Model:").grid(row=1, column=0, sticky="e", padx=(0, 6), pady=2) + self._model_entry = ttk.Entry(grid) + self._model_entry.grid(row=1, column=1, sticky="ew", pady=2) + self._model_entry.insert(0, self._cfg.get("model", "")) + + ttk.Label(grid, text="API Key:").grid(row=2, column=0, sticky="e", padx=(0, 6), pady=2) + key_frame = ttk.Frame(grid) + key_frame.grid(row=2, column=1, sticky="ew", pady=2) + self._key_entry = ttk.Entry(key_frame, show="*") + self._key_entry.pack(side="left", fill="x", expand=True) + self._key_entry.insert(0, self._cfg.get("api_key", "")) + self._reveal_key = tk.BooleanVar(value=False) + ttk.Checkbutton(key_frame, text="Show", variable=self._reveal_key, + command=lambda: self._key_entry.configure(show="" if self._reveal_key.get() else "*")).pack(side="left", padx=(4, 0)) + + ttk.Label(grid, text="Health Check:").grid(row=3, column=0, sticky="e", padx=(0, 6), pady=2) + spin_frame = ttk.Frame(grid) + spin_frame.grid(row=3, column=1, sticky="w", pady=2) + self._interval_spin = ttk.Spinbox(spin_frame, from_=2, to=30, width=5) + self._interval_spin.set(self._cfg.get("health_check_interval_s", 5)) + self._interval_spin.pack(side="left") + ttk.Label(spin_frame, text="seconds").pack(side="left", padx=(4, 0)) + + opts_frame = ttk.Frame(frame) + opts_frame.pack(fill="x", pady=(4, 0)) + self._auto_restart_var = tk.BooleanVar(value=self._cfg.get("auto_restart_proxy", True)) + ttk.Checkbutton(opts_frame, text="Auto-restart proxy on crash", + variable=self._auto_restart_var).pack(side="left") + self._auto_switch_var = tk.BooleanVar(value=self._cfg.get("auto_switch_provider", False)) + ttk.Checkbutton(opts_frame, text="Auto-switch provider on repeated failure", + variable=self._auto_switch_var).pack(side="left", padx=(12, 0)) + + ttk.Button(frame, text="Save Configuration", command=self._on_save).pack(pady=(8, 0)) + + stats = self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}) + stats_text = (f"AI diagnostic calls: {stats.get('ai_calls', 0)} | " + f"Tokens used: {stats.get('tokens_used', 0):,} | " + f"Known patterns: {len(self._store.get('incidents', {}))}") + ttk.Label(main, text=stats_text, font=("Segoe UI", 8)).pack(anchor="w", pady=(8, 0)) + + inc_frame = ttk.LabelFrame(main, text="Recent Incidents", padding=4) + inc_frame.pack(fill="both", expand=True, pady=(4, 0)) + self._inc_text = tk.Text(inc_frame, height=8, wrap="word", state="disabled") + inc_sb = ttk.Scrollbar(inc_frame, orient="vertical", command=self._inc_text.yview) + self._inc_text.configure(yscrollcommand=inc_sb.set) + self._inc_text.pack(side="left", fill="both", expand=True) + inc_sb.pack(side="right", fill="y") + self._refresh_incidents() + + btn_frame = ttk.Frame(main) + btn_frame.pack(fill="x", pady=(8, 0)) + ttk.Button(btn_frame, text="View Monitoring Log", + command=lambda: open_file(str(PROXY_CONFIG_DIR / "monitoring.log"))).pack(side="left") + ttk.Button(btn_frame, text="Clear Incident Store", command=self._on_clear_store).pack(side="left", padx=(8, 0)) + ttk.Button(btn_frame, text="Close", command=self._dlg.destroy).pack(side="right") + + def _on_toggle(self): + self._cfg["enabled"] = self._toggle_var.get() + save_monitoring_config(self._cfg) + + def _on_save(self): + self._cfg["provider_url"] = self._url_entry.get().strip() + self._cfg["model"] = self._model_entry.get().strip() + self._cfg["api_key"] = self._key_entry.get().strip() + try: + self._cfg["health_check_interval_s"] = int(self._interval_spin.get()) + except ValueError: + pass + self._cfg["auto_restart_proxy"] = self._auto_restart_var.get() + self._cfg["auto_switch_provider"] = self._auto_switch_var.get() + save_monitoring_config(self._cfg) + self._inc_text.configure(state="normal") + self._inc_text.delete("1.0", "end") + self._inc_text.insert("end", "Configuration saved.\n") + self._inc_text.configure(state="disabled") + + def _on_clear_store(self): + save_incident_store({"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}) + self._store = {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}} + self._refresh_incidents() + + def _refresh_incidents(self): + lines = [] + for pattern, inc in sorted(self._store.get("incidents", {}).items(), + key=lambda x: x[1].get("last_seen", ""), reverse=True): + sc = inc.get("success_count", 0) + fc = inc.get("fail_count", 0) + rate = sc / max(sc + fc, 1) + lines.append( + f"[{inc.get('last_seen', '?')[:16]}] {pattern}\n" + f" fix={inc.get('fix', '?')} success_rate={rate:.0%} seen={inc.get('occurrences', 0)}x\n" + ) + if not lines: + lines.append("No incidents recorded yet.\n\nEnable AI Monitoring and use Codex to populate the store.\n") + self._inc_text.configure(state="normal") + self._inc_text.delete("1.0", "end") + self._inc_text.insert("end", "\n".join(lines)) + self._inc_text.configure(state="disabled") + + +# ═══════════════════════════════════════════════════════════════════════ +# Usage Dashboard +# ═══════════════════════════════════════════════════════════════════════ + +class UsageWindow: + def __init__(self, parent): + self._U = _usage_theme() + self._dlg = tk.Toplevel(parent) + self._dlg.title("Usage Dashboard") + self._dlg.geometry("720x640") + self._dlg.transient(parent) + self._dlg.configure(bg=self._U["base"]) + + self._build_header() + self._build_summary_strip() + ttk.Separator(self._dlg).pack(fill="x", padx=16) + + self._cards_frame = tk.Frame(self._dlg, bg=self._U["base"]) + canvas = tk.Canvas(self._cards_frame, bg=self._U["base"], highlightthickness=0) + scrollbar = ttk.Scrollbar(self._cards_frame, orient="vertical", command=canvas.yview) + self._cards_inner = tk.Frame(canvas, bg=self._U["base"]) + self._cards_inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=self._cards_inner, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side="left", fill="both", expand=True, padx=(16, 0)) + scrollbar.pack(side="right", fill="y") + self._cards_frame.pack(fill="both", expand=True, pady=(8, 0)) self._refresh() - self.show_all() - def _build_header(self, parent): - hdr = Gtk.Box(spacing=8) - hdr.set_margin_start(16) - hdr.set_margin_end(16) - hdr.set_margin_top(12) - hdr.set_margin_bottom(6) - parent.pack_start(hdr, False, False, 0) + def _build_header(self): + U = self._U + hdr = tk.Frame(self._dlg, bg=U["base"]) + hdr.pack(fill="x", padx=16, pady=(12, 6)) + tk.Label(hdr, text="⚡", fg=U["accent"], bg=U["base"], font=("Segoe UI", 14)).pack(side="left") + tk.Label(hdr, text="Usage Dashboard", fg=U["text"], bg=U["base"], + font=("Segoe UI", 14, "bold")).pack(side="left", padx=(4, 0)) + self._status_dots = tk.Label(hdr, text="", fg=U["text"], bg=U["base"], font=("Segoe UI", 9)) + self._status_dots.pack(side="left", padx=(8, 0)) + self._updated_lbl = tk.Label(hdr, text="Never", fg=U["dim"], bg=U["base"], font=("Segoe UI", 8)) + self._updated_lbl.pack(side="right") + refresh_btn = tk.Button(hdr, text="Refresh", fg=U["text"], bg=U["surface0"], + activebackground=U["surface1"], relief="flat", bd=0, + command=self._refresh, padx=12, pady=2) + refresh_btn.pack(side="right", padx=(8, 0)) - bolt = Gtk.Label() - bolt.set_markup(f'\u26A1') - hdr.pack_start(bolt, False, False, 0) - - title = Gtk.Label() - title.set_markup(f'Usage Dashboard') - hdr.pack_start(title, False, False, 0) - - self._status_dots = Gtk.Label() - hdr.pack_start(self._status_dots, False, False, 8) - - self._updated_lbl = Gtk.Label() - self._updated_lbl.set_markup(f'Never') - hdr.pack_end(self._updated_lbl, False, False, 4) - - refresh_btn = Gtk.Button(label="Refresh") - _apply_css(refresh_btn, f""" - button {{ color: {_U["text"]}; background-color: {_U["surface0"]}; - border: 1px solid {_U["surface1"]}; border-radius: 6px; padding: 4px 12px; }} - button:hover {{ background-color: {_U["surface1"]}; }} - """) - refresh_btn.connect("clicked", lambda b: self._refresh()) - hdr.pack_end(refresh_btn, False, False, 0) - - def _build_summary_strip(self, parent): - strip = Gtk.Box(spacing=0) - strip.set_margin_start(16) - strip.set_margin_end(16) - strip.set_margin_bottom(6) - _apply_css(strip, f"box {{ background-color: {_U["surface0"]}; border-radius: 8px; padding: 8px 12px; }}") - parent.pack_start(strip, False, False, 0) - - self._kpi_boxes = {} - for key, label, icon in [ - ("providers", "Providers", "\U0001F4CA"), - ("requests", "Requests", "\u26A1"), - ("tokens", "Tokens", "\U0001F9E0"), - ("latency", "Avg Latency", "\u23F1"), - ]: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) - lbl = Gtk.Label() - lbl.set_markup(f'{icon} {label}') - lbl.set_xalign(0) - box.pack_start(lbl, False, False, 0) - val = Gtk.Label() - val.set_markup(f'-') - val.set_xalign(0) - box.pack_start(val, False, False, 0) - box.set_margin_end(20) - strip.pack_start(box, False, False, 0) - self._kpi_boxes[key] = val + def _build_summary_strip(self): + U = self._U + strip = tk.Frame(self._dlg, bg=U["surface0"], padx=12, pady=8) + strip.pack(fill="x", padx=16, pady=(0, 6)) + self._kpi_labels = {} + for key, label, icon in [("providers", "Providers", "\U0001F4CA"), + ("requests", "Requests", "⚡"), + ("tokens", "Tokens", "\U0001F9E0"), + ("latency", "Avg Latency", "⏱")]: + box = tk.Frame(strip, bg=U["surface0"]) + box.pack(side="left", padx=(0, 20)) + tk.Label(box, text=f"{icon} {label}", fg=U["dim"], bg=U["surface0"], + font=("Segoe UI", 8), anchor="w").pack(anchor="w") + val = tk.Label(box, text="-", fg=U["text"], bg=U["surface0"], + font=("Segoe UI", 9, "bold"), anchor="w") + val.pack(anchor="w") + self._kpi_labels[key] = val def _refresh(self): - for c in self._cards_box.get_children(): - self._cards_box.remove(c) - stats = _load_usage_stats() + for w in self._cards_inner.winfo_children(): + w.destroy() + stats = load_usage_stats() updated = stats.get("updated") if updated: - self._updated_lbl.set_markup(f'{updated}') + self._updated_lbl.configure(text=updated) providers = stats.get("providers", {}) if not providers: - empty = Gtk.Label() - empty.set_markup(f'No usage data yet.\nLaunch a session to start tracking.') - empty.set_margin_top(60) - self._cards_box.pack_start(empty, False, False, 0) - self._cards_box.show_all() + tk.Label(self._cards_inner, text="No usage data yet.\nLaunch a session to start tracking.", + fg=self._U["dim"], bg=self._U["base"], font=("Segoe UI", 11)).pack(pady=60) return - total_req = 0 - total_tok_in = 0 - total_tok_out = 0 + total_req = total_tok_in = total_tok_out = 0 total_dur = 0.0 - n_ok = 0 - n_warn = 0 - n_err = 0 + n_ok = n_warn = n_err = 0 sorted_providers = sorted(providers.items(), key=lambda x: x[1].get("total_requests", 0), reverse=True) for prov_name, prov_data in sorted_providers: @@ -4963,7 +1547,6 @@ class UsageWindow(Gtk.Window): total_dur += prov_data.get("total_duration_s", 0.0) fail = prov_data.get("failures", 0) fail_pct = fail / t if t > 0 else 0 - _, sc = _status_pill(0, fail_pct) if fail_pct > 0.15: n_err += 1 elif fail_pct > 0.05: @@ -4971,42 +1554,28 @@ class UsageWindow(Gtk.Window): else: n_ok += 1 - self._kpi_boxes["providers"].set_markup( - f'{len(providers)}') - self._kpi_boxes["requests"].set_markup( - f'{total_req:,}') + self._kpi_labels["providers"].configure(text=str(len(providers))) + self._kpi_labels["requests"].configure(text=f"{total_req:,}") tok_sum = total_tok_in + total_tok_out tok_str = f"{_fmt_tok(tok_sum)} in:{_fmt_tok(total_tok_in)} out:{_fmt_tok(total_tok_out)}" if tok_sum else "N/A" - self._kpi_boxes["tokens"].set_markup( - f'{tok_str}') + self._kpi_labels["tokens"].configure(text=tok_str) avg_lat = total_dur / total_req if total_req > 0 else 0 - self._kpi_boxes["latency"].set_markup( - f'{_fmt_dur(avg_lat)}') + self._kpi_labels["latency"].configure(text=_fmt_dur(avg_lat)) - dots_parts = [] + dots = "" if n_ok: - dots_parts.append(f'\u25CF{n_ok}') + dots += f"●{n_ok} " if n_warn: - dots_parts.append(f'\u25D0{n_warn}') + dots += f"◐{n_warn} " if n_err: - dots_parts.append(f'\u2717{n_err}') - if dots_parts: - self._status_dots.set_markup(" ".join(dots_parts)) + dots += f"✗{n_err}" + self._status_dots.configure(text=dots) for prov_name, prov_data in sorted_providers: - card = self._build_card(prov_name, prov_data) - self._cards_box.pack_start(card, False, False, 0) - self._cards_box.show_all() + self._build_card(prov_name, prov_data) def _build_card(self, name, data): - card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - card.set_margin_start(12) - card.set_margin_end(12) - _apply_css(card, f""" - box {{ background-color: {_U["surface0"]}; border-radius: 10px; - border: 1px solid {_U["surface1"]}; }} - """) - + U = self._U total = data.get("total_requests", 0) ok = data.get("successes", 0) fail = data.get("failures", 0) @@ -5014,284 +1583,138 @@ class UsageWindow(Gtk.Window): fail_pct = fail / total if total > 0 else 0 status_text, status_color = _status_pill(success_rate, fail_pct) - border_color = status_color - _apply_css(card, f""" - box {{ background-color: {_U["surface0"]}; border-radius: 10px; - border: 1px solid {border_color}; }} - """) + card = tk.Frame(self._cards_inner, bg=U["surface0"], padx=14, pady=10, + highlightbackground=status_color, highlightthickness=1) + card.pack(fill="x", pady=(0, 6)) - inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) - inner.set_margin_start(14) - inner.set_margin_end(14) - inner.set_margin_top(10) - inner.set_margin_bottom(10) - card.pack_start(inner, False, False, 0) - - top = Gtk.Box(spacing=6) - inner.pack_start(top, False, False, 0) - - dot = Gtk.Label() - dot.set_markup(f'\u25CF') - top.pack_start(dot, False, False, 0) - - name_lbl = Gtk.Label() + top = tk.Frame(card, bg=U["surface0"]) + top.pack(fill="x") + tk.Label(top, text="●", fg=status_color, bg=U["surface0"], font=("Segoe UI", 10)).pack(side="left") short = name.replace("https://", "").replace("http://", "").split("/")[0] - name_lbl.set_markup(f'{short}') - top.pack_start(name_lbl, False, False, 0) - - pill = Gtk.Label() - pill.set_markup(f' {status_text} ') - top.pack_start(pill, False, False, 4) - - req_lbl = Gtk.Label() - req_lbl.set_markup(f'{total} req') - top.pack_start(req_lbl, False, False, 6) - + tk.Label(top, text=short, fg=U["text"], bg=U["surface0"], + font=("Segoe UI", 10, "bold")).pack(side="left", padx=(4, 0)) + tk.Label(top, text=f" {status_text} ", fg=U["base"], bg=status_color, + font=("Segoe UI", 8, "bold")).pack(side="left", padx=(4, 0)) + tk.Label(top, text=f"{total} req", fg=U["subtext"], bg=U["surface0"], + font=("Segoe UI", 8)).pack(side="left", padx=(6, 0)) last_used = data.get("last_used", "") if last_used: - lu_lbl = Gtk.Label() - lu_lbl.set_markup(f'{last_used}') - top.pack_end(lu_lbl, False, False, 0) - - sep1 = Gtk.Separator() - _apply_css(sep1, f"separator {{ background-color: {status_color}; margin-top: 4px; }}") - inner.pack_start(sep1, False, False, 0) - - gauge_box = Gtk.Box(spacing=4) - gauge_box.set_margin_top(4) - inner.pack_start(gauge_box, False, False, 0) - - gauge_label = Gtk.Label() - gauge_label.set_markup(f'\u26A1') - gauge_box.pack_start(gauge_label, False, False, 0) - - bar = Gtk.ProgressBar() - bar.set_fraction(success_rate) - bar_pct = int(success_rate * 100) - bar.set_text(f"{bar_pct}%") - bar.set_show_text(True) - bar_css = f""" - progress {{ background-color: {status_color}; border-radius: 6px; }} - trough {{ background-color: {_U["surface1"]}; border-radius: 6px; min-height: 12px; }} - """ - _apply_css(bar, bar_css) - bar.set_hexpand(True) - gauge_box.pack_start(bar, True, True, 0) + tk.Label(top, text=last_used, fg=U["dim"], bg=U["surface0"], + font=("Segoe UI", 7)).pack(side="right") + gauge = tk.Frame(card, bg=U["surface0"]) + gauge.pack(fill="x", pady=(4, 0)) + bar_frame = tk.Frame(gauge, bg=U["surface1"], height=12) + bar_frame.pack(fill="x", side="left", expand=True) + bar_frame.pack_propagate(False) + fill_pct = int(success_rate * 100) + fill_frame = tk.Frame(bar_frame, bg=status_color, height=12) + fill_frame.place(relwidth=success_rate, relheight=1.0) + tk.Label(gauge, text=f"{fill_pct}%", fg=U["subtext"], bg=U["surface0"], + font=("Segoe UI", 8)).pack(side="left", padx=(4, 0)) if fail > 0: - fail_lbl = Gtk.Label() - fail_lbl.set_markup(f'{fail} fail') - gauge_box.pack_end(fail_lbl, False, False, 0) - - metrics_box = Gtk.Box(spacing=0) - metrics_box.set_margin_top(4) - inner.pack_start(metrics_box, False, False, 0) + tk.Label(gauge, text=f"{fail} fail", fg=U["red"], bg=U["surface0"], + font=("Segoe UI", 8)).pack(side="right") + metrics = tk.Frame(card, bg=U["surface0"]) + metrics.pack(fill="x", pady=(4, 0)) t_in = data.get("total_tokens_in", 0) t_out = data.get("total_tokens_out", 0) dur = data.get("total_duration_s", 0.0) avg_dur = dur / total if total > 0 else 0 - - for label, value, color in [ - ("Tokens In", f"{_fmt_tok(t_in)}", _U["sapphire"]), - ("Tokens Out", f"{_fmt_tok(t_out)}", _U["peach"]), - ("Avg Latency", _fmt_dur(avg_dur), _U["sky"]), - ("Duration", _fmt_dur(dur), _U["lavender"]), - ]: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - l = Gtk.Label() - l.set_markup(f'{label}') - l.set_xalign(0) - box.pack_start(l, False, False, 0) - v = Gtk.Label() - v.set_markup(f'{value}') - v.set_xalign(0) - box.pack_start(v, False, False, 0) - box.set_margin_end(16) - metrics_box.pack_start(box, False, False, 0) + for label, value, color in [("Tokens In", _fmt_tok(t_in), U["sapphire"]), + ("Tokens Out", _fmt_tok(t_out), U["peach"]), + ("Avg Latency", _fmt_dur(avg_dur), U["sky"]), + ("Duration", _fmt_dur(dur), U["lavender"])]: + box = tk.Frame(metrics, bg=U["surface0"]) + box.pack(side="left", padx=(0, 16)) + tk.Label(box, text=label, fg=U["dim"], bg=U["surface0"], font=("Segoe UI", 7)).pack(anchor="w") + tk.Label(box, text=value, fg=color, bg=U["surface0"], + font=("Segoe UI", 9, "bold")).pack(anchor="w") models = data.get("models", {}) if models: - self._build_models_section(inner, models, total) + models_frame = tk.Frame(card, bg=U["surface0"]) + models_frame.pack(fill="x", pady=(4, 0)) + tk.Label(models_frame, text="Models:", fg=U["lavender"], bg=U["surface0"], + font=("Segoe UI", 8, "bold")).pack(anchor="w") + sorted_models = sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True) + for i, (mname, mdata) in enumerate(sorted_models[:6]): + m_req = mdata.get("requests", 0) + pct = m_req / total * 100 if total > 0 else 0 + color = U["model_palette"][i % len(U["model_palette"])] + row = tk.Frame(models_frame, bg=U["surface0"]) + row.pack(fill="x") + tk.Label(row, text=f"● {mname}", fg=color, bg=U["surface0"], + font=("Segoe UI", 7)).pack(side="left") + tk.Label(row, text=f"{pct:.0f}% ({m_req})", fg=U["dim"], bg=U["surface0"], + font=("Segoe UI", 7)).pack(side="left", padx=(8, 0)) last_err = data.get("last_error") if last_err: - err_box = Gtk.Box(spacing=4) - err_box.set_margin_top(4) - inner.pack_start(err_box, False, False, 0) - icon = Gtk.Label() - icon.set_markup(f'\u26A0') - err_box.pack_start(icon, False, False, 0) - err_lbl = Gtk.Label() - err_lbl.set_markup(f'{last_err}') - err_lbl.set_xalign(0) - err_lbl.set_line_wrap(True) - err_box.pack_start(err_lbl, False, False, 0) - - return card - - def _build_models_section(self, parent, models, total_req): - sep_m = Gtk.Separator() - _apply_css(sep_m, f"separator {{ background-color: {_U["lavender"]}; margin-top: 4px; margin-bottom: 2px; }}") - parent.pack_start(sep_m, False, False, 0) - - header = Gtk.Box(spacing=4) - header.set_margin_top(2) - parent.pack_start(header, False, False, 0) - icon = Gtk.Label() - icon.set_markup(f'\U0001F916') - header.pack_start(icon, False, False, 0) - lbl = Gtk.Label() - lbl.set_markup(f'Models') - header.pack_start(lbl, False, False, 0) - - sorted_models = sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True) - - if total_req > 0: - comp_bar = Gtk.Box(spacing=0) - _apply_css(comp_bar, f"box {{ background-color: {_U["surface1"]}; border-radius: 4px; min-height: 8px; margin-top: 2px; }}") - parent.pack_start(comp_bar, False, False, 0) - for i, (mname, mdata) in enumerate(sorted_models): - m_req = mdata.get("requests", 0) - pct = m_req / total_req - if pct < 0.01: - continue - seg = Gtk.Box() - color = _U["model_palette"][i % len(_U["model_palette"])] - _apply_css(seg, f"box {{ background-color: {color}; min-height: 8px; }}") - seg.set_size_request(max(int(pct * 400), 4), 8) - comp_bar.pack_start(seg, False, False, 0) - - models_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) - models_box.set_margin_top(2) - parent.pack_start(models_box, False, False, 0) - - for i, (mname, mdata) in enumerate(sorted_models[:6]): - row = Gtk.Box(spacing=6) - models_box.pack_start(row, False, False, 0) - color = _U["model_palette"][i % len(_U["model_palette"])] - dot = Gtk.Label() - dot.set_markup(f'\u25CF') - row.pack_start(dot, False, False, 0) - m_lbl = Gtk.Label() - m_lbl.set_markup(f'{mname}') - m_lbl.set_xalign(0) - m_lbl.set_size_request(120, -1) - row.pack_start(m_lbl, False, False, 0) - - m_req = mdata.get("requests", 0) - pct = m_req / total_req * 100 if total_req > 0 else 0 - - m_bar = Gtk.ProgressBar() - m_bar.set_fraction(m_req / total_req if total_req > 0 else 0) - _apply_css(m_bar, f""" - progress {{ background-color: {color}; border-radius: 3px; }} - trough {{ background-color: {_U["surface1"]}; border-radius: 3px; min-height: 6px; }} - """) - m_bar.set_size_request(80, -1) - row.pack_start(m_bar, False, False, 0) - - pct_lbl = Gtk.Label() - pct_lbl.set_markup(f'{pct:.0f}% ({m_req})') - row.pack_start(pct_lbl, False, False, 0) - - m_in = mdata.get("tokens_in", 0) - m_out = mdata.get("tokens_out", 0) - if m_in or m_out: - tok_lbl = Gtk.Label() - tok_lbl.set_markup(f'in:{_fmt_tok(m_in)} out:{_fmt_tok(m_out)}') - row.pack_end(tok_lbl, False, False, 0) + err_frame = tk.Frame(card, bg=U["surface0"]) + err_frame.pack(fill="x", pady=(4, 0)) + tk.Label(err_frame, text=f"⚠ {last_err}", fg=U["red"], bg=U["surface0"], + font=("Segoe UI", 7)).pack(anchor="w") -def main(): - for d in [LOG_DIR, PROXY_CONFIG_DIR]: - d.mkdir(parents=True, exist_ok=True) - - # Create default endpoints if none exist - if not ENDPOINTS_FILE.exists(): - save_endpoints({ - "default": "OpenAI", - "endpoints": [ - {"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1", - "api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"], - "provider_preset": "OpenAI"}, - {"name": "Z.AI", "backend_type": "openai-compat", - "base_url": "https://api.z.ai/api/coding/paas/v4", - "api_key": "", "default_model": "glm-5.1", - "models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"], - "provider_preset": "Custom"}, - ], - }) - - w = LauncherWin() - w.connect("destroy", Gtk.main_quit) - Gtk.main() - -class RequestHistoryWindow(Gtk.Window): - _SNAP_DIR = Path.home() / ".cache/codex-proxy/requests" +# ═══════════════════════════════════════════════════════════════════════ +# Request History Window +# ═══════════════════════════════════════════════════════════════════════ +class RequestHistoryWindow: def __init__(self, parent): - Gtk.Window.__init__(self, title="Request History") - self.set_transient_for(parent) - self.set_default_size(720, 500) - self.set_position(Gtk.WindowPosition.CENTER) + self._snap_dir = PROXY_CONFIG_DIR / "requests" + self._dlg = tk.Toplevel(parent) + self._dlg.title("Request History") + self._dlg.geometry("720x500") + self._dlg.transient(parent) - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - vbox.set_margin_start(10) - vbox.set_margin_end(10) - vbox.set_margin_top(10) - vbox.set_margin_bottom(10) - self.add(vbox) + main = ttk.Frame(self._dlg, padding=10) + main.pack(fill="both", expand=True) - hdr = Gtk.Box(spacing=8) - vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Request History") - lbl.set_use_markup(True) - hdr.pack_start(lbl, False, False, 0) - refresh_btn = Gtk.Button(label="Refresh") - refresh_btn.connect("clicked", lambda b: self._load()) - hdr.pack_end(refresh_btn, False, False, 0) - clear_btn = Gtk.Button(label="Clear All") - clear_btn.connect("clicked", lambda b: self._clear_all()) - hdr.pack_end(clear_btn, False, False, 0) + hdr = ttk.Frame(main) + hdr.pack(fill="x") + ttk.Label(hdr, text="Request History", font=("Segoe UI", 11, "bold")).pack(side="left") + ttk.Button(hdr, text="Clear All", command=self._clear_all).pack(side="right") + ttk.Button(hdr, text="Refresh", command=self._load).pack(side="right", padx=(0, 4)) - paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) - vbox.pack_start(paned, True, True, 0) + paned = ttk.PanedWindow(main, orient="vertical") + paned.pack(fill="both", expand=True, pady=(6, 0)) - top_sw = Gtk.ScrolledWindow() - top_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - paned.pack1(top_sw, resize=True, shrink=False) + top_frame = ttk.Frame(paned) + cols = ("time", "model", "status", "duration", "id", "error") + self._tree = ttk.Treeview(top_frame, columns=cols, show="headings", height=10) + for col, heading, w in [("time", "Time", 140), ("model", "Model", 140), ("status", "Status", 80), + ("duration", "Duration", 70), ("id", "ID", 180), ("error", "Error", 120)]: + self._tree.heading(col, text=heading) + self._tree.column(col, width=w, minwidth=50) + tree_sb = ttk.Scrollbar(top_frame, orient="vertical", command=self._tree.yview) + self._tree.configure(yscrollcommand=tree_sb.set) + self._tree.pack(side="left", fill="both", expand=True) + tree_sb.pack(side="right", fill="y") + paned.add(top_frame, weight=1) - self._store = Gtk.ListStore(str, str, str, str, str, str) - self._tree = Gtk.TreeView(model=self._store) - for i, (title, w) in enumerate([("Time", 140), ("Model", 140), ("Status", 80), ("Duration", 70), ("ID", 180), ("Error", 120)]): - col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i) - col.set_resizable(True) - col.set_min_width(w) - self._tree.append_column(col) - self._tree.connect("row-activated", self._on_row_activated) - top_sw.add(self._tree) + bottom_frame = ttk.Frame(paned) + self._detail = tk.Text(bottom_frame, height=10, wrap="word", font=("Consolas", 9)) + detail_sb = ttk.Scrollbar(bottom_frame, orient="vertical", command=self._detail.yview) + self._detail.configure(yscrollcommand=detail_sb.set) + self._detail.pack(side="left", fill="both", expand=True) + detail_sb.pack(side="right", fill="y") + paned.add(bottom_frame, weight=1) - self._detail = Gtk.TextView() - self._detail.set_editable(False) - self._detail.set_monospace(True) - self._detail.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - bottom_sw = Gtk.ScrolledWindow() - bottom_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - bottom_sw.add(self._detail) - paned.pack2(bottom_sw, resize=True, shrink=False) + self._tree.bind("<>", self._on_select) self._snapshots = [] self._load() - self.show_all() def _load(self): - self._store.clear() + for item in self._tree.get_children(): + self._tree.delete(item) self._snapshots = [] - snap_dir = self._SNAP_DIR - if not snap_dir.exists(): + if not self._snap_dir.exists(): return - files = sorted(snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + files = sorted(self._snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) for f in files[:200]: try: data = json.loads(f.read_text()) @@ -5303,172 +1726,146 @@ class RequestHistoryWindow(Gtk.Window): dur = f"{meta['duration_s']:.1f}s" if meta.get("duration_s") is not None else "-" rid = meta.get("request_id", "")[:28] err = (meta.get("error") or "")[:60] - self._store.append([ts, model, status, dur, rid, err]) + self._tree.insert("", "end", values=(ts, model, status, dur, rid, err)) except Exception: pass - def _on_row_activated(self, tree, path, column): - idx = path[0] + def _on_select(self, event): + sel = self._tree.selection() + if not sel: + return + idx = self._tree.index(sel[0]) if idx < len(self._snapshots): data = self._snapshots[idx] - buf = self._detail.get_buffer() - buf.set_text(json.dumps(data, indent=2, ensure_ascii=False)[:50000]) + self._detail.delete("1.0", "end") + self._detail.insert("end", json.dumps(data, indent=2, ensure_ascii=False)[:50000]) def _clear_all(self): - d = Gtk.MessageDialog(self, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO, - "Delete all request snapshots?") - r = d.run() - d.destroy() - if r != Gtk.ResponseType.YES: + if not messagebox.askyesno("Clear All", "Delete all request snapshots?", parent=self._dlg): return - snap_dir = self._SNAP_DIR - if snap_dir.exists(): - for f in snap_dir.glob("*.json"): + if self._snap_dir.exists(): + for f in self._snap_dir.glob("*.json"): try: f.unlink() except Exception: pass - self._store.clear() + for item in self._tree.get_children(): + self._tree.delete(item) self._snapshots = [] - self._detail.get_buffer().set_text("") + self._detail.delete("1.0", "end") -class BenchmarkWindow(Gtk.Window): + +# ═══════════════════════════════════════════════════════════════════════ +# Benchmark Window +# ═══════════════════════════════════════════════════════════════════════ + +class BenchmarkWindow: _BENCH_PROMPT = "In exactly 3 bullet points, explain why the sky is blue." _BENCH_TOOLS = [{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}}}] def __init__(self, parent): - Gtk.Window.__init__(self, title="Model Benchmark") - self.set_transient_for(parent) - self.set_default_size(820, 560) - self.set_position(Gtk.WindowPosition.CENTER) + self._dlg = tk.Toplevel(parent) + self._dlg.title("Model Benchmark") + self._dlg.geometry("820x560") + self._dlg.transient(parent) self._running = False self._ep_data = load_endpoints() - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - vbox.set_margin_start(10) - vbox.set_margin_end(10) - vbox.set_margin_top(10) - vbox.set_margin_bottom(10) - self.add(vbox) + main = ttk.Frame(self._dlg, padding=10) + main.pack(fill="both", expand=True) - hdr = Gtk.Box(spacing=8) - vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Multi-Provider Benchmark") - lbl.set_use_markup(True) - hdr.pack_start(lbl, False, False, 0) - self._run_btn = Gtk.Button(label="Run Benchmark") - self._run_btn.connect("clicked", lambda b: self._run()) - hdr.pack_end(self._run_btn, False, False, 0) + hdr = ttk.Frame(main) + hdr.pack(fill="x") + ttk.Label(hdr, text="Multi-Provider Benchmark", font=("Segoe UI", 11, "bold")).pack(side="left") + self._run_btn = ttk.Button(hdr, text="Run Benchmark", command=self._run) + self._run_btn.pack(side="right") - lanes_box = Gtk.Box(spacing=6) - vbox.pack_start(lanes_box, False, False, 0) + lanes_frame = ttk.Frame(main) + lanes_frame.pack(fill="x", pady=(8, 0)) self._lanes = [] - for i in range(3): - frame = Gtk.Frame(label=f"{'A' if i == 0 else 'B' if i == 1 else 'C'}" if i < 2 else None) + self._c_var = tk.BooleanVar(value=False) + for i, lane_label in enumerate(["A", "B", "C"]): if i == 2: - self._c_frame = frame - self._c_check = Gtk.CheckButton(label="Enable Lane C") - self._c_check.set_active(False) - frame.set_label_widget(self._c_check) - frame.set_sensitive(False) - self._c_check.connect("toggled", lambda b: frame.set_sensitive(b.get_active())) - inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - inner.set_margin_start(6) - inner.set_margin_end(6) - inner.set_margin_top(4) - inner.set_margin_bottom(4) - frame.add(inner) - lanes_box.pack_start(frame, True, True, 0) + lf = ttk.LabelFrame(lanes_frame, text="Lane C (optional)") + cb = ttk.Checkbutton(lanes_frame, text="Enable Lane C", variable=self._c_var, + command=lambda: lf.configure() if not self._c_var.get() else None) + else: + lf = ttk.LabelFrame(lanes_frame, text=f"Lane {lane_label}") + lf.pack(side="left", fill="both", expand=True, padx=(0, 4 if i < 2 else 0)) - row_ep = Gtk.Box(spacing=4) - inner.pack_start(row_ep, False, False, 0) - row_ep.pack_start(Gtk.Label(label="Endpoint:"), False, False, 0) - ep_combo = Gtk.ComboBoxText() - for ep in self._ep_data.get("endpoints", []): - ep_combo.append(ep["name"], ep["name"]) - row_ep.pack_start(ep_combo, True, True, 0) + ep_frame = ttk.Frame(lf, padding=4) + ep_frame.pack(fill="x") + ttk.Label(ep_frame, text="Endpoint:").pack(side="left") + ep_combo = ttk.Combobox(ep_frame, values=[e["name"] for e in self._ep_data.get("endpoints", [])], state="readonly") + ep_combo.pack(side="left", fill="x", expand=True, padx=(4, 0)) - row_m = Gtk.Box(spacing=4) - inner.pack_start(row_m, False, False, 0) - row_m.pack_start(Gtk.Label(label="Model:"), False, False, 0) - m_combo = Gtk.ComboBoxText() - m_combo.set_entry_text_column(0) - row_m.pack_start(m_combo, True, True, 0) - - ep_combo.connect("changed", lambda b, mc=m_combo: self._update_lane_models(b, mc)) + m_frame = ttk.Frame(lf, padding=4) + m_frame.pack(fill="x") + ttk.Label(m_frame, text="Model:").pack(side="left") + m_combo = ttk.Combobox(m_frame, state="readonly") + m_combo.pack(side="left", fill="x", expand=True, padx=(4, 0)) + ep_combo.bind("<>", lambda e, mc=m_combo: self._update_lane_models(ep_combo, mc)) self._lanes.append({"ep": ep_combo, "model": m_combo}) default_name = self._ep_data.get("default") - if default_name: - self._lanes[0]["ep"].set_active_id(default_name) eps = self._ep_data.get("endpoints", []) + if default_name: + self._lanes[0]["ep"].set(default_name) if len(eps) > 1: - self._lanes[1]["ep"].set_active_id(eps[1]["name"]) + self._lanes[1]["ep"].set(eps[1]["name"]) elif eps: - self._lanes[1]["ep"].set_active_id(eps[0]["name"]) + self._lanes[1]["ep"].set(eps[0]["name"]) if len(eps) > 2: - self._lanes[2]["ep"].set_active_id(eps[2]["name"]) + self._lanes[2]["ep"].set(eps[2]["name"]) elif len(eps) > 1: - self._lanes[2]["ep"].set_active_id(eps[1]["name"]) + self._lanes[2]["ep"].set(eps[1]["name"]) - tests_box = Gtk.Box(spacing=6) - vbox.pack_start(tests_box, False, False, 0) - self._test_ttft = Gtk.CheckButton(label="Time to First Token") - self._test_ttft.set_active(True) - tests_box.pack_start(self._test_ttft, False, False, 0) - self._test_total = Gtk.CheckButton(label="Total Latency") - self._test_total.set_active(True) - tests_box.pack_start(self._test_total, False, False, 0) - self._test_tools = Gtk.CheckButton(label="Tool Call") - self._test_tools.set_active(True) - tests_box.pack_start(self._test_tools, False, False, 0) - self._test_tps = Gtk.CheckButton(label="Tokens/sec") - self._test_tps.set_active(True) - tests_box.pack_start(self._test_tps, False, False, 0) + tests_frame = ttk.Frame(main) + tests_frame.pack(fill="x", pady=(8, 0)) + self._test_ttft = tk.BooleanVar(value=True) + self._test_total = tk.BooleanVar(value=True) + self._test_tools = tk.BooleanVar(value=True) + self._test_tps = tk.BooleanVar(value=True) + ttk.Checkbutton(tests_frame, text="Time to First Token", variable=self._test_ttft).pack(side="left") + ttk.Checkbutton(tests_frame, text="Total Latency", variable=self._test_total).pack(side="left", padx=(8, 0)) + ttk.Checkbutton(tests_frame, text="Tool Call", variable=self._test_tools).pack(side="left", padx=(8, 0)) + ttk.Checkbutton(tests_frame, text="Tokens/sec", variable=self._test_tps).pack(side="left", padx=(8, 0)) - results_sw = Gtk.ScrolledWindow() - results_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - vbox.pack_start(results_sw, True, True, 0) + results_frame = ttk.Frame(main) + results_frame.pack(fill="both", expand=True, pady=(8, 0)) + cols = ("test", "a", "b", "c", "winner") + self._results_tree = ttk.Treeview(results_frame, columns=cols, show="headings", height=6) + for col, heading in [("test", "Test"), ("a", "Lane A"), ("b", "Lane B"), ("c", "Lane C"), ("winner", "Winner")]: + self._results_tree.heading(col, text=heading) + self._results_tree.column(col, width=150, minwidth=80) + rsb = ttk.Scrollbar(results_frame, orient="vertical", command=self._results_tree.yview) + self._results_tree.configure(yscrollcommand=rsb.set) + self._results_tree.pack(side="left", fill="both", expand=True) + rsb.pack(side="right", fill="y") - self._results_store = Gtk.ListStore(str, str, str, str, str) - self._results_tree = Gtk.TreeView(model=self._results_store) - for i, title in enumerate(["Test", "Lane A", "Lane B", "Lane C", "Winner"]): - col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i) - col.set_resizable(True) - self._results_tree.append_column(col) - results_sw.add(self._results_tree) - - self._status = Gtk.Label(label="Select endpoints and models per lane, then Run Benchmark.") - self._status.set_xalign(0) - vbox.pack_start(self._status, False, False, 0) - - self.show_all() + self._status_var = tk.StringVar(value="Select endpoints and models per lane, then Run Benchmark.") + ttk.Label(main, textvariable=self._status_var).pack(anchor="w", pady=(4, 0)) def _update_lane_models(self, ep_combo, model_combo): - name = ep_combo.get_active_text() + name = ep_combo.get() if not name: return ep = get_endpoint(name) models = (ep or {}).get("models", []) - active = model_combo.get_active_text() - model_combo.remove_all() - for m in models: - model_combo.append(m, m) - if active and any(m == active for m in models): - model_combo.set_active_id(active) - elif models: - model_combo.set_active(0) + model_combo["values"] = models + if models: + model_combo.set(models[0]) def _collect_lanes(self): active = [] for i, lane in enumerate(self._lanes): - if i == 2 and not self._c_check.get_active(): + if i == 2 and not self._c_var.get(): continue - ep_name = lane["ep"].get_active_text() - model = lane["model"].get_active_text() + ep_name = lane["ep"].get() + model = lane["model"].get() if not ep_name or not model: continue ep = get_endpoint(ep_name) @@ -5477,44 +1874,13 @@ class BenchmarkWindow(Gtk.Window): active.append({"ep": ep, "model": model, "label": f"{ep_name}/{model}"}) return active - def _run(self): - if self._running: - return - lanes = self._collect_lanes() - if len(lanes) < 2: - self._status.set_text("Need at least 2 lanes with endpoint + model selected.") - return - self._running = True - self._run_btn.set_sensitive(False) - self._results_store.clear() - self._status.set_text("Running benchmark…") - threading.Thread(target=self._run_bench, args=(lanes,), daemon=True).start() - def _bench_single(self, ep, model, stream, with_tools=False): url = normalize_base_url(ep.get("base_url", "")) key = (ep.get("api_key") or "").strip() bt = ep.get("backend_type", "openai-compat") if bt == "anthropic": test_url = f"{url}/v1/messages" - headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} - body = {"model": model, "max_tokens": 100, "stream": stream, - "messages": [{"role": "user", "content": self._BENCH_PROMPT}]} - if with_tools: - body["tools"] = self._BENCH_TOOLS - body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}] - data = json.dumps(body).encode() - elif bt.startswith("gemini-oauth"): - token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json" - token_path = Path.home() / f".cache/codex-proxy/{token_name}" - oauth_token = "" - if token_path.exists(): - try: - td = json.loads(token_path.read_text()) - oauth_token = td.get("access_token", "") - except Exception: - pass - test_url = f"{url}/v1/chat/completions" - headers = {"Authorization": f"Bearer {oauth_token}", "content-type": "application/json"} + headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} body = {"model": model, "max_tokens": 100, "stream": stream, "messages": [{"role": "user", "content": self._BENCH_PROMPT}]} if with_tools: @@ -5523,7 +1889,7 @@ class BenchmarkWindow(Gtk.Window): data = json.dumps(body).encode() else: test_url = f"{url}/chat/completions" - headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"} + headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"} body = {"model": model, "max_tokens": 100, "stream": stream, "messages": [{"role": "user", "content": self._BENCH_PROMPT}]} if with_tools: @@ -5564,7 +1930,7 @@ class BenchmarkWindow(Gtk.Window): "detail": f"tools={has_tools}, tok={payload.get('usage', {}).get('total_tokens', '?')}"} content = msg.get("content", "")[:50] return {"ttft": ttft or total, "total": total, - "detail": f"{content[:40]}… tok={payload.get('usage', {}).get('total_tokens', '?')}"} + "detail": f"{content[:40]}... tok={payload.get('usage', {}).get('total_tokens', '?')}"} return {"ttft": ttft or total, "total": total, "detail": result_text[:60]} except Exception as e: total = time.time() - t0 @@ -5578,29 +1944,12 @@ class BenchmarkWindow(Gtk.Window): max_tok = 512 if bt == "anthropic": test_url = f"{url}/v1/messages" - headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} - body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True, - "messages": [{"role": "user", "content": prompt}]}).encode() - elif bt.startswith("gemini-oauth"): - token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json" - token_path = Path.home() / f".cache/codex-proxy/{token_name}" - oauth_token = "" - if token_path.exists(): - try: - td = json.loads(token_path.read_text()) - oauth_token = td.get("access_token", "") - except Exception: - pass - test_url = f"{url}/v1/chat/completions" - headers = {"Authorization": f"Bearer {oauth_token}", "content-type": "application/json"} - body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True, - "messages": [{"role": "user", "content": prompt}]}).encode() + headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"} else: test_url = f"{url}/chat/completions" - headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"} - body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True, - "messages": [{"role": "user", "content": prompt}]}).encode() - + headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"} + body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True, + "messages": [{"role": "user", "content": prompt}]}).encode() req = urllib.request.Request(test_url, data=body, headers=headers, method="POST") t0 = time.time() first_token_t = None @@ -5617,30 +1966,17 @@ class BenchmarkWindow(Gtk.Window): buf += chunk total = time.time() - t0 text = buf.decode(errors="replace") - if bt == "anthropic": - for line in text.split("\n"): - if "content_block_delta" in line and "text_delta" in line: - try: - idx = line.index("{") - evt = json.loads(line[idx:]) - delta = evt.get("delta", {}) - token_count += len(delta.get("text", "")) / 4 - except Exception: - pass - if token_count == 0: - token_count = max(1, len(text) / 4) - else: - for line in text.split("\n"): - if line.startswith("data: ") and line != "data: [DONE]": - try: - d = json.loads(line[6:]) - content = d.get("choices", [{}])[0].get("delta", {}).get("content", "") - if content: - token_count += max(1, len(content) / 4) - except Exception: - pass - if token_count == 0: - token_count = max(1, len(text) / 4) + for line in text.split("\n"): + if line.startswith("data: ") and line != "data: [DONE]": + try: + d = json.loads(line[6:]) + content = d.get("choices", [{}])[0].get("delta", {}).get("content", "") + if content: + token_count += max(1, len(content) / 4) + except Exception: + pass + if token_count == 0: + token_count = max(1, len(text) / 4) gen_time = (time.time() - first_token_t) if first_token_t else total tps = token_count / gen_time if gen_time > 0 else 0 return {"tps": tps, "tokens": int(token_count), "gen_time": gen_time, "total": total, @@ -5649,22 +1985,36 @@ class BenchmarkWindow(Gtk.Window): total = time.time() - t0 return {"tps": 0, "tokens": 0, "gen_time": total, "total": total, "detail": f"Error: {str(e)[:40]}"} + def _run(self): + if self._running: + return + lanes = self._collect_lanes() + if len(lanes) < 2: + self._status_var.set("Need at least 2 lanes with endpoint + model selected.") + return + self._running = True + self._run_btn.configure(state="disabled") + for item in self._results_tree.get_children(): + self._results_tree.delete(item) + self._status_var.set("Running benchmark...") + threading.Thread(target=self._run_bench, args=(lanes,), daemon=True).start() + def _run_bench(self, lanes): results = [] tests = [] - if self._test_ttft.get_active(): + if self._test_ttft.get(): tests.append(("TTFT (stream)", True, False)) - if self._test_total.get_active(): + if self._test_total.get(): tests.append(("Total latency", False, False)) - if self._test_tools.get_active(): + if self._test_tools.get(): tests.append(("Tool call", False, True)) - run_tps = self._test_tps.get_active() + run_tps = self._test_tps.get() for test_name, stream, tools in tests: lane_results = [] for lane in lanes: label = lane["label"] - GLib.idle_add(self._status.set_text, f"{test_name}: {label}…") + self._dlg.after(0, lambda l=label: self._status_var.set(f"Running {test_name}: {l}...")) r = self._bench_single(lane["ep"], lane["model"], stream, tools) lane_results.append((label, r)) @@ -5672,7 +2022,7 @@ class BenchmarkWindow(Gtk.Window): values = [(lr[0], lr[1][metric]) for lr in lane_results] sorted_v = sorted(values, key=lambda x: x[1]) best_val = sorted_v[0][1] - second_val = sorted_v[1][1] + second_val = sorted_v[1][1] if len(sorted_v) > 1 else best_val + 1 if best_val < second_val * 0.85: winner = sorted_v[0][0] else: @@ -5683,7 +2033,7 @@ class BenchmarkWindow(Gtk.Window): v = lr[1][metric] cols.append(f"{v:.2f}s ({lr[1]['detail'][:30]})") while len(cols) < 3: - cols.append("—") + cols.append("--") cols.append(winner) results.append(tuple([test_name] + cols)) @@ -5691,7 +2041,7 @@ class BenchmarkWindow(Gtk.Window): lane_tps = [] for lane in lanes: label = lane["label"] - GLib.idle_add(self._status.set_text, f"Tokens/sec: {label}…") + self._dlg.after(0, lambda l=label: self._status_var.set(f"Tokens/sec: {l}...")) r = self._bench_tps(lane["ep"], lane["model"]) lane_tps.append((label, r)) @@ -5709,18 +2059,1255 @@ class BenchmarkWindow(Gtk.Window): tps = lt[1]["tps"] cols_tps.append(f"{tps:.1f} t/s ({lt[1]['detail'][:25]})") while len(cols_tps) < 3: - cols_tps.append("—") + cols_tps.append("--") cols_tps.append(winner_tps) results.append(tuple(["Tokens/sec"] + cols_tps)) def _show(): for row in results: - self._results_store.append(row) - self._status.set_text("Benchmark complete.") + self._results_tree.insert("", "end", values=row) + self._status_var.set("Benchmark complete.") self._running = False - self._run_btn.set_sensitive(True) + self._run_btn.configure(state="normal") - GLib.idle_add(_show) + self._dlg.after(0, _show) + + +# ═══════════════════════════════════════════════════════════════════════ +# Main Launcher Window +# ═══════════════════════════════════════════════════════════════════════ + +def _oauth_discover_project_win(access_token, token_path, tokens): + project_id = "" + try: + lr = urllib.request.Request( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + data=json.dumps({}).encode(), + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {access_token}", + "User-Agent": "google-api-nodejs-client/9.15.1"}) + lresp = urllib.request.urlopen(lr, timeout=15) + ldata = json.loads(lresp.read()) + p = ldata.get("cloudaicompanionProject", "") + if isinstance(p, dict): + project_id = p.get("id", "") + elif isinstance(p, str): + project_id = p + except Exception: + pass + if not project_id: + return "" + try: + test_url = f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={project_id}" + test_req = urllib.request.Request(test_url, + headers={"Authorization": f"Bearer {access_token}", + "User-Agent": "google-api-nodejs-client/9.15.1"}) + urllib.request.urlopen(test_req, timeout=10) + except urllib.error.HTTPError as e: + if e.code == 403 and "SERVICE_DISABLED" in (e.read().decode()[:500]): + try: + list_req = urllib.request.Request( + "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE", + headers={"Authorization": f"Bearer {access_token}"}) + list_resp = urllib.request.urlopen(list_req, timeout=15) + projects = json.loads(list_resp.read()).get("projects", []) + for proj in projects: + pid = proj.get("projectId", "") + if not pid or pid == project_id: + continue + try: + t2 = urllib.request.Request( + f"https://cloudcode-pa.googleapis.com/v1internal:listModels?project={pid}", + headers={"Authorization": f"Bearer {access_token}", + "User-Agent": "google-api-nodejs-client/9.15.1"}) + urllib.request.urlopen(t2, timeout=10) + project_id = pid + break + except Exception: + continue + except Exception: + pass + tokens["project_id"] = project_id + with open(token_path, "w") as f: + json.dump(tokens, f, indent=2) + return project_id + +class LauncherWin: + def __init__(self, root): + self._root = root + self._proc = None + self._endpoints_data = load_endpoints() + self._refresh_running = False + recover_config_if_needed() + + main = ttk.Frame(root, padding=16) + main.pack(fill="both", expand=True) + main.pack_propagate(False) + + + # Title + hdr = ttk.Frame(main) + hdr.pack(fill="x") + ttk.Label(hdr, text=f"Codex Launcher v{CHANGELOG[0][0]}", font=("Segoe UI", 13, "bold")).pack(side="left") + + # Toolbar — two rows to fit all buttons + tb1 = ttk.Frame(main) + tb1.pack(fill="x", pady=(6, 0)) + ttk.Button(tb1, text="Endpoints...", command=self._open_mgr).pack(side="left") + ttk.Button(tb1, text="AI Monitor", command=self._open_monitoring).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="AI BGP", command=self._open_bgp).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="Usage", command=self._open_usage).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="Benchmark", command=self._open_benchmark).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="History", command=self._open_history).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="OAuth Secrets", command=self._edit_oauth_secrets).pack(side="left", padx=(6, 0)) + ttk.Button(tb1, text="Changelog", command=self._show_changelog).pack(side="right") + + # Detection status — one row per item so long paths don't truncate + self._cli_info = detect_codex_cli() + self._desktop_info = detect_codex_desktop() + + cli_row = ttk.Frame(main) + cli_row.pack(fill="x", pady=(4, 0)) + if self._cli_info: + cli_path, cli_ver = self._cli_info + ttk.Label(cli_row, text=f"✓ Codex CLI {cli_ver}", foreground="#2ea043").pack(side="left") + ttk.Label(cli_row, text=f" ({cli_path})", foreground="gray").pack(side="left") + else: + ttk.Label(cli_row, text="✗ Codex CLI -- not found", foreground="#d29922").pack(side="left") + ttk.Button(cli_row, text="Install", command=lambda: self._show_install_guide("cli")).pack(side="left", padx=(6, 0)) + + desk_row = ttk.Frame(main) + desk_row.pack(fill="x", pady=(2, 0)) + if self._desktop_info: + ttk.Label(desk_row, text="✓ Codex Desktop", foreground="#2ea043").pack(side="left") + ttk.Label(desk_row, text=f" ({self._desktop_info})", foreground="gray").pack(side="left") + else: + ttk.Label(desk_row, text="✗ Codex Desktop -- not found", foreground="#d29922").pack(side="left") + ttk.Button(desk_row, text="Install", command=lambda: self._show_install_guide("desktop")).pack(side="left", padx=(6, 0)) + + self._missing = [] + if not self._cli_info: + self._missing.append("cli") + if not self._desktop_info: + self._missing.append("desktop") + + # Auth status + auth_frame = ttk.Frame(main) + auth_frame.pack(fill="x", pady=(6, 0)) + self._auth_label = ttk.Label(auth_frame, text="Checking auth...") + self._auth_label.pack(side="left") + self._relogin_btn = ttk.Button(auth_frame, text="Re-login", command=self._codex_relogin, state="disabled") + self._relogin_btn.pack(side="right") + threading.Thread(target=self._check_auth_async, daemon=True).start() + + # Ops bar + ops_frame = ttk.Frame(main) + ops_frame.pack(fill="x", pady=(6, 0)) + self._refresh_all_btn = ttk.Button(ops_frame, text="Refresh Models", command=self._refresh_all_models) + self._refresh_all_btn.pack(side="left") + ttk.Button(ops_frame, text="Backup Profile", command=self._backup_profile).pack(side="left", padx=(8, 0)) + ttk.Button(ops_frame, text="Import Profile", command=self._import_profile).pack(side="left", padx=(8, 0)) + + # Endpoint + Model selectors + sel_frame = ttk.Frame(main) + sel_frame.pack(fill="x", pady=(6, 0)) + ttk.Label(sel_frame, text="Endpoint:").pack(side="left") + self._combo_ep = ttk.Combobox(sel_frame, state="readonly", width=24) + self._combo_ep.pack(side="left", padx=(4, 0)) + self._combo_ep.bind("<>", lambda e: self._on_endpoint_changed()) + ttk.Label(sel_frame, text="Model:").pack(side="left", padx=(12, 0)) + self._combo_model = ttk.Combobox(sel_frame, state="readonly", width=24) + self._combo_model.pack(side="left", padx=(4, 0)) + + # Launch buttons + btn_frame1 = ttk.Frame(main) + btn_frame1.pack(fill="x", pady=(8, 0)) + self._btn_desktop = ttk.Button(btn_frame1, text="Launch Desktop", command=lambda: self._launch("desktop")) + if "desktop" in self._missing: + self._btn_desktop.configure(state="disabled") + self._btn_desktop.pack(side="left", fill="x", expand=True, padx=(0, 4)) + self._btn_cli = ttk.Button(btn_frame1, text="Launch CLI", command=lambda: self._launch("cli")) + if "cli" in self._missing: + self._btn_cli.configure(state="disabled") + self._btn_cli.pack(side="left", fill="x", expand=True) + + btn_frame2 = ttk.Frame(main) + btn_frame2.pack(fill="x", pady=(4, 0)) + self._btn_codex_desktop = ttk.Button(btn_frame2, text="Codex Default (Desktop)", + command=lambda: self._launch_codex_default("desktop")) + if "desktop" in self._missing: + self._btn_codex_desktop.configure(state="disabled") + self._btn_codex_desktop.pack(side="left", fill="x", expand=True, padx=(0, 4)) + self._btn_codex_cli = ttk.Button(btn_frame2, text="Codex Default (CLI)", + command=lambda: self._launch_codex_default("cli")) + if "cli" in self._missing: + self._btn_codex_cli.configure(state="disabled") + self._btn_codex_cli.pack(side="left", fill="x", expand=True) + + # Log area + self._log_text = scrolledtext.ScrolledText(main, height=10, state="disabled", wrap="word", + font=("Consolas", 9)) + self._log_text.pack(fill="both", expand=True, pady=(8, 0)) + + # Bottom bar + bb = ttk.Frame(main) + bb.pack(fill="x", pady=(6, 0)) + ttk.Button(bb, text="Clear Log", command=self._clear_log).pack(side="left") + self._restart_btn = ttk.Button(bb, text="Restart Proxy", command=self._restart_proxy, state="disabled") + self._restart_btn.pack(side="left", padx=(4, 0)) + ttk.Button(bb, text="AI Assistant", command=self._open_assistant).pack(side="left", padx=(4, 0)) + self._kill_btn = ttk.Button(bb, text="Kill && Cleanup", command=self._kill, state="disabled") + self._kill_btn.pack(side="left", fill="x", expand=True, padx=(8, 0)) + ttk.Button(bb, text="View Log", command=self._open_proxy_log_dir).pack(side="left") + ttk.Button(bb, text="Close", command=self._do_close).pack(side="left", padx=(8, 0)) + + self._rebuild_combo() + self._log_dependency_status() + self._start_watcher() + + # ── Logging ────────────────────────────────────────────────────── + + def log(self, msg): + self._root.after(0, self._append_log, msg) + + def _append_log(self, msg): + self._log_text.configure(state="normal") + self._log_text.insert("end", msg + "\n") + self._log_text.see("end") + self._log_text.configure(state="disabled") + + def _clear_log(self): + self._log_text.configure(state="normal") + self._log_text.delete("1.0", "end") + self._log_text.configure(state="disabled") + + def _restart_proxy(self): + self._kill() + ep_name = load_endpoints().get("default") + if not ep_name: + self.log("No default endpoint set.") + return + for ep in load_endpoints().get("endpoints", []): + if ep.get("name") == ep_name: + time.sleep(0.3) + start_proxy_for(ep, self.log) + self.log(f"Proxy restarted for {ep_name}") + return + self.log(f"Endpoint '{ep_name}' not found.") + + def _log_dependency_status(self): + if self._cli_info: + _, ver = self._cli_info + self.log(f"✓ Codex CLI detected ({ver})") + else: + self.log("✗ Codex CLI NOT found -- CLI launch disabled.") + if self._desktop_info: + self.log(f"✓ Codex Desktop detected ({self._desktop_info})") + else: + self.log("✗ Codex Desktop NOT found -- Desktop launch disabled.") + if self._missing: + self.log("Install missing tools before using the launcher.") + else: + self.log("All dependencies OK.") + + # ── Auth ───────────────────────────────────────────────────────── + + def _check_auth_async(self): + status, msg = check_codex_auth() + self._root.after(0, lambda: self._update_auth_status(status, msg)) + + def _update_auth_status(self, status, msg): + if status == "logged_in": + self._auth_label.configure(text=f"✓ Auth: {msg}", foreground="#2ea043") + self._relogin_btn.configure(state="normal" if "cli" not in self._missing else "disabled") + elif status == "not_installed": + self._auth_label.configure(text="Auth: N/A (CLI not installed)", foreground="#888") + else: + self._auth_label.configure(text=f"⚠ Auth: {msg}", foreground="#d29922") + self._relogin_btn.configure(state="normal" if "cli" not in self._missing else "disabled") + + def _codex_relogin(self): + self.log("Opening codex login in terminal...") + term = detect_terminal() + if not term: + self.log("ERROR: no terminal emulator found for re-login") + return + term_name, term_args, term_path = term + cmd_parts = [term_name] + term_args + ["codex", "login"] + if IS_WINDOWS: + subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + subprocess.Popen(cmd_parts, preexec_fn=os.setsid) + self.log("Login flow started in terminal. Re-checking auth in 30s...") + self._auth_label.configure(text="Auth: waiting for login...") + threading.Thread(target=lambda: (time.sleep(30), self._check_auth_async()), daemon=True).start() + + # ── Combo management ───────────────────────────────────────────── + + def _rebuild_combo(self): + self._endpoints_data = load_endpoints() + ep_names = [e["name"] for e in self._endpoints_data["endpoints"]] + bgp_names = [f"\U0001F500 {p['name']}" for p in load_bgp_pools().get("pools", [])] + all_names = ep_names + bgp_names + self._combo_ep["values"] = all_names + if all_names: + default = self._endpoints_data.get("default") + if default and default in ep_names: + self._combo_ep.set(default) + else: + self._combo_ep.set(all_names[0]) + self._on_endpoint_changed() + + def _on_endpoint_changed(self): + name = self._combo_ep.get() + is_bgp = name.startswith("\U0001F500 ") + bgp_name = name[2:] if is_bgp else None + ep = get_endpoint(name) if name and not is_bgp else None + models = [] + if is_bgp: + for p in load_bgp_pools().get("pools", []): + if p["name"] == bgp_name: + seen = set() + for r in p.get("routes", []): + m = r.get("model", "") + if m and m not in seen: + models.append(m) + seen.add(m) + break + elif ep: + models = ep.get("models", []) + self._combo_model["values"] = models + if ep and ep.get("default_model") in models: + self._combo_model.set(ep["default_model"]) + elif models: + self._combo_model.set(models[0]) + else: + self._combo_model.set("") + + # ── Window openers ─────────────────────────────────────────────── + + def _on_endpoints_updated(self): + self._rebuild_combo() + + def _open_mgr(self): + EndpointMgr(self._root, on_update=self._on_endpoints_updated) + + def _open_bgp(self): + BGPPoolMgr(self._root, on_update=self._on_endpoints_updated) + + def _open_monitoring(self): + AIMonitoringWindow(self._root) + + def _open_usage(self): + UsageWindow(self._root) + + def _open_history(self): + RequestHistoryWindow(self._root) + + def _open_benchmark(self): + BenchmarkWindow(self._root) + + def _open_proxy_log_dir(self): + log_dir = str(PROXY_CONFIG_DIR) + req_log = PROXY_CONFIG_DIR / "requests.log" + if IS_WINDOWS: + if req_log.exists(): + os.startfile(str(req_log)) + else: + os.startfile(log_dir) + else: + import subprocess as _sp + _sp.Popen(["xdg-open", log_dir]) + + def _open_assistant(self): + assist_path = str(Path(__file__).resolve().parent / "flet-codex-assist.py") + if Path(assist_path).exists(): + subprocess.Popen([sys.executable, assist_path], creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if IS_WINDOWS else 0) + + def _google_reoauth(self, provider, parent_dlg=None): + import http.server + is_antigravity = provider == "google-antigravity" + sec_key = "antigravity" if is_antigravity else "gemini_cli" + secrets_data = load_oauth_secrets() + sec = secrets_data.get(sec_key, {}) + CLIENT_ID = sec.get("client_id", "") + CLIENT_SECRET = sec.get("client_secret", "") + if not CLIENT_ID or not CLIENT_SECRET: + messagebox.showerror("Missing OAuth secrets", + f"No client_id/client_secret for {sec_key}.\nSet them in OAuth Secrets first.") + return + token_file = "google-antigravity-oauth-token.json" if is_antigravity else "google-cli-oauth-token.json" + token_path = str(PROXY_CONFIG_DIR / token_file) + provider_kind = "antigravity" if is_antigravity else "cli" + + if is_antigravity: + SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + ] + port = 51121 + redirect_uri = f"http://localhost:{port}/oauth-callback" + else: + SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ] + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + redirect_uri = f"http://127.0.0.1:{port}/oauth2callback" + + state = secrets.token_hex(32) + verifier = secrets.token_urlsafe(64) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + + scope_str = " ".join(SCOPES) + auth_url = ( + f"https://accounts.google.com/o/oauth2/v2/auth?" + f"client_id={CLIENT_ID}" + f"&redirect_uri={urllib.parse.quote(redirect_uri)}" + f"&response_type=code" + f"&scope={urllib.parse.quote(scope_str)}" + f"&access_type=offline" + f"&prompt=select_account%20consent" + f"&state={state}" + f"&code_challenge={challenge}" + f"&code_challenge_method=S256" + ) + + oauth_dlg = tk.Toplevel(parent_dlg or self._root) + oauth_dlg.title(f"Re-OAuth: {'Antigravity' if is_antigravity else 'Gemini CLI'}") + oauth_dlg.geometry("520x200") + if parent_dlg: + oauth_dlg.transient(parent_dlg) + else: + oauth_dlg.transient(self._root) + oauth_dlg.grab_set() + tk.Label(oauth_dlg, text=f"Re-authenticating {'Antigravity' if is_antigravity else 'Gemini CLI'}", + font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w") + link_lbl = tk.Label(oauth_dlg, text="Click here to open Google authorization", fg="blue", cursor="hand2") + link_lbl.pack(padx=16, anchor="w") + link_lbl.bind("", lambda e: open_url(auth_url)) + status_var = tk.StringVar(value="Waiting for browser callback...") + tk.Label(oauth_dlg, textvariable=status_var).pack(padx=16, pady=(8, 0), anchor="w") + + code_holder = [None] + error_holder = [None] + + class OAuthHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self2): + qs = urllib.parse.urlparse(self2.path).query + params = urllib.parse.parse_qs(qs) + if "code" in params: + if params.get("state", [None])[0] != state: + self2.send_response(400) + self2.end_headers() + self2.wfile.write(b"CSRF state mismatch") + error_holder[0] = "CSRF state mismatch" + return + code_holder[0] = params["code"][0] + self2.send_response(302) + self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini") + self2.end_headers() + else: + error_holder[0] = params.get("error", ["unknown"])[0] + self2.send_response(302) + self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini") + self2.end_headers() + def log_message(self2, fmt, *args): + pass + + try: + bind_host = "localhost" if is_antigravity else "127.0.0.1" + server = http.server.HTTPServer((bind_host, port), OAuthHandler) + except OSError: + status_var.set(f"Port {port} in use — close other apps and retry.") + return + + def _wait(): + deadline = time.time() + 120 + while code_holder[0] is None and error_holder[0] is None and time.time() < deadline: + server.handle_request() + server.server_close() + if code_holder[0]: + try: + tok_data = urllib.parse.urlencode({ + "code": code_holder[0], "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, + "redirect_uri": redirect_uri, "grant_type": "authorization_code", + "code_verifier": verifier, + }).encode() + req = urllib.request.Request("https://oauth2.googleapis.com/token", data=tok_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + resp = urllib.request.urlopen(req, timeout=30) + tokens = json.loads(resp.read()) + tokens["client_id"] = CLIENT_ID + tokens["client_secret"] = CLIENT_SECRET + tokens["provider_kind"] = provider_kind + tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600) + os.makedirs(os.path.dirname(token_path), exist_ok=True) + with open(token_path, "w") as f: + json.dump(tokens, f, indent=2) + project_id = _oauth_discover_project_win(tokens["access_token"], token_path, tokens) + self._root.after(0, lambda: status_var.set(f"OK! Project: {project_id or 'none'}")) + self._root.after(2000, oauth_dlg.destroy) + except Exception as e: + self._root.after(0, lambda: status_var.set(f"Failed: {str(e)[:200]}")) + else: + self._root.after(0, lambda: status_var.set(f"Failed: {error_holder[0] or 'No code received'}")) + + open_url(auth_url) + threading.Thread(target=_wait, daemon=True).start() + oauth_dlg.wait_window() + + def _codebuff_reoauth_standalone(self, parent_dlg=None): + import uuid + oauth_dlg = tk.Toplevel(parent_dlg or self._root) + oauth_dlg.title("Freebuff / Codebuff Login") + oauth_dlg.geometry("520x240") + if parent_dlg: + oauth_dlg.transient(parent_dlg) + else: + oauth_dlg.transient(self._root) + oauth_dlg.grab_set() + tk.Label(oauth_dlg, text="Sign in with GitHub via Codebuff", font=("Segoe UI", 11, "bold")).pack(padx=16, pady=(12, 0), anchor="w") + status_var = tk.StringVar(value="Requesting login URL...") + tk.Label(oauth_dlg, textvariable=status_var).pack(padx=16, pady=(8, 0), anchor="w") + link_lbl = tk.Label(oauth_dlg, text="", fg="blue", cursor="hand2") + link_lbl.pack(padx=16, anchor="w") + result = {"success": False, "user": None, "error": None} + + def _thread(): + try: + fp_id = str(uuid.uuid4()) + body = json.dumps({"fingerprintId": fp_id}).encode() + req = urllib.request.Request("https://www.codebuff.com/api/auth/cli/code", + data=body, headers={"Content-Type": "application/json", "User-Agent": UA}) + resp = urllib.request.urlopen(req, timeout=30) + rdata = json.loads(resp.read()) + login_url = rdata.get("loginUrl", "") or rdata.get("login_url", "") + fp_hash = rdata.get("fingerprintHash", "") or rdata.get("fingerprint_hash", "") + expires_at = rdata.get("expiresAt", 0) or rdata.get("expires_at", 0) + if not login_url: + result["error"] = "No login URL" + self._root.after(0, _done) + return + def _set(): + status_var.set("Open this URL in your browser to log in:") + link_lbl.configure(text=login_url) + link_lbl.bind("", lambda e: open_url(login_url)) + self._root.after(0, _set) + open_url(login_url) + poll = f"https://www.codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fp_id)}&fingerprintHash={urllib.parse.quote(fp_hash)}&expiresAt={expires_at}" + deadline = time.time() + 300 + while time.time() < deadline: + time.sleep(2) + try: + pr = urllib.request.Request(poll, headers={"User-Agent": UA}) + pd = json.loads(urllib.request.urlopen(pr, timeout=10).read()) + if pd.get("user", {}).get("authToken"): + result["success"] = True + result["user"] = pd["user"] + self._root.after(0, _done) + return + except Exception: + pass + result["error"] = "Timed out" + except Exception as e: + result["error"] = str(e)[:200] + self._root.after(0, _done) + + def _done(): + if result["success"] and result["user"]: + u = result["user"] + cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json") + os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True) + creds = {"default": {"id": u.get("id", ""), "name": u.get("name", ""), + "email": u.get("email", ""), "authToken": u.get("authToken", ""), + "fingerprintId": u.get("fingerprintId", ""), "fingerprintHash": u.get("fingerprintHash", "")}} + with open(cb_creds_path, "w") as f: + json.dump(creds, f, indent=2) + status_var.set(f"Logged in as {u.get('email', 'OK')}") + link_lbl.configure(text="") + self._root.after(2000, oauth_dlg.destroy) + else: + status_var.set(f"Failed: {result.get('error', 'unknown')}") + + threading.Thread(target=_thread, daemon=True).start() + oauth_dlg.wait_window() + + def _edit_oauth_secrets(self): + import tkinter.simpledialog + data = load_oauth_secrets() + if not data: + data = {"antigravity": {"client_id": "", "client_secret": ""}, + "gemini_cli": {"client_id": "", "client_secret": ""}} + + dlg = tk.Toplevel(self._root) + dlg.title("OAuth Secrets & Credentials") + dlg.geometry("620x650") + dlg.transient(self._root) + dlg.grab_set() + + canvas = tk.Canvas(dlg) + scrollbar = ttk.Scrollbar(dlg, orient="vertical", command=canvas.yview) + frame = ttk.Frame(canvas, padding=16) + frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + ttk.Label(frame, text="Google OAuth 2.0 Client Credentials", font=("Segoe UI", 10, "bold")).pack(anchor="w") + ttk.Label(frame, text=str(OAUTH_SECRETS_PATH), foreground="gray").pack(anchor="w", pady=(0, 8)) + + fields = {} + nf = ttk.Frame(frame) + nf.pack(fill="x") + row = 0 + google_token_dir = str(PROXY_CONFIG_DIR) + for section_key, section_label, oauth_prov, token_file in [ + ("antigravity", "Antigravity (CloudCode)", "google-antigravity", "google-antigravity-oauth-token.json"), + ("gemini_cli", "Gemini CLI", "google-cli", "google-cli-oauth-token.json"), + ]: + ttk.Label(nf, text=f"\n{section_label}", font=("Segoe UI", 9, "bold")).grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2)) + row += 1 + sec = data.get(section_key, {}) + token_path = os.path.join(google_token_dir, token_file) + has_token = False + try: + with open(token_path) as tf: + td = json.load(tf) + has_token = bool(td.get("refresh_token") or td.get("access_token")) + except Exception: + pass + token_status = "Token: valid" if has_token else "Token: missing" + token_color = "#2ea043" if has_token else "#d29922" + ttk.Label(nf, text=token_status, foreground=token_color).grid(row=row, column=0, sticky="w", padx=(8, 4), pady=2) + import_btn = ttk.Button(nf, text="Import JSON", + command=lambda sk=section_key: self._import_oauth_json(fields, sk)) + import_btn.grid(row=row, column=2, padx=(4, 0), pady=2, sticky="e") + reauth_btn = ttk.Button(nf, text="Re-OAuth", + command=lambda p=oauth_prov: self._google_reoauth(p, dlg)) + reauth_btn.grid(row=row, column=3, padx=(4, 0), pady=2, sticky="e") + row += 1 + for fk, fl in [("client_id", "Client ID"), ("client_secret", "Client Secret")]: + ttk.Label(nf, text=fl + ":").grid(row=row, column=0, sticky="w", padx=(8, 4), pady=2) + entry = ttk.Entry(nf, width=55) + entry.insert(0, sec.get(fk, "")) + entry.grid(row=row, column=1, columnspan=3, sticky="ew", pady=2) + if fk == "client_secret": + entry.configure(show="*") + fields[(section_key, fk)] = entry + row += 1 + + nf.columnconfigure(1, weight=1) + + ttk.Label(frame, text="Import client_secret_*.json from Google Cloud Console → Credentials", foreground="gray").pack(anchor="w") + + ttk.Separator(frame).pack(fill="x", pady=(12, 8)) + + ttk.Label(frame, text="Freebuff / Codebuff Credentials", font=("Segoe UI", 10, "bold")).pack(anchor="w") + ttk.Label(frame, text=str(HOME / ".config" / "manicode" / "credentials.json"), foreground="gray").pack(anchor="w", pady=(0, 8)) + + cb_creds_path = str(HOME / ".config" / "manicode" / "credentials.json") + cb_fields = {} + try: + with open(cb_creds_path) as f: + cb_data = json.load(f) + except Exception: + cb_data = {} + cb_default = cb_data.get("default", {}) + + cb_info = f"Email: {cb_default.get('email', 'not logged in')}" + cb_name = cb_default.get("name", "") + if cb_name: + cb_info = f"{cb_name} — {cb_info}" + has_cb_token = bool(cb_default.get("authToken", "")) + status_text = "Logged in" if has_cb_token else "Not logged in" + status_color = "#2ea043" if has_cb_token else "#d29922" + ttk.Label(frame, text=cb_info).pack(anchor="w") + ttk.Label(frame, text=f"Status: {status_text}", foreground=status_color, font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(0, 4)) + + cb_nf = ttk.Frame(frame) + cb_nf.pack(fill="x") + cb_row = [0] + for fk, fl in [("authToken", "Auth Token"), ("fingerprintId", "Fingerprint ID")]: + ttk.Label(cb_nf, text=fl + ":").grid(row=cb_row[0], column=0, sticky="w", padx=(8, 4), pady=2) + entry = ttk.Entry(cb_nf, width=55, show="*") + entry.insert(0, cb_default.get(fk, "")) + entry.grid(row=cb_row[0], column=1, sticky="ew", pady=2) + cb_fields[fk] = entry + cb_row[0] += 1 + cb_nf.columnconfigure(1, weight=1) + + ttk.Button(frame, text="Re-OAuth (GitHub Login)", + command=lambda: self._codebuff_reoauth_standalone(dlg)).pack(anchor="w", pady=(4, 0)) + + cb_accounts = cb_data.get("accounts", []) + if cb_accounts: + ttk.Label(frame, text=f"Additional accounts: {len(cb_accounts)} (edit credentials.json manually)", foreground="gray").pack(anchor="w") + + btnf = ttk.Frame(frame) + btnf.pack(fill="x", pady=(12, 0)) + ttk.Button(btnf, text="Cancel", command=dlg.destroy).pack(side="right", padx=(4, 0)) + save_btn = ttk.Button(btnf, text="Save") + save_btn.pack(side="right", padx=(4, 0)) + + def _save(): + for (sk, fk), entry in fields.items(): + if sk not in data: + data[sk] = {} + data[sk][fk] = entry.get().strip() + try: + save_oauth_secrets(data) + except Exception as e: + messagebox.showerror("Save failed", str(e), parent=dlg) + return + cb_updated = dict(cb_default) + for fk, entry in cb_fields.items(): + val = entry.get().strip() + if val: + cb_updated[fk] = val + if cb_updated: + cb_data["default"] = cb_updated + try: + os.makedirs(os.path.dirname(cb_creds_path), exist_ok=True) + with open(cb_creds_path, "w") as f: + json.dump(cb_data, f, indent=2) + except Exception as e: + messagebox.showerror("Save failed", str(e), parent=dlg) + return + dlg.destroy() + + save_btn.configure(command=_save) + + def _import_oauth_json(self, fields, section_key): + path = filedialog.askopenfilename( + title="Import Google OAuth Client Secret JSON", + filetypes=[("JSON files", "*.json")]) + if not path: + return + try: + with open(path, encoding="utf-8") as f: + raw = json.load(f) + creds = raw.get("installed") or raw.get("web") or raw + cid = creds.get("client_id", "") + csec = creds.get("client_secret", "") + if not cid or not csec: + raise ValueError("JSON does not contain client_id and client_secret") + if (section_key, "client_id") in fields: + fields[(section_key, "client_id")].delete(0, "end") + fields[(section_key, "client_id")].insert(0, cid) + if (section_key, "client_secret") in fields: + fields[(section_key, "client_secret")].delete(0, "end") + fields[(section_key, "client_secret")].insert(0, csec) + except Exception as e: + messagebox.showerror("Import failed", str(e)) + + # ── Watcher ────────────────────────────────────────────────────── + + def _start_watcher(self): + cfg = load_monitoring_config() + if not cfg.get("enabled"): + return + self._watcher = HealthWatcher( + on_failure=lambda c: self.log(f"[AI Monitor] Proxy unresponsive (failures={c})"), + on_recovery=lambda: self.log("[AI Monitor] Proxy recovered"), + on_signal=lambda fid, cat, line: None, + on_action=self._on_watcher_action, + ) + self._watcher.start() + self.log("AI Monitoring: watchdog started") + + def _on_watcher_action(self, action, trigger): + cfg = load_monitoring_config() + if action == "restart_proxy" and cfg.get("auto_restart_proxy"): + self.log(f"[AI Monitor] Auto-restarting proxy (trigger: {trigger})") + self._root.after(0, self._restart_proxy_from_watcher) + elif action in ("clear_schema_cache", "delete_provider_caps"): + try: + cap_file = PROXY_CONFIG_DIR / "provider-caps.json" + if cap_file.exists(): + cap_file.unlink() + self.log("[AI Monitor] Cleared corrupt schema cache") + except Exception as e: + self.log(f"[AI Monitor] Failed to clear cache: {e}") + elif action == "kill_stale_restart": + self.log(f"[AI Monitor] Killing stale processes + restarting (trigger: {trigger})") + self._kill() + self._root.after(0, self._restart_proxy_from_watcher) + else: + self.log(f"[AI Monitor] Alert: {action} (trigger: {trigger})") + + def _restart_proxy_from_watcher(self): + try: + ep_name = load_endpoints().get("default") + if not ep_name: + return + for ep in load_endpoints().get("endpoints", []): + if ep.get("name") == ep_name: + start_proxy_for(ep, self.log) + break + except Exception as e: + self.log(f"[AI Monitor] Proxy restart failed: {e}") + + # ── Profile operations ─────────────────────────────────────────── + + def _backup_profile(self): + filename = filedialog.asksaveasfilename( + title="Backup Codex Profile", + defaultextension=".json", + initialfile=f"codex-profile-{time.strftime('%Y%m%d-%H%M%S')}.json", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not filename: + return + try: + save_profile_bundle(filename) + self.log(f"Profile backed up to {filename}") + except Exception as e: + messagebox.showerror("Backup Failed", str(e)) + + def _refresh_all_models(self): + if self._refresh_running: + return + self._refresh_running = True + self._refresh_all_btn.configure(state="disabled") + self.log("Refreshing models for all providers...") + threading.Thread(target=self._refresh_all_models_worker, daemon=True).start() + + def _refresh_all_models_worker(self): + try: + data = load_endpoints() + updated = 0 + failed = [] + for idx, ep in enumerate(list(data["endpoints"])): + refreshed, err = refresh_endpoint_models(ep) + if refreshed: + data["endpoints"][idx] = refreshed + updated += 1 + else: + failed.append(f"{ep['name']}: {err}") + if updated: + save_endpoints(data) + self._root.after(0, lambda: self._finish_refresh(updated, failed)) + except Exception as e: + self._root.after(0, lambda: self._finish_refresh_error(str(e))) + + def _finish_refresh(self, updated, failed): + if updated: + self._rebuild_combo() + self.log(f"Refreshed models for {updated} provider(s)") + if failed: + messagebox.showwarning("Refresh", "Some providers could not auto-fetch models.\n\n" + + "\n".join(failed)) + elif updated: + messagebox.showinfo("Refresh", f"Refreshed models for {updated} provider(s).") + else: + messagebox.showinfo("Refresh", "No providers were refreshed.") + self._refresh_running = False + self._refresh_all_btn.configure(state="normal") + + def _finish_refresh_error(self, err): + messagebox.showerror("Refresh Failed", err) + self._refresh_running = False + self._refresh_all_btn.configure(state="normal") + + def _import_profile(self): + if self._proc and self._proc.poll() is None: + messagebox.showwarning("Import", "Stop Codex before importing a profile.") + return + filename = filedialog.askopenfilename( + title="Import Codex Profile", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + if not filename: + return + if not messagebox.askyesno("Import", + "Importing will replace the current endpoints and Codex config. Continue?"): + return + try: + import_profile_bundle(filename) + self._rebuild_combo() + self.log(f"Profile imported from {filename}") + messagebox.showinfo("Import", "Profile imported successfully.") + except Exception as e: + messagebox.showerror("Import Failed", str(e)) + + # ── Dialogs ────────────────────────────────────────────────────── + + def _show_changelog(self): + dlg = tk.Toplevel(self._root) + dlg.title("Changelog") + dlg.geometry("540x480") + dlg.transient(self._root) + text = scrolledtext.ScrolledText(dlg, wrap="word", font=("Segoe UI", 9)) + text.pack(fill="both", expand=True, padx=12, pady=12) + for ver, date, items in CHANGELOG: + text.insert("end", f"v{ver} ({date})\n") + for item in items: + text.insert("end", f" • {item}\n") + text.insert("end", "\n") + text.configure(state="disabled") + ttk.Button(dlg, text="Close", command=dlg.destroy).pack(pady=(0, 10)) + + def _show_install_guide(self, which): + if which == "cli": + guide = ("Codex CLI is required to use CLI launch features.\n\n" + "Install with npm:\n npm install -g @openai/codex\n\n" + "Or download from:\n https://github.com/openai/codex\n\n" + "After installing, restart the launcher.") + else: + guide = ("Codex Desktop is required to use Desktop launch features.\n\n" + "Download from:\n https://codex.desktop.openai.com\n\n" + "After installing, restart the launcher.") + messagebox.showinfo(f"Install Codex {which.title()}", guide) + + # ── Launch ─────────────────────────────────────────────────────── + + def _set_busy(self, busy): + has_cli = "cli" not in self._missing + has_desk = "desktop" not in self._missing + def _update(): + self._btn_desktop.configure(state="disabled" if busy or not has_desk else "normal") + self._btn_cli.configure(state="disabled" if busy or not has_cli else "normal") + self._btn_codex_desktop.configure(state="disabled" if busy or not has_desk else "normal") + self._btn_codex_cli.configure(state="disabled" if busy or not has_cli else "normal") + self._kill_btn.configure(state="normal" if busy else "disabled") + self._restart_btn.configure(state="normal" if busy else "disabled") + self._root.after(0, _update) + + def _launch(self, target): + name = self._combo_ep.get() + if not name: + self.log("ERROR: no endpoint selected") + return + model = self._combo_model.get() + if not model: + self.log("ERROR: no model selected") + return + + is_bgp = name.startswith("\U0001F500 ") + if is_bgp: + pool_name = name[2:] + pool = None + for p in load_bgp_pools().get("pools", []): + if p["name"] == pool_name: + pool = p + break + if not pool: + self.log(f"ERROR: BGP pool '{pool_name}' not found") + return + self._set_busy(True) + target_name = "Desktop" if target == "desktop" else "CLI" + self.log(f"=== BGP: {pool_name} / {model} -> {target_name} ===") + threading.Thread(target=self._run_bgp, args=(pool, model, target), daemon=True).start() + return + + ep = get_endpoint(name) + if not ep: + self.log("ERROR: endpoint not found") + return + self._set_busy(True) + target_name = "Desktop" if target == "desktop" else "CLI" + self.log(f"=== {ep['name']} / {model} -> {target_name} ===") + threading.Thread(target=self._run, args=(ep, model, target), daemon=True).start() + + def _launch_codex_default(self, target): + if "cli" not in self._missing: + status, msg = check_codex_auth() + if status != "logged_in": + if not messagebox.askyesno("Auth Warning", + f"Codex auth check: {msg}\n\n" + "Launch may fail without valid authentication.\nContinue anyway?"): + self._set_busy(False) + return + self._set_busy(True) + target_name = "Desktop" if target == "desktop" else "CLI" + self.log(f"=== Codex Default (OAuth) -> {target_name} ===") + threading.Thread(target=self._run_codex_default, args=(target,), daemon=True).start() + + def _run(self, ep, model, target): + keep_session_alive = False + try: + self.log("Cleaning up stale processes...") + safe_cleanup_owned(self.log) + recover_config_if_needed(self.log) + + needs_proxy = ep["backend_type"] != "native" + if needs_proxy: + self.log("Starting translation proxy...") + try: + proxy_port = start_proxy_for(ep, self.log) + except RuntimeError as e: + self._root.after(0, lambda: messagebox.showerror("Proxy Failed", str(e))) + return + self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})...") + begin_config_transaction(f"launch:{ep['name']}") + write_config_for_translated(ep, model, proxy_port) + else: + self.log(f"Configuring Codex for {ep['name']} (native)...") + begin_config_transaction(f"launch:{ep['name']}") + write_config_for_native(ep, model) + + if target == "desktop": + if needs_proxy: + kill_existing_desktop(self.log) + keep_session_alive = self._launch_desktop(ep, model) + else: + self._launch_cli(ep, model) + except Exception as e: + self.log(f"ERROR: {e}") + finally: + if keep_session_alive: + self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.") + self._set_busy(False) + self.log("Ready. Use Kill && Cleanup when finished.") + else: + stop_proxy() + restore_config() + end_config_transaction() + self._set_busy(False) + self.log("Ready.") + + def _run_bgp(self, pool, model, target): + keep_session_alive = False + try: + self.log("Cleaning up stale processes...") + safe_cleanup_owned(self.log) + recover_config_if_needed(self.log) + + self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes...") + port, bgp_ep = start_bgp_proxy(pool, model, self.log) + + begin_config_transaction(f"launch:bgp:{pool['name']}") + write_config_for_translated(bgp_ep, model, port) + + if target == "desktop": + kill_existing_desktop(self.log) + keep_session_alive = self._launch_desktop(bgp_ep, model) + else: + self._launch_cli(bgp_ep, model) + except Exception as e: + self.log(f"ERROR: {e}") + finally: + if keep_session_alive: + self.log("Warm-start handoff detected; keeping proxy/config active.") + self._set_busy(False) + self.log("Ready. Use Kill && Cleanup when finished.") + else: + stop_proxy() + restore_config() + end_config_transaction() + self._set_busy(False) + self.log("Ready.") + + def _run_codex_default(self, target): + try: + self.log("Cleaning up stale processes...") + safe_cleanup_owned(self.log) + stop_proxy() + recover_config_if_needed(self.log) + self.log("Resetting config to Codex defaults (OAuth)...") + begin_config_transaction("launch:default") + if CONFIG.exists(): + CONFIG.unlink() + if target == "desktop": + self._launch_desktop_direct() + else: + self._launch_cli_default() + except Exception as e: + self.log(f"ERROR: {e}") + finally: + restore_config() + end_config_transaction() + self._set_busy(False) + self.log("Ready.") + + def _launch_desktop(self, ep, model): + desktop_path = self._desktop_info + if not desktop_path: + self.log("ERROR: Codex Desktop not found") + return False + + if IS_WINDOWS: + self._proc = subprocess.Popen( + [desktop_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + self._proc = subprocess.Popen( + [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=os.setsid) + + pid = self._proc.pid + self.log(f"Desktop started (PID {pid})") + self.log(f"Log: {LAUNCH_LOG}") + + t0 = time.time() + stall_warned = False + while self._proc and self._proc.poll() is None: + time.sleep(1.5) + el = time.time() - t0 + if el > 20 and not stall_warned: + self.log("Still starting after 20s -- possible stall. Click Kill if window doesn't appear.") + self.log(f"--- last log lines ---\n{last_log_lines()}") + stall_warned = True + + if self._proc: + rc = self._proc.poll() + el = time.time() - t0 + self.log(f"Desktop exited (code {rc}) after {el:.0f}s") + if el < 12: + self.log("TIP: Quick exit -- may be warm-start handoff (normal) or crash.") + last_lines = last_log_lines() + self.log(f"--- last log lines ---\n{last_lines}") + if rc == 0 and "warm-start" in last_lines.lower(): + self._proc = None + return True + self._proc = None + return False + + def _launch_cli(self, ep, model): + self.log(f"Launching Codex CLI with {ep['name']}...") + term = detect_terminal() + if not term: + self.log("ERROR: no terminal found") + return + + term_name, term_args, _ = term + cmd_parts = [term_name] + term_args + if ep["backend_type"] == "native": + cmd_parts.extend(["codex", "-c", f"model={model}"]) + else: + cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"]) + + self.log(f"Running: {' '.join(cmd_parts)}") + if IS_WINDOWS: + self._proc = subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) + pid = self._proc.pid + self.log(f"CLI started in terminal (PID {pid})") + + while self._proc and self._proc.poll() is None: + time.sleep(1.5) + if self._proc: + rc = self._proc.poll() + self.log(f"CLI exited (code {rc})") + self._proc = None + + def _launch_desktop_direct(self): + self.log("Launching Codex Desktop (default OAuth)...") + desktop_path = self._desktop_info + if not desktop_path: + self.log("ERROR: Codex Desktop not found") + return + if IS_WINDOWS: + self._proc = subprocess.Popen( + [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + self._proc = subprocess.Popen( + [desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=os.setsid) + pid = self._proc.pid + self.log(f"Desktop started (PID {pid})") + + t0 = time.time() + stall_warned = False + while self._proc and self._proc.poll() is None: + time.sleep(1.5) + el = time.time() - t0 + if el > 20 and not stall_warned: + self.log("Still starting after 20s -- possible stall.") + self.log(f"--- last log lines ---\n{last_log_lines()}") + stall_warned = True + if self._proc: + rc = self._proc.poll() + el = time.time() - t0 + self.log(f"Desktop exited (code {rc}) after {el:.0f}s") + self._proc = None + + def _launch_cli_default(self): + self.log("Launching Codex CLI (default OAuth)...") + term = detect_terminal() + if not term: + self.log("ERROR: no terminal found") + return + term_name, term_args, _ = term + cmd_parts = [term_name] + term_args + ["codex"] + self.log(f"Running: {' '.join(cmd_parts)}") + if IS_WINDOWS: + self._proc = subprocess.Popen(cmd_parts, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + else: + self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) + pid = self._proc.pid + self.log(f"CLI started in terminal (PID {pid})") + while self._proc and self._proc.poll() is None: + time.sleep(1.5) + if self._proc: + rc = self._proc.poll() + self.log(f"CLI exited (code {rc})") + self._proc = None + + # ── Kill ───────────────────────────────────────────────────────── + + def _kill(self): + self.log("=== Killing ===") + if self._proc and self._proc.poll() is None: + try: + if IS_WINDOWS: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(self._proc.pid)], + capture_output=True, timeout=10) + else: + import signal as sig + pgid = os.getpgid(self._proc.pid) + os.killpg(pgid, sig.SIGTERM) + time.sleep(1) + if self._proc.poll() is None: + os.killpg(pgid, sig.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + self._proc = None + stop_proxy() + safe_cleanup_owned(self.log) + restore_config() + end_config_transaction() + LOG_DIR.mkdir(parents=True, exist_ok=True) + if LAUNCH_LOG.exists(): + try: + LAUNCH_LOG.unlink() + except Exception: + pass + self.log("Cleanup complete") + self._set_busy(False) + self.log("Ready.") + + def _do_close(self): + if self._proc and self._proc.poll() is None: + if not messagebox.askyesno("Confirm", "Codex is still running. Kill it?"): + return + self._kill() + stop_proxy() + self._root.destroy() + + +# ═══════════════════════════════════════════════════════════════════════ +# Entry point +# ═══════════════════════════════════════════════════════════════════════ if __name__ == "__main__": - main() + ensure_dirs() + create_default_endpoints() + + root = tk.Tk() + root.title("Codex Launcher") + root.geometry("800x680") + root.minsize(640, 520) + app = LauncherWin(root) + root.mainloop()