CC Adapter (17 fixes): - Multi-format tool-call parser chain: DSML → bash → explore → XML → raw JSON → fallback - Three-tier argument parser (direct/unescape/unicode_escape) - Recursive double/triple-wrap unwrapping (_unwrap_cmd) - Post-extraction sanitizer validation - DSML tag support (current CC model format) - Self-revive watchdog (50 restarts, progressive backoff) - Debug-to-file logging (cc-debug.log) - Inline self-test (19 tests via --self-test) - ErrorAnalyzer with 4xx learning on retry - Schema cache with 24h TTL Launcher: - AI Assist integration - Updated usage dashboard - Reasoning controls per-provider - Updated cleanup patterns .deb: v3.5.0 (70KB) — v3.3.0 kept as fallback
4237 lines
179 KiB
Python
Executable File
4237 lines
179 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Codex Launcher GUI — manage endpoints, launch Desktop or CLI with any provider."""
|
|
|
|
import gi
|
|
gi.require_version("Gtk", "3.0")
|
|
from gi.repository import Gtk, GLib
|
|
import subprocess, os, signal, sys, threading, time, json, urllib.request, urllib.parse, urllib.error, tempfile, shutil
|
|
import hashlib, socket, ssl, contextlib, re
|
|
import base64, secrets
|
|
from pathlib import Path
|
|
|
|
HOME = Path.home()
|
|
START_SH = Path("/opt/codex-desktop/start.sh")
|
|
CONFIG = HOME / ".codex/config.toml"
|
|
CONFIG_BAK = HOME / ".codex/config.toml.launcher-bak"
|
|
CLEANUP = HOME / ".local/bin/cleanup-codex-stale.sh"
|
|
PROXY = HOME / ".local/bin/translate-proxy.py"
|
|
ENDPOINTS_FILE = HOME / ".codex/endpoints.json"
|
|
BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json"
|
|
LOG_DIR = HOME / ".cache/codex-desktop"
|
|
LAUNCH_LOG = LOG_DIR / "launcher.log"
|
|
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
|
|
DEFAULT_CONFIG = """model = ""
|
|
model_provider = ""
|
|
model_catalog_json = ""
|
|
"""
|
|
|
|
CHANGELOG = [
|
|
("2.6.1", "2026-05-20", [
|
|
"Google OAuth rebuilt to emulate Gemini CLI — no client_secret.json needed",
|
|
"Uses Google's public OAuth client_id (same as gemini-cli)",
|
|
"PKCE + CSRF state protection for secure auth",
|
|
"Just click OAuth Login → browser opens → authorize → done",
|
|
"Includes cloud-platform scope for Gemini Code Assist compatibility",
|
|
]),
|
|
("2.6.0", "2026-05-20", [
|
|
"Usage Dashboard — per-provider request/token/latency tracking",
|
|
"Visual cards with success rate bars, model breakdown, error tracking",
|
|
"Google OAuth: browse for client_secret.json instead of fixed path",
|
|
]),
|
|
("2.5.1", "2026-05-20", [
|
|
"Adaptive retry for 429/502/503 errors with exponential backoff",
|
|
"BGP routes also retry transient errors before failing over",
|
|
"Proxy socket reuse — no more 'Address already in use' crashes",
|
|
"BGP route count shown at proxy startup",
|
|
]),
|
|
("2.5.0", "2026-05-20", [
|
|
"AI BGP — multi-provider routing with automatic failover",
|
|
"Create BGP pools with ordered routes from any configured endpoint",
|
|
"Each route uses its own endpoint URL, API key, and model",
|
|
"Failover strategy: tries primary, falls back on error/timeout",
|
|
"BGP pools appear in endpoint dropdown with shuffle icon",
|
|
"Up/down reordering for route priority in pool editor",
|
|
"Fixed TOML config breakage from multi-line paste in fields",
|
|
]),
|
|
("2.4.0", "2026-05-20", [
|
|
"Added OpenAdapter provider preset (api.openadapter.in)",
|
|
"One API key access to 40+ models — GLM, DeepSeek, Kimi, Qwen, Claude, GPT, Gemini",
|
|
"Fixed Add/Edit dialog crash (missing _on_reasoning_toggled method)",
|
|
"Redesigned Google OAuth flow with live status dialog",
|
|
]),
|
|
("2.3.2", "2026-05-20", [
|
|
"Added Google Gemini provider with OAuth support",
|
|
"Two presets: 'Google Gemini (API Key)' and 'Google Gemini (OAuth)'",
|
|
"OAuth Login button in endpoint editor — full Google OAuth2 flow with auto-refresh",
|
|
"Auto-refreshes OAuth access tokens when expired (no manual re-login needed)",
|
|
"Supports gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, and more",
|
|
"Uses Gemini's OpenAI-compatible endpoint — works with existing proxy",
|
|
]),
|
|
("2.3.0", "2026-05-20", [
|
|
"Adaptive Crof self-healing system — auto-adjusts to Crof model limits",
|
|
"Tracks per-model success/failure history, learns item count limits dynamically",
|
|
"Proactively compacts input when above learned limit before sending to Crof",
|
|
"Auto-retries on finish_reason=length — aggressively compacts and resends",
|
|
"Prevents 'stream disconnected' and 'incomplete' errors on long conversations",
|
|
]),
|
|
("2.2.1", "2026-05-20", [
|
|
"Fixed compaction orphaning function_call_output items — root cause of Crof incomplete responses",
|
|
"Compaction now respects function_call/function_call_output pairs — no more dangling tool results",
|
|
"Fixed reasoning control: reasoning_effort=none now always sends enable_thinking=false too",
|
|
]),
|
|
("2.2.0", "2026-05-20", [
|
|
"Added per-provider Reasoning On/Off toggle in endpoint editor",
|
|
"Added Reasoning Effort level per provider: None, Minimal, Low, Medium, High, Max",
|
|
"When reasoning is OFF: sends enable_thinking=false + reasoning_effort=none to upstream API",
|
|
"When reasoning is ON: sends user-selected effort level (default: Medium)",
|
|
"Fixes Crof mimo-v2.5-pro and similar reasoning models exhausting output tokens",
|
|
"Strip reasoning_content from proxy output — Codex doesn't use it",
|
|
"Force max_tokens=64000 minimum for openai-compat providers",
|
|
]),
|
|
("2.1.3", "2026-05-19", [
|
|
"Fixed Crof mimo-v2.5-pro stopping: reasoning_content exhausted all output tokens",
|
|
"Strip reasoning_content from proxy output — Codex doesn't use it, avoids token waste",
|
|
"Force max_tokens=64000 minimum for openai-compat providers — gives models room for both reasoning and content",
|
|
]),
|
|
("2.1.2", "2026-05-19", [
|
|
"Fixed Crof.ai and providers stopping after first tool call (root cause: None tool IDs)",
|
|
"Codex sends function_call items with id=None — proxy now matches tool results to calls by position",
|
|
"Fixed orphan message output item when response has only tool calls (no text)",
|
|
"Auto-trims long conversations (>30 items) to prevent context overflow on providers like Crof",
|
|
"Added request/response logging to ~/.cache/codex-proxy/requests.log",
|
|
]),
|
|
("2.1.1", "2026-05-19", [
|
|
"Fixed proxy: map 'developer' role to 'system' for Chat Completions providers",
|
|
"Fixed proxy: map 'developer' role to 'user' for Anthropic providers",
|
|
"Forward 'instructions' field from Responses API as system message/param",
|
|
"Fixes DeepSeek and other providers rejecting unknown 'developer' role",
|
|
]),
|
|
("2.1.0", "2026-05-19", [
|
|
"Added Codex auth status detection (codex login status)",
|
|
"Added Re-login button to re-authenticate via codex login",
|
|
"Auto-checks auth before launching Codex Default mode",
|
|
"Warns if OAuth token expired or missing before launch",
|
|
]),
|
|
("2.0.1", "2026-05-19", [
|
|
"Added Codex CLI/Desktop installation verifier to main page",
|
|
"Disables Desktop/CLI launch buttons when corresponding tool is missing",
|
|
"Shows install instructions in status area on startup",
|
|
]),
|
|
("2.0.0", "2026-05-19", [
|
|
"Initial release: multi-provider Codex Launcher",
|
|
"Translation proxy: Responses API to Chat Completions + Anthropic Messages",
|
|
"GTK endpoint manager with 10+ provider presets",
|
|
"Codex Default mode (built-in OAuth, zero config)",
|
|
"Browser UA injection for Cloudflare-protected providers (OpenCode)",
|
|
"Streaming SSE, tool calls, reasoning content support",
|
|
"Profile backup/import, model auto-fetch, bulk import",
|
|
"Refresh Models in background thread",
|
|
"URL normalization to prevent double-path bugs",
|
|
"Config backup/restore around sessions",
|
|
".deb installer package",
|
|
]),
|
|
]
|
|
|
|
PROVIDER_PRESETS = {
|
|
"Custom": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "",
|
|
"models": [],
|
|
},
|
|
"OpenAI": {
|
|
"backend_type": "native",
|
|
"base_url": "https://api.openai.com/v1",
|
|
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
},
|
|
"Anthropic": {
|
|
"backend_type": "anthropic",
|
|
"base_url": "https://api.anthropic.com/v1",
|
|
"models": ["claude-sonnet-4-5", "claude-3-5-haiku-latest"],
|
|
},
|
|
"OpenCode Zen (OpenAI-compatible)": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://opencode.ai/zen/v1",
|
|
"models": [
|
|
"glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
|
|
"minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free",
|
|
"deepseek-v4-flash-free", "nemotron-3-super-free",
|
|
"qwen3.6-plus", "qwen3.5-plus", "big-pickle",
|
|
],
|
|
},
|
|
"OpenCode Zen (Anthropic)": {
|
|
"backend_type": "anthropic",
|
|
"base_url": "https://opencode.ai/zen/v1",
|
|
"models": [
|
|
"claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5",
|
|
"claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5",
|
|
"claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku",
|
|
],
|
|
},
|
|
"OpenCode Go (OpenAI-compatible)": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://opencode.ai/zen/go/v1",
|
|
"models": [
|
|
"glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
|
|
"mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m2.5",
|
|
"qwen3.6-plus", "qwen3.5-plus", "deepseek-v4-pro", "deepseek-v4-flash",
|
|
],
|
|
},
|
|
"OpenCode Go (Anthropic)": {
|
|
"backend_type": "anthropic",
|
|
"base_url": "https://opencode.ai/zen/go/v1",
|
|
"models": ["minimax-m2.7", "minimax-m2.5"],
|
|
},
|
|
"Crof.ai": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://crof.ai/v1",
|
|
"models": [],
|
|
},
|
|
"NVIDIA NIM": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://integrate.api.nvidia.com/v1",
|
|
"models": [],
|
|
},
|
|
"Kilo.ai Gateway": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://api.kilo.ai/api/gateway",
|
|
"models": [],
|
|
},
|
|
"Command Code": {
|
|
"backend_type": "command-code",
|
|
"base_url": "https://api.commandcode.ai",
|
|
"cc_version": "0.26.8",
|
|
"models": [
|
|
"deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro",
|
|
"anthropic:claude-sonnet-4-6", "anthropic:claude-haiku-4-5-20251001",
|
|
"anthropic:claude-opus-4-7", "anthropic:claude-opus-4-6",
|
|
"openai:gpt-5.5", "openai:gpt-5.4", "openai:gpt-5.4-mini", "openai:gpt-5.3-codex",
|
|
"moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5",
|
|
"zai-org/GLM-5.1", "zai-org/GLM-5",
|
|
"MiniMaxAI/MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.5",
|
|
"Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus",
|
|
"stepfun/Step-3.5-Flash", "google/gemini-3.1-flash-lite",
|
|
],
|
|
},
|
|
"OpenRouter": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://openrouter.ai/api/v1",
|
|
"models": [],
|
|
},
|
|
"Google Gemini (API Key)": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
"models": [
|
|
"gemini-2.5-flash", "gemini-2.5-pro",
|
|
"gemini-2.0-flash", "gemini-2.0-flash-lite",
|
|
"gemini-2.5-flash-preview-native-audio-dialog",
|
|
],
|
|
},
|
|
"Google Gemini (OAuth)": {
|
|
"backend_type": "gemini-oauth-cli",
|
|
"base_url": "https://cloudcode-pa.googleapis.com",
|
|
"oauth_provider": "google-cli",
|
|
"models": [
|
|
"gemini-2.5-flash", "gemini-2.5-pro",
|
|
],
|
|
},
|
|
"Google Antigravity (OAuth)": {
|
|
"backend_type": "gemini-oauth-antigravity",
|
|
"base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com",
|
|
"oauth_provider": "google-antigravity",
|
|
"models": [
|
|
"antigravity-gemini-3-flash",
|
|
"antigravity-gemini-3-pro",
|
|
"antigravity-gemini-3.1-pro",
|
|
"antigravity-claude-sonnet-4-6",
|
|
"antigravity-claude-opus-4-6-thinking",
|
|
"gemini-2.5-flash", "gemini-2.5-pro",
|
|
"gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
|
|
],
|
|
},
|
|
"OpenAdapter": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://api.openadapter.in/v1",
|
|
"models": [
|
|
"0G-DeepSeek-V3",
|
|
"0G-DeepSeek-v4-Pro",
|
|
"0G-GLM-5",
|
|
"0G-GLM-5.1",
|
|
"0G-Qwen3.6",
|
|
"0G-Qwen-VL",
|
|
],
|
|
},
|
|
"Z.ai Coding": {
|
|
"backend_type": "openai-compat",
|
|
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
|
"models": [
|
|
"glm-5.1", "glm-4.7", "GLM-4-Plus", "GLM-4-Long",
|
|
"GLM-4-Flash", "GLM-4-FlashX", "GLM-Z1-Flash",
|
|
],
|
|
},
|
|
}
|
|
|
|
def safe_name(name):
|
|
base = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name).strip("._-") or "endpoint"
|
|
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
|
|
return f"{base}-{digest}"
|
|
|
|
def label_for_backend(backend_type):
|
|
return {
|
|
"openai-compat": "OpenAI-compatible",
|
|
"anthropic": "Anthropic",
|
|
"command-code": "Command Code",
|
|
"native": "Native",
|
|
}.get(backend_type, backend_type)
|
|
|
|
def normalize_model_id(text):
|
|
value = text.strip().lower()
|
|
if not value:
|
|
return ""
|
|
value = value.replace("/", "-")
|
|
value = value.replace("+", "plus")
|
|
value = "".join(ch if ch.isalnum() or ch in ".-" else "-" for ch in value)
|
|
while "--" in value:
|
|
value = value.replace("--", "-")
|
|
return value.strip("-.")
|
|
|
|
def normalize_base_url(url):
|
|
base = (url or "").strip().rstrip("/")
|
|
for suffix in ("/chat/completions", "/responses", "/messages"):
|
|
if base.endswith(suffix):
|
|
base = base[: -len(suffix)]
|
|
break
|
|
return base.rstrip("/")
|
|
|
|
def parse_model_list(text):
|
|
out = []
|
|
seen = set()
|
|
for raw in text.replace(",", "\n").splitlines():
|
|
mid = normalize_model_id(raw)
|
|
if mid and mid not in seen:
|
|
seen.add(mid)
|
|
out.append(mid)
|
|
return out
|
|
|
|
def apply_provider_preset(endpoint, preset_name):
|
|
preset = PROVIDER_PRESETS.get(preset_name)
|
|
if not preset:
|
|
return endpoint
|
|
updated = dict(endpoint)
|
|
updated["provider_preset"] = preset_name
|
|
updated["backend_type"] = preset["backend_type"]
|
|
updated["base_url"] = normalize_base_url(preset["base_url"])
|
|
if preset.get("cc_version") and not updated.get("cc_version"):
|
|
updated["cc_version"] = preset["cc_version"]
|
|
if not updated.get("models") or (preset.get("backend_type") or "").startswith("gemini-oauth"):
|
|
updated["models"] = list(preset.get("models", []))
|
|
if preset.get("oauth_provider"):
|
|
updated["oauth_provider"] = preset["oauth_provider"]
|
|
if not updated.get("default_model") and updated.get("models"):
|
|
updated["default_model"] = updated["models"][0]
|
|
return updated
|
|
|
|
def _doctor_check_streaming(base_url, key, bt, model, add):
|
|
if bt == "anthropic":
|
|
test_url = f"{base_url}/v1/messages"
|
|
headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
|
|
body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1, "stream": True,
|
|
"messages": [{"role": "user", "content": "hi"}]}).encode()
|
|
else:
|
|
test_url = f"{base_url}/chat/completions"
|
|
headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
|
|
body = json.dumps({"model": model, "max_tokens": 1, "stream": True,
|
|
"messages": [{"role": "user", "content": "hi"}]}).encode()
|
|
try:
|
|
req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
|
|
t0 = time.time()
|
|
resp = urllib.request.urlopen(req, timeout=20)
|
|
content_type = resp.headers.get("content-type", "")
|
|
first_chunk = resp.read(512)
|
|
lat = (time.time() - t0) * 1000
|
|
is_sse = "text/event-stream" in content_type or first_chunk.startswith(b"data:")
|
|
if is_sse:
|
|
add("Streaming support", True, f"SSE OK in {lat:.0f}ms")
|
|
else:
|
|
add("Streaming support", False, f"Expected SSE, got {content_type[:60]}")
|
|
except urllib.error.HTTPError as e:
|
|
body_text = ""
|
|
try:
|
|
body_text = e.read(200).decode(errors="replace")
|
|
except Exception:
|
|
pass
|
|
if e.code == 429:
|
|
add("Streaming support", None, "Rate limited (skipped)")
|
|
elif e.code in (400, 404, 422):
|
|
add("Streaming support", False, f"HTTP {e.code}: {body_text[:80]}")
|
|
else:
|
|
add("Streaming support", False, f"HTTP {e.code}")
|
|
except Exception as e:
|
|
add("Streaming support", False, str(e)[:100])
|
|
|
|
def _doctor_check_toolcall(base_url, key, bt, model, add):
|
|
tool = {"type": "function", "function": {"name": "test_tool", "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}}}
|
|
if bt == "anthropic":
|
|
test_url = f"{base_url}/v1/messages"
|
|
headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
|
|
body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 50, "stream": False,
|
|
"tools": [tool], "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode()
|
|
else:
|
|
test_url = f"{base_url}/chat/completions"
|
|
headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
|
|
body = json.dumps({"model": model, "max_tokens": 50, "stream": False, "tools": [tool],
|
|
"messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode()
|
|
try:
|
|
req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
|
|
t0 = time.time()
|
|
resp = urllib.request.urlopen(req, timeout=30)
|
|
raw = resp.read()
|
|
lat = (time.time() - t0) * 1000
|
|
payload = json.loads(raw)
|
|
has_tools = False
|
|
if bt == "anthropic":
|
|
for block in (payload.get("content") or []):
|
|
if block.get("type") == "tool_use":
|
|
has_tools = True
|
|
break
|
|
else:
|
|
choices = payload.get("choices") or []
|
|
for ch in choices:
|
|
if (ch.get("message", {}).get("tool_calls")):
|
|
has_tools = True
|
|
break
|
|
if has_tools:
|
|
add("Tool-call support", True, f"Tool call received in {lat:.0f}ms")
|
|
else:
|
|
add("Tool-call support", None, f"Responded but no tool_call ({lat:.0f}ms)")
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 429:
|
|
add("Tool-call support", None, "Rate limited (skipped)")
|
|
elif e.code in (400, 404, 422):
|
|
err_body = ""
|
|
try:
|
|
err_body = e.read(200).decode(errors="replace")
|
|
except Exception:
|
|
pass
|
|
add("Tool-call support", False, f"HTTP {e.code}: {err_body[:80]}")
|
|
else:
|
|
add("Tool-call support", False, f"HTTP {e.code}")
|
|
except Exception as e:
|
|
add("Tool-call support", False, str(e)[:100])
|
|
|
|
def run_endpoint_doctor(endpoint):
|
|
"""Comprehensive health checks for an endpoint. Returns [(name, ok, detail), ...].
|
|
ok: True=pass, False=fail, None=warn/skip."""
|
|
checks = []
|
|
def add(name, ok, detail=""):
|
|
checks.append((name, ok, detail))
|
|
|
|
url = normalize_base_url(endpoint.get("base_url") or "")
|
|
key = (endpoint.get("api_key") or "").strip()
|
|
bt = endpoint.get("backend_type", "openai-compat")
|
|
model = endpoint.get("default_model") or endpoint.get("models", [""])[0] if endpoint.get("models") else ""
|
|
|
|
# 1. URL format
|
|
parsed = urllib.parse.urlparse(url)
|
|
has_url = bool(parsed.scheme and parsed.netloc)
|
|
add("URL format", has_url, url if has_url else "Missing scheme or host")
|
|
if not has_url:
|
|
return checks
|
|
|
|
host = parsed.hostname
|
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
|
|
# 2. DNS resolution
|
|
try:
|
|
t0 = time.time()
|
|
addrs = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
dns_ms = (time.time() - t0) * 1000
|
|
add("DNS resolution", True, f"{addrs[0][4][0]} ({dns_ms:.0f}ms)")
|
|
except socket.gaierror as e:
|
|
add("DNS resolution", False, str(e))
|
|
return checks
|
|
|
|
# 3. TCP/TLS connection
|
|
try:
|
|
t0 = time.time()
|
|
sock = socket.create_connection((host, port), timeout=10)
|
|
tcp_ms = (time.time() - t0) * 1000
|
|
if parsed.scheme == "https":
|
|
ctx = ssl.create_default_context()
|
|
try:
|
|
ssock = ctx.wrap_socket(sock, server_hostname=host)
|
|
tls_ms = (time.time() - t0) * 1000
|
|
add("TLS connection", True, f"TCP {tcp_ms:.0f}ms + handshake {tls_ms:.0f}ms")
|
|
ssock.close()
|
|
except ssl.SSLError as e:
|
|
add("TLS certificate", False, str(e)[:120])
|
|
sock.close()
|
|
return checks
|
|
else:
|
|
add("TCP connection", True, f"{tcp_ms:.0f}ms")
|
|
sock.close()
|
|
except (socket.timeout, ConnectionRefusedError, OSError) as e:
|
|
add("TCP connection", False, str(e)[:100])
|
|
return checks
|
|
|
|
# 4. Auth + /models (backend-aware)
|
|
if bt == "anthropic":
|
|
add("/models endpoint", None, "Anthropic has no /models endpoint — testing via /messages")
|
|
try:
|
|
t0 = time.time()
|
|
msg_url = f"{url}/v1/messages"
|
|
body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1,
|
|
"messages": [{"role": "user", "content": "hi"}]}).encode()
|
|
req = urllib.request.Request(msg_url, data=body, headers={
|
|
"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json",
|
|
}, method="POST")
|
|
urllib.request.urlopen(req, timeout=15)
|
|
lat = (time.time() - t0) * 1000
|
|
add("Auth valid", True, f"Responded in {lat:.0f}ms")
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (401, 403):
|
|
add("Auth valid", False, f"HTTP {e.code} — check API key")
|
|
elif e.code == 400:
|
|
add("Auth valid", True, "Authenticated (model or param error)")
|
|
else:
|
|
add("Auth valid", False, f"HTTP {e.code}")
|
|
except Exception as e:
|
|
add("Auth valid", False, str(e)[:100])
|
|
elif bt.startswith("gemini-oauth"):
|
|
token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json"
|
|
token_path = Path.home() / f".cache/codex-proxy/{token_name}"
|
|
if token_path.exists():
|
|
try:
|
|
td = json.loads(token_path.read_text())
|
|
exp = td.get("expires_at", 0)
|
|
if exp > time.time():
|
|
remaining = exp - time.time()
|
|
add("OAuth token", True, f"Valid ({remaining / 60:.0f} min remaining)")
|
|
else:
|
|
add("OAuth token", False, "Token expired — re-login required")
|
|
except Exception as e:
|
|
add("OAuth token", False, str(e)[:80])
|
|
else:
|
|
add("OAuth token", False, f"No token file ({token_name})")
|
|
try:
|
|
t0 = time.time()
|
|
ids, err = fetch_models_for_endpoint(endpoint)
|
|
lat = (time.time() - t0) * 1000
|
|
if ids:
|
|
add("Network reachable", True, f"{lat:.0f}ms")
|
|
add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)")
|
|
if model:
|
|
add("Selected model exists", model in ids,
|
|
model if model in ids else f"'{model}' not in {ids[:5]}...")
|
|
elif err and ("401" in str(err) or "403" in str(err)):
|
|
add("Network reachable", True, f"{lat:.0f}ms")
|
|
add("Auth valid", False, str(err)[:100])
|
|
else:
|
|
add("Network reachable", False, str(err or "no response")[:100])
|
|
except Exception as e:
|
|
add("Network", False, str(e)[:100])
|
|
else:
|
|
try:
|
|
t0 = time.time()
|
|
ids, err = fetch_models_for_endpoint(endpoint)
|
|
lat = (time.time() - t0) * 1000
|
|
if ids:
|
|
add("Network reachable", True, f"{lat:.0f}ms")
|
|
add("Auth valid", True)
|
|
add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)")
|
|
if model:
|
|
add("Selected model exists", model in ids,
|
|
model if model in ids else f"'{model}' not found in {len(ids)} models")
|
|
else:
|
|
add("Selected model", False, "No model selected")
|
|
elif err and ("401" in str(err) or "403" in str(err)):
|
|
add("Network reachable", True, f"{lat:.0f}ms")
|
|
add("Auth valid", False, f"HTTP 401/403 — check API key")
|
|
elif err and "429" in str(err):
|
|
add("Network reachable", True, f"{lat:.0f}ms")
|
|
add("Auth valid", True, "Authenticated but rate-limited")
|
|
add("/models endpoint", None, "Rate limited — skipped")
|
|
else:
|
|
add("Network reachable", False, str(err or "no response")[:100])
|
|
except Exception as e:
|
|
add("Network", False, str(e)[:100])
|
|
|
|
# 5. Streaming smoke test
|
|
if bt not in ("native", "command-code"):
|
|
_doctor_check_streaming(url, key, bt, model, add)
|
|
|
|
# 6. Tool-call support test
|
|
if bt not in ("native", "command-code"):
|
|
_doctor_check_toolcall(url, key, bt, model, add)
|
|
|
|
return checks
|
|
|
|
def _show_doctor_results(parent, endpoint_name, checks):
|
|
dlg = Gtk.Dialog(title=f"Doctor: {endpoint_name}", parent=parent, modal=True)
|
|
dlg.add_button("Close", Gtk.ResponseType.CLOSE)
|
|
dlg.set_default_size(480, 400)
|
|
area = dlg.get_content_area()
|
|
area.set_margin_start(12)
|
|
area.set_margin_end(12)
|
|
area.set_margin_top(12)
|
|
area.set_margin_bottom(12)
|
|
area.set_spacing(4)
|
|
passed = sum(1 for _, ok, _ in checks if ok is True)
|
|
failed = sum(1 for _, ok, _ in checks if ok is False)
|
|
warned = sum(1 for _, ok, _ in checks if ok is None)
|
|
hdr = Gtk.Label()
|
|
hdr.set_markup(f'<b>{endpoint_name}</b> '
|
|
f'<span foreground="#27ae60">{passed} passed</span> '
|
|
f'<span foreground="#e74c3c">{failed} failed</span> '
|
|
f'<span foreground="#f39c12">{warned} warnings</span>')
|
|
area.pack_start(hdr, False, False, 6)
|
|
sep = Gtk.Separator()
|
|
area.pack_start(sep, False, False, 4)
|
|
for name, ok, detail in checks:
|
|
row = Gtk.Box(spacing=6)
|
|
if ok is True:
|
|
color, sym = "#27ae60", "\u2713"
|
|
elif ok is False:
|
|
color, sym = "#e74c3c", "\u2717"
|
|
else:
|
|
color, sym = "#f39c12", "\u25CB"
|
|
icon = Gtk.Label()
|
|
icon.set_markup(f'<span foreground="{color}" weight="bold">{sym}</span>')
|
|
row.pack_start(icon, False, False, 0)
|
|
lbl = Gtk.Label()
|
|
lbl.set_markup(f'<b>{name}</b>')
|
|
row.pack_start(lbl, False, False, 0)
|
|
if detail:
|
|
det = Gtk.Label()
|
|
det.set_markup(f'<span foreground="#7f8c8d" size="small">{detail}</span>')
|
|
det.set_line_wrap(True)
|
|
row.pack_end(det, False, False, 0)
|
|
area.pack_start(row, False, False, 2)
|
|
dlg.show_all()
|
|
dlg.run()
|
|
dlg.destroy()
|
|
|
|
def endpoint_models_url(endpoint):
|
|
base = normalize_base_url(endpoint.get("base_url") or "")
|
|
if not base:
|
|
return ""
|
|
return f"{base}/models"
|
|
|
|
def endpoint_model_headers(endpoint):
|
|
key = (endpoint.get("api_key") or "").strip()
|
|
backend = endpoint.get("backend_type", "openai-compat")
|
|
headers = {}
|
|
if backend == "anthropic":
|
|
if key:
|
|
headers["x-api-key"] = key
|
|
headers["anthropic-version"] = "2023-06-01"
|
|
elif key:
|
|
headers["Authorization"] = f"Bearer {key}"
|
|
return headers
|
|
|
|
def fetch_models_for_endpoint(endpoint, timeout=10):
|
|
url = endpoint_models_url(endpoint)
|
|
if not url:
|
|
return None, "Base URL is empty"
|
|
try:
|
|
req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint))
|
|
raw = urllib.request.urlopen(req, timeout=timeout).read()
|
|
payload = json.loads(raw)
|
|
items = payload.get("data") or payload.get("models") or []
|
|
ids = []
|
|
seen = set()
|
|
for item in items:
|
|
mid = item.get("id") if isinstance(item, dict) else None
|
|
if mid and mid not in seen:
|
|
seen.add(mid)
|
|
ids.append(mid)
|
|
if not ids:
|
|
return None, "No models returned"
|
|
return ids, None
|
|
except Exception as e:
|
|
return None, str(e)
|
|
|
|
def refresh_endpoint_models(endpoint):
|
|
ids, err = fetch_models_for_endpoint(endpoint)
|
|
if not ids:
|
|
return None, err
|
|
updated = dict(endpoint)
|
|
updated["models"] = ids
|
|
if updated.get("default_model") not in ids:
|
|
updated["default_model"] = ids[0]
|
|
return updated, None
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Endpoint storage
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
def load_endpoints():
|
|
if ENDPOINTS_FILE.exists():
|
|
try:
|
|
return json.loads(ENDPOINTS_FILE.read_text())
|
|
except Exception:
|
|
pass
|
|
return {"default": None, "endpoints": []}
|
|
|
|
def save_endpoints(data):
|
|
ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
ENDPOINTS_FILE.write_text(json.dumps(data, indent=2))
|
|
|
|
def load_bgp_pools():
|
|
if BGP_POOLS_FILE.exists():
|
|
try:
|
|
return json.loads(BGP_POOLS_FILE.read_text())
|
|
except Exception:
|
|
pass
|
|
return {"pools": []}
|
|
|
|
def save_bgp_pools(data):
|
|
BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
BGP_POOLS_FILE.write_text(json.dumps(data, indent=2))
|
|
|
|
def get_endpoint(name):
|
|
for e in load_endpoints()["endpoints"]:
|
|
if e["name"] == name:
|
|
return e
|
|
return None
|
|
|
|
def now_utc_iso():
|
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
|
|
def build_profile_bundle():
|
|
return {
|
|
"version": 1,
|
|
"exported_at": now_utc_iso(),
|
|
"endpoints": load_endpoints(),
|
|
"codex_config_toml": CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "",
|
|
}
|
|
|
|
def save_profile_bundle(path):
|
|
bundle = build_profile_bundle()
|
|
Path(path).write_text(json.dumps(bundle, indent=2), encoding="utf-8")
|
|
|
|
def import_profile_bundle(path):
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Invalid profile bundle")
|
|
|
|
endpoints = data.get("endpoints")
|
|
if not isinstance(endpoints, dict) or "endpoints" not in endpoints:
|
|
raise ValueError("Profile bundle missing endpoints")
|
|
|
|
# Keep a local rollback point before overwriting the current profile.
|
|
if CONFIG.exists():
|
|
shutil.copy2(str(CONFIG), str(CONFIG_BAK))
|
|
if ENDPOINTS_FILE.exists():
|
|
shutil.copy2(str(ENDPOINTS_FILE), str(ENDPOINTS_FILE.with_suffix(".json.import-bak")))
|
|
|
|
save_endpoints(endpoints)
|
|
|
|
cfg = data.get("codex_config_toml", "")
|
|
if isinstance(cfg, str) and cfg.strip():
|
|
CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
CONFIG.write_text(cfg, encoding="utf-8")
|
|
return endpoints
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Config management
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
def backup_config():
|
|
if CONFIG.exists():
|
|
tmp = CONFIG_BAK.with_suffix(".tmp")
|
|
shutil.copy2(str(CONFIG), str(tmp))
|
|
os.replace(str(tmp), str(CONFIG_BAK))
|
|
|
|
def restore_config():
|
|
if CONFIG_BAK.exists():
|
|
tmp = CONFIG.with_suffix(".tmp")
|
|
shutil.copy2(str(CONFIG_BAK), str(tmp))
|
|
os.replace(str(tmp), str(CONFIG))
|
|
|
|
def write_secure_text(path, text):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(text, encoding="utf-8")
|
|
os.chmod(str(tmp), 0o600)
|
|
os.replace(str(tmp), str(path))
|
|
|
|
CONFIG_TXN = HOME / ".codex/config.toml.launcher-txn.json"
|
|
|
|
def begin_config_transaction(reason):
|
|
txn = {"started_at": time.time(), "reason": reason,
|
|
"config_existed": CONFIG.exists(), "backup_path": str(CONFIG_BAK)}
|
|
if CONFIG.exists():
|
|
backup_config()
|
|
CONFIG_TXN.parent.mkdir(parents=True, exist_ok=True)
|
|
CONFIG_TXN.write_text(json.dumps(txn, indent=2))
|
|
|
|
def end_config_transaction():
|
|
CONFIG_TXN.unlink(missing_ok=True)
|
|
|
|
def recover_config_if_needed(logfn=None):
|
|
if not CONFIG_TXN.exists():
|
|
return
|
|
try:
|
|
txn = json.loads(CONFIG_TXN.read_text())
|
|
if txn.get("config_existed") and CONFIG_BAK.exists():
|
|
restore_config()
|
|
if logfn:
|
|
logfn("Recovered Codex config from interrupted session.")
|
|
elif CONFIG.exists():
|
|
CONFIG.unlink()
|
|
if logfn:
|
|
logfn("Removed generated config from interrupted session.")
|
|
finally:
|
|
CONFIG_TXN.unlink(missing_ok=True)
|
|
|
|
def write_config_for_native(endpoint, selected_model):
|
|
"""Write config for native OpenAI (no proxy needed)."""
|
|
backup_config()
|
|
model_catalog = _gen_model_catalog(endpoint, selected_model)
|
|
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
|
|
|
lines = [
|
|
f'model = "{_toml_safe(selected_model)}"\n',
|
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
|
f'model_catalog_json = "{mc_path}"\n',
|
|
f'\n[model_providers."{endpoint["name"]}"]\n',
|
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
|
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
|
|
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n',
|
|
f'\n[profiles."{endpoint["name"]}"]\n',
|
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
|
f'model = "{_toml_safe(selected_model)}"\n',
|
|
f'model_catalog_json = "{mc_path}"\n',
|
|
f'service_tier = "default"\n',
|
|
f'approvals_reviewer = "user"\n',
|
|
]
|
|
write_secure_text(CONFIG, "".join(lines))
|
|
|
|
def _toml_safe(val):
|
|
val = str(val).replace('"', '\\"')
|
|
return val.split('\n', 1)[0].strip()
|
|
|
|
def _resolve_secret(value):
|
|
value = (value or "").strip()
|
|
m = re.fullmatch(r"\$\{ENV:([A-Z0-9_]+)\}", value)
|
|
if m:
|
|
return os.environ.get(m.group(1), "")
|
|
return value
|
|
|
|
def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
|
|
backup_config()
|
|
model_catalog = _gen_model_catalog(endpoint, selected_model)
|
|
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
|
|
mc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
mc_path.write_text(json.dumps(model_catalog, indent=2))
|
|
|
|
lines = [
|
|
f'model = "{_toml_safe(selected_model)}"\n',
|
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
|
f'model_catalog_json = "{mc_path}"\n',
|
|
f'\n[model_providers."{endpoint["name"]}"]\n',
|
|
f'name = "{_toml_safe(endpoint["name"])}"\n',
|
|
f'base_url = "http://127.0.0.1:{proxy_port}"\n',
|
|
f'experimental_bearer_token = "codex-launcher-local"\n',
|
|
f'\n[profiles."{endpoint["name"]}"]\n',
|
|
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
|
|
f'model = "{_toml_safe(selected_model)}"\n',
|
|
f'model_catalog_json = "{mc_path}"\n',
|
|
f'service_tier = "fast"\n',
|
|
f'approvals_reviewer = "user"\n',
|
|
]
|
|
write_secure_text(CONFIG, "".join(lines))
|
|
|
|
def _gen_model_catalog(endpoint, selected_model=None):
|
|
default_model = selected_model or endpoint.get("default_model")
|
|
models = []
|
|
for mid in endpoint.get("models", []):
|
|
models.append({
|
|
"slug": mid, "model": mid, "display_name": mid,
|
|
"description": f"{endpoint['name']} {mid}",
|
|
"hidden": False, "isDefault": mid == default_model,
|
|
"shell_type": "shell_command", "visibility": "list",
|
|
"default_reasoning_level": "medium",
|
|
"supported_reasoning_levels": [
|
|
{"effort": "low", "description": "Fast"},
|
|
{"effort": "medium", "description": "Balanced"},
|
|
{"effort": "high", "description": "Deep"},
|
|
{"effort": "xhigh", "description": "Extra deep"},
|
|
],
|
|
"supportedReasoningEfforts": [
|
|
{"reasoningEffort": "low", "description": "Fast"},
|
|
{"reasoningEffort": "medium", "description": "Balanced"},
|
|
{"reasoningEffort": "high", "description": "Deep"},
|
|
{"reasoningEffort": "xhigh", "description": "Extra deep"},
|
|
],
|
|
"priority": 30, "context_size": 128000,
|
|
"additional_speed_tiers": [], "service_tiers": [],
|
|
"supports_reasoning_summaries": True, "support_verbosity": True,
|
|
"reasoning": True, "tool_call": True,
|
|
"supports_parallel_tool_calls": True,
|
|
"experimental_supported_tools": [], "supported_in_api": True,
|
|
"truncation_policy": {"mode": "tokens", "limit": 128000},
|
|
"base_instructions": "You are Codex, a coding agent.",
|
|
})
|
|
return {"models": models}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Proxy management
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
_proxy_proc = None
|
|
_proxy_port = None
|
|
|
|
PID_REGISTRY = HOME / ".cache" / "codex-launcher" / "pids.json"
|
|
|
|
def _pick_free_port():
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
def _load_pid_registry():
|
|
if PID_REGISTRY.exists():
|
|
try:
|
|
return json.loads(PID_REGISTRY.read_text())
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
def _save_pid_registry(data):
|
|
PID_REGISTRY.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = PID_REGISTRY.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2))
|
|
os.replace(str(tmp), str(PID_REGISTRY))
|
|
|
|
def _register_pgid(kind, pid):
|
|
data = _load_pid_registry()
|
|
try:
|
|
pgid = os.getpgid(pid)
|
|
except ProcessLookupError:
|
|
return
|
|
data[kind] = {"pid": pid, "pgid": pgid, "ts": time.time()}
|
|
_save_pid_registry(data)
|
|
|
|
def safe_cleanup_owned(logfn=None):
|
|
data = _load_pid_registry()
|
|
changed = False
|
|
for kind, meta in list(data.items()):
|
|
pgid = meta.get("pgid")
|
|
if not pgid:
|
|
continue
|
|
try:
|
|
os.killpg(pgid, signal.SIGTERM)
|
|
if logfn:
|
|
logfn(f"Stopped {kind} (pgid {pgid})")
|
|
changed = True
|
|
except ProcessLookupError:
|
|
changed = True
|
|
except Exception as e:
|
|
if logfn:
|
|
logfn(f"Could not stop {kind}: {e}")
|
|
if changed:
|
|
_save_pid_registry({})
|
|
|
|
def _start_proxy_for(endpoint, logfn):
|
|
global _proxy_proc, _proxy_port
|
|
_stop_proxy()
|
|
port = _pick_free_port()
|
|
_proxy_port = port
|
|
|
|
model_list = endpoint.get("models", [])
|
|
if (endpoint.get("backend_type") or "").startswith("gemini-oauth") and (endpoint.get("oauth_provider") or "").startswith("google"):
|
|
token_name = "google-antigravity-oauth-token.json" if endpoint.get("oauth_provider") == "google-antigravity" else "google-cli-oauth-token.json"
|
|
token_path = os.path.expanduser(f"~/.cache/codex-proxy/{token_name}")
|
|
try:
|
|
with open(token_path) as tf:
|
|
td = json.load(tf)
|
|
discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", [])
|
|
if discovered:
|
|
model_list = discovered
|
|
except Exception:
|
|
pass
|
|
pcfg = {
|
|
"port": port,
|
|
"backend_type": endpoint["backend_type"],
|
|
"target_url": normalize_base_url(endpoint["base_url"]),
|
|
"api_key": endpoint["api_key"],
|
|
"cc_version": endpoint.get("cc_version", ""),
|
|
"oauth_provider": endpoint.get("oauth_provider", ""),
|
|
"reasoning_enabled": endpoint.get("reasoning_enabled", True),
|
|
"reasoning_effort": endpoint.get("reasoning_effort", "medium"),
|
|
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
|
|
for m in model_list],
|
|
}
|
|
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}-{port}.json"
|
|
pcfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
pcfg_path.write_text(json.dumps(pcfg, indent=2))
|
|
_start_proxy_with_config(pcfg_path, port, logfn)
|
|
return port
|
|
|
|
def _start_proxy_with_config(pcfg_path, port, logfn):
|
|
global _proxy_proc
|
|
_proxy_proc = subprocess.Popen(
|
|
["python3", str(PROXY), "--config", str(pcfg_path)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
preexec_fn=os.setsid,
|
|
text=True,
|
|
)
|
|
_register_pgid("proxy", _proxy_proc.pid)
|
|
|
|
def _pipe_stderr():
|
|
if not _proxy_proc.stderr:
|
|
return
|
|
for line in _proxy_proc.stderr:
|
|
GLib.idle_add(logfn, f"[proxy] {line.rstrip()}")
|
|
threading.Thread(target=_pipe_stderr, daemon=True).start()
|
|
|
|
deadline = time.time() + 15
|
|
last_err = None
|
|
while time.time() < deadline:
|
|
if _proxy_proc.poll() is not None:
|
|
raise RuntimeError(f"Proxy exited early with code {_proxy_proc.returncode}")
|
|
try:
|
|
urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=2)
|
|
logfn(f"Proxy ready on port {port}")
|
|
return
|
|
except Exception as e:
|
|
last_err = e
|
|
time.sleep(0.3)
|
|
try:
|
|
os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM)
|
|
_proxy_proc.wait(timeout=3)
|
|
except Exception:
|
|
with contextlib.suppress(Exception):
|
|
os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL)
|
|
raise RuntimeError(f"Proxy failed health check on port {port}: {last_err}")
|
|
|
|
def _stop_proxy():
|
|
global _proxy_proc
|
|
if _proxy_proc and _proxy_proc.poll() is None:
|
|
try:
|
|
os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM)
|
|
time.sleep(0.5)
|
|
if _proxy_proc.poll() is None:
|
|
os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
_proxy_proc = None
|
|
|
|
def _kill_existing_desktop(logfn=None):
|
|
import subprocess as _sp
|
|
try:
|
|
out = _sp.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5)
|
|
pids = [p for p in out.stdout.strip().splitlines() if p.strip().isdigit()]
|
|
if not pids:
|
|
return
|
|
main_pid = int(pids[0])
|
|
pgid = os.getpgid(main_pid)
|
|
if pgid > 0:
|
|
os.killpg(pgid, signal.SIGTERM)
|
|
if logfn:
|
|
logfn(f"Killed existing Codex Desktop (pid {main_pid}, pgid {pgid})")
|
|
time.sleep(2)
|
|
try:
|
|
os.killpg(pgid, signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
except Exception as e:
|
|
if logfn:
|
|
logfn(f"Note: could not kill existing Desktop: {e}")
|
|
|
|
def _run_cleanup(logfn=None):
|
|
safe_cleanup_owned(logfn)
|
|
|
|
def _last_log_lines(n=15):
|
|
try:
|
|
t = LAUNCH_LOG.read_text()
|
|
return "\n".join(t.splitlines()[-n:])
|
|
except Exception:
|
|
return "(no log file)"
|
|
|
|
def _detect_codex_cli():
|
|
try:
|
|
path = shutil.which("codex")
|
|
if not path:
|
|
return None
|
|
out = subprocess.run(["codex", "--version"], capture_output=True, text=True, timeout=5)
|
|
ver = (out.stdout or "").strip() or (out.stderr or "").strip() or "unknown"
|
|
return (path, ver)
|
|
except Exception:
|
|
return None
|
|
|
|
def _detect_codex_desktop():
|
|
if START_SH.exists():
|
|
return str(START_SH)
|
|
return None
|
|
|
|
def _check_codex_auth():
|
|
try:
|
|
out = subprocess.run(
|
|
["codex", "login", "status"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
text = (out.stdout or "").strip()
|
|
if not text:
|
|
text = (out.stderr or "").strip()
|
|
if out.returncode == 0 and text:
|
|
return ("logged_in", text)
|
|
if text:
|
|
return ("error", text)
|
|
return ("unknown", "No output from codex login status")
|
|
except FileNotFoundError:
|
|
return ("not_installed", "codex not found")
|
|
except Exception as e:
|
|
return ("error", str(e))
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Main window
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class LauncherWin(Gtk.Window):
|
|
def __init__(self):
|
|
super().__init__(title="Codex Launcher")
|
|
self.set_default_size(560, 460)
|
|
self.set_border_width(12)
|
|
self.set_position(Gtk.WindowPosition.CENTER)
|
|
self._proc = None
|
|
self._endpoints_data = load_endpoints()
|
|
recover_config_if_needed()
|
|
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
self.add(vbox)
|
|
|
|
# header row
|
|
hdr = Gtk.Box(spacing=8)
|
|
vbox.pack_start(hdr, False, False, 0)
|
|
lbl = Gtk.Label(label="<b>Codex Launcher v3.3.0</b>")
|
|
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)
|
|
mgr_btn = Gtk.Button(label="Manage Endpoints")
|
|
mgr_btn.connect("clicked", lambda b: self._open_mgr())
|
|
hdr.pack_end(mgr_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"<span foreground='#2ea043'>✔ Codex CLI</span> <small>{cli_ver} ({cli_path})</small>")
|
|
cli_lbl.set_use_markup(True)
|
|
ver_box.pack_start(cli_lbl, False, False, 0)
|
|
else:
|
|
cli_lbl = Gtk.Label()
|
|
cli_lbl.set_markup("<span foreground='#d29922'>✘ Codex CLI — not found</span>")
|
|
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"<span foreground='#2ea043'>✔ Codex Desktop</span> <small>({self._desktop_info})</small>")
|
|
desk_lbl.set_use_markup(True)
|
|
ver_box.pack_start(desk_lbl, False, False, 0)
|
|
else:
|
|
desk_lbl = Gtk.Label()
|
|
desk_lbl.set_markup("<span foreground='#d29922'>✘ Codex Desktop — not found</span>")
|
|
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("<span foreground='#888'>Checking auth…</span>")
|
|
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)
|
|
|
|
# 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._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()
|
|
|
|
# ── 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"<span foreground='#2ea043'>✔ Auth: {msg}</span>")
|
|
self._relogin_btn.set_sensitive("cli" not in self._missing)
|
|
elif status == "not_installed":
|
|
self._auth_label.set_markup("<span foreground='#888'>Auth: N/A (CLI not installed)</span>")
|
|
else:
|
|
self._auth_label.set_markup(f"<span foreground='#d29922'>⚠ Auth: {msg}</span>")
|
|
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("<span foreground='#888'>Auth: waiting for login…</span>")
|
|
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)
|
|
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_usage(self):
|
|
try:
|
|
self._usage_window = UsageWindow(self)
|
|
self._usage_window.connect("destroy", lambda *_: setattr(self, "_usage_window", None))
|
|
except Exception as e:
|
|
import traceback; traceback.print_exc()
|
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
|
|
d.run(); d.destroy()
|
|
|
|
def _open_history(self):
|
|
try:
|
|
self._history_window = RequestHistoryWindow(self)
|
|
self._history_window.connect("destroy", lambda *_: setattr(self, "_history_window", None))
|
|
except Exception as e:
|
|
import traceback; traceback.print_exc()
|
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
|
|
d.run(); d.destroy()
|
|
|
|
def _open_benchmark(self):
|
|
try:
|
|
self._benchmark_window = BenchmarkWindow(self)
|
|
self._benchmark_window.connect("destroy", lambda *_: setattr(self, "_benchmark_window", None))
|
|
except Exception as e:
|
|
import traceback; traceback.print_exc()
|
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
|
|
d.run(); d.destroy()
|
|
|
|
def _open_assistant(self):
|
|
import subprocess, sys
|
|
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
|
|
subprocess.Popen([sys.executable, _py], start_new_session=True)
|
|
|
|
def _backup_profile(self):
|
|
chooser = Gtk.FileChooserDialog(
|
|
title="Backup Codex Profile",
|
|
parent=self,
|
|
action=Gtk.FileChooserAction.SAVE,
|
|
)
|
|
chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_SAVE, Gtk.ResponseType.OK)
|
|
chooser.set_do_overwrite_confirmation(True)
|
|
chooser.set_current_name(f"codex-profile-{time.strftime('%Y%m%d-%H%M%S')}.json")
|
|
resp = chooser.run()
|
|
filename = chooser.get_filename() if resp == Gtk.ResponseType.OK else None
|
|
chooser.destroy()
|
|
if not filename:
|
|
return
|
|
try:
|
|
save_profile_bundle(filename)
|
|
self.log(f"Profile backed up to {filename}")
|
|
except Exception as e:
|
|
self._show_message(Gtk.MessageType.ERROR, f"Backup failed:\n{e}")
|
|
|
|
def _refresh_all_models(self):
|
|
if getattr(self, "_refresh_running", False):
|
|
return
|
|
self._refresh_running = True
|
|
self._refresh_all_btn.set_sensitive(False)
|
|
self.log("Refreshing models for all providers...")
|
|
threading.Thread(target=self._refresh_all_models_worker, daemon=True).start()
|
|
|
|
def _refresh_all_models_worker(self):
|
|
try:
|
|
data = load_endpoints()
|
|
updated = 0
|
|
failed = []
|
|
|
|
for idx, ep in enumerate(list(data["endpoints"])):
|
|
refreshed, err = refresh_endpoint_models(ep)
|
|
if refreshed:
|
|
data["endpoints"][idx] = refreshed
|
|
updated += 1
|
|
else:
|
|
failed.append(f"{ep['name']}: {err}")
|
|
|
|
if updated:
|
|
save_endpoints(data)
|
|
|
|
GLib.idle_add(self._finish_refresh_all_models, updated, failed)
|
|
except Exception as e:
|
|
GLib.idle_add(self._finish_refresh_all_models_error, str(e))
|
|
|
|
def _finish_refresh_all_models(self, updated, failed):
|
|
try:
|
|
if updated:
|
|
self._rebuild_combo()
|
|
if getattr(self, "_mgr_window", None):
|
|
try:
|
|
self._mgr_window._rebuild()
|
|
except Exception:
|
|
pass
|
|
self.log(f"Refreshed models for {updated} provider(s)")
|
|
|
|
if failed:
|
|
self._show_message(
|
|
Gtk.MessageType.WARNING,
|
|
"Some providers could not auto-fetch models.\n\n"
|
|
+ "\n".join(failed)
|
|
+ "\n\nThose providers were left unchanged so you can manage them manually."
|
|
)
|
|
elif updated:
|
|
self._show_message(Gtk.MessageType.INFO, f"Refreshed models for {updated} provider(s).")
|
|
else:
|
|
self._show_message(Gtk.MessageType.INFO, "No providers were refreshed.")
|
|
finally:
|
|
self._refresh_running = False
|
|
self._refresh_all_btn.set_sensitive(True)
|
|
return False
|
|
|
|
def _finish_refresh_all_models_error(self, err):
|
|
try:
|
|
self._show_message(Gtk.MessageType.ERROR, f"Refresh failed:\n{err}")
|
|
finally:
|
|
self._refresh_running = False
|
|
self._refresh_all_btn.set_sensitive(True)
|
|
return False
|
|
|
|
def _import_profile(self):
|
|
if self._proc and self._proc.poll() is None:
|
|
self._show_message(Gtk.MessageType.WARNING, "Stop Codex before importing a profile.")
|
|
return
|
|
|
|
chooser = Gtk.FileChooserDialog(
|
|
title="Import Codex Profile",
|
|
parent=self,
|
|
action=Gtk.FileChooserAction.OPEN,
|
|
)
|
|
chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
|
|
resp = chooser.run()
|
|
filename = chooser.get_filename() if resp == Gtk.ResponseType.OK else None
|
|
chooser.destroy()
|
|
if not filename:
|
|
return
|
|
|
|
confirm = Gtk.MessageDialog(
|
|
self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
|
|
"Importing will replace the current endpoints and Codex config. Continue?"
|
|
)
|
|
ok = confirm.run() == Gtk.ResponseType.YES
|
|
confirm.destroy()
|
|
if not ok:
|
|
return
|
|
|
|
try:
|
|
import_profile_bundle(filename)
|
|
self._rebuild_combo()
|
|
self.log(f"Profile imported from {filename}")
|
|
self._show_message(Gtk.MessageType.INFO, "Profile imported successfully.")
|
|
except Exception as e:
|
|
self._show_message(Gtk.MessageType.ERROR, f"Import failed:\n{e}")
|
|
|
|
def _on_endpoints_updated(self):
|
|
self._rebuild_combo()
|
|
|
|
def _show_message(self, msg_type, text):
|
|
d = Gtk.MessageDialog(self, 0, msg_type, Gtk.ButtonsType.OK, text)
|
|
d.run()
|
|
d.destroy()
|
|
|
|
def _show_changelog(self):
|
|
d = Gtk.Dialog(title="Changelog", transient_for=self, modal=True)
|
|
d.set_default_size(520, 480)
|
|
d.add_button("Close", Gtk.ResponseType.CLOSE)
|
|
area = d.get_content_area()
|
|
area.set_margin_start(12)
|
|
area.set_margin_end(12)
|
|
area.set_margin_top(12)
|
|
area.set_margin_bottom(12)
|
|
sw = Gtk.ScrolledWindow()
|
|
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
area.pack_start(sw, True, True, 0)
|
|
buf = Gtk.TextBuffer()
|
|
tv = Gtk.TextView(buffer=buf)
|
|
tv.set_editable(False)
|
|
tv.set_cursor_visible(False)
|
|
tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
|
sw.add(tv)
|
|
lines = []
|
|
for ver, date, items in CHANGELOG:
|
|
lines.append(f"<b>v{ver}</b> ({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…")
|
|
try:
|
|
proxy_port = _start_proxy_for(ep, self.log)
|
|
except RuntimeError as e:
|
|
GLib.idle_add(self._show_error_dialog, "Proxy startup failed", str(e))
|
|
return
|
|
self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})…")
|
|
begin_config_transaction(f"launch:{ep['name']}")
|
|
write_config_for_translated(ep, model, proxy_port)
|
|
else:
|
|
self.log(f"Configuring Codex for {ep['name']} (native)…")
|
|
begin_config_transaction(f"launch:{ep['name']}")
|
|
write_config_for_native(ep, model)
|
|
|
|
if target == "desktop":
|
|
if needs_proxy:
|
|
_kill_existing_desktop(self.log)
|
|
keep_session_alive = self._launch_desktop(ep, model)
|
|
else:
|
|
self._launch_cli(ep, model)
|
|
|
|
except Exception as e:
|
|
self.log(f"ERROR: {e}")
|
|
finally:
|
|
if keep_session_alive:
|
|
self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.")
|
|
self._set_busy(False)
|
|
self.log("Ready. Use Kill && Cleanup when finished.")
|
|
else:
|
|
_stop_proxy()
|
|
restore_config()
|
|
end_config_transaction()
|
|
self._set_busy(False)
|
|
self.log("Ready.")
|
|
|
|
def _run_bgp(self, pool, model, target):
|
|
keep_session_alive = False
|
|
try:
|
|
self.log("Cleaning up stale processes…")
|
|
_run_cleanup(self.log)
|
|
recover_config_if_needed(self.log)
|
|
|
|
port = _pick_free_port()
|
|
self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes on :{port}…")
|
|
bgp_ep = {
|
|
"name": pool["name"],
|
|
"backend_type": "openai-compat",
|
|
"base_url": "http://bgp.placeholder",
|
|
"api_key": "",
|
|
"default_model": model,
|
|
"models": list(dict.fromkeys(r.get("model", model) for r in pool.get("routes", []))),
|
|
}
|
|
pcfg = {
|
|
"port": port,
|
|
"backend_type": "openai-compat",
|
|
"target_url": "http://bgp.placeholder",
|
|
"api_key": "",
|
|
"bgp_routes": pool.get("routes", []),
|
|
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]],
|
|
}
|
|
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}-{port}.json"
|
|
pcfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
pcfg_path.write_text(json.dumps(pcfg, indent=2))
|
|
try:
|
|
_start_proxy_with_config(pcfg_path, port, self.log)
|
|
except RuntimeError as e:
|
|
GLib.idle_add(self._show_error_dialog, "BGP proxy startup failed", str(e))
|
|
return
|
|
|
|
begin_config_transaction(f"launch:bgp:{pool['name']}")
|
|
write_config_for_translated(bgp_ep, model, port)
|
|
|
|
if target == "desktop":
|
|
_kill_existing_desktop(self.log)
|
|
keep_session_alive = self._launch_desktop(bgp_ep, model)
|
|
else:
|
|
self._launch_cli(bgp_ep, model)
|
|
|
|
except Exception as e:
|
|
self.log(f"ERROR: {e}")
|
|
finally:
|
|
if keep_session_alive:
|
|
self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.")
|
|
self._set_busy(False)
|
|
self.log("Ready. Use Kill && Cleanup when finished.")
|
|
else:
|
|
_stop_proxy()
|
|
restore_config()
|
|
end_config_transaction()
|
|
self._set_busy(False)
|
|
self.log("Ready.")
|
|
|
|
def _run_codex_default(self, target):
|
|
try:
|
|
self.log("Cleaning up stale processes…")
|
|
_run_cleanup(self.log)
|
|
_stop_proxy()
|
|
recover_config_if_needed(self.log)
|
|
|
|
self.log("Resetting config to Codex defaults (OAuth)…")
|
|
begin_config_transaction("launch:default")
|
|
if CONFIG.exists():
|
|
CONFIG.unlink()
|
|
|
|
if target == "desktop":
|
|
self._launch_desktop_direct()
|
|
else:
|
|
self._launch_cli_default()
|
|
except Exception as e:
|
|
self.log(f"ERROR: {e}")
|
|
finally:
|
|
restore_config()
|
|
end_config_transaction()
|
|
self._set_busy(False)
|
|
self.log("Ready.")
|
|
|
|
def _show_error_dialog(self, title, message):
|
|
dialog = Gtk.MessageDialog(
|
|
transient_for=self, flags=0,
|
|
message_type=Gtk.MessageType.ERROR,
|
|
buttons=Gtk.ButtonsType.CLOSE, text=str(title))
|
|
dialog.format_secondary_text(str(message))
|
|
dialog.run()
|
|
dialog.destroy()
|
|
|
|
def _launch_desktop(self, ep, model):
|
|
args = [str(START_SH)]
|
|
if ep["backend_type"] != "native":
|
|
args += ["--", "--ozone-platform=wayland"]
|
|
|
|
self._proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid)
|
|
pid = self._proc.pid
|
|
self.log(f"Desktop started (PID {pid})")
|
|
self.log(f"Log: {LAUNCH_LOG}")
|
|
|
|
t0 = time.time()
|
|
stall_warned = False
|
|
while self._proc and self._proc.poll() is None:
|
|
time.sleep(1.5)
|
|
el = time.time() - t0
|
|
if el > 20 and not stall_warned:
|
|
self.log("⚠ Still starting after 20 s — possible stall. Click Kill if window doesn't appear.")
|
|
self.log(f"--- last log lines ---\n{_last_log_lines()}")
|
|
stall_warned = True
|
|
|
|
if self._proc:
|
|
rc = self._proc.poll()
|
|
el = time.time() - t0
|
|
self.log(f"Desktop exited (code {rc}) after {el:.0f}s")
|
|
if el < 12:
|
|
self.log("TIP: Quick exit — may be warm-start handoff (normal) or crash. Kill && retry if needed.")
|
|
last_lines = _last_log_lines()
|
|
self.log(f"--- last log lines ---\n{last_lines}")
|
|
if rc == 0 and "warm-start" in last_lines.lower():
|
|
self._proc = None
|
|
return True
|
|
self._proc = None
|
|
return False
|
|
|
|
def _launch_cli(self, ep, model):
|
|
"""Launch codex CLI in a terminal with the selected endpoint."""
|
|
self.log(f"Launching Codex CLI with {ep['name']}…")
|
|
|
|
# Find a terminal emulator
|
|
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
|
|
|
|
# For proxied endpoints, the proxy is already running (from _run)
|
|
# For native, no proxy needed
|
|
cmd_parts = [term] + term_args
|
|
|
|
if ep["backend_type"] == "native":
|
|
# Just run codex directly — config.toml is already set up
|
|
cmd_parts.extend(["codex", "-c", f"model={model}"])
|
|
else:
|
|
# Proxy is running, run codex with the profile
|
|
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"])
|
|
|
|
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
|
|
|
|
cmd_parts = [term] + term_args + ["codex"]
|
|
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()
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# 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="<b>Endpoints</b>")
|
|
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'<b>{ep_name}</b> <span foreground="{color}">{status}</span>')
|
|
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'<span foreground="{sc}" weight="bold">{sym}</span>')
|
|
lbl = Gtk.Label()
|
|
lbl.set_markup(f'<span size="small"><b>{name}</b>'
|
|
+ (f' <span foreground="#7f8c8d">{detail}</span>' if detail else '')
|
|
+ '</span>')
|
|
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)"),
|
|
("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()
|
|
|
|
# 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))
|
|
|
|
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"])
|
|
is_oauth = bool(preset.get("oauth_provider"))
|
|
self._oauth_btn.set_visible(is_oauth)
|
|
if is_oauth:
|
|
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 len(self._model_store) == 0:
|
|
for mid in preset["models"]:
|
|
self._model_store.append([mid])
|
|
self._refresh_default_combo(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('<span foreground="#27ae60" weight="bold">ON</span>')
|
|
else:
|
|
self._lbl_reasoning.set_markup('<span foreground="#e67e22" weight="bold">OFF</span>')
|
|
|
|
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 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")
|
|
|
|
if is_antigravity:
|
|
CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
|
CLIENT_SECRET = "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"
|
|
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:
|
|
CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
|
CLIENT_SECRET = "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"
|
|
SCOPES = [
|
|
"https://www.googleapis.com/auth/cloud-platform",
|
|
"https://www.googleapis.com/auth/userinfo.email",
|
|
"https://www.googleapis.com/auth/userinfo.profile",
|
|
]
|
|
port = 0
|
|
redirect_uri = None
|
|
callback_path = "/oauth2callback"
|
|
provider_kind = "cli"
|
|
|
|
import http.server
|
|
|
|
state = secrets.token_hex(32)
|
|
verifier = secrets.token_urlsafe(64)
|
|
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
|
|
if port == 0:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
port = s.getsockname()[1]
|
|
redirect_uri = f"http://127.0.0.1:{port}/oauth2callback"
|
|
|
|
scope_str = " ".join(SCOPES)
|
|
auth_url = (
|
|
f"https://accounts.google.com/o/oauth2/v2/auth?"
|
|
f"client_id={CLIENT_ID}"
|
|
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
|
|
f"&response_type=code"
|
|
f"&scope={urllib.parse.quote(scope_str)}"
|
|
f"&access_type=offline"
|
|
f"&prompt=select_account%20consent"
|
|
f"&state={state}"
|
|
f"&code_challenge={challenge}"
|
|
f"&code_challenge_method=S256"
|
|
)
|
|
|
|
dlg = Gtk.Dialog(title="Google OAuth (Gemini Mode)", parent=self, modal=True)
|
|
dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
|
dlg.set_default_size(520, 280)
|
|
area = dlg.get_content_area()
|
|
area.set_margin_start(16)
|
|
area.set_margin_end(16)
|
|
area.set_margin_top(12)
|
|
area.set_margin_bottom(12)
|
|
area.set_spacing(8)
|
|
|
|
area.pack_start(Gtk.Label(label="<b>Sign in with Google</b>", use_markup=True, xalign=0), False, False, 0)
|
|
area.pack_start(Gtk.Label(label="Emulating Gemini CLI OAuth — no client_secret.json needed.", xalign=0), False, False, 0)
|
|
|
|
link_lbl = Gtk.Label()
|
|
link_lbl.set_markup(f'<a href="{auth_url}">Click here to open Google authorization</a>')
|
|
link_lbl.set_line_wrap(True)
|
|
area.pack_start(link_lbl, False, False, 4)
|
|
|
|
self._oauth_status = Gtk.Label(label="Opening browser…", xalign=0)
|
|
area.pack_start(self._oauth_status, False, False, 4)
|
|
|
|
spinner = Gtk.Spinner()
|
|
spinner.start()
|
|
area.pack_start(spinner, False, False, 8)
|
|
|
|
area.show_all()
|
|
|
|
code_holder = [None]
|
|
error_holder = [None]
|
|
received_state = [None]
|
|
|
|
class OAuthHandler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self2):
|
|
qs = urllib.parse.urlparse(self2.path).query
|
|
params = urllib.parse.parse_qs(qs)
|
|
received_state[0] = params.get("state", [None])[0]
|
|
with open("/tmp/codex-oauth-debug.log", "a") as _dbg:
|
|
_dbg.write(f"[{time.strftime('%H:%M:%S')}] GET {self2.path} state={received_state[0]} code={'code' in params}\n")
|
|
if self2.path.find(callback_path) == -1:
|
|
self2.send_response(302)
|
|
self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
|
|
self2.end_headers()
|
|
error_holder[0] = "unexpected request"
|
|
return
|
|
if "code" in params:
|
|
if received_state[0] != state:
|
|
self2.send_response(400)
|
|
self2.send_header("Content-Type", "text/html")
|
|
self2.end_headers()
|
|
self2.wfile.write(b"<html><body style='font-family:sans-serif;text-align:center;padding-top:80px'>"
|
|
b"<h2 style='color:#e74c3c'>CSRF state mismatch.</h2></body></html>")
|
|
error_holder[0] = "CSRF state mismatch"
|
|
return
|
|
code_holder[0] = params["code"][0]
|
|
self2.send_response(302)
|
|
self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini")
|
|
self2.end_headers()
|
|
else:
|
|
error_holder[0] = params.get("error", ["unknown"])[0]
|
|
self2.send_response(302)
|
|
self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
|
|
self2.end_headers()
|
|
def log_message(self2, fmt, *args):
|
|
with open("/tmp/codex-oauth-debug.log", "a") as _dbg:
|
|
_dbg.write(f"[{time.strftime('%H:%M:%S')}] {fmt % args}\n")
|
|
|
|
try:
|
|
bind_host = "localhost" if is_antigravity else "127.0.0.1"
|
|
server = http.server.HTTPServer((bind_host, port), OAuthHandler)
|
|
except OSError:
|
|
self._oauth_status.set_text(f"Port {port} already in use — close other apps and retry.")
|
|
spinner.stop()
|
|
dlg.run(); dlg.destroy()
|
|
return
|
|
|
|
def _oauth_log(msg):
|
|
with open("/tmp/codex-oauth-debug.log", "a") as _f:
|
|
_f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
|
|
|
|
_oauth_log(f"Starting OAuth: port={port} redirect_uri={redirect_uri}")
|
|
|
|
def wait_for_code():
|
|
_oauth_log("wait_for_code thread started")
|
|
deadline = time.time() + 120
|
|
while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
|
|
server.handle_request()
|
|
server.server_close()
|
|
_oauth_log(f"Server closed. code={'yes' if code_holder[0] else 'no'} error={'yes' if error_holder[0] else 'no'}")
|
|
if code_holder[0]:
|
|
try:
|
|
_oauth_log("Exchanging code for token...")
|
|
token_data = urllib.parse.urlencode({
|
|
"code": code_holder[0],
|
|
"client_id": CLIENT_ID,
|
|
"client_secret": CLIENT_SECRET,
|
|
"redirect_uri": redirect_uri,
|
|
"grant_type": "authorization_code",
|
|
"code_verifier": verifier,
|
|
}).encode()
|
|
req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data,
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
resp = urllib.request.urlopen(req, timeout=30)
|
|
tokens = json.loads(resp.read())
|
|
tokens["client_id"] = CLIENT_ID
|
|
tokens["client_secret"] = CLIENT_SECRET
|
|
tokens["provider_kind"] = provider_kind
|
|
tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
|
|
os.makedirs(os.path.dirname(token_path), exist_ok=True)
|
|
with open(token_path, "w") as f:
|
|
json.dump(tokens, f, indent=2)
|
|
os.chmod(token_path, 0o600)
|
|
_oauth_log(f"Token saved to {token_path}")
|
|
project_id = ""
|
|
try:
|
|
_oauth_log("Discovering project ID via loadCodeAssist...")
|
|
lr = urllib.request.Request(
|
|
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
|
data=json.dumps({}).encode(),
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {tokens['access_token']}",
|
|
"User-Agent": "google-api-nodejs-client/9.15.1",
|
|
})
|
|
lresp = urllib.request.urlopen(lr, timeout=15)
|
|
ldata = json.loads(lresp.read())
|
|
p = ldata.get("cloudaicompanionProject", "")
|
|
if isinstance(p, dict):
|
|
project_id = p.get("id", "")
|
|
elif isinstance(p, str):
|
|
project_id = p
|
|
_oauth_log(f"Project ID: {project_id or '(none)'}")
|
|
if project_id:
|
|
tokens["project_id"] = project_id
|
|
with open(token_path, "w") as f2:
|
|
json.dump(tokens, f2, indent=2)
|
|
os.chmod(token_path, 0o600)
|
|
except Exception as pe:
|
|
_oauth_log(f"loadCodeAssist failed (non-fatal): {pe}")
|
|
if is_antigravity:
|
|
found_models = [
|
|
"gemini-2.5-flash", "gemini-2.5-pro",
|
|
"gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
|
|
"gemini-3-pro-low", "gemini-3-pro-high",
|
|
"gemini-3.1-pro-low", "gemini-3.1-pro-high",
|
|
"gemini-3-flash-low", "gemini-3-flash-medium", "gemini-3-flash-high",
|
|
"claude-sonnet-4-6", "claude-opus-4-6-thinking",
|
|
"claude-opus-4-6-thinking-low", "claude-opus-4-6-thinking-medium", "claude-opus-4-6-thinking-high",
|
|
"gemini-claude-sonnet-4-6",
|
|
"gemini-claude-opus-4-6-thinking-low", "gemini-claude-opus-4-6-thinking-medium", "gemini-claude-opus-4-6-thinking-high",
|
|
"gemini-3-pro-image",
|
|
]
|
|
probe_candidates = [
|
|
"gemini-2.5-flash", "gemini-2.5-pro",
|
|
"gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
|
|
]
|
|
_oauth_log(f"Probing {len(probe_candidates)} model candidates...")
|
|
for mc in probe_candidates:
|
|
try:
|
|
pr = urllib.request.Request(
|
|
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent",
|
|
data=json.dumps({
|
|
"project": project_id,
|
|
"model": mc,
|
|
"request": {"contents": [{"role": "user", "parts": [{"text": "x"}]}],
|
|
"generationConfig": {"maxOutputTokens": 1}},
|
|
}).encode(),
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {tokens['access_token']}",
|
|
"User-Agent": "google-api-nodejs-client/9.15.1",
|
|
"Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
|
|
})
|
|
pr.get_method = lambda: "POST"
|
|
resp = urllib.request.urlopen(pr, timeout=10)
|
|
resp.read()
|
|
found_models.append(mc)
|
|
_oauth_log(f" {mc} → available")
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 429:
|
|
found_models.append(mc)
|
|
_oauth_log(f" {mc} → available (rate limited)")
|
|
else:
|
|
e.read()
|
|
_oauth_log(f" {mc} → HTTP {e.code}")
|
|
except Exception as e:
|
|
_oauth_log(f" {mc} → error: {e}")
|
|
else:
|
|
found_models = ["gemini-2.5-flash", "gemini-2.5-pro"]
|
|
if found_models:
|
|
tokens["available_models"] = found_models
|
|
with open(token_path, "w") as f3:
|
|
json.dump(tokens, f3, indent=2)
|
|
os.chmod(token_path, 0o600)
|
|
_oauth_log(f"Discovered {len(found_models)} models: {found_models}")
|
|
else:
|
|
_oauth_log("No models discovered (will use defaults)")
|
|
GLib.idle_add(self._oauth_success, dlg, tokens.get("access_token", ""), spinner)
|
|
return
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode(errors='replace')
|
|
_oauth_log(f"Token exchange HTTP {e.code}: {body}")
|
|
GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed ({e.code}): {body[:200]}", spinner)
|
|
return
|
|
except Exception as e:
|
|
_oauth_log(f"Token exchange FAILED: {e}")
|
|
GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed: {e}", spinner)
|
|
return
|
|
_oauth_log(f"OAuth failed: {error_holder[0] or 'timeout'}")
|
|
GLib.idle_add(self._oauth_failed, dlg,
|
|
error_holder[0] or "No authorization code received.", spinner)
|
|
|
|
threading.Thread(target=wait_for_code, daemon=True).start()
|
|
subprocess.Popen(["xdg-open", auth_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
dlg.connect("response", lambda d, r: d.destroy())
|
|
dlg.run()
|
|
|
|
def _oauth_success(self, dlg, access_token, spinner):
|
|
spinner.stop()
|
|
self._entry_key.set_text(access_token)
|
|
self._oauth_status.set_markup('<span foreground="#27ae60" weight="bold">Authorization successful! Token saved.</span>')
|
|
dlg.set_title("Google OAuth — Success")
|
|
GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK))
|
|
|
|
def _oauth_failed(self, dlg, msg, spinner):
|
|
spinner.stop()
|
|
self._oauth_status.set_markup(f'<span foreground="#e74c3c">{msg}</span>')
|
|
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 _refresh_default_combo(self, active=None):
|
|
if active is None:
|
|
active = self._combo_default.get_active_text()
|
|
self._combo_default.remove_all()
|
|
for row in self._model_store:
|
|
self._combo_default.append(row[0], row[0])
|
|
if active and any(row[0] == active for row in self._model_store):
|
|
self._combo_default.set_active_id(active)
|
|
elif len(self._model_store) > 0:
|
|
self._combo_default.set_active(0)
|
|
|
|
def _fetch_models(self):
|
|
ok, err = self._try_fetch_models()
|
|
if not ok:
|
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK,
|
|
f"Failed to fetch models:\n{err}")
|
|
d.run()
|
|
d.destroy()
|
|
|
|
def _try_fetch_models(self):
|
|
endpoint = {
|
|
"base_url": self._entry_url.get_text().strip(),
|
|
"api_key": self._entry_key.get_text().strip(),
|
|
"backend_type": self._combo_type.get_active_id() or "openai-compat",
|
|
}
|
|
ids, err = fetch_models_for_endpoint(endpoint)
|
|
if ids:
|
|
current = self._combo_default.get_active_text()
|
|
added = 0
|
|
for mid in ids:
|
|
# check dupes
|
|
found = any(self._model_store[i][0] == mid for i in range(len(self._model_store)))
|
|
if not found:
|
|
self._model_store.append([mid])
|
|
added += 1
|
|
self._refresh_default_combo(current)
|
|
return True, None
|
|
return False, err or "No models returned by endpoint"
|
|
|
|
def _diagnose_endpoint(self):
|
|
ep = {
|
|
"base_url": self._entry_url.get_text().strip(),
|
|
"api_key": self._entry_key.get_text().strip(),
|
|
"backend_type": self._combo_type.get_active_id() or "openai-compat",
|
|
"default_model": self._combo_default.get_active_text() or "",
|
|
}
|
|
name = ep.get("default_model") or "endpoint"
|
|
wait_dlg = Gtk.Dialog(title="Running Doctor…", parent=self, modal=True)
|
|
wait_dlg.set_default_size(280, 80)
|
|
lbl = Gtk.Label(label="Running endpoint diagnostics…")
|
|
lbl.set_margin_top(16)
|
|
lbl.set_margin_bottom(16)
|
|
wait_dlg.get_content_area().pack_start(lbl, True, True, 0)
|
|
wait_dlg.show_all()
|
|
|
|
def _run():
|
|
checks = run_endpoint_doctor(ep)
|
|
GLib.idle_add(wait_dlg.destroy)
|
|
GLib.idle_add(_show_doctor_results, self, name, checks)
|
|
|
|
threading.Thread(target=_run, daemon=True).start()
|
|
wait_dlg.run()
|
|
|
|
def _on_response(self, dialog, response):
|
|
if response != Gtk.ResponseType.OK:
|
|
self.destroy()
|
|
return
|
|
|
|
name = self._entry_name.get_text().strip()
|
|
if not name:
|
|
self._show_error("Name is required")
|
|
return
|
|
bt = self._combo_type.get_active_id() or PROVIDER_PRESETS.get(self._combo_preset.get_active_text() or "", {}).get("backend_type") or "openai-compat"
|
|
url = self._entry_url.get_text().strip()
|
|
key = self._entry_key.get_text().strip()
|
|
models = [self._model_store[i][0] for i in range(len(self._model_store))]
|
|
if not models:
|
|
ok, err = self._try_fetch_models()
|
|
if ok:
|
|
models = [self._model_store[i][0] for i in range(len(self._model_store))]
|
|
else:
|
|
d = Gtk.MessageDialog(
|
|
self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
|
|
f"Auto-fetch failed ({err}).\n\nAdd models manually now?"
|
|
)
|
|
r = d.run()
|
|
d.destroy()
|
|
if r == Gtk.ResponseType.YES:
|
|
self._entry_model.grab_focus()
|
|
return
|
|
self.destroy()
|
|
return
|
|
|
|
if not models:
|
|
self._show_error("At least one model is required")
|
|
self._entry_model.grab_focus()
|
|
return
|
|
default = self._combo_default.get_active_text() or models[0]
|
|
|
|
data = load_endpoints()
|
|
|
|
# If renaming, remove old entry
|
|
if self._existing_name and self._existing_name != name:
|
|
data["endpoints"] = [e for e in data["endpoints"] if e["name"] != self._existing_name]
|
|
|
|
# Check for duplicate name
|
|
existing = [e for e in data["endpoints"] if e["name"] == name and e != self._data]
|
|
if existing:
|
|
self._show_error(f'Endpoint "{name}" already exists')
|
|
return
|
|
|
|
new_ep = {"name": name, "backend_type": bt, "base_url": url,
|
|
"api_key": key, "default_model": default, "models": models,
|
|
"provider_preset": self._combo_preset.get_active_text() or "Custom"}
|
|
cc_ver = self._entry_cc_ver.get_text().strip()
|
|
if cc_ver:
|
|
new_ep["cc_version"] = cc_ver
|
|
new_ep["reasoning_enabled"] = self._switch_reasoning.get_active()
|
|
new_ep["reasoning_effort"] = self._combo_effort.get_active_id() or "medium"
|
|
preset_name = self._combo_preset.get_active_text() or "Custom"
|
|
preset = PROVIDER_PRESETS.get(preset_name, {})
|
|
if preset.get("oauth_provider"):
|
|
new_ep["oauth_provider"] = preset["oauth_provider"]
|
|
new_ep["base_url"] = normalize_base_url(new_ep["base_url"])
|
|
|
|
# Update or append
|
|
found = False
|
|
for i, e in enumerate(data["endpoints"]):
|
|
if e["name"] == name:
|
|
data["endpoints"][i] = new_ep
|
|
found = True
|
|
break
|
|
if not found:
|
|
data["endpoints"].append(new_ep)
|
|
if data.get("default") is None:
|
|
data["default"] = name
|
|
|
|
save_endpoints(data)
|
|
self._parent_mgr._rebuild()
|
|
self._parent_mgr._parent._on_endpoints_updated()
|
|
self.destroy()
|
|
|
|
def _show_error(self, msg):
|
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
|
|
d.run(); d.destroy()
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Entry point
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# BGP Pool Manager
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
class BGPPoolMgr(Gtk.Window):
|
|
def __init__(self, parent):
|
|
super().__init__(title="AI BGP — Pool Manager")
|
|
self.set_transient_for(parent)
|
|
self.set_default_size(620, 440)
|
|
self._parent = parent
|
|
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
vbox.set_margin_start(12)
|
|
vbox.set_margin_end(12)
|
|
vbox.set_margin_top(12)
|
|
vbox.set_margin_bottom(12)
|
|
self.add(vbox)
|
|
|
|
hdr = Gtk.Box(spacing=8)
|
|
vbox.pack_start(hdr, False, False, 0)
|
|
hdr.pack_start(Gtk.Label(label="<b>AI BGP Pools</b> — multi-provider routing with automatic failover", use_markup=True), False, False, 0)
|
|
|
|
self._store = Gtk.ListStore(str, str, str)
|
|
self._tree = Gtk.TreeView(model=self._store)
|
|
for i, (title, w) in enumerate([("Pool Name", 200), ("Routes", 250), ("Strategy", 100)]):
|
|
r = Gtk.CellRendererText()
|
|
c = Gtk.TreeViewColumn(title, r, text=i)
|
|
c.set_min_width(w)
|
|
self._tree.append_column(c)
|
|
self._tree.set_headers_visible(True)
|
|
sw = Gtk.ScrolledWindow()
|
|
sw.add(self._tree)
|
|
vbox.pack_start(sw, True, True, 0)
|
|
|
|
sel = self._tree.get_selection()
|
|
sel.connect("changed", lambda *_: self._on_select())
|
|
|
|
bbox = Gtk.Box(spacing=8)
|
|
vbox.pack_start(bbox, False, False, 0)
|
|
self._add_btn = Gtk.Button(label="Create Pool")
|
|
self._add_btn.connect("clicked", lambda b: self._add_pool())
|
|
bbox.pack_start(self._add_btn, True, True, 0)
|
|
self._edit_btn = Gtk.Button(label="Edit Pool")
|
|
self._edit_btn.connect("clicked", lambda b: self._edit_pool())
|
|
self._edit_btn.set_sensitive(False)
|
|
bbox.pack_start(self._edit_btn, True, True, 0)
|
|
self._del_btn = Gtk.Button(label="Delete Pool")
|
|
self._del_btn.connect("clicked", lambda b: self._del_pool())
|
|
self._del_btn.set_sensitive(False)
|
|
bbox.pack_start(self._del_btn, True, True, 0)
|
|
close_btn = Gtk.Button(label="Close")
|
|
close_btn.connect("clicked", lambda b: self.destroy())
|
|
bbox.pack_start(close_btn, True, True, 0)
|
|
|
|
self._rebuild()
|
|
self.show_all()
|
|
|
|
def _rebuild(self):
|
|
self._store.clear()
|
|
for pool in load_bgp_pools().get("pools", []):
|
|
routes_str = " → ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", []))
|
|
self._store.append([pool["name"], routes_str, pool.get("strategy", "failover")])
|
|
|
|
def _selected_name(self):
|
|
sel = self._tree.get_selection()
|
|
m, i = sel.get_selected()
|
|
return self._store[i][0] if i else None
|
|
|
|
def _on_select(self):
|
|
name = self._selected_name()
|
|
self._edit_btn.set_sensitive(bool(name))
|
|
self._del_btn.set_sensitive(bool(name))
|
|
|
|
def _add_pool(self):
|
|
d = BGPPoolEditDialog(self, None)
|
|
d.connect("response", lambda *_: self._rebuild())
|
|
|
|
def _edit_pool(self):
|
|
name = self._selected_name()
|
|
if name:
|
|
d = BGPPoolEditDialog(self, name)
|
|
d.connect("response", lambda *_: self._rebuild())
|
|
|
|
def _del_pool(self):
|
|
name = self._selected_name()
|
|
if not name:
|
|
return
|
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
|
|
f'Delete BGP pool "{name}"?')
|
|
r = d.run(); d.destroy()
|
|
if r != Gtk.ResponseType.YES:
|
|
return
|
|
data = load_bgp_pools()
|
|
data["pools"] = [p for p in data["pools"] if p["name"] != name]
|
|
save_bgp_pools(data)
|
|
self._rebuild()
|
|
self._parent._on_endpoints_updated()
|
|
|
|
|
|
class BGPPoolEditDialog(Gtk.Dialog):
|
|
def __init__(self, parent, existing_name):
|
|
title = "Edit BGP Pool" if existing_name else "Create BGP Pool"
|
|
Gtk.Dialog.__init__(self, title=title, parent=parent, modal=True)
|
|
self.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
|
self.add_button("Save", Gtk.ResponseType.OK)
|
|
self.set_default_size(580, 480)
|
|
|
|
self._existing_name = existing_name
|
|
self._parent_mgr = parent
|
|
|
|
data = load_bgp_pools()
|
|
pool = None
|
|
if existing_name:
|
|
for p in data.get("pools", []):
|
|
if p["name"] == existing_name:
|
|
pool = p
|
|
break
|
|
if not pool:
|
|
pool = {"name": "", "strategy": "failover", "routes": []}
|
|
|
|
area = self.get_content_area()
|
|
area.set_margin_start(12)
|
|
area.set_margin_end(12)
|
|
area.set_margin_top(12)
|
|
area.set_margin_bottom(12)
|
|
area.set_spacing(8)
|
|
|
|
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
|
|
area.pack_start(grid, False, False, 0)
|
|
|
|
grid.attach(Gtk.Label(label="Pool Name:", xalign=1), 0, 0, 1, 1)
|
|
self._entry_name = Gtk.Entry(text=pool["name"])
|
|
grid.attach(self._entry_name, 1, 0, 1, 1)
|
|
|
|
grid.attach(Gtk.Label(label="Strategy:", xalign=1), 0, 1, 1, 1)
|
|
self._combo_strategy = Gtk.ComboBoxText()
|
|
self._combo_strategy.append("failover", "Failover (try primary, fall back on error)")
|
|
self._combo_strategy.append("race", "Race (send to all, return fastest)")
|
|
self._combo_strategy.set_active_id(pool.get("strategy", "failover"))
|
|
grid.attach(self._combo_strategy, 1, 1, 1, 1)
|
|
|
|
area.pack_start(Gtk.Label(label="<b>Routes</b> (drag to reorder priority)", use_markup=True, xalign=0), False, False, 8)
|
|
|
|
self._route_store = Gtk.ListStore(str, str, str, str, str, str)
|
|
for r in pool.get("routes", []):
|
|
self._route_store.append([
|
|
r.get("name", ""), r.get("endpoint_name", ""),
|
|
r.get("target_url", ""), r.get("api_key", ""),
|
|
r.get("model", ""), str(r.get("priority", 99))
|
|
])
|
|
|
|
self._route_tree = Gtk.TreeView(model=self._route_store)
|
|
for i, (title, w) in enumerate([
|
|
("Route Name", 120), ("Endpoint", 120), ("URL", 150),
|
|
("API Key", 80), ("Model", 120), ("Priority", 60)
|
|
]):
|
|
renderer = Gtk.CellRendererText()
|
|
renderer.set_property("editable", False)
|
|
col = Gtk.TreeViewColumn(title, renderer, text=i)
|
|
col.set_min_width(w)
|
|
col.set_resizable(True)
|
|
self._route_tree.append_column(col)
|
|
self._route_tree.set_headers_visible(True)
|
|
sw = Gtk.ScrolledWindow()
|
|
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
sw.add(self._route_tree)
|
|
sw.set_min_content_height(200)
|
|
area.pack_start(sw, True, True, 0)
|
|
|
|
bbox = Gtk.Box(spacing=6)
|
|
area.pack_start(bbox, False, False, 0)
|
|
add_r = Gtk.Button(label="Add Route")
|
|
add_r.connect("clicked", lambda b: self._add_route())
|
|
bbox.pack_start(add_r, True, True, 0)
|
|
edit_r = Gtk.Button(label="Edit Route")
|
|
edit_r.connect("clicked", lambda b: self._edit_route())
|
|
bbox.pack_start(edit_r, True, True, 0)
|
|
rm_r = Gtk.Button(label="Remove Route")
|
|
rm_r.connect("clicked", lambda b: self._remove_route())
|
|
bbox.pack_start(rm_r, True, True, 0)
|
|
up_r = Gtk.Button(label="↑ Up")
|
|
up_r.connect("clicked", lambda b: self._move_route(-1))
|
|
bbox.pack_start(up_r, True, True, 0)
|
|
down_r = Gtk.Button(label="↓ Down")
|
|
down_r.connect("clicked", lambda b: self._move_route(1))
|
|
bbox.pack_start(down_r, True, True, 0)
|
|
|
|
self.show_all()
|
|
|
|
if self.run() == Gtk.ResponseType.OK:
|
|
self._save()
|
|
|
|
self.destroy()
|
|
|
|
def _save(self):
|
|
name = self._entry_name.get_text().strip()
|
|
if not name:
|
|
return
|
|
strategy = self._combo_strategy.get_active_id() or "failover"
|
|
routes = []
|
|
for i, row in enumerate(self._route_store):
|
|
if not row[2]:
|
|
continue
|
|
routes.append({
|
|
"name": row[0] or f"Route {i+1}",
|
|
"endpoint_name": row[1],
|
|
"target_url": row[2],
|
|
"api_key": row[3],
|
|
"model": row[4],
|
|
"priority": i + 1,
|
|
"reasoning_enabled": True,
|
|
"reasoning_effort": "medium",
|
|
})
|
|
data = load_bgp_pools()
|
|
if self._existing_name:
|
|
data["pools"] = [p for p in data["pools"] if p["name"] != self._existing_name]
|
|
data["pools"].append({"name": name, "strategy": strategy, "routes": routes})
|
|
save_bgp_pools(data)
|
|
self._parent_mgr._parent._on_endpoints_updated()
|
|
|
|
def _add_route(self):
|
|
endpoints = load_endpoints().get("endpoints", [])
|
|
if not endpoints:
|
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK,
|
|
"No endpoints configured. Add endpoints in Manage Endpoints first.")
|
|
d.run(); d.destroy()
|
|
return
|
|
d = BGPRouteDialog(self, endpoints, None)
|
|
if d.result:
|
|
r = d.result
|
|
self._route_store.append([
|
|
r.get("name", ""), r.get("endpoint_name", ""),
|
|
r.get("target_url", ""), r.get("api_key", ""),
|
|
r.get("model", ""), str(r.get("priority", 99))
|
|
])
|
|
|
|
def _edit_route(self):
|
|
sel = self._route_tree.get_selection()
|
|
m, i = sel.get_selected()
|
|
if not i:
|
|
return
|
|
endpoints = load_endpoints().get("endpoints", [])
|
|
existing = {
|
|
"name": m[i][0], "endpoint_name": m[i][1],
|
|
"target_url": m[i][2], "api_key": m[i][3],
|
|
"model": m[i][4], "priority": int(m[i][5]) if m[i][5] else 99,
|
|
}
|
|
d = BGPRouteDialog(self, endpoints, existing)
|
|
if d.result:
|
|
r = d.result
|
|
m[i][0] = r.get("name", "")
|
|
m[i][1] = r.get("endpoint_name", "")
|
|
m[i][2] = r.get("target_url", "")
|
|
m[i][3] = r.get("api_key", "")
|
|
m[i][4] = r.get("model", "")
|
|
m[i][5] = str(r.get("priority", 99))
|
|
|
|
def _remove_route(self):
|
|
sel = self._route_tree.get_selection()
|
|
m, i = sel.get_selected()
|
|
if i:
|
|
self._route_store.remove(i)
|
|
|
|
def _move_route(self, direction):
|
|
sel = self._route_tree.get_selection()
|
|
m, i = sel.get_selected()
|
|
if not i:
|
|
return
|
|
path = m.get_path(i)
|
|
idx = path.get_indices()[0]
|
|
new_idx = idx + direction
|
|
if new_idx < 0 or new_idx >= len(self._route_store):
|
|
return
|
|
row_data = [m[idx][c] for c in range(6)]
|
|
self._route_store.remove(m.get_iter(Gtk.TreePath(idx)))
|
|
new_iter = self._route_store.insert(new_idx)
|
|
for c, v in enumerate(row_data):
|
|
self._route_store.set_value(new_iter, c, v)
|
|
|
|
|
|
class BGPRouteDialog(Gtk.Dialog):
|
|
def __init__(self, parent, endpoints, existing):
|
|
Gtk.Dialog.__init__(self, title="BGP Route", parent=parent, modal=True)
|
|
self.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
|
self.add_button("OK", Gtk.ResponseType.OK)
|
|
self.set_default_size(440, 300)
|
|
self.result = None
|
|
|
|
area = self.get_content_area()
|
|
area.set_margin_start(12)
|
|
area.set_margin_end(12)
|
|
area.set_margin_top(12)
|
|
area.set_margin_bottom(12)
|
|
area.set_spacing(6)
|
|
|
|
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
|
|
area.pack_start(grid, False, False, 0)
|
|
|
|
def add_row(row, label, widget):
|
|
grid.attach(Gtk.Label(label=label, xalign=1), 0, row, 1, 1)
|
|
grid.attach(widget, 1, row, 1, 1)
|
|
|
|
self._entry_name = Gtk.Entry(text=existing.get("name", "") if existing else "")
|
|
add_row(0, "Route Name:", self._entry_name)
|
|
|
|
self._combo_ep = Gtk.ComboBoxText()
|
|
ep_names = [e["name"] for e in endpoints]
|
|
for en in ep_names:
|
|
self._combo_ep.append(en, en)
|
|
if existing and existing.get("endpoint_name") in ep_names:
|
|
self._combo_ep.set_active_id(existing["endpoint_name"])
|
|
elif ep_names:
|
|
self._combo_ep.set_active(0)
|
|
self._combo_ep.connect("changed", lambda b: self._on_ep_changed(endpoints))
|
|
add_row(1, "Endpoint:", self._combo_ep)
|
|
|
|
self._entry_url = Gtk.Entry()
|
|
add_row(2, "URL:", self._entry_url)
|
|
|
|
self._entry_key = Gtk.Entry()
|
|
self._entry_key.set_visibility(False)
|
|
add_row(3, "API Key:", self._entry_key)
|
|
|
|
self._combo_model = Gtk.ComboBoxText()
|
|
add_row(4, "Model:", self._combo_model)
|
|
|
|
if existing:
|
|
self._entry_url.set_text(existing.get("target_url", ""))
|
|
self._entry_key.set_text(existing.get("api_key", ""))
|
|
self._on_ep_changed(endpoints)
|
|
if existing and existing.get("model"):
|
|
self._combo_model.set_active_id(existing["model"])
|
|
|
|
self.show_all()
|
|
if self.run() == Gtk.ResponseType.OK:
|
|
ep_name = self._combo_ep.get_active_text() or ""
|
|
ep = None
|
|
for e in endpoints:
|
|
if e["name"] == ep_name:
|
|
ep = e
|
|
break
|
|
self.result = {
|
|
"name": self._entry_name.get_text().strip() or ep_name,
|
|
"endpoint_name": ep_name,
|
|
"target_url": self._entry_url.get_text().strip(),
|
|
"api_key": self._entry_key.get_text().strip(),
|
|
"model": self._combo_model.get_active_text() or "",
|
|
"priority": 99,
|
|
}
|
|
if ep:
|
|
self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True)
|
|
self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium")
|
|
self.result["oauth_provider"] = ep.get("oauth_provider", "")
|
|
self.destroy()
|
|
|
|
def _on_ep_changed(self, endpoints):
|
|
ep_name = self._combo_ep.get_active_text()
|
|
ep = None
|
|
for e in endpoints:
|
|
if e["name"] == ep_name:
|
|
ep = e
|
|
break
|
|
if ep:
|
|
self._entry_url.set_text(normalize_base_url(ep.get("base_url", "")))
|
|
self._entry_key.set_text(ep.get("api_key", ""))
|
|
self._combo_model.remove_all()
|
|
for m in ep.get("models", []):
|
|
mid = normalize_model_id(m) if m else ""
|
|
self._combo_model.append(mid, m)
|
|
if ep.get("default_model"):
|
|
self._combo_model.set_active_id(normalize_model_id(ep["default_model"]))
|
|
elif len(ep.get("models", [])) > 0:
|
|
self._combo_model.set_active(0)
|
|
|
|
|
|
_U = {
|
|
"base": "#0C0E16", "surface0": "#161928", "surface1": "#1E2235",
|
|
"surface2": "#2A2F47", "text": "#E4E6F0", "subtext": "#B0B4C8",
|
|
"dim": "#5C6180", "accent": "#7EB8F7", "blue": "#5DA4E8",
|
|
"sapphire": "#4EC5C1", "green": "#59D4A0", "yellow": "#F0C75E",
|
|
"red": "#F06A77", "peach": "#F09860", "teal": "#4EC5C1",
|
|
"lavender": "#A899F0", "sky": "#70C8E8", "maroon": "#C44B5C",
|
|
"flamingo": "#E878B0", "rosewater": "#F0D0C0",
|
|
"model_palette": ["#F09860", "#4EC5C1", "#5DA4E8", "#59D4A0",
|
|
"#F0C75E", "#A899F0", "#70C8E8", "#E878B0",
|
|
"#C44B5C", "#F0D0C0", "#7EB8F7", "#F06A77"],
|
|
}
|
|
|
|
_USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json"
|
|
|
|
def _load_usage_stats():
|
|
try:
|
|
if _USAGE_STATS_FILE.exists():
|
|
return json.loads(_USAGE_STATS_FILE.read_text())
|
|
except Exception:
|
|
pass
|
|
return {"providers": {}, "updated": None}
|
|
|
|
def _fmt_tok(n):
|
|
if n >= 1_000_000:
|
|
return f"{n/1_000_000:.1f}M"
|
|
if n >= 1_000:
|
|
return f"{n/1_000:.1f}K"
|
|
return str(n)
|
|
|
|
def _fmt_dur(s):
|
|
if s >= 3600:
|
|
return f"{s/3600:.1f}h"
|
|
if s >= 60:
|
|
return f"{s/60:.1f}m"
|
|
return f"{s:.1f}s"
|
|
|
|
def _status_pill(success_rate, fail_pct):
|
|
if fail_pct > 0.15:
|
|
return ("ERR", _U["red"])
|
|
if fail_pct > 0.05:
|
|
return ("WARN", _U["yellow"])
|
|
return ("OK", _U["green"])
|
|
|
|
def _make_css_widget(css_str):
|
|
p = Gtk.CssProvider()
|
|
p.load_from_data(css_str.encode())
|
|
return p
|
|
|
|
def _apply_css(widget, css_str):
|
|
ctx = widget.get_style_context()
|
|
ctx.add_provider(_make_css_widget(css_str), Gtk.STYLE_PROVIDER_PRIORITY_USER)
|
|
|
|
|
|
class UsageWindow(Gtk.Window):
|
|
def __init__(self, parent):
|
|
super().__init__(title="Usage Dashboard")
|
|
self.set_transient_for(parent)
|
|
self.set_default_size(720, 640)
|
|
self.set_position(Gtk.WindowPosition.CENTER)
|
|
self._parent = parent
|
|
|
|
_apply_css(self, f"""
|
|
window {{ background-color: {_U["base"]}; }}
|
|
separator {{ background-color: {_U["surface1"]}; }}
|
|
""")
|
|
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
self.add(vbox)
|
|
|
|
self._build_header(vbox)
|
|
self._build_summary_strip(vbox)
|
|
sep = Gtk.Separator()
|
|
vbox.pack_start(sep, False, False, 0)
|
|
|
|
self._cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
self._cards_box.set_margin_top(8)
|
|
sw = Gtk.ScrolledWindow()
|
|
sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
|
sw.add(self._cards_box)
|
|
vbox.pack_start(sw, True, True, 0)
|
|
|
|
self._refresh()
|
|
self.show_all()
|
|
|
|
def _build_header(self, parent):
|
|
hdr = Gtk.Box(spacing=8)
|
|
hdr.set_margin_start(16)
|
|
hdr.set_margin_end(16)
|
|
hdr.set_margin_top(12)
|
|
hdr.set_margin_bottom(6)
|
|
parent.pack_start(hdr, False, False, 0)
|
|
|
|
bolt = Gtk.Label()
|
|
bolt.set_markup(f'<span foreground="{_U["accent"]}" font="14">\u26A1</span>')
|
|
hdr.pack_start(bolt, False, False, 0)
|
|
|
|
title = Gtk.Label()
|
|
title.set_markup(f'<span font="14" weight="bold" foreground="{_U["text"]}">Usage Dashboard</span>')
|
|
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'<span foreground="{_U["dim"]}" size="small">Never</span>')
|
|
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'<span foreground="{_U["dim"]}" size="x-small">{icon} {label}</span>')
|
|
lbl.set_xalign(0)
|
|
box.pack_start(lbl, False, False, 0)
|
|
val = Gtk.Label()
|
|
val.set_markup(f'<span weight="bold" foreground="{_U["text"]}" size="small">-</span>')
|
|
val.set_xalign(0)
|
|
box.pack_start(val, False, False, 0)
|
|
box.set_margin_end(20)
|
|
strip.pack_start(box, False, False, 0)
|
|
self._kpi_boxes[key] = val
|
|
|
|
def _refresh(self):
|
|
for c in self._cards_box.get_children():
|
|
self._cards_box.remove(c)
|
|
stats = _load_usage_stats()
|
|
updated = stats.get("updated")
|
|
if updated:
|
|
self._updated_lbl.set_markup(f'<span foreground="{_U["dim"]}" size="small">{updated}</span>')
|
|
providers = stats.get("providers", {})
|
|
if not providers:
|
|
empty = Gtk.Label()
|
|
empty.set_markup(f'<span foreground="{_U["dim"]}" size="large">No usage data yet.\nLaunch a session to start tracking.</span>')
|
|
empty.set_margin_top(60)
|
|
self._cards_box.pack_start(empty, False, False, 0)
|
|
self._cards_box.show_all()
|
|
return
|
|
|
|
total_req = 0
|
|
total_tok_in = 0
|
|
total_tok_out = 0
|
|
total_dur = 0.0
|
|
n_ok = 0
|
|
n_warn = 0
|
|
n_err = 0
|
|
|
|
sorted_providers = sorted(providers.items(), key=lambda x: x[1].get("total_requests", 0), reverse=True)
|
|
for prov_name, prov_data in sorted_providers:
|
|
t = prov_data.get("total_requests", 0)
|
|
total_req += t
|
|
total_tok_in += prov_data.get("total_tokens_in", 0)
|
|
total_tok_out += prov_data.get("total_tokens_out", 0)
|
|
total_dur += prov_data.get("total_duration_s", 0.0)
|
|
fail = prov_data.get("failures", 0)
|
|
fail_pct = fail / t if t > 0 else 0
|
|
_, sc = _status_pill(0, fail_pct)
|
|
if fail_pct > 0.15:
|
|
n_err += 1
|
|
elif fail_pct > 0.05:
|
|
n_warn += 1
|
|
else:
|
|
n_ok += 1
|
|
|
|
self._kpi_boxes["providers"].set_markup(
|
|
f'<span weight="bold" foreground="{_U["text"]}" size="small">{len(providers)}</span>')
|
|
self._kpi_boxes["requests"].set_markup(
|
|
f'<span weight="bold" foreground="{_U["text"]}" size="small">{total_req:,}</span>')
|
|
tok_sum = total_tok_in + total_tok_out
|
|
tok_str = f"{_fmt_tok(tok_sum)} in:{_fmt_tok(total_tok_in)} out:{_fmt_tok(total_tok_out)}" if tok_sum else "N/A"
|
|
self._kpi_boxes["tokens"].set_markup(
|
|
f'<span weight="bold" foreground="{_U["text"]}" size="small">{tok_str}</span>')
|
|
avg_lat = total_dur / total_req if total_req > 0 else 0
|
|
self._kpi_boxes["latency"].set_markup(
|
|
f'<span weight="bold" foreground="{_U["text"]}" size="small">{_fmt_dur(avg_lat)}</span>')
|
|
|
|
dots_parts = []
|
|
if n_ok:
|
|
dots_parts.append(f'<span foreground="{_U["green"]}">\u25CF{n_ok}</span>')
|
|
if n_warn:
|
|
dots_parts.append(f'<span foreground="{_U["yellow"]}">\u25D0{n_warn}</span>')
|
|
if n_err:
|
|
dots_parts.append(f'<span foreground="{_U["red"]}">\u2717{n_err}</span>')
|
|
if dots_parts:
|
|
self._status_dots.set_markup(" ".join(dots_parts))
|
|
|
|
for prov_name, prov_data in sorted_providers:
|
|
card = self._build_card(prov_name, prov_data)
|
|
self._cards_box.pack_start(card, False, False, 0)
|
|
self._cards_box.show_all()
|
|
|
|
def _build_card(self, name, data):
|
|
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
card.set_margin_start(12)
|
|
card.set_margin_end(12)
|
|
_apply_css(card, f"""
|
|
box {{ background-color: {_U["surface0"]}; border-radius: 10px;
|
|
border: 1px solid {_U["surface1"]}; }}
|
|
""")
|
|
|
|
total = data.get("total_requests", 0)
|
|
ok = data.get("successes", 0)
|
|
fail = data.get("failures", 0)
|
|
success_rate = ok / total if total > 0 else 1.0
|
|
fail_pct = fail / total if total > 0 else 0
|
|
status_text, status_color = _status_pill(success_rate, fail_pct)
|
|
|
|
border_color = status_color
|
|
_apply_css(card, f"""
|
|
box {{ background-color: {_U["surface0"]}; border-radius: 10px;
|
|
border: 1px solid {border_color}; }}
|
|
""")
|
|
|
|
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
|
|
inner.set_margin_start(14)
|
|
inner.set_margin_end(14)
|
|
inner.set_margin_top(10)
|
|
inner.set_margin_bottom(10)
|
|
card.pack_start(inner, False, False, 0)
|
|
|
|
top = Gtk.Box(spacing=6)
|
|
inner.pack_start(top, False, False, 0)
|
|
|
|
dot = Gtk.Label()
|
|
dot.set_markup(f'<span foreground="{status_color}" font="10">\u25CF</span>')
|
|
top.pack_start(dot, False, False, 0)
|
|
|
|
name_lbl = Gtk.Label()
|
|
short = name.replace("https://", "").replace("http://", "").split("/")[0]
|
|
name_lbl.set_markup(f'<span weight="bold" foreground="{_U["text"]}" size="medium">{short}</span>')
|
|
top.pack_start(name_lbl, False, False, 0)
|
|
|
|
pill = Gtk.Label()
|
|
pill.set_markup(f'<span foreground="{_U["base"]}" bgalpha="80%" background="{status_color}" weight="bold" size="x-small"> {status_text} </span>')
|
|
top.pack_start(pill, False, False, 4)
|
|
|
|
req_lbl = Gtk.Label()
|
|
req_lbl.set_markup(f'<span foreground="{_U["subtext"]}" size="small">{total} req</span>')
|
|
top.pack_start(req_lbl, False, False, 6)
|
|
|
|
last_used = data.get("last_used", "")
|
|
if last_used:
|
|
lu_lbl = Gtk.Label()
|
|
lu_lbl.set_markup(f'<span foreground="{_U["dim"]}" size="x-small">{last_used}</span>')
|
|
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'<span foreground="{_U["yellow"]}" size="x-small">\u26A1</span>')
|
|
gauge_box.pack_start(gauge_label, False, False, 0)
|
|
|
|
bar = Gtk.ProgressBar()
|
|
bar.set_fraction(success_rate)
|
|
bar_pct = int(success_rate * 100)
|
|
bar.set_text(f"{bar_pct}%")
|
|
bar.set_show_text(True)
|
|
bar_css = f"""
|
|
progress {{ background-color: {status_color}; border-radius: 6px; }}
|
|
trough {{ background-color: {_U["surface1"]}; border-radius: 6px; min-height: 12px; }}
|
|
"""
|
|
_apply_css(bar, bar_css)
|
|
bar.set_hexpand(True)
|
|
gauge_box.pack_start(bar, True, True, 0)
|
|
|
|
if fail > 0:
|
|
fail_lbl = Gtk.Label()
|
|
fail_lbl.set_markup(f'<span foreground="{_U["red"]}" size="x-small">{fail} fail</span>')
|
|
gauge_box.pack_end(fail_lbl, False, False, 0)
|
|
|
|
metrics_box = Gtk.Box(spacing=0)
|
|
metrics_box.set_margin_top(4)
|
|
inner.pack_start(metrics_box, False, False, 0)
|
|
|
|
t_in = data.get("total_tokens_in", 0)
|
|
t_out = data.get("total_tokens_out", 0)
|
|
dur = data.get("total_duration_s", 0.0)
|
|
avg_dur = dur / total if total > 0 else 0
|
|
|
|
for label, value, color in [
|
|
("Tokens In", f"{_fmt_tok(t_in)}", _U["sapphire"]),
|
|
("Tokens Out", f"{_fmt_tok(t_out)}", _U["peach"]),
|
|
("Avg Latency", _fmt_dur(avg_dur), _U["sky"]),
|
|
("Duration", _fmt_dur(dur), _U["lavender"]),
|
|
]:
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
l = Gtk.Label()
|
|
l.set_markup(f'<span foreground="{_U["dim"]}" size="x-small">{label}</span>')
|
|
l.set_xalign(0)
|
|
box.pack_start(l, False, False, 0)
|
|
v = Gtk.Label()
|
|
v.set_markup(f'<span weight="bold" foreground="{color}" size="small">{value}</span>')
|
|
v.set_xalign(0)
|
|
box.pack_start(v, False, False, 0)
|
|
box.set_margin_end(16)
|
|
metrics_box.pack_start(box, False, False, 0)
|
|
|
|
models = data.get("models", {})
|
|
if models:
|
|
self._build_models_section(inner, models, total)
|
|
|
|
last_err = data.get("last_error")
|
|
if last_err:
|
|
err_box = Gtk.Box(spacing=4)
|
|
err_box.set_margin_top(4)
|
|
inner.pack_start(err_box, False, False, 0)
|
|
icon = Gtk.Label()
|
|
icon.set_markup(f'<span foreground="{_U["red"]}" size="x-small">\u26A0</span>')
|
|
err_box.pack_start(icon, False, False, 0)
|
|
err_lbl = Gtk.Label()
|
|
err_lbl.set_markup(f'<span foreground="{_U["red"]}" size="x-small">{last_err}</span>')
|
|
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'<span foreground="{_U["lavender"]}" size="x-small">\U0001F916</span>')
|
|
header.pack_start(icon, False, False, 0)
|
|
lbl = Gtk.Label()
|
|
lbl.set_markup(f'<span foreground="{_U["lavender"]}" weight="bold" size="x-small">Models</span>')
|
|
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'<span foreground="{color}" size="x-small">\u25CF</span>')
|
|
row.pack_start(dot, False, False, 0)
|
|
m_lbl = Gtk.Label()
|
|
m_lbl.set_markup(f'<span foreground="{_U["subtext"]}" size="x-small">{mname}</span>')
|
|
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'<span foreground="{_U["dim"]}" size="x-small">{pct:.0f}% ({m_req})</span>')
|
|
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'<span foreground="{_U["dim"]}" size="x-small">in:{_fmt_tok(m_in)} out:{_fmt_tok(m_out)}</span>')
|
|
row.pack_end(tok_lbl, False, False, 0)
|
|
|
|
|
|
def main():
|
|
for d in [LOG_DIR, PROXY_CONFIG_DIR]:
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create default endpoints if none exist
|
|
if not ENDPOINTS_FILE.exists():
|
|
save_endpoints({
|
|
"default": "OpenAI",
|
|
"endpoints": [
|
|
{"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1",
|
|
"api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"],
|
|
"provider_preset": "OpenAI"},
|
|
{"name": "Z.AI", "backend_type": "openai-compat",
|
|
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
|
"api_key": "", "default_model": "glm-5.1",
|
|
"models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"],
|
|
"provider_preset": "Custom"},
|
|
],
|
|
})
|
|
|
|
w = LauncherWin()
|
|
w.connect("destroy", Gtk.main_quit)
|
|
Gtk.main()
|
|
|
|
class RequestHistoryWindow(Gtk.Window):
|
|
_SNAP_DIR = Path.home() / ".cache/codex-proxy/requests"
|
|
|
|
def __init__(self, parent):
|
|
Gtk.Window.__init__(self, title="Request History")
|
|
self.set_transient_for(parent)
|
|
self.set_default_size(720, 500)
|
|
self.set_position(Gtk.WindowPosition.CENTER)
|
|
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
vbox.set_margin_start(10)
|
|
vbox.set_margin_end(10)
|
|
vbox.set_margin_top(10)
|
|
vbox.set_margin_bottom(10)
|
|
self.add(vbox)
|
|
|
|
hdr = Gtk.Box(spacing=8)
|
|
vbox.pack_start(hdr, False, False, 0)
|
|
lbl = Gtk.Label(label="<b>Request History</b>")
|
|
lbl.set_use_markup(True)
|
|
hdr.pack_start(lbl, False, False, 0)
|
|
refresh_btn = Gtk.Button(label="Refresh")
|
|
refresh_btn.connect("clicked", lambda b: self._load())
|
|
hdr.pack_end(refresh_btn, False, False, 0)
|
|
clear_btn = Gtk.Button(label="Clear All")
|
|
clear_btn.connect("clicked", lambda b: self._clear_all())
|
|
hdr.pack_end(clear_btn, False, False, 0)
|
|
|
|
paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
|
|
vbox.pack_start(paned, True, True, 0)
|
|
|
|
top_sw = Gtk.ScrolledWindow()
|
|
top_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
paned.pack1(top_sw, resize=True, shrink=False)
|
|
|
|
self._store = Gtk.ListStore(str, str, str, str, str, str)
|
|
self._tree = Gtk.TreeView(model=self._store)
|
|
for i, (title, w) in enumerate([("Time", 140), ("Model", 140), ("Status", 80), ("Duration", 70), ("ID", 180), ("Error", 120)]):
|
|
col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i)
|
|
col.set_resizable(True)
|
|
col.set_min_width(w)
|
|
self._tree.append_column(col)
|
|
self._tree.connect("row-activated", self._on_row_activated)
|
|
top_sw.add(self._tree)
|
|
|
|
self._detail = Gtk.TextView()
|
|
self._detail.set_editable(False)
|
|
self._detail.set_monospace(True)
|
|
self._detail.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
|
bottom_sw = Gtk.ScrolledWindow()
|
|
bottom_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
bottom_sw.add(self._detail)
|
|
paned.pack2(bottom_sw, resize=True, shrink=False)
|
|
|
|
self._snapshots = []
|
|
self._load()
|
|
self.show_all()
|
|
|
|
def _load(self):
|
|
self._store.clear()
|
|
self._snapshots = []
|
|
snap_dir = self._SNAP_DIR
|
|
if not snap_dir.exists():
|
|
return
|
|
files = sorted(snap_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
for f in files[:200]:
|
|
try:
|
|
data = json.loads(f.read_text())
|
|
meta = data.get("_meta", {})
|
|
self._snapshots.append(data)
|
|
ts = meta.get("ts_iso", "")[:19].replace("T", " ")
|
|
model = meta.get("model", "?")
|
|
status = meta.get("status", "unknown")
|
|
dur = f"{meta['duration_s']:.1f}s" if meta.get("duration_s") is not None else "-"
|
|
rid = meta.get("request_id", "")[:28]
|
|
err = (meta.get("error") or "")[:60]
|
|
self._store.append([ts, model, status, dur, rid, err])
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_row_activated(self, tree, path, column):
|
|
idx = path[0]
|
|
if idx < len(self._snapshots):
|
|
data = self._snapshots[idx]
|
|
buf = self._detail.get_buffer()
|
|
buf.set_text(json.dumps(data, indent=2, ensure_ascii=False)[:50000])
|
|
|
|
def _clear_all(self):
|
|
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO,
|
|
"Delete all request snapshots?")
|
|
r = d.run()
|
|
d.destroy()
|
|
if r != Gtk.ResponseType.YES:
|
|
return
|
|
snap_dir = self._SNAP_DIR
|
|
if snap_dir.exists():
|
|
for f in snap_dir.glob("*.json"):
|
|
try:
|
|
f.unlink()
|
|
except Exception:
|
|
pass
|
|
self._store.clear()
|
|
self._snapshots = []
|
|
self._detail.get_buffer().set_text("")
|
|
|
|
class BenchmarkWindow(Gtk.Window):
|
|
_BENCH_PROMPT = "In exactly 3 bullet points, explain why the sky is blue."
|
|
_BENCH_TOOLS = [{"type": "function", "function": {"name": "get_weather",
|
|
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}}}}]
|
|
|
|
def __init__(self, parent):
|
|
Gtk.Window.__init__(self, title="Model Benchmark")
|
|
self.set_transient_for(parent)
|
|
self.set_default_size(820, 560)
|
|
self.set_position(Gtk.WindowPosition.CENTER)
|
|
self._running = False
|
|
self._ep_data = load_endpoints()
|
|
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
vbox.set_margin_start(10)
|
|
vbox.set_margin_end(10)
|
|
vbox.set_margin_top(10)
|
|
vbox.set_margin_bottom(10)
|
|
self.add(vbox)
|
|
|
|
hdr = Gtk.Box(spacing=8)
|
|
vbox.pack_start(hdr, False, False, 0)
|
|
lbl = Gtk.Label(label="<b>Multi-Provider Benchmark</b>")
|
|
lbl.set_use_markup(True)
|
|
hdr.pack_start(lbl, False, False, 0)
|
|
self._run_btn = Gtk.Button(label="Run Benchmark")
|
|
self._run_btn.connect("clicked", lambda b: self._run())
|
|
hdr.pack_end(self._run_btn, False, False, 0)
|
|
|
|
lanes_box = Gtk.Box(spacing=6)
|
|
vbox.pack_start(lanes_box, False, False, 0)
|
|
|
|
self._lanes = []
|
|
for i in range(3):
|
|
frame = Gtk.Frame(label=f"{'A' if i == 0 else 'B' if i == 1 else 'C'}" if i < 2 else None)
|
|
if i == 2:
|
|
self._c_frame = frame
|
|
self._c_check = Gtk.CheckButton(label="Enable Lane C")
|
|
self._c_check.set_active(False)
|
|
frame.set_label_widget(self._c_check)
|
|
frame.set_sensitive(False)
|
|
self._c_check.connect("toggled", lambda b: frame.set_sensitive(b.get_active()))
|
|
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
|
inner.set_margin_start(6)
|
|
inner.set_margin_end(6)
|
|
inner.set_margin_top(4)
|
|
inner.set_margin_bottom(4)
|
|
frame.add(inner)
|
|
lanes_box.pack_start(frame, True, True, 0)
|
|
|
|
row_ep = Gtk.Box(spacing=4)
|
|
inner.pack_start(row_ep, False, False, 0)
|
|
row_ep.pack_start(Gtk.Label(label="Endpoint:"), False, False, 0)
|
|
ep_combo = Gtk.ComboBoxText()
|
|
for ep in self._ep_data.get("endpoints", []):
|
|
ep_combo.append(ep["name"], ep["name"])
|
|
row_ep.pack_start(ep_combo, True, True, 0)
|
|
|
|
row_m = Gtk.Box(spacing=4)
|
|
inner.pack_start(row_m, False, False, 0)
|
|
row_m.pack_start(Gtk.Label(label="Model:"), False, False, 0)
|
|
m_combo = Gtk.ComboBoxText()
|
|
m_combo.set_entry_text_column(0)
|
|
row_m.pack_start(m_combo, True, True, 0)
|
|
|
|
ep_combo.connect("changed", lambda b, mc=m_combo: self._update_lane_models(b, mc))
|
|
|
|
self._lanes.append({"ep": ep_combo, "model": m_combo})
|
|
|
|
default_name = self._ep_data.get("default")
|
|
if default_name:
|
|
self._lanes[0]["ep"].set_active_id(default_name)
|
|
eps = self._ep_data.get("endpoints", [])
|
|
if len(eps) > 1:
|
|
self._lanes[1]["ep"].set_active_id(eps[1]["name"])
|
|
elif eps:
|
|
self._lanes[1]["ep"].set_active_id(eps[0]["name"])
|
|
if len(eps) > 2:
|
|
self._lanes[2]["ep"].set_active_id(eps[2]["name"])
|
|
elif len(eps) > 1:
|
|
self._lanes[2]["ep"].set_active_id(eps[1]["name"])
|
|
|
|
tests_box = Gtk.Box(spacing=6)
|
|
vbox.pack_start(tests_box, False, False, 0)
|
|
self._test_ttft = Gtk.CheckButton(label="Time to First Token")
|
|
self._test_ttft.set_active(True)
|
|
tests_box.pack_start(self._test_ttft, False, False, 0)
|
|
self._test_total = Gtk.CheckButton(label="Total Latency")
|
|
self._test_total.set_active(True)
|
|
tests_box.pack_start(self._test_total, False, False, 0)
|
|
self._test_tools = Gtk.CheckButton(label="Tool Call")
|
|
self._test_tools.set_active(True)
|
|
tests_box.pack_start(self._test_tools, False, False, 0)
|
|
self._test_tps = Gtk.CheckButton(label="Tokens/sec")
|
|
self._test_tps.set_active(True)
|
|
tests_box.pack_start(self._test_tps, False, False, 0)
|
|
|
|
results_sw = Gtk.ScrolledWindow()
|
|
results_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
vbox.pack_start(results_sw, True, True, 0)
|
|
|
|
self._results_store = Gtk.ListStore(str, str, str, str, str)
|
|
self._results_tree = Gtk.TreeView(model=self._results_store)
|
|
for i, title in enumerate(["Test", "Lane A", "Lane B", "Lane C", "Winner"]):
|
|
col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i)
|
|
col.set_resizable(True)
|
|
self._results_tree.append_column(col)
|
|
results_sw.add(self._results_tree)
|
|
|
|
self._status = Gtk.Label(label="Select endpoints and models per lane, then Run Benchmark.")
|
|
self._status.set_xalign(0)
|
|
vbox.pack_start(self._status, False, False, 0)
|
|
|
|
self.show_all()
|
|
|
|
def _update_lane_models(self, ep_combo, model_combo):
|
|
name = ep_combo.get_active_text()
|
|
if not name:
|
|
return
|
|
ep = get_endpoint(name)
|
|
models = (ep or {}).get("models", [])
|
|
active = model_combo.get_active_text()
|
|
model_combo.remove_all()
|
|
for m in models:
|
|
model_combo.append(m, m)
|
|
if active and any(m == active for m in models):
|
|
model_combo.set_active_id(active)
|
|
elif models:
|
|
model_combo.set_active(0)
|
|
|
|
def _collect_lanes(self):
|
|
active = []
|
|
for i, lane in enumerate(self._lanes):
|
|
if i == 2 and not self._c_check.get_active():
|
|
continue
|
|
ep_name = lane["ep"].get_active_text()
|
|
model = lane["model"].get_active_text()
|
|
if not ep_name or not model:
|
|
continue
|
|
ep = get_endpoint(ep_name)
|
|
if not ep:
|
|
continue
|
|
active.append({"ep": ep, "model": model, "label": f"{ep_name}/{model}"})
|
|
return active
|
|
|
|
def _run(self):
|
|
if self._running:
|
|
return
|
|
lanes = self._collect_lanes()
|
|
if len(lanes) < 2:
|
|
self._status.set_text("Need at least 2 lanes with endpoint + model selected.")
|
|
return
|
|
self._running = True
|
|
self._run_btn.set_sensitive(False)
|
|
self._results_store.clear()
|
|
self._status.set_text("Running benchmark…")
|
|
threading.Thread(target=self._run_bench, args=(lanes,), daemon=True).start()
|
|
|
|
def _bench_single(self, ep, model, stream, with_tools=False):
|
|
url = normalize_base_url(ep.get("base_url", ""))
|
|
key = (ep.get("api_key") or "").strip()
|
|
bt = ep.get("backend_type", "openai-compat")
|
|
if bt == "anthropic":
|
|
test_url = f"{url}/v1/messages"
|
|
headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
|
|
body = {"model": model, "max_tokens": 100, "stream": stream,
|
|
"messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
|
|
if with_tools:
|
|
body["tools"] = self._BENCH_TOOLS
|
|
body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
|
|
data = json.dumps(body).encode()
|
|
elif bt.startswith("gemini-oauth"):
|
|
token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json"
|
|
token_path = Path.home() / f".cache/codex-proxy/{token_name}"
|
|
oauth_token = ""
|
|
if token_path.exists():
|
|
try:
|
|
td = json.loads(token_path.read_text())
|
|
oauth_token = td.get("access_token", "")
|
|
except Exception:
|
|
pass
|
|
test_url = f"{url}/v1/chat/completions"
|
|
headers = {"Authorization": f"Bearer {oauth_token}", "content-type": "application/json"}
|
|
body = {"model": model, "max_tokens": 100, "stream": stream,
|
|
"messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
|
|
if with_tools:
|
|
body["tools"] = self._BENCH_TOOLS
|
|
body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
|
|
data = json.dumps(body).encode()
|
|
else:
|
|
test_url = f"{url}/chat/completions"
|
|
headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
|
|
body = {"model": model, "max_tokens": 100, "stream": stream,
|
|
"messages": [{"role": "user", "content": self._BENCH_PROMPT}]}
|
|
if with_tools:
|
|
body["tools"] = self._BENCH_TOOLS
|
|
body["messages"] = [{"role": "user", "content": "Use get_weather for Paris"}]
|
|
data = json.dumps(body).encode()
|
|
|
|
req = urllib.request.Request(test_url, data=data, headers=headers, method="POST")
|
|
t0 = time.time()
|
|
ttft = None
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=60)
|
|
if stream:
|
|
first_chunk_time = None
|
|
chunks = []
|
|
while True:
|
|
chunk = resp.read(4096)
|
|
if not chunk:
|
|
break
|
|
if first_chunk_time is None:
|
|
first_chunk_time = time.time()
|
|
ttft = first_chunk_time - t0
|
|
chunks.append(chunk)
|
|
total = time.time() - t0
|
|
result_text = b"".join(chunks).decode(errors="replace")[:300]
|
|
else:
|
|
raw = resp.read()
|
|
total = time.time() - t0
|
|
result_text = raw.decode(errors="replace")[:300]
|
|
payload = json.loads(raw)
|
|
choices = payload.get("choices", [])
|
|
if choices:
|
|
msg = choices[0].get("message", {})
|
|
if with_tools:
|
|
tcs = msg.get("tool_calls", [])
|
|
has_tools = len(tcs) > 0
|
|
return {"ttft": ttft or total, "total": total,
|
|
"detail": f"tools={has_tools}, tok={payload.get('usage', {}).get('total_tokens', '?')}"}
|
|
content = msg.get("content", "")[:50]
|
|
return {"ttft": ttft or total, "total": total,
|
|
"detail": f"{content[:40]}… tok={payload.get('usage', {}).get('total_tokens', '?')}"}
|
|
return {"ttft": ttft or total, "total": total, "detail": result_text[:60]}
|
|
except Exception as e:
|
|
total = time.time() - t0
|
|
return {"ttft": ttft or total, "total": total, "detail": f"Error: {str(e)[:40]}"}
|
|
|
|
def _bench_tps(self, ep, model):
|
|
url = normalize_base_url(ep.get("base_url", ""))
|
|
key = (ep.get("api_key") or "").strip()
|
|
bt = ep.get("backend_type", "openai-compat")
|
|
prompt = "Write a detailed paragraph about artificial intelligence in at least 150 words."
|
|
max_tok = 512
|
|
if bt == "anthropic":
|
|
test_url = f"{url}/v1/messages"
|
|
headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
|
|
body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
|
|
"messages": [{"role": "user", "content": prompt}]}).encode()
|
|
elif bt.startswith("gemini-oauth"):
|
|
token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json"
|
|
token_path = Path.home() / f".cache/codex-proxy/{token_name}"
|
|
oauth_token = ""
|
|
if token_path.exists():
|
|
try:
|
|
td = json.loads(token_path.read_text())
|
|
oauth_token = td.get("access_token", "")
|
|
except Exception:
|
|
pass
|
|
test_url = f"{url}/v1/chat/completions"
|
|
headers = {"Authorization": f"Bearer {oauth_token}", "content-type": "application/json"}
|
|
body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
|
|
"messages": [{"role": "user", "content": prompt}]}).encode()
|
|
else:
|
|
test_url = f"{url}/chat/completions"
|
|
headers = {"Authorization": f"Bearer {key}", "content-type": "application/json"}
|
|
body = json.dumps({"model": model, "max_tokens": max_tok, "stream": True,
|
|
"messages": [{"role": "user", "content": prompt}]}).encode()
|
|
|
|
req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
|
|
t0 = time.time()
|
|
first_token_t = None
|
|
token_count = 0
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=90)
|
|
buf = b""
|
|
while True:
|
|
chunk = resp.read(4096)
|
|
if not chunk:
|
|
break
|
|
if first_token_t is None:
|
|
first_token_t = time.time()
|
|
buf += chunk
|
|
total = time.time() - t0
|
|
text = buf.decode(errors="replace")
|
|
if bt == "anthropic":
|
|
for line in text.split("\n"):
|
|
if "content_block_delta" in line and "text_delta" in line:
|
|
try:
|
|
idx = line.index("{")
|
|
evt = json.loads(line[idx:])
|
|
delta = evt.get("delta", {})
|
|
token_count += len(delta.get("text", "")) / 4
|
|
except Exception:
|
|
pass
|
|
if token_count == 0:
|
|
token_count = max(1, len(text) / 4)
|
|
else:
|
|
for line in text.split("\n"):
|
|
if line.startswith("data: ") and line != "data: [DONE]":
|
|
try:
|
|
d = json.loads(line[6:])
|
|
content = d.get("choices", [{}])[0].get("delta", {}).get("content", "")
|
|
if content:
|
|
token_count += max(1, len(content) / 4)
|
|
except Exception:
|
|
pass
|
|
if token_count == 0:
|
|
token_count = max(1, len(text) / 4)
|
|
gen_time = (time.time() - first_token_t) if first_token_t else total
|
|
tps = token_count / gen_time if gen_time > 0 else 0
|
|
return {"tps": tps, "tokens": int(token_count), "gen_time": gen_time, "total": total,
|
|
"detail": f"{int(token_count)} tok / {gen_time:.1f}s"}
|
|
except Exception as e:
|
|
total = time.time() - t0
|
|
return {"tps": 0, "tokens": 0, "gen_time": total, "total": total, "detail": f"Error: {str(e)[:40]}"}
|
|
|
|
def _run_bench(self, lanes):
|
|
results = []
|
|
tests = []
|
|
if self._test_ttft.get_active():
|
|
tests.append(("TTFT (stream)", True, False))
|
|
if self._test_total.get_active():
|
|
tests.append(("Total latency", False, False))
|
|
if self._test_tools.get_active():
|
|
tests.append(("Tool call", False, True))
|
|
run_tps = self._test_tps.get_active()
|
|
|
|
for test_name, stream, tools in tests:
|
|
lane_results = []
|
|
for lane in lanes:
|
|
label = lane["label"]
|
|
GLib.idle_add(self._status.set_text, f"{test_name}: {label}…")
|
|
r = self._bench_single(lane["ep"], lane["model"], stream, tools)
|
|
lane_results.append((label, r))
|
|
|
|
metric = "ttft" if stream else "total"
|
|
values = [(lr[0], lr[1][metric]) for lr in lane_results]
|
|
sorted_v = sorted(values, key=lambda x: x[1])
|
|
best_val = sorted_v[0][1]
|
|
second_val = sorted_v[1][1]
|
|
if best_val < second_val * 0.85:
|
|
winner = sorted_v[0][0]
|
|
else:
|
|
winner = "Tie"
|
|
|
|
cols = []
|
|
for lr in lane_results:
|
|
v = lr[1][metric]
|
|
cols.append(f"{v:.2f}s ({lr[1]['detail'][:30]})")
|
|
while len(cols) < 3:
|
|
cols.append("—")
|
|
cols.append(winner)
|
|
results.append(tuple([test_name] + cols))
|
|
|
|
if run_tps:
|
|
lane_tps = []
|
|
for lane in lanes:
|
|
label = lane["label"]
|
|
GLib.idle_add(self._status.set_text, f"Tokens/sec: {label}…")
|
|
r = self._bench_tps(lane["ep"], lane["model"])
|
|
lane_tps.append((label, r))
|
|
|
|
tps_vals = [(lt[0], lt[1]["tps"]) for lt in lane_tps]
|
|
sorted_tps = sorted(tps_vals, key=lambda x: x[1], reverse=True)
|
|
best_tps = sorted_tps[0][1]
|
|
second_tps = sorted_tps[1][1] if len(sorted_tps) > 1 else 0
|
|
if best_tps > 0 and second_tps > 0 and best_tps > second_tps * 1.15:
|
|
winner_tps = sorted_tps[0][0]
|
|
else:
|
|
winner_tps = "Tie"
|
|
|
|
cols_tps = []
|
|
for lt in lane_tps:
|
|
tps = lt[1]["tps"]
|
|
cols_tps.append(f"{tps:.1f} t/s ({lt[1]['detail'][:25]})")
|
|
while len(cols_tps) < 3:
|
|
cols_tps.append("—")
|
|
cols_tps.append(winner_tps)
|
|
results.append(tuple(["Tokens/sec"] + cols_tps))
|
|
|
|
def _show():
|
|
for row in results:
|
|
self._results_store.append(row)
|
|
self._status.set_text("Benchmark complete.")
|
|
self._running = False
|
|
self._run_btn.set_sensitive(True)
|
|
|
|
GLib.idle_add(_show)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|