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

CSRF state mismatch.

") + self2.wfile.write(b"" + b"

CSRF state mismatch.

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