From d1fef5d98498edb31365b470376a52b552c1f743 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 19 May 2026 14:57:31 +0400 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Codex=20Launcher=20?= =?UTF-8?q?=E2=80=94=20Any=20AI=20Provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-provider integration for OpenAI Codex CLI/Desktop. - Translation proxy: Responses API ↔ Chat Completions / Anthropic Messages - GTK launcher with endpoint management, provider presets, Desktop/CLI launch - Codex Default mode (built-in OAuth, zero config) - Browser UA injection for Cloudflare-protected providers - Streaming SSE, tool calls, reasoning content support - Profile backup/import, model auto-fetch, bulk import - Zero pip dependencies (pure Python stdlib + GTK) --- .gitignore | 11 + README.md | 447 +++++++++ install.sh | 29 + src/cleanup-codex-stale.sh | 57 ++ src/codex-launcher-gui | 1362 +++++++++++++++++++++++++++ src/codex-launcher.desktop.template | 10 + src/translate-proxy.py | 632 +++++++++++++ 7 files changed, 2548 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 install.sh create mode 100755 src/cleanup-codex-stale.sh create mode 100755 src/codex-launcher-gui create mode 100644 src/codex-launcher.desktop.template create mode 100755 src/translate-proxy.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0745a38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.bak +*.pyc +__pycache__ +.endpoints.json +config.toml +*.log +.cache/ +.codex/ +*.swp +*~ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..df44b4b --- /dev/null +++ b/README.md @@ -0,0 +1,447 @@ +

+ + Z.AI 10% OFF + +

+ +

+ Get 10% OFF Z.AI coding plans
+ z.ai/subscribe +

+ +--- + +

Codex Launcher — Any AI Provider

+ +

+ Run OpenAI Codex CLI & Desktop with any AI provider.
+ OpenCode • Z.AI • Anthropic • OpenRouter • Crof.ai • NVIDIA NIM • Kilo.ai • and more +

+ +

+ Python 3.8+ + GTK 3.0 + MIT License + Zero pip dependencies +

+ +

+ + + + + +

+ +--- + +## The Problem + +OpenAI's Codex CLI v2.0+ exclusively uses the **Responses API** — a protocol that is incompatible with virtually every other AI provider: + +| Provider | API | Works with Codex? | +|----------|-----|:-:| +| OpenAI | Responses API | ✅ | +| Z.AI | Chat Completions | ❌ | +| OpenCode | Chat Completions | ❌ | +| Anthropic | Messages API | ❌ | +| Ollama | Chat Completions | ❌ | +| OpenRouter | Chat Completions | ❌ | +| NVIDIA NIM | Chat Completions | ❌ | +| Crof.ai | Chat Completions | ❌ | + +The protocols differ in **endpoint paths**, **message formats**, **tool-call structures**, **streaming events**, and **completion semantics**. You can't just swap a base URL. + +## The Solution + +A three-component system: + +1. **Translation Proxy** — translates Responses API ↔ Chat Completions / Anthropic Messages in real-time +2. **Config Engine** — generates Codex config files on-the-fly per provider, with backup/restore +3. **GTK Launcher** — manages endpoints, launches Desktop or CLI, handles the full lifecycle + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Codex Launcher GUI │ +│ (endpoint management + lifecycle) │ +└──────────┬─────────────────┬──────────────────┬────────────────────┘ + │ │ │ + ┌──────▼──────┐ ┌──────▼──────┐ ┌────────▼─────────┐ + │ Codex │ │ Native │ │ Translation │ + │ Default │ │ OpenAI │ │ Proxy │ + │ (remove │ │ (direct │ │ (port 8080) │ + │ config) │ │ URL) │ │ │ + └──────┬──────┘ └──────┬──────┘ └────────┬─────────┘ + │ │ │ + ▼ ▼ ┌────────┴────────┐ + ┌──────────────┐ ┌───────────┐ │ │ + │ Built-in │ │ config. │ ▼ ▼ + │ Codex OAuth │ │ toml │ ┌────────────┐ ┌───────────┐ + └──────────────┘ └───────────┘ │ OpenAI │ │ Anthropic │ + │ Chat Comp. │ │ Messages │ + └────────────┘ └───────────┘ +``` + +--- + +## Features + +### Multi-Provider Support +- **Native OpenAI** — direct connection, no proxy needed +- **OpenAI-compatible** — Z.AI, OpenCode Zen/Go, Crof.ai, NVIDIA NIM, Kilo.ai, OpenRouter, Ollama, Together, Groq, and any provider with a Chat Completions endpoint +- **Anthropic** — Claude models via the Messages API +- **Codex Default** — built-in Codex OAuth with official models, zero config + +### Translation Proxy (`translate-proxy.py`) +- Full Responses API ↔ Chat Completions / Anthropic Messages bidirectional translation +- **Streaming SSE** support with proper event sequencing (`response.created` → `response.output_text.delta` → `response.completed`) +- **Tool calls** — full function calling support including parallel tool calls +- **Reasoning content** — forwards `reasoning_content` fields from providers that support it +- **Browser UA injection** — bypasses Cloudflare bot detection for providers like OpenCode +- **Smart URL construction** — prevents double-path bugs (`/v1/chat/completions/chat/completions`) +- **Header forwarding** — preserves client identity headers while filtering hop-by-hop headers +- Zero dependencies — pure Python stdlib + +### GTK Launcher (`codex-launcher-gui`) +- **Endpoint manager** — add, edit, delete, set default providers +- **Provider presets** — one-click setup for 10+ providers with pre-filled URLs and model lists +- **Model auto-fetch** — pulls available models directly from provider APIs +- **Bulk model import** — paste a comma/newline-separated list of model IDs +- **Launch Desktop** — starts Codex Desktop with the selected provider and model +- **Launch CLI** — opens Codex CLI in a terminal with the selected provider +- **Codex Default** — launch with built-in OAuth, no proxy or custom config +- **Profile backup/import** — export and import endpoint configurations as portable JSON bundles +- **Threaded operations** — model refresh runs in background, UI stays responsive +- **Process lifecycle** — stall detection, kill/cleanup, config backup/restore around sessions +- **Config normalization** — automatically strips stale API path suffixes from URLs + +### Process Management +- Kills stale electron/webview/app-server processes from previous sessions +- Removes stale PID files and sockets +- Manages proxy lifecycle (start, health-check, stop) +- Config backup before launch, automatic restore after exit + +--- + +## Technology Stack + +| Component | Technology | Why | +|-----------|-----------|-----| +| Translation Proxy | Python 3 stdlib (`http.server`, `urllib`, `json`) | Zero dependencies, runs anywhere | +| GUI Launcher | Python 3 + GTK 3.0 (`PyGObject/gi`) | Native Linux desktop integration | +| Config Engine | Python 3 (`toml` generation, `json` catalogs) | Dynamic, no hardcoded configs | +| Process Mgmt | bash + `os.setsid`/`os.killpg` | Unix process group lifecycle | +| Streaming | Server-Sent Events (SSE) | Required by Codex Responses API | +| API Translation | Responses API ↔ Chat Completions / Anthropic Messages | Protocol bridging | + +**Zero pip dependencies.** Everything uses Python stdlib + system GTK bindings. + +--- + +## Quick Start + +### Prerequisites + +- **Codex CLI** ≥ 2.0 (`npm install -g @openai/codex` or bundled with Codex Desktop) +- **Codex Desktop** installed at `/opt/codex-desktop/` (optional, for Desktop mode) +- **Python 3.8+** (stdlib only) +- **python3-gi** for GTK (`sudo apt install python3-gi`) +- bash, curl, lsof + +### Install + +```bash +git clone https://github.rommark.dev/admin/Codex-Launcher---Any-AI-Porovider.git +cd Codex-Launcher---Any-AI-Porovider +./install.sh +``` + +### Run + +Open **Codex Launcher** from your app grid, or: + +```bash +codex-launcher-gui +``` + +### First Launch + +1. Click **Manage Endpoints** → **Add** +2. Select a provider preset (e.g., "OpenCode Zen (OpenAI-compatible)") +3. Enter your API key +4. Click **Fetch from API** to auto-discover models, or add them manually +5. Click **Save** +6. Select the endpoint and model from the dropdowns +7. Click **Launch Desktop** or **Launch CLI** + +--- + +## Development Journey + +### Phase 1: The Z.AI Proxy — Protocol Reverse Engineering + +**Problem:** Codex CLI v2.0 switched exclusively to the Responses API. Z.AI (and every other provider) uses Chat Completions. They are fundamentally incompatible. + +**Approach:** +1. Captured Codex's HTTP traffic to understand the exact Responses API request/response shape +2. Mapped the protocol differences: + - `/v1/responses` → `/chat/completions` (endpoint) + - `input` array with typed items → `messages` array with role/content (message format) + - `function_call` items → `tool_calls` array on assistant messages (tool format) + - SSE `response.output_text.delta` events → `delta.content` chunks (streaming) +3. Built the initial `zai-proxy.py` — a 200-line HTTP server that translates in both directions +4. Hit a critical bug: Codex hung in "thinking" state. Discovered that merely emitting `response.done` is insufficient — the `response.completed` event **must** contain the full output item array +5. Added streaming SSE with proper event sequencing — this was the breakthrough that made it work + +**Testing:** Manual end-to-end testing with `curl`, Codex CLI `--profile zai`, and Codex Desktop. Verified streaming, tool calls, and reasoning content. + +### Phase 2: Multi-Provider Architecture — The Unified Proxy + +**Problem:** Maintaining separate proxies for each provider (Z.AI, Anthropic, OpenRouter, etc.) was unmaintainable. + +**Approach:** +1. Abstracted the translation into a backend plugin architecture: + - `openai-compat` backend: translates Responses → Chat Completions (works for any OpenAI-compatible API) + - `anthropic` backend: translates Responses → Anthropic Messages API +2. Unified config loading: JSON config file, CLI arguments, and environment variables +3. Shared HTTP server, model serving, and SSE framework +4. Per-backend: message conversion, tool conversion, response conversion, stream conversion + +**Key design decisions:** +- Pure Python stdlib — no Flask, no aiohttp, no pip dependencies. The proxy must work on any system with Python 3. +- `http.server.BaseHTTPRequestHandler` — simple, synchronous, but sufficient for single-user desktop use +- Config via JSON file — the launcher writes a proxy config to `~/.cache/codex-proxy/` for each endpoint + +### Phase 3: The GTK Launcher — Desktop Integration + +**Problem:** Users had to manage config files, start proxies manually, and remember which wrapper script to use. + +**Approach:** +1. Built a GTK 3.0 GUI with three layers: main window, endpoint manager dialog, edit endpoint dialog +2. Implemented dynamic config generation: for each launch, generate `config.toml` with the right provider definition, profile, and model catalog +3. Model catalog generation with dual field naming (`slug` + `model`, `supported_reasoning_levels` + `supportedReasoningEfforts`) — required because Codex CLI and Codex Desktop use different field names +4. Process lifecycle: backup config → cleanup stale processes → start proxy → write config → launch Codex → wait → restore config → stop proxy + +**Threading challenges:** +- GTK requires all UI updates on the main loop. Used `GLib.idle_add()` for all cross-thread communication +- Model refresh was blocking the UI — moved to a background thread with idle_add completion callbacks +- Proxy startup waits up to 15 seconds with health checks before proceeding + +### Phase 4: Endpoint Management & Provider Presets + +**Approach:** +1. JSON-based endpoint storage (`~/.codex/endpoints.json`) +2. Provider presets with pre-filled URLs and model lists for common providers +3. Auto-fetch models from provider `/v1/models` endpoints +4. Bulk model import (paste comma/newline-separated model IDs) +5. Profile backup/import — portable JSON bundles with endpoints + config + +**URL normalization:** Discovered that saved URLs sometimes had `/chat/completions` appended from manual entry. Added `normalize_base_url()` that strips trailing API path suffixes to prevent double-path bugs. + +### Phase 5: Cloudflare Bot Detection — The OpenCode 403 Saga + +**Problem:** OpenCode Zen/Go returned 403 (Cloudflare error 1010) for all requests. + +**Investigation:** +1. Tested direct `curl` requests — all returned 403 regardless of auth header type (Bearer, x-api-key, both, none) +2. Examined Codex logs — found `turn.has_metadata_header=true` and `error code: 1010` +3. Error 1010 is Cloudflare's "Access denied" — bot detection based on User-Agent and browser headers +4. Python's `urllib` sends `User-Agent: Python-urllib/3.x` which triggers the block + +**Solution:** +1. Added `_BROWSER_HEADERS` — a set of Chrome-like headers (User-Agent, Sec-Ch-Ua, Sec-Fetch-*) +2. `forwarded_headers()` with `browser_ua=True` — injects browser headers while preserving incoming client headers and filtering hop-by-hop headers +3. Applied only to `openai-compat` backend where Cloudflare is common +4. This resolved the 403 — upstream now returns 401 (auth) instead of 403 (bot block), confirming the headers work + +### Phase 6: Codex Default Mode — OAuth Without Config + +**Problem:** Users wanted to quickly switch back to built-in Codex OAuth without maintaining a separate endpoint. + +**Approach:** +1. Added "Codex Default (Desktop)" and "Codex Default (CLI)" buttons +2. On launch: backup config → **delete** `config.toml` entirely → start Codex → restore config after exit +3. Key insight: writing empty strings (`model = ""`, `model_provider = ""`) causes Codex to error with "Model provider `` not found". The config must not exist at all for Codex to fall back to built-in defaults. + +--- + +## Architecture Deep Dive + +### Request Flow (OpenAI-compatible provider) + +``` +Codex CLI/Desktop + │ + │ POST /responses (Responses API) + │ Body: { model: "glm-5.1", input: [...], stream: true } + ▼ +┌─────────────────────────────────┐ +│ translate-proxy.py (port 8080) │ +│ │ +│ 1. Parse Responses API body │ +│ 2. Convert input → messages │ +│ 3. Convert tools format │ +│ 4. Inject browser UA headers │ +│ │ +│ POST /v1/chat/completions │──→ Upstream Provider +│ Body: { model, messages, ... }│ (opencode.ai, z.ai, etc.) +│ │ +│ 5. Receive Chat Comp. response │ ←── SSE stream or JSON +│ 6. Convert response format │ +│ 7. Emit Responses API SSE │ +│ │ +└─────────────────────────────────┘ + │ + │ SSE: response.created + │ SSE: response.output_text.delta (streamed tokens) + │ SSE: response.output_text.done + │ SSE: response.completed + ▼ +Codex CLI/Desktop (receives Responses API events) +``` + +### Config Lifecycle + +``` +┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ +│ Backup │───→│ Cleanup │───→│ Generate │───→│ Launch │───→│ Restore │ +│ config │ │ stale │ │ config │ │ Codex │ │ config │ +│ .toml │ │ processes│ │ + models │ │ process │ │ from │ +│ │ │ │ │ │ │ │ │ backup │ +└─────────┘ └──────────┘ └──────────┘ └──────────┘ └─────────┘ +``` + +### Model Catalog Format + +The launcher generates model catalog JSON with dual field naming to satisfy both Codex CLI and Codex Desktop: + +```json +{ + "models": [{ + "slug": "glm-5.1", // CLI reads this + "model": "glm-5.1", // Desktop reads this + "supported_reasoning_levels": [...], // CLI + "supportedReasoningEfforts": [...], // Desktop + ... + }] +} +``` + +--- + +## Provider Presets + +| Preset | Backend | Base URL | +|--------|---------|----------| +| OpenAI | Native | `https://api.openai.com/v1` | +| Anthropic | Anthropic | `https://api.anthropic.com/v1` | +| OpenCode Zen | OpenAI-compat | `https://opencode.ai/zen/v1` | +| OpenCode Go | OpenAI-compat | `https://opencode.ai/zen/go/v1` | +| Crof.ai | OpenAI-compat | `https://crof.ai/v1` | +| NVIDIA NIM | OpenAI-compat | `https://integrate.api.nvidia.com/v1` | +| Kilo.ai | OpenAI-compat | `https://api.kilo.ai/api/gateway` | +| OpenRouter | OpenAI-compat | `https://openrouter.ai/api/v1` | +| Z.AI | OpenAI-compat | `https://api.z.ai/api/coding/paas/v4` | +| Custom | Any | User-defined | + +--- + +## File Structure + +``` +src/ +├── translate-proxy.py # Translation proxy (openai-compat + anthropic) +├── codex-launcher-gui # GTK launcher GUI +├── cleanup-codex-stale.sh # Stale process cleanup +└── codex-launcher.desktop.template # Desktop entry template + +install.sh # One-command installer +README.md # This file +``` + +### Installed Locations + +``` +~/.local/bin/translate-proxy.py # Proxy +~/.local/bin/codex-launcher-gui # Launcher +~/.local/bin/cleanup-codex-stale.sh # Cleanup +~/.local/share/applications/codex-launcher.desktop # App grid entry +~/.codex/endpoints.json # Endpoint storage +~/.codex/config.toml # Codex config (auto-generated) +~/.cache/codex-proxy/ # Proxy configs + model catalogs +``` + +--- + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| Window opens then disappears | Stale processes from previous session | Click **Kill && Cleanup** | +| Window never opens | Startup freeze | Kill && Cleanup, then retry | +| "Model provider `` not found" | Empty strings in config | V2 deletes config entirely for Default mode | +| 403 Forbidden from OpenCode | Cloudflare bot detection | Proxy injects browser UA headers | +| 401 Unauthorized from OpenCode | Invalid key or no credits | Check API key and billing | +| Double path in URL | Stale `/chat/completions` in base URL | `normalize_base_url()` strips suffixes | +| Proxy stops when terminal closes | SIGHUP to subprocess | Launcher uses `os.setsid` process groups | +| Models not showing in picker | Wrong model catalog format | Must have both `slug` + `model` fields | +| Codex hangs in "thinking" | Missing `response.completed` | Proxy emits full SSE event sequence | + +--- + +## Adding a New Provider + +1. Click **Manage Endpoints** → **Add** +2. Choose a preset or set **Custom** +3. Set backend type: `OpenAI-compatible`, `Anthropic`, or `Native` +4. Enter base URL and API key +5. Click **Fetch from API** or add models manually +6. Save and launch + +For providers behind Cloudflare, the proxy automatically injects browser headers. For providers with non-standard APIs, add a new backend module to `translate-proxy.py` following the `oa_*` / `an_*` pattern. + +--- + +## Manual Proxy Usage + +```bash +# Start proxy for any OpenAI-compatible provider +python3 src/translate-proxy.py \ + --backend openai-compat \ + --target-url https://api.your-provider.com/v1 \ + --api-key YOUR_KEY \ + --port 8080 + +# Or use a JSON config +python3 src/translate-proxy.py --config my-proxy-config.json + +# Then run Codex +codex --profile my-profile -c model=my-model +``` + +--- + +## Requirements + +- Python ≥ 3.8 +- python3-gi (`sudo apt install python3-gi`) +- Codex CLI ≥ 2.0 +- Codex Desktop (optional, for Desktop mode) +- bash, curl, lsof + +**No pip dependencies.** Zero. Pure stdlib + system GTK. + +--- + +## License + +MIT + +--- + +

+ Get 10% OFF Z.AI coding plans
+ + Z.AI 10% OFF + +

diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6b58698 --- /dev/null +++ b/install.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$HOME/.local/bin" +APP_DIR="$HOME/.local/share/applications" + +mkdir -p "$BIN_DIR" "$APP_DIR" + +cp "$SCRIPT_DIR/src/translate-proxy.py" "$BIN_DIR/" +cp "$SCRIPT_DIR/src/codex-launcher-gui" "$BIN_DIR/" +cp "$SCRIPT_DIR/src/cleanup-codex-stale.sh" "$BIN_DIR/" + +chmod +x "$BIN_DIR/translate-proxy.py" +chmod +x "$BIN_DIR/codex-launcher-gui" +chmod +x "$BIN_DIR/cleanup-codex-stale.sh" + +USERNAME=$(whoami) +sed "s/YOUR_USERNAME/$USERNAME/g" "$SCRIPT_DIR/src/codex-launcher.desktop.template" > "$APP_DIR/codex-launcher.desktop" + +update-desktop-database "$APP_DIR" 2>/dev/null || true + +echo "Installed." +echo " translate-proxy.py -> $BIN_DIR/translate-proxy.py" +echo " codex-launcher-gui -> $BIN_DIR/codex-launcher-gui" +echo " cleanup-codex-stale -> $BIN_DIR/cleanup-codex-stale.sh" +echo " desktop entry -> $APP_DIR/codex-launcher.desktop" +echo "" +echo "Open 'Codex Launcher' from your app grid, or run: codex-launcher-gui" diff --git a/src/cleanup-codex-stale.sh b/src/cleanup-codex-stale.sh new file mode 100755 index 0000000..7d70d3e --- /dev/null +++ b/src/cleanup-codex-stale.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Cleanup script for Codex Desktop - kills stale processes before launch + +echo "Cleaning up stale Codex processes..." >&2 + +# Kill codex app-server processes +for pid in $(ps aux 2>/dev/null | grep -E "codex .*app-server" | grep -v grep | awk '{print $2}'); do + kill -9 "$pid" 2>/dev/null || true + echo " Killed app-server pid=$pid" +done + +# Kill webview server +for pid in $(ps aux 2>/dev/null | grep webview-server.py | grep -v grep | awk '{print $2}'); do + kill -9 "$pid" 2>/dev/null || true + echo " Killed webview-server pid=$pid" +done + +# Kill main electron process for codex-desktop +for pid in $(ps aux 2>/dev/null | grep "/opt/codex-desktop/electron" | grep "class=codex-desktop" | grep -v grep | awk '{print $2}'); do + kill -9 "$pid" 2>/dev/null || true + echo " Killed electron pid=$pid" +done + +# Kill all remaining child processes of codex-desktop +for pid in $(ps aux 2>/dev/null | grep "/opt/codex-desktop/" | grep -v grep | awk '{print $2}'); do + kill -9 "$pid" 2>/dev/null || true +done + +# Kill zai proxy (if any) +for pid in $(ps aux 2>/dev/null | grep zai-proxy.py | grep -v grep | awk '{print $2}'); do + kill "$pid" 2>/dev/null || true +done + +# Kill unified translation proxy (if any) +for pid in $(ps aux 2>/dev/null | grep translate-proxy.py | grep -v grep | awk '{print $2}'); do + kill "$pid" 2>/dev/null || true +done + +# Remove stale socket and PID files +rm -f "$HOME/.codex/.launch-action-socket" 2>/dev/null || true +rm -f "$HOME/.codex/.codex-desktop-launch-action" 2>/dev/null || true +rm -f "$HOME/.local/share/codex-desktop/.launch-action-socket" 2>/dev/null || true +rm -f "$HOME/.cache/codex-desktop/.launch-action-socket" 2>/dev/null || true +rm -f "$HOME/.local/share/codex-desktop/.codex-desktop-pid" 2>/dev/null || true +rm -f "$HOME/.cache/codex-desktop/.codex-desktop-pid" 2>/dev/null || true +rm -f "$HOME/.local/share/codex-desktop/.webview-pid" 2>/dev/null || true +rm -f "$HOME/.cache/codex-desktop/.webview-pid" 2>/dev/null || true + +sleep 1 + +# Verify no remaining process on port 5175 (webview) +if lsof -ti :5175 2>/dev/null | grep -q .; then + echo " Warning: Port 5175 still in use" + lsof -ti :5175 2>/dev/null | xargs kill -9 2>/dev/null || true +fi + +echo "Cleanup complete" diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui new file mode 100755 index 0000000..b99f8bc --- /dev/null +++ b/src/codex-launcher-gui @@ -0,0 +1,1362 @@ +#!/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, tempfile, shutil +import hashlib +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" +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 = "" +""" + +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": [], + }, + "OpenRouter": { + "backend_type": "openai-compat", + "base_url": "https://openrouter.ai/api/v1", + "models": [], + }, +} + +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", + "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 not updated.get("models"): + updated["models"] = list(preset.get("models", [])) + if not updated.get("default_model") and updated.get("models"): + updated["default_model"] = updated["models"][0] + return updated + +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 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(): + shutil.copy2(str(CONFIG), str(CONFIG_BAK)) + +def restore_config(): + if CONFIG_BAK.exists(): + CONFIG_BAK.rename(CONFIG) + +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 = "{selected_model}"\n', + f'model_provider = "{endpoint["name"]}"\n', + f'model_catalog_json = "{mc_path}"\n', + f'\n[model_providers."{endpoint["name"]}"]\n', + f'name = "{endpoint["name"]}"\n', + f'base_url = "{endpoint["base_url"]}"\n', + f'experimental_bearer_token = "{endpoint["api_key"]}"\n', + f'\n[profiles."{endpoint["name"]}"]\n', + f'model_provider = "{endpoint["name"]}"\n', + f'model = "{selected_model}"\n', + f'model_catalog_json = "{mc_path}"\n', + f'service_tier = "default"\n', + f'approvals_reviewer = "user"\n', + ] + CONFIG.write_text("".join(lines)) + +def write_config_for_translated(endpoint, selected_model): + """Write config pointing at local proxy.""" + 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 = "{selected_model}"\n', + f'model_provider = "{endpoint["name"]}"\n', + f'model_catalog_json = "{mc_path}"\n', + f'\n[model_providers."{endpoint["name"]}"]\n', + f'name = "{endpoint["name"]}"\n', + f'base_url = "http://127.0.0.1:8080"\n', + f'experimental_bearer_token = "{endpoint["api_key"]}"\n', + f'\n[profiles."{endpoint["name"]}"]\n', + f'model_provider = "{endpoint["name"]}"\n', + f'model = "{selected_model}"\n', + f'model_catalog_json = "{mc_path}"\n', + f'service_tier = "fast"\n', + f'approvals_reviewer = "user"\n', + ] + CONFIG.write_text("".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 + +def _start_proxy_for(endpoint, logfn): + global _proxy_proc + _stop_proxy() + + pcfg = { + "port": 8080, + "backend_type": endpoint["backend_type"], + "target_url": normalize_base_url(endpoint["base_url"]), + "api_key": endpoint["api_key"], + "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]} + for m in endpoint.get("models", [])], + } + pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}.json" + pcfg_path.parent.mkdir(parents=True, exist_ok=True) + pcfg_path.write_text(json.dumps(pcfg, indent=2)) + + _proxy_proc = subprocess.Popen( + ["python3", str(PROXY), "--config", str(pcfg_path)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + preexec_fn=os.setsid, + ) + + for _ in range(30): + try: + urllib.request.urlopen("http://127.0.0.1:8080/v1/models", timeout=2) + logfn("Proxy ready on port 8080") + return + except Exception: + time.sleep(0.5) + logfn("WARNING: proxy may not have started in time") + +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 _run_cleanup(): + subprocess.run(["bash", str(CLEANUP)], capture_output=True, timeout=30) + +def _last_log_lines(n=15): + try: + t = LAUNCH_LOG.read_text() + return "\n".join(t.splitlines()[-n:]) + except Exception: + return "(no log file)" + +# ═══════════════════════════════════════════════════════════════════ +# 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() + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add(vbox) + + # header row + hdr = Gtk.Box(spacing=8) + vbox.pack_start(hdr, False, False, 0) + lbl = Gtk.Label(label="Codex Launcher") + lbl.set_use_markup(True) + hdr.pack_start(lbl, 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) + + 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")) + 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")) + 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")) + 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")) + 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) + 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() + + # ── 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 _set_busy(self, busy): + GLib.idle_add(lambda: ( + self._btn_desktop.set_sensitive(not busy), + self._btn_cli.set_sensitive(not busy), + self._btn_codex_desktop.set_sensitive(not busy), + self._btn_codex_cli.set_sensitive(not busy), + self._kill_btn.set_sensitive(busy), + )) + + 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) + if 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() + ep = get_endpoint(name) if name else None + self._model_combo.remove_all() + if 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): + self._mgr_window = EndpointMgr(self) + self._mgr_window.connect("destroy", lambda *_: setattr(self, "_mgr_window", None)) + + 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() + + # ── launch ─────────────────────────────────────────────────── + + def _launch(self, target): + name = self._combo.get_active_text() + ep = get_endpoint(name) if name else None + if not ep: + self.log("ERROR: no endpoint selected") + return + model = self._model_combo.get_active_text() + if not model: + self.log("ERROR: no model selected") + 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): + 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): + try: + self.log("Cleaning up stale processes…") + _run_cleanup() + + needs_proxy = ep["backend_type"] != "native" + + if needs_proxy: + self.log("Starting translation proxy…") + _start_proxy_for(ep, self.log) + self.log(f"Configuring Codex for {ep['name']} (proxied)…") + write_config_for_translated(ep, model) + else: + self.log(f"Configuring Codex for {ep['name']} (native)…") + write_config_for_native(ep, model) + + if target == "desktop": + self._launch_desktop(ep, model) + else: + self._launch_cli(ep, model) + + except Exception as e: + self.log(f"ERROR: {e}") + finally: + _stop_proxy() + restore_config() + self._set_busy(False) + self.log("Ready.") + + def _run_codex_default(self, target): + try: + self.log("Cleaning up stale processes…") + _run_cleanup() + _stop_proxy() + + self.log("Resetting config to Codex defaults (OAuth)…") + backup_config() + 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() + self._set_busy(False) + self.log("Ready.") + + 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.") + self.log(f"--- last log lines ---\n{_last_log_lines()}") + self._proc = None + + 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() + restore_config() + 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="Endpoints") + title_lbl.set_use_markup(True) + vbox.pack_start(title_lbl, False, False, 0) + + sw = Gtk.ScrolledWindow() + vbox.pack_start(sw, True, True, 0) + self._store = Gtk.ListStore(str, str, str, str) # name, provider, backend, default_model + self._tree = Gtk.TreeView(model=self._store) + for i, title in enumerate(["Name", "Provider", "Type", "Default Model"]): + col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i) + col.set_resizable(True) + self._tree.append_column(col) + sw.add(self._tree) + + btn_bar = Gtk.Box(spacing=8) + vbox.pack_start(btn_bar, False, False, 0) + self._add_btn = Gtk.Button(label="Add") + self._add_btn.connect("clicked", lambda b: self._add()) + btn_bar.pack_start(self._add_btn, False, False, 0) + self._edit_btn = Gtk.Button(label="Edit") + self._edit_btn.connect("clicked", lambda b: self._edit()) + btn_bar.pack_start(self._edit_btn, False, False, 0) + self._delete_btn = Gtk.Button(label="Delete") + self._delete_btn.connect("clicked", lambda b: self._delete()) + btn_bar.pack_start(self._delete_btn, False, False, 0) + self._default_btn = Gtk.Button(label="Set Default") + self._default_btn.connect("clicked", lambda b: self._set_default()) + btn_bar.pack_start(self._default_btn, False, False, 0) + self._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): + self._dialog = EditEndpointDialog(self, None) + self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None)) + + def _edit(self): + name = self._selected() + if name: + self._dialog = EditEndpointDialog(self, name) + self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None)) + + 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() + +# ═══════════════════════════════════════════════════════════════════ +# Edit endpoint dialog +# ═══════════════════════════════════════════════════════════════════ + +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, 420) + + 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)"), + ("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) + add_row(4, "API Key:", self._entry_key) + + # Models + mlbl = Gtk.Label(label="Models:", xalign=0) + area.pack_start(mlbl, False, False, 4) + + mbox = Gtk.Box(spacing=6) + area.pack_start(mbox, False, False, 0) + self._entry_model = Gtk.Entry() + mbox.pack_start(self._entry_model, True, True, 0) + self._add_model_btn = Gtk.Button(label="Add") + self._add_model_btn.connect("clicked", lambda b: self._add_model()) + mbox.pack_start(self._add_model_btn, False, False, 0) + self._add_list_btn = Gtk.Button(label="Add List") + self._add_list_btn.connect("clicked", lambda b: self._add_models_from_text()) + mbox.pack_start(self._add_list_btn, False, False, 0) + self._fetch_models_btn = Gtk.Button(label="Fetch from API") + self._fetch_models_btn.connect("clicked", lambda b: self._fetch_models()) + mbox.pack_start(self._fetch_models_btn, False, False, 0) + + 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"]) + 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("") + 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 _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 _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() + 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"} + 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 +# ═══════════════════════════════════════════════════════════════════ + +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() + +if __name__ == "__main__": + main() diff --git a/src/codex-launcher.desktop.template b/src/codex-launcher.desktop.template new file mode 100644 index 0000000..efaed3f --- /dev/null +++ b/src/codex-launcher.desktop.template @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Codex Launcher +Comment=Launch Codex Desktop with any AI provider +Exec=/home/YOUR_USERNAME/.local/bin/codex-launcher-gui +Icon=/opt/codex-desktop/resources/app.asar.unpacked/images/icon.png +Terminal=false +StartupNotify=true +Categories=Development;IDE; +Keywords=ai;coding;launcher; diff --git a/src/translate-proxy.py b/src/translate-proxy.py new file mode 100755 index 0000000..5f1d632 --- /dev/null +++ b/src/translate-proxy.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +""" +translate-proxy.py — Responses API → backend API translation proxy. + +Backends: + openai-compat — any OpenAI-compatible Chat Completions API + anthropic — Anthropic Messages API + +Usage: + python3 translate-proxy.py --config proxy-config.json + python3 translate-proxy.py --backend openai-compat --target-url https://... --api-key sk-... +""" + +import json, http.server, urllib.request, time, uuid, os, sys, argparse + +# ═══════════════════════════════════════════════════════════════════ +# Config +# ═══════════════════════════════════════════════════════════════════ + +DEFAULT_MODELS = { + "openai-compat": [ + {"id": "gpt-4o-mini", "object": "model", "created": 1700000000, "owned_by": "custom"}, + ], + "anthropic": [ + {"id": "claude-sonnet-4-20250514", "object": "model", "created": 1700000000, "owned_by": "anthropic"}, + ], +} + +def load_config(): + p = argparse.ArgumentParser(description="Responses API translation proxy") + p.add_argument("--config", help="JSON config file path") + p.add_argument("--port", type=int, default=None) + p.add_argument("--backend", default=None, choices=["openai-compat", "anthropic"]) + p.add_argument("--target-url", default=None) + p.add_argument("--api-key", default=None) + p.add_argument("--models-file", default=None, help="JSON file with model list array") + args = p.parse_args() + + cfg = {} + if args.config: + with open(args.config) as f: + cfg = json.load(f) + + for ck, ak in [("port", "port"), ("backend_type", "backend"), + ("target_url", "target_url"), ("api_key", "api_key")]: + v = getattr(args, ak, None) + if v is not None: + cfg[ck] = v + + env_map = { + "port": ("PROXY_PORT", "ZAI_PROXY_PORT", int), + "backend_type": ("PROXY_BACKEND", None, str), + "target_url": ("PROXY_TARGET_URL", "ZAI_BASE_URL", str), + "api_key": ("PROXY_API_KEY", "ZAI_API_KEY", str), + } + for ck, (ev1, ev2, conv) in env_map.items(): + if ck not in cfg: + v = os.environ.get(ev1) or (os.environ.get(ev2) if ev2 else None) + if v: + cfg[ck] = conv(v) if conv == int else v + + cfg.setdefault("port", 8080) + cfg.setdefault("backend_type", "openai-compat") + cfg.setdefault("target_url", "http://localhost:11434/v1") + cfg.setdefault("api_key", "") + + models = cfg.get("models", []) + if not models and args.models_file: + with open(args.models_file) as f: + models = json.load(f) + if not models: + models = DEFAULT_MODELS.get(cfg["backend_type"], []) + cfg["models"] = models + + return cfg + +CONFIG = load_config() +PORT = CONFIG["port"] +BACKEND = CONFIG["backend_type"] +TARGET_URL = CONFIG["target_url"].rstrip("/") +API_KEY = CONFIG["api_key"] +MODELS = CONFIG["models"] + +# ═══════════════════════════════════════════════════════════════════ +# Shared helpers +# ═══════════════════════════════════════════════════════════════════ + +_pool = uuid.uuid4().hex[:8] + +_HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "content-length", +} + +def uid(prefix="id"): + return f"{prefix}-{_pool}-{uuid.uuid4().hex[:12]}" + +def emit(event, data): + return f"event: {event}\ndata: {json.dumps(data)}\n\n" + +def upstream_target(base_url, suffix): + base = base_url.rstrip("/") + if base.endswith(suffix): + return base + return f"{base}{suffix}" + +_BROWSER_HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", + "Accept": "application/json, text/event-stream, */*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Ch-Ua": '"Chromium";v="137", "Not/A)Brand";v="99"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"Linux"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", +} + +def forwarded_headers(request_headers, extra=None, browser_ua=False): + headers = {} + if browser_ua: + headers.update(_BROWSER_HEADERS) + for key, value in request_headers.items(): + if key.lower() in _HOP_BY_HOP_HEADERS: + continue + if browser_ua and key.lower() == "user-agent": + continue + headers[key] = value + if extra: + headers.update(extra) + return headers + +# ═══════════════════════════════════════════════════════════════════ +# OpenAI-compat backend +# ═══════════════════════════════════════════════════════════════════ + +def oa_input_to_messages(input_data): + msgs = [] + if isinstance(input_data, str): + msgs.append({"role": "user", "content": input_data}) + elif isinstance(input_data, list): + for item in input_data: + t = item.get("type") + if t == "message": + role = item.get("role", "user") + text = "" + for part in item.get("content", []): + pt = part.get("type", "") + if pt in ("input_text", "output_text"): + text += part.get("text", "") + elif pt == "input_image": + img = part.get("image_url", part) + msgs.append({"role": role, "content": [{"type": "text", "text": text}, + {"type": "image_url", "image_url": img}]}) + text = None + break + if text is not None: + msgs.append({"role": role, "content": text}) + elif t == "function_call": + msgs.append({"role": "assistant", "content": None, "tool_calls": [ + {"id": item.get("call_id", item.get("id", uid("tc"))), + "type": "function", + "function": {"name": item.get("name", ""), + "arguments": item.get("arguments", "{}")}} + ]}) + elif t == "function_call_output": + msgs.append({"role": "tool", "tool_call_id": item.get("id", ""), + "content": item.get("output", "")}) + return msgs + +def oa_convert_tools(tools): + if not tools: + return None + out = [] + for t in tools: + if t.get("type") != "function": + continue + fn = t.get("function", {}) + if fn: + out.append(t) + else: + out.append({ + "type": "function", + "function": {"name": t.get("name", ""), "description": t.get("description", ""), + "parameters": t.get("parameters", {})} + }) + return out or None + +def oa_resp_to_responses(chat_resp, model, resp_id=None): + choice = chat_resp["choices"][0] + msg = choice["message"] + content = msg.get("content") or "" + finish = choice.get("finish_reason", "stop") + fm = {"stop": "completed", "length": "incomplete", "tool_calls": "completed", "content_filter": "incomplete"} + status = fm.get(finish, "incomplete") + outputs = [] + rc = msg.get("reasoning_content") + if rc: + outputs.append({"type": "reasoning", "id": uid("rsn"), "status": "completed", + "content": [{"type": "text", "text": rc}]}) + if content: + outputs.append({"type": "message", "id": uid("msg"), "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": content, "annotations": []}]}) + for tc in msg.get("tool_calls") or []: + fn = tc.get("function", {}) + outputs.append({"type": "function_call", "id": uid("fc"), "call_id": tc.get("id"), + "name": fn.get("name"), "arguments": fn.get("arguments", "{}"), "status": "completed"}) + usage = chat_resp.get("usage", {}) + return {"id": resp_id or uid("resp"), "object": "response", "created": int(time.time()), + "model": model, "status": status, "output": outputs, + "usage": {"input_tokens": usage.get("prompt_tokens", 0), + "output_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + "input_tokens_details": {"cached_tokens": usage.get("prompt_tokens_details", {}).get("cached_tokens", 0)}}} + +def oa_stream_to_sse(chat_stream, model, req_id): + resp_id = req_id or uid("resp") + msg_id = uid("msg") + text_buf = "" + tc_buf = {} + fr = None + + yield emit("response.created", {"type": "response.created", + "response": {"id": resp_id, "object": "response", "model": model, + "status": "in_progress", "created": int(time.time()), "output": []}}) + yield emit("response.in_progress", {"type": "response.in_progress", "response": {"id": resp_id}}) + yield emit("response.output_item.added", {"type": "response.output_item.added", + "item": {"type": "message", "id": msg_id, "role": "assistant", "status": "in_progress", "content": []}}) + yield emit("response.content_part.added", {"type": "response.content_part.added", + "part": {"type": "output_text", "text": "", "annotations": []}, "item_id": msg_id}) + + for line in chat_stream: + line = line.decode("utf-8", errors="replace").strip() + if not line or line.startswith(":") or line == "data: [DONE]": + continue + if not line.startswith("data: "): + continue + try: + chunk = json.loads(line[6:]) + except json.JSONDecodeError: + continue + choices = chunk.get("choices", []) + if not choices: + continue + delta = choices[0].get("delta", {}) + fr = choices[0].get("finish_reason") + + content = delta.get("content") + if content: + text_buf += content + yield emit("response.output_text.delta", {"type": "response.output_text.delta", + "delta": content, "item_id": msg_id, "content_index": 0}) + + for tc in delta.get("tool_calls") or []: + idx = tc.get("index", 0) + if idx not in tc_buf: + fid = uid("fc") + tc_buf[idx] = {"id": fid, "call_id": tc.get("id", fid), "name": "", "args": ""} + yield emit("response.output_item.added", {"type": "response.output_item.added", + "item": {"type": "function_call", "id": fid, "call_id": tc_buf[idx]["call_id"], + "name": "", "arguments": "", "status": "in_progress"}}) + fn = tc.get("function", {}) + if "name" in fn and fn["name"]: + tc_buf[idx]["name"] = fn["name"] + if "arguments" in fn and fn["arguments"]: + tc_buf[idx]["args"] += fn["arguments"] + yield emit("response.output_text.delta", {"type": "response.function_call_arguments.delta", + "delta": fn["arguments"], "item_id": tc_buf[idx]["id"]}) + + rc = delta.get("reasoning_content") + if rc: + yield emit("response.reasoning.delta", {"type": "response.reasoning.delta", "delta": rc}) + + if text_buf: + yield emit("response.output_text.done", {"type": "response.output_text.done", + "text": text_buf, "item_id": msg_id, "content_index": 0}) + yield emit("response.content_part.done", {"type": "response.content_part.done", + "part": {"type": "output_text", "text": text_buf, "annotations": []}, "item_id": msg_id}) + yield emit("response.output_item.done", {"type": "response.output_item.done", + "item": {"type": "message", "id": msg_id, "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": text_buf, "annotations": []}]}}) + + for idx in sorted(tc_buf): + t = tc_buf[idx] + yield emit("response.function_call_arguments.done", {"type": "response.function_call_arguments.done", + "item_id": t["id"], "name": t["name"], "arguments": t["args"]}) + yield emit("response.output_item.done", {"type": "response.output_item.done", + "item": {"type": "function_call", "id": t["id"], "call_id": t["call_id"], + "name": t["name"], "arguments": t["args"], "status": "completed"}}) + + fm = {"stop": "completed", "length": "incomplete", "tool_calls": "completed", "content_filter": "incomplete"} + status = fm.get(fr, "incomplete") + final_out = [] + if text_buf: + final_out.append({"type": "message", "id": msg_id, "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": text_buf, "annotations": []}]}) + for idx in sorted(tc_buf): + t = tc_buf[idx] + final_out.append({"type": "function_call", "id": t["id"], "call_id": t["call_id"], + "name": t["name"], "arguments": t["args"], "status": "completed"}) + yield emit("response.completed", {"type": "response.completed", + "response": {"id": resp_id, "object": "response", "model": model, + "status": status, "created": int(time.time()), "output": final_out}}) + +# ═══════════════════════════════════════════════════════════════════ +# Anthropic backend +# ═══════════════════════════════════════════════════════════════════ + +def an_input_to_messages(input_data): + msgs = [] + if isinstance(input_data, str): + msgs.append({"role": "user", "content": input_data}) + elif isinstance(input_data, list): + for item in input_data: + t = item.get("type") + if t == "message": + role = item.get("role", "user") + text = "" + for part in item.get("content", []): + pt = part.get("type", "") + if pt in ("input_text", "output_text"): + text += part.get("text", "") + if role == "assistant": + msgs.append({"role": "assistant", "content": text}) + else: + msgs.append({"role": "user", "content": text}) + elif t == "function_call": + msgs.append({"role": "assistant", "content": [ + {"type": "tool_use", "id": item.get("call_id", item.get("id", uid("tu"))), + "name": item.get("name", ""), + "input": json.loads(item.get("arguments", "{}"))} + ]}) + elif t == "function_call_output": + msgs.append({"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": item.get("id", ""), + "content": item.get("output", "")} + ]}) + return msgs + +def an_convert_tools(tools): + if not tools: + return None + out = [] + for t in tools: + if t.get("type") != "function": + continue + fn = t.get("function", {}) + if fn: + out.append({"name": fn.get("name"), "description": fn.get("description", ""), + "input_schema": fn.get("parameters", {"type": "object", "properties": {}})}) + else: + out.append({"name": t.get("name"), "description": t.get("description", ""), + "input_schema": t.get("parameters", {"type": "object", "properties": {}})}) + return out or None + +def an_resp_to_responses(anthro_resp, model, resp_id=None): + blocks = anthro_resp.get("content", []) + sr = anthro_resp.get("stop_reason", "end_turn") + sm = {"end_turn": "completed", "max_tokens": "incomplete", "stop_sequence": "completed", "tool_use": "completed"} + status = sm.get(sr, "incomplete") + outputs = [] + for b in blocks: + bt = b.get("type", "") + if bt == "text": + outputs.append({"type": "message", "id": uid("msg"), "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": b.get("text", ""), "annotations": []}]}) + elif bt == "tool_use": + outputs.append({"type": "function_call", "id": uid("fc"), "call_id": b.get("id", ""), + "name": b.get("name", ""), "arguments": json.dumps(b.get("input", {})), + "status": "completed"}) + elif bt == "thinking": + outputs.append({"type": "reasoning", "id": uid("rsn"), "status": "completed", + "content": [{"type": "text", "text": b.get("thinking", "")}]}) + usage = anthro_resp.get("usage", {}) + return {"id": resp_id or uid("resp"), "object": "response", "created": int(time.time()), + "model": model, "status": status, "output": outputs, + "usage": {"input_tokens": usage.get("input_tokens", 0), + "output_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0), + "input_tokens_details": {"cached_tokens": 0}}} + +def an_stream_to_sse(stream, model, req_id): + resp_id = req_id or uid("resp") + completed = [] + msg_id = uid("msg") + text_buf = "" + tc_id = None + tc_call_id = None + tc_name = "" + tc_args = "" + block_type = None + stop_reason = "end_turn" + + yield emit("response.created", {"type": "response.created", + "response": {"id": resp_id, "object": "response", "model": model, + "status": "in_progress", "created": int(time.time()), "output": []}}) + yield emit("response.in_progress", {"type": "response.in_progress", "response": {"id": resp_id}}) + + for raw in stream: + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + if line.startswith("event: "): + evt_type = line[7:] + continue + if not line.startswith("data: "): + continue + try: + data = json.loads(line[6:]) + except json.JSONDecodeError: + continue + + et = data.get("type", "") + + if et == "message_start": + pass + + elif et == "content_block_start": + cb_type = data.get("content_block", {}).get("type", "") + block_type = cb_type + if cb_type == "text": + msg_id = uid("msg") + yield emit("response.output_item.added", {"type": "response.output_item.added", + "item": {"type": "message", "id": msg_id, "role": "assistant", + "status": "in_progress", "content": []}}) + yield emit("response.content_part.added", {"type": "response.content_part.added", + "part": {"type": "output_text", "text": "", "annotations": []}, "item_id": msg_id}) + elif cb_type == "tool_use": + cb = data.get("content_block", {}) + tc_id = uid("fc") + tc_call_id = cb.get("id", tc_id) + tc_name = cb.get("name", "") + yield emit("response.output_item.added", {"type": "response.output_item.added", + "item": {"type": "function_call", "id": tc_id, "call_id": tc_call_id, + "name": tc_name, "arguments": "", "status": "in_progress"}}) + elif cb_type == "thinking": + pass + + elif et == "content_block_delta": + dd = data.get("delta", {}) + dt = dd.get("type", "") + if dt == "text_delta": + txt = dd.get("text", "") + text_buf += txt + yield emit("response.output_text.delta", {"type": "response.output_text.delta", + "delta": txt, "item_id": msg_id, "content_index": 0}) + elif dt == "input_json_delta": + pj = dd.get("partial_json", "") + tc_args += pj + yield emit("response.output_text.delta", {"type": "response.function_call_arguments.delta", + "delta": pj, "item_id": tc_id}) + elif dt == "thinking_delta": + tk = dd.get("thinking", "") + yield emit("response.reasoning.delta", {"type": "response.reasoning.delta", "delta": tk}) + + elif et == "content_block_stop": + if block_type == "text": + yield emit("response.output_text.done", {"type": "response.output_text.done", + "text": text_buf, "item_id": msg_id, "content_index": 0}) + yield emit("response.content_part.done", {"type": "response.content_part.done", + "part": {"type": "output_text", "text": text_buf, "annotations": []}, "item_id": msg_id}) + yield emit("response.output_item.done", {"type": "response.output_item.done", + "item": {"type": "message", "id": msg_id, "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": text_buf, "annotations": []}]}}) + completed.append({"type": "message", "id": msg_id, "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": text_buf, "annotations": []}]}) + text_buf = "" + elif block_type == "tool_use": + yield emit("response.function_call_arguments.done", {"type": "response.function_call_arguments.done", + "item_id": tc_id, "name": tc_name, "arguments": tc_args}) + yield emit("response.output_item.done", {"type": "response.output_item.done", + "item": {"type": "function_call", "id": tc_id, "call_id": tc_call_id, + "name": tc_name, "arguments": tc_args, "status": "completed"}}) + completed.append({"type": "function_call", "id": tc_id, "call_id": tc_call_id, + "name": tc_name, "arguments": tc_args, "status": "completed"}) + tc_id = None + tc_args = "" + block_type = None + + elif et == "message_delta": + stop_reason = data.get("delta", {}).get("stop_reason", "end_turn") + + elif et == "message_stop": + sm = {"end_turn": "completed", "max_tokens": "incomplete", + "stop_sequence": "completed", "tool_use": "completed"} + status = sm.get(stop_reason, "incomplete") + yield emit("response.completed", {"type": "response.completed", + "response": {"id": resp_id, "object": "response", "model": model, + "status": status, "created": int(time.time()), "output": completed}}) + +# ═══════════════════════════════════════════════════════════════════ +# HTTP Server +# ═══════════════════════════════════════════════════════════════════ + +class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + if self.path in ("/v1/models", "/models"): + self.send_json(200, {"object": "list", "data": MODELS}) + else: + self.send_error(404) + + def do_POST(self): + if self.path in ("/v1/responses", "/responses"): + self._handle() + else: + self.send_error(404) + + def _handle(self): + try: + clen = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(clen)) + except Exception as e: + return self.send_json(400, {"error": {"message": f"Bad request: {e}"}}) + + model = body.get("model", MODELS[0]["id"] if MODELS else "unknown") + stream = body.get("stream", False) + + if BACKEND == "anthropic": + self._handle_anthropic(body, model, stream) + else: + self._handle_openai_compat(body, model, stream) + + def _handle_openai_compat(self, body, model, stream): + input_data = body.get("input", "") + chat_body = {"model": model, "messages": oa_input_to_messages(input_data)} + for k in ("temperature", "top_p", "max_output_tokens"): + if k in body: + chat_body["max_tokens" if k == "max_output_tokens" else k] = body[k] + tools = oa_convert_tools(body.get("tools")) + if tools: + chat_body["tools"] = tools + if body.get("tool_choice"): + chat_body["tool_choice"] = body["tool_choice"] + chat_body["stream"] = stream + + target = upstream_target(TARGET_URL, "/chat/completions") + fwd = forwarded_headers(self.headers, { + "Content-Type": "application/json", + "Authorization": f"Bearer {API_KEY}", + }, browser_ua=True) + print(f"[translate-proxy] POST {target} model={model} stream={stream} ua={fwd.get('User-Agent','')[:50]}", file=sys.stderr) + req = urllib.request.Request( + target, + data=json.dumps(chat_body).encode(), + headers=fwd, + ) + self._forward(req, stream, model, + lambda r: oa_resp_to_responses(json.loads(r.read()), model), + lambda s: oa_stream_to_sse(s, model, body.get("request_id") or body.get("id"))) + + def _handle_anthropic(self, body, model, stream): + input_data = body.get("input", "") + an_body = {"model": model, "messages": an_input_to_messages(input_data), + "max_tokens": body.get("max_output_tokens", 8192)} + for k in ("temperature", "top_p"): + if k in body: + an_body[k] = body[k] + tools = an_convert_tools(body.get("tools")) + if tools: + an_body["tools"] = tools + if body.get("tool_choice"): + tc = body["tool_choice"] + if isinstance(tc, str): + an_body["tool_choice"] = {"type": tc} + elif isinstance(tc, dict): + an_body["tool_choice"] = tc + an_body["stream"] = stream + + target = upstream_target(TARGET_URL, "/messages") + req = urllib.request.Request( + target, + data=json.dumps(an_body).encode(), + headers=forwarded_headers(self.headers, { + "Content-Type": "application/json", + "x-api-key": API_KEY, + "anthropic-version": "2023-06-01", + }), + ) + self._forward(req, stream, model, + lambda r: an_resp_to_responses(json.loads(r.read()), model), + lambda s: an_stream_to_sse(s, model, body.get("request_id") or body.get("id"))) + + def _forward(self, req, stream, model, nonstream_fn, stream_fn): + try: + upstream = urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + err = e.read().decode() + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) + except Exception as e: + return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}}) + + if stream: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + for event in stream_fn(upstream): + self.wfile.write(event.encode("utf-8")) + self.wfile.flush() + else: + result = nonstream_fn(upstream) + self.send_json(200, result) + + def send_json(self, status, data): + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + msg = fmt % args if args else fmt + print(f"[translate-proxy] {BACKEND} {msg}", file=sys.stderr) + +if __name__ == "__main__": + server = http.server.HTTPServer(("127.0.0.1", PORT), Handler) + print(f"translate-proxy ({BACKEND}) listening on http://127.0.0.1:{PORT}", flush=True) + print(f"Target: {TARGET_URL}", flush=True) + print(f"Models: {[m['id'] for m in MODELS]}", flush=True) + server.serve_forever()