29 Commits

27 changed files with 20461 additions and 2878 deletions

83
AGENTS.md Normal file
View File

@@ -0,0 +1,83 @@
# Project: Codex Launcher — Any AI Provider
## Overview
OpenAI Codex CLI & Desktop launcher that proxies to **any** AI provider.
Python-only (stdlib), zero pip dependencies. Supports Responses API, Chat Completions,
Anthropic Messages API, Command Code, and more via a translation proxy.
Maintained by:
- **roman-ryzenadvanced** — original Linux/GTK development
- **cobra91** — Windows port (tkinter GUI, MSIX support)
## Architecture
```
codex-launcher-gui.py (tkinter on Windows / GTK on Linux)
→ codex_launcher_lib.py (shared library: endpoints, config, process mgmt)
→ translate-proxy.py (HTTP proxy: Responses API → backend API)
→ upstream provider (OpenAI, Anthropic, DeepSeek, Antigravity, etc.)
```
### Key Files
| File | Purpose |
|------|---------|
| `src/codex-launcher-gui.py` | Windows tkinter GUI |
| `src/codex_launcher_lib.py` | Shared library (endpoints, config, process management) |
| `src/translate-proxy.py` | Translation proxy (core routing, adapters, streaming) |
| `src/antigravity_grpc/` | gRPC client for Antigravity provider |
### Backend Types
| Type | Wire Protocol | Example |
|------|--------------|---------|
| `openai-compat` | Chat Completions | DeepSeek, OpenRouter, Crof.ai |
| `anthropic` | Anthropic Messages | Anthropic direct, OpenCode Zen |
| `command-code` | Command Code /alpha/generate | CommandCode API |
| `gemini-oauth-*` | Google OAuth | Google Antigravity |
## Platform Compatibility
**MUST work on both Linux and Windows.** No exceptions.
### Platform-Specific Patterns
- **Process management**: `os.setsid()` + `os.killpg()` on Linux, `CREATE_NEW_PROCESS_GROUP` on Windows
- **Process listing**: `pgrep` on Linux, `tasklist` / `wmic` on Windows
- **Desktop launch**: exe path on Linux, `shell:AppsFolder\{AUMID}` for MSIX on Windows
- **Signals**: `signal.SIGTERM` on Linux, `taskkill /F` on Windows
- **Paths**: `~/.local/bin/` on Linux, `%LOCALAPPDATA%\Programs\Codex-Launcher\` on Windows
- **Config**: `~/.codex/config.toml` (same format on both)
- **POSIX-only APIs**: `os.getpgid()`, `/proc/{pid}/stat`, `os.setsid()` — always guard with `sys.platform` checks
### Testing Cross-Platform
- Never assume Unix-only APIs exist (`pgrep`, `getpgid`, `SIGTERM`)
- Use `sys.platform == "win32"` for Windows branches
- Test proxy startup on both platforms before committing
- Provider presets (PROVIDER_PRESETS) work identically on both
## Coding Conventions
- Python 3.8+ stdlib only, zero pip dependencies
- `snake_case` for functions/variables, `UPPER_CASE` for globals
- Immutable patterns: create new dicts/objects, don't mutate in-place
- Error handling: catch at boundaries, never silently swallow errors
- Thread-safe: use `threading.Lock` for shared state, `threading.Semaphore` for concurrency
## Common Pitfalls
- **MSIX exe paths**: `C:\Program Files\WindowsApps\` exes cannot be launched via `subprocess.Popen` — use `shell:AppsFolder` protocol
- **File locking on Windows**: Python can't overwrite files open in another process
- **Path separators**: always use `os.path.join()` or `Path` objects, never hardcoded `/`
- **Signal handling**: Windows doesn't support `SIGUSR1`/`SIGUSR2` — use events or named pipes
## Testing
- **Run before every commit**: `python -m pytest tests/ -v`
- **All tests must pass** before pushing a PR
- Test files live in `tests/` directory
- Tests use `pytest` (not unittest runner)
- Platform-specific tests must skip gracefully on other OS: `pytest.mark.skipif(sys.platform != "linux", reason="Linux-only")`
- Never mock filesystem paths with hardcoded separators — use `os.path.join` or `tmp_path`

View File

@@ -1,5 +1,149 @@
# Changelog # Changelog
## v10.13.6 (2026-05-27)
**Anti-Loop Resilience, Auto Token Refresh, Budget Cap, MSIX Support**
### New Features
- **Cross-session loop tracker**: Keys by user request hash — detects loops even when client creates new sessions per retry. Resets counter on new tasks.
- **Tool-call budget**: 150 calls max per task, warning at 80. Injects directive to stop reading and write, instead of killing the session.
- **File-path read-loop detection**: Same file read 5+ times or 30+ total file reads triggers force-finalize
- **Auto 401 token refresh**: On 401 transient, force-refreshes Google OAuth token and retries once (both v2 + OA compat handlers)
- **Model-aware idle timeout**: Flash/mini/haiku models get 120s timeout instead of 300s
- **Smart compaction summary**: Directive text when read-loop detected in compacted history
- **`_send_ag_finalize()` helper**: Returns synthetic response for hard terminations
- **Default provider policy**: Unrecognized providers get balanced compaction (128K context, 60 items)
- **Anti-stall self-kill fix**: No longer kills own parent process or process group
- **Codex Desktop Updater**: Check/install/rollback/service management + manual rebuild from source
- **E2E test suite**: `bash test-antigravity.sh --task` for real CLI task testing
### Bug Fixes
- Fix `task_retry_count` counting every turn instead of same-task retries (spam bug)
- Fix tool-call budget killing session instead of injecting directive
- Fix `_schema` NameError in smart-continue nudge (cobra91 PR #17)
- Fix `_anti_stall_cleanup()` killing own parent/shell wrapper process
- Fix OA compat path loop tracker indentation
- Fix Codex CLI 0.134.0 profile system: separate `~/.codex/<slug>.config.toml` files
- Fix compaction causing model loops: `max_input_items: 60→200` for 1M-token models
- Merge cobra91 PR #17: MSIX Desktop launch, button state, `_schema` fix
## v3.13.5 (2026-05-27)
**Anti-Loop & Flash Model Resilience, Auto Token Refresh**
### New Features
- **Cross-session loop tracker**: Keys by user request hash — detects loops even when client creates new sessions per retry. Resets counter on new tasks.
- **Tool-call budget**: 150 calls max per task, warning at 80. Injects directive to stop reading and write, instead of killing the session.
- **File-path read-loop detection**: Same file read 5+ times or 30+ total file reads triggers force-finalize
- **Smart compaction summary**: Directive text when read-loop detected in compacted history
- **Model-aware idle timeout**: Flash/mini/haiku models get 120s timeout instead of 300s
- **Auto 401 token refresh**: On 401 transient, force-refreshes Google OAuth token and retries once
- **`_send_ag_finalize()` helper**: Returns synthetic response for hard terminations
- **Default provider policy**: Unrecognized providers get balanced compaction (128K context, 60 items)
- **Anti-stall self-kill fix**: No longer kills own parent process or process group
- **E2E test suite with real CLI task**: `test-antigravity.sh --task`
### Bug Fixes
- Fix `_schema` NameError in smart-continue nudge (cobra91 PR #17)
- Fix `_anti_stall_cleanup()` killing own parent/shell wrapper
- Fix task_retry_count counting every turn instead of same-task retries
- Fix tool-call budget cap killing session instead of injecting directive
- Merged cobra91 PR #17: MSIX Desktop launch, button state
## v3.13.0 (2026-05-27)
**Codex Desktop Updater, Antigravity E2E, Profile System Fix**
### New Features
- **Codex Desktop Updater**: `CodexUpdaterWindow` class — check updates, install, rollback, service management, manual rebuild from source (`ilysenko/codex-desktop-linux`)
- **Antigravity E2E test suite**: `~/.local/bin/test-antigravity.sh` — validates token, REST endpoints, proxy adapter, model resolution
- **Antigravity prod endpoint working**: `cloudcode-pa.googleapis.com` returns 200 with real responses for `gemini-3-flash`
### Bug Fixes
- **Fix Antigravity endpoint order**: prod (`cloudcode-pa.googleapis.com`) first, then daily-sandbox, then autopush-sandbox
- **Fix Antigravity model resolution**: `gemini-3.5-flash-high``gemini-3-flash` via `_model_alias` map
- **Fix OAUTH_PROVIDER derivation**: auto-derived from `BACKEND` env var when running without `--config`
- **Fix `service_disabled` bail**: only returns error from prod endpoint, skips sandbox endpoints
- **Fix compaction causing model loops**: `max_input_items: 60→200` (prod), `80→250` (sandbox); `tool_output_limit: 6000→8000`; `compaction: "aggressive"→"conservative"` — model was "forgetting" earlier reads due to aggressive compaction
- **Fix Codex CLI 0.134.0 profile system**: profiles now written to separate `~/.codex/<slug>.config.toml` files instead of `[profiles.*]` sections in main config
- **Fix updater false success**: checks for "successfully"/"No update ready" in output text, not return code
## v3.12.1 (2026-05-27)
**Fix Antigravity Adapter (PR #15)**
### Bug Fixes
- Simplified model resolution, removed broken `_sanitize_gemini_schema()`
- Restored correct headers
- Expanded model alias map for all Antigravity variants
- Re-enabled gRPC fallback by default
## v3.12.0 (2026-05-27)
**gRPC Auto-Fallback for Antigravity Provider (PR #13)**
### New Features
- **gRPC auto-fallback**: When REST API returns 404 (model not found), automatically retries via gRPC
- **New `antigravity_grpc` module**: Full protobuf client with CloudCode PredictionService stubs
- **Display name remapping**: gRPC uses display names (e.g. "Gemini 3.5 Flash (High)") instead of REST slugs
- **Streaming and unary support**: gRPC fallback works for both streaming and non-streaming requests
- **Dynamic version fetch with validation**: Probes fetched versions to ensure they work before caching
- **Antigravity v2 handler rewrite**: Based on anti-api approach with proper safety settings, stopSequences, sessionId
- **Lazy import**: grpcio is only imported when needed — zero impact if not installed
### Bug Fixes
- Antigravity 404 caused by invalid version — now validates with probe requests
- Version fallback: auto-retries with re-fetched version if all endpoints return 404
## v3.11.12 (2026-05-26)
**New Antigravity v2 Handler (Mimicking anti-api)**
### New Features
- **Complete rewrite of Antigravity handler** based on https://github.com/ink1ing/anti-api approach
- Safety settings (all OFF), stopSequences, sessionId, requestType: agent
- functionResponse uses `response: { result: string }` format matching anti-api
- Endpoint priority: `daily-cloudcode-pa.googleapis.com` first
- Simplified sanitizer: only deduplicates consecutive user text, never touches tool messages
## v3.11.11 (2026-05-26)
## v3.11.11 (2026-05-26)
**Antigravity Fix: Stricter function_call/output Pairing + Gemini Sanitizer Rewrite (PR #12)**
### Bug Fixes
- **Stricter function_call/output pairing**: Only includes pairs where BOTH call and output exist — no orphan calls sent to Gemini
- **Gemini sanitizer rewritten**: Tool messages (`functionCall`/`functionResponse`) are always preserved as-is, never merged or skipped
- **Text merging more conservative**: Checks last message for tool content before merging consecutive text messages
- **Final trimming safe**: Only removes plain `message` items, never `function_call_output` (which would break tool pairs)
- **Merge PR #12**: Fix by qwen-chat coder
## v3.11.10 (2026-05-26)
## v3.11.10 (2026-05-26)
**Antigravity Fix: Interleave function_call/output Pairs, Gemini Turn Trimming (PR #11)**
### Bug Fixes
- **Fix Antigravity function_call/output ordering**: Tool calls and their responses are now properly interleaved in sequence (`function_call``function_call_output``function_call` → ...) instead of being grouped separately
- **Gemini sanitizer trimming**: Leading/trailing non-user turns removed for Google API compliance (Google requires conversation to start and end with user turn)
- **Stricter role boundary enforcement**: `functionCall` (model) and `functionResponse` (user) never merged across role boundaries
- **Merge PR #11**: Fix by qwen-chat coder
## v3.11.9 (2026-05-26)
## v3.11.9 (2026-05-26)
**Antigravity Fix: Preserve functionCall/functionResponse in Gemini Sanitizer (PR #10)**
### Bug Fixes
- **Fix Antigravity multi-turn tool use**: The Gemini message sanitizer was incorrectly merging/dropping `functionCall` and `functionResponse` turns, causing Antigravity to think forever without responding. These turns are now always preserved as separate messages.
- **Merge PR #10**: `fix: preserve functionCall/functionResponse in Gemini sanitizer` (qwen-chat coder)
## v3.11.8 (2026-05-26)
## v3.11.8 (2026-05-26) ## v3.11.8 (2026-05-26)
**Vision Cache Persistence, PR #8 Merge** **Vision Cache Persistence, PR #8 Merge**

32
CLAUDE.md Normal file
View File

@@ -0,0 +1,32 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Codex Launcher — Any AI Provider. Run OpenAI Codex CLI & Desktop with any AI provider.
## Pre-Commit Checklist
- [ ] Run unit tests: `python -m pytest tests/ -v` (all must pass)
- [ ] Verify cross-platform: no `os.getpgid`, `/proc/`, `pgrep`, `SIGUSR*` without `sys.platform` guard
- [ ] Check syntax: `python -c "import py_compile; py_compile.compile('src/translate-proxy.py', doraise=True)"`
- [ ] No hardcoded Unix paths or Windows-only APIs without platform checks
- [ ] No secrets or API keys in source code
## Development Commands
```bash
# Run tests
python -m pytest tests/ -v
# Syntax check
python -c "import py_compile; py_compile.compile('src/translate-proxy.py', doraise=True)"
# Run proxy locally
python src/translate-proxy.py --port 8080
```
## Agent Guidelines
See @AGENTS.md for architecture details, platform compatibility rules, and coding conventions.

View File

@@ -138,6 +138,12 @@ A three-component system:
- **has_content function_call fix** (v3.11.6) — tool-call-only responses now correctly flagged as having content, preventing infinite loops on OpenAdapter/Z.AI/OpenRouter providers - **has_content function_call fix** (v3.11.6) — tool-call-only responses now correctly flagged as having content, preventing infinite loops on OpenAdapter/Z.AI/OpenRouter providers
- **Vision/OCR preprocessing** (v3.11.6) — when provider rejects images, automatically calls a configurable vision fallback API (Kilo.ai) to describe images as text for text-only models; MD5-cached; retries on vision errors with preprocessed text - **Vision/OCR preprocessing** (v3.11.6) — when provider rejects images, automatically calls a configurable vision fallback API (Kilo.ai) to describe images as text for text-only models; MD5-cached; retries on vision errors with preprocessed text
- **Auth config-missing fix** (v3.11.6) — graceful handling when Codex config.toml is missing instead of showing raw os error - **Auth config-missing fix** (v3.11.6) — graceful handling when Codex config.toml is missing instead of showing raw os error
- **Codex Desktop Updater** (v10.13.6) — built-in updater window with Check/Install/Rollback buttons, service management, and manual rebuild from source (`ilysenko/codex-desktop-linux`)
- **Codex CLI 0.134.0 profile system** (v10.13.6) — profiles written to separate `~/.codex/<slug>.config.toml` files for compatibility with Codex CLI 0.134.0+
- **Anti-loop resilience** (v10.13.6) — cross-session loop tracker keyed by user request hash, tool-call budget (150 calls), file read-loop detection, auto 401 token refresh
- **Conservative compaction for large models** (v10.13.6) — `max_input_items: 200` for Antigravity's 1M-token models; prevents model from "forgetting" earlier file reads
- **Antigravity E2E test suite** (v10.13.6) — `bash test-antigravity.sh [--task]` validates token, REST endpoints, proxy adapter, model resolution; `--task` runs real CLI task with anomaly detection
- **MSIX Desktop support** (v10.13.6) — Windows Store install detection, `shell:AppsFolder` launch, tasklist-based process monitoring (cobra91 PR #17)
- Zero dependencies — pure Python stdlib - Zero dependencies — pure Python stdlib
### Command Code Adapter ### Command Code Adapter

View File

@@ -20,12 +20,81 @@ BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json"
LOG_DIR = HOME / ".cache/codex-desktop" LOG_DIR = HOME / ".cache/codex-desktop"
LAUNCH_LOG = LOG_DIR / "launcher.log" LAUNCH_LOG = LOG_DIR / "launcher.log"
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy" PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
ACTIVE_ENDPOINT_FILE = HOME / ".codex/.active-endpoint.json"
DEFAULT_CONFIG = """model = "" DEFAULT_CONFIG = """model = ""
model_provider = "" model_provider = ""
model_catalog_json = "" model_catalog_json = ""
""" """
CHANGELOG = [ CHANGELOG = [
("10.13.8", "2026-05-27", [
"Fix: force_finalize skips Gemini call entirely (was hallucinating tool calls without tools)",
"Fix: _send_ag_finalize returns status=failed (was stored as valid history causing loops)",
"Fix: _forward_gemini_sse wrapped in try/except for TimeoutError/BrokenPipe",
"Fix: file tracker mutations inside lock scope (was racing in ThreadingHTTPServer)",
"Fix: compaction summary strips raw tool outputs (was re-triggering read loops)",
"Fix: post-compaction write directive when 10+ reads with 0 writes",
"Fix: detect get_goal/completion_budget null-tool loops (3+ → force finalize)",
"Fix: read-loop threshold raised to 8 same-file / 40 total (was too aggressive at 5/30)",
"Fix: strip timestamps from loop hash, base64 image data from normalizer",
]),
("3.12.1", "2026-05-27", [
"Fix Antigravity adapter (PR #15): simplified model resolution",
"Removed broken schema sanitization, restored headers",
"Re-enabled gRPC fallback by default",
]),
("3.12.0", "2026-05-27", [
"gRPC auto-fallback for Antigravity (PR #13)",
"Dynamic version fetch with probe validation",
"Antigravity v2 handler rewrite (anti-api)",
]),
("3.11.10", "2026-05-26", [
"Fix Antigravity: interleave function_call/output pairs (PR #11)",
"Gemini sanitizer: trim non-user turns for Google API compliance",
]),
("3.11.9", "2026-05-26", [
"Fix Antigravity: preserve functionCall/functionResponse (PR #10)",
"Prevents tool responses from being dropped in multi-turn sessions",
]),
("3.11.8", "2026-05-26", [
"Vision cache persisted across requests (PR #8 merge)",
"No redundant vision API calls for same image URL",
]),
("3.11.7", "2026-05-26", [
"Vision auto-detect: uses provider's vision model for image description",
"Vision preprocessing replaces image stripping",
"Fix AttributeError in image_url string handling",
"Merge PR #6: vision/OCR preprocessing, PR #7: 177 unit tests",
"Auth os error 2 fix: proper config-missing message in GUI",
]),
("3.11.6", "2026-05-26", [
"Antigravity loop breakers: per-session tracking, repeated tool detection",
"has_content fix: function_call counts as valid output",
"Latest user instruction appended once per request for Antigravity",
"Antigravity-only changes, no touch to other providers",
]),
("3.11.5", "2026-05-26", [
"Token-aware compaction: fixes context_length_exceeded on small-context models",
"Proactive compaction triggers on token count, not just item count",
"Universal adaptive compaction for all providers (removed crof.ai gates)",
"Vision model detection + image stripping for non-vision models",
"Per-model token limit learning from error messages",
"Smart-continue text-tool detection for text-only models",
"Active endpoint sync: auto-removes stale references on startup",
]),
("3.11.0", "2026-05-26", [
"Merge cobra PR: concurrency semaphore (max 3), auto-continue for truncated text",
"SO_REUSEADDR on sticky port, proxy-stderr.log, stream diagnostics logging",
"Timeout/OSError handler sends response.failed SSE instead of silent drop",
"Restart Proxy button: only restarts proxy without killing Codex Desktop",
"Tool call argument normalizer: fixes Arguments→arguments, strips markdown wrapping",
"Smart-continue loop (2× retries): escalating nudges when model stops text-only mid-task",
"XML tool call extraction: parses <name> patterns from text, injects as real calls",
"Auto-continue + smart-continue ordered with skip guard to avoid double-firing",
"API key hot-reload with mtime tracking + /admin/reload + /admin/verify-key endpoints",
"GUI hot-reload: auto-refreshes proxy key on endpoint edit, verifies with upstream",
"Synthetic tool-results disabled: was causing deepseek-v4-pro truncation on opencode.ai",
]),
("3.10.4", "2026-05-25", [ ("3.10.4", "2026-05-25", [
"OAuth Secrets editor in GUI — update client ID/secret without editing files", "OAuth Secrets editor in GUI — update client ID/secret without editing files",
"Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)", "Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
@@ -425,6 +494,9 @@ def safe_name(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}" return f"{base}-{digest}"
def _profile_slug(name):
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
def label_for_backend(backend_type): def label_for_backend(backend_type):
return { return {
"openai-compat": "OpenAI-compatible", "openai-compat": "OpenAI-compatible",
@@ -910,6 +982,27 @@ def restore_config():
shutil.copy2(str(CONFIG_BAK), str(tmp)) shutil.copy2(str(CONFIG_BAK), str(tmp))
os.replace(str(tmp), str(CONFIG)) os.replace(str(tmp), str(CONFIG))
def set_active_endpoint(name):
ACTIVE_ENDPOINT_FILE.parent.mkdir(parents=True, exist_ok=True)
write_secure_text(ACTIVE_ENDPOINT_FILE, json.dumps({"active": name}, indent=2))
def validate_active_endpoint(logfn=None):
if not ACTIVE_ENDPOINT_FILE.exists():
return
try:
d = json.loads(ACTIVE_ENDPOINT_FILE.read_text())
active = d.get("active", "")
if not active:
return
eps = load_endpoints()
names = {ep.get("name", "") for ep in eps}
if active not in names:
ACTIVE_ENDPOINT_FILE.unlink()
if logfn:
logfn(f"Removed stale active-endpoint '{active}' (provider no longer exists)")
except Exception:
pass
def write_secure_text(path, text): def write_secure_text(path, text):
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp") tmp = path.with_suffix(path.suffix + ".tmp")
@@ -953,23 +1046,29 @@ def write_config_for_native(endpoint, selected_model):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True) mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2)) mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
lines = [ main_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n', f'model_catalog_json = "{mc_str}"\n',
f'\n[model_providers."{endpoint["name"]}"]\n', f'\n[model_providers."{endpoint["name"]}"]\n',
f'name = "{_toml_safe(endpoint["name"])}"\n', f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n', f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\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', write_secure_text(CONFIG, "".join(main_lines))
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_catalog_json = "{mc_path}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "default"\n', f'service_tier = "default"\n',
f'approvals_reviewer = "user"\n', f'approvals_reviewer = "user"\n',
] ]
write_secure_text(CONFIG, "".join(lines)) write_secure_text(profile_path, "".join(profile_lines))
def _toml_safe(val): def _toml_safe(val):
val = str(val).replace('"', '\\"') val = str(val).replace('"', '\\"')
@@ -988,12 +1087,12 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True) mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2)) mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
lines = [ main_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'review_model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n', f'model_catalog_json = "{mc_str}"\n',
f'\n[model_providers."{endpoint["name"]}"]\n', f'\n[model_providers."{endpoint["name"]}"]\n',
f'name = "{_toml_safe(endpoint["name"])}"\n', f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "http://127.0.0.1:{proxy_port}"\n', f'base_url = "http://127.0.0.1:{proxy_port}"\n',
@@ -1002,15 +1101,19 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
f'request_max_retries = 1\n', f'request_max_retries = 1\n',
f'stream_max_retries = 0\n', f'stream_max_retries = 0\n',
f'stream_idle_timeout_ms = 600000\n', f'stream_idle_timeout_ms = 600000\n',
f'\n[profiles."{endpoint["name"]}"]\n', ]
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', write_secure_text(CONFIG, "".join(main_lines))
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'review_model = "{_toml_safe(selected_model)}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n', f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "fast"\n', f'service_tier = "fast"\n',
f'approvals_reviewer = "user"\n', f'approvals_reviewer = "user"\n',
] ]
write_secure_text(CONFIG, "".join(lines)) write_secure_text(profile_path, "".join(profile_lines))
def _gen_model_catalog(endpoint, selected_model=None): def _gen_model_catalog(endpoint, selected_model=None):
default_model = selected_model or endpoint.get("default_model") default_model = selected_model or endpoint.get("default_model")
@@ -1253,6 +1356,9 @@ def _check_codex_auth():
if out.returncode == 0 and text: if out.returncode == 0 and text:
return ("logged_in", text) return ("logged_in", text)
if text: if text:
_tl = text.lower()
if "no such file" in _tl or "os error 2" in _tl or "not found" in _tl:
return ("not_configured", "Config missing — launch once to create")
return ("error", text) return ("error", text)
return ("unknown", "No output from codex login status") return ("unknown", "No output from codex login status")
except FileNotFoundError: except FileNotFoundError:
@@ -1849,6 +1955,7 @@ class LauncherWin(Gtk.Window):
self._proc = None self._proc = None
self._endpoints_data = load_endpoints() self._endpoints_data = load_endpoints()
recover_config_if_needed() recover_config_if_needed()
validate_active_endpoint()
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add(vbox) self.add(vbox)
@@ -1856,7 +1963,7 @@ class LauncherWin(Gtk.Window):
# header row # header row
hdr = Gtk.Box(spacing=8) hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0) vbox.pack_start(hdr, False, False, 0)
lbl = Gtk.Label(label="<b>Codex Launcher v3.10.9</b>") lbl = Gtk.Label(label=f"<b>Codex Launcher v{CHANGELOG[0][0]}</b>")
lbl.set_use_markup(True) lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0) hdr.pack_start(lbl, False, False, 0)
changelog_btn = Gtk.Button(label="Changelog") changelog_btn = Gtk.Button(label="Changelog")
@@ -1883,6 +1990,9 @@ class LauncherWin(Gtk.Window):
oauth_btn = Gtk.Button(label="OAuth Secrets") oauth_btn = Gtk.Button(label="OAuth Secrets")
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets()) oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
hdr.pack_end(oauth_btn, False, False, 0) hdr.pack_end(oauth_btn, False, False, 0)
updater_btn = Gtk.Button(label="Update Desktop")
updater_btn.connect("clicked", lambda b: self._open_updater())
hdr.pack_end(updater_btn, False, False, 0)
# verification status bar # verification status bar
self._cli_info = _detect_codex_cli() self._cli_info = _detect_codex_cli()
@@ -2095,6 +2205,8 @@ class LauncherWin(Gtk.Window):
self._relogin_btn.set_sensitive("cli" not in self._missing) self._relogin_btn.set_sensitive("cli" not in self._missing)
elif status == "not_installed": elif status == "not_installed":
self._auth_label.set_markup("<span foreground='#888'>Auth: N/A (CLI not installed)</span>") self._auth_label.set_markup("<span foreground='#888'>Auth: N/A (CLI not installed)</span>")
elif status == "not_configured":
self._auth_label.set_markup("<span foreground='#d29922'>⚠ Config missing — launch once to create</span>")
else: else:
self._auth_label.set_markup(f"<span foreground='#d29922'>⚠ Auth: {msg}</span>") self._auth_label.set_markup(f"<span foreground='#d29922'>⚠ Auth: {msg}</span>")
self._relogin_btn.set_sensitive("cli" not in self._missing) self._relogin_btn.set_sensitive("cli" not in self._missing)
@@ -2331,6 +2443,18 @@ class LauncherWin(Gtk.Window):
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py") _py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
subprocess.Popen([sys.executable, _py], start_new_session=True) subprocess.Popen([sys.executable, _py], start_new_session=True)
def _open_updater(self):
try:
if not UPDATER_BIN and not _detect_codex_desktop():
self.log("Codex Desktop not installed. Nothing to update.")
return
self._updater_window = CodexUpdaterWindow()
self._updater_window.connect("destroy", lambda *_: setattr(self, "_updater_window", None))
except Exception as e:
import traceback; traceback.print_exc()
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
d.run(); d.destroy()
def _backup_profile(self): def _backup_profile(self):
chooser = Gtk.FileChooserDialog( chooser = Gtk.FileChooserDialog(
title="Backup Codex Profile", title="Backup Codex Profile",
@@ -2594,6 +2718,8 @@ class LauncherWin(Gtk.Window):
begin_config_transaction(f"launch:{ep['name']}") begin_config_transaction(f"launch:{ep['name']}")
write_config_for_native(ep, model) write_config_for_native(ep, model)
set_active_endpoint(ep["name"])
if target == "desktop": if target == "desktop":
if needs_proxy: if needs_proxy:
_kill_existing_desktop(self.log) _kill_existing_desktop(self.log)
@@ -2651,6 +2777,7 @@ class LauncherWin(Gtk.Window):
begin_config_transaction(f"launch:bgp:{pool['name']}") begin_config_transaction(f"launch:bgp:{pool['name']}")
write_config_for_translated(bgp_ep, model, port) write_config_for_translated(bgp_ep, model, port)
set_active_endpoint(pool["name"])
if target == "desktop": if target == "desktop":
_kill_existing_desktop(self.log) _kill_existing_desktop(self.log)
@@ -2771,7 +2898,7 @@ class LauncherWin(Gtk.Window):
cmd_parts.extend(["codex", "-c", f"model={model}", cmd_parts.extend(["codex", "-c", f"model={model}",
"-s", sandbox, "-a", approval]) "-s", sandbox, "-a", approval])
else: else:
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}", cmd_parts.extend(["codex", "--profile", _profile_slug(ep["name"]), "-c", f"model={model}",
"-s", sandbox, "-a", approval]) "-s", sandbox, "-a", approval])
self.log(f"Running: {' '.join(cmd_parts)}") self.log(f"Running: {' '.join(cmd_parts)}")
@@ -4398,10 +4525,54 @@ class EditEndpointDialog(Gtk.Dialog):
data["default"] = name data["default"] = name
save_endpoints(data) save_endpoints(data)
self._hot_reload_proxy_key(new_ep)
self._parent_mgr._rebuild() self._parent_mgr._rebuild()
self._parent_mgr._parent._on_endpoints_updated() self._parent_mgr._parent._on_endpoints_updated()
self.destroy() self.destroy()
def _hot_reload_proxy_key(self, ep):
try:
ep_name = ep.get("name", "")
proxy_port = None
import glob as _glob
for cfg_file in _glob.glob(str(PROXY_CONFIG_DIR / "proxy-*.json")):
try:
with open(cfg_file) as f:
pcfg = json.load(f)
if ep_name.lower().replace(" ", "-") in cfg_file.lower():
proxy_port = pcfg.get("port")
pcfg["api_key"] = ep.get("api_key", "")
with open(cfg_file, "w") as f:
json.dump(pcfg, f, indent=2)
break
except Exception:
continue
if proxy_port:
import urllib.request as _ur
try:
url = f"http://127.0.0.1:{proxy_port}/admin/reload"
resp = _ur.urlopen(url, timeout=3)
result = json.loads(resp.read())
reloaded = result.get("reloaded", False)
preview = result.get("api_key_preview", "?")
self._parent_mgr._parent.log(
f"[hot-reload] key {'updated' if reloaded else 'unchanged'}: {preview}")
if reloaded:
verify_url = f"http://127.0.0.1:{proxy_port}/admin/verify-key"
vresp = _ur.urlopen(verify_url, timeout=10)
vresult = json.loads(vresp.read())
valid = vresult.get("valid", False)
if valid:
self._parent_mgr._parent.log(
f"[hot-reload] key verified OK ({vresult.get('models', '?')} models)")
else:
self._parent_mgr._parent.log(
f"[hot-reload] WARNING: key verification failed: {vresult.get('error', 'unknown')}")
except Exception:
pass
except Exception:
pass
def _show_error(self, msg): def _show_error(self, msg):
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg) d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
d.run(); d.destroy() d.run(); d.destroy()
@@ -5722,5 +5893,510 @@ class BenchmarkWindow(Gtk.Window):
GLib.idle_add(_show) GLib.idle_add(_show)
# ═══════════════════════════════════════════════════════════════════
# Codex Desktop Updater — auto-update from ilysenko/codex-desktop-linux
# ═══════════════════════════════════════════════════════════════════
UPSTREAM_REPO = "ilysenko/codex-desktop-linux"
UPDATER_BIN = shutil.which("codex-update-manager") or ""
UPDATER_STATE_FILE = Path.home() / ".local/state/codex-update-manager/state.json"
UPDATER_SERVICE_LOG = Path.home() / ".local/state/codex-update-manager/service.log"
def _get_updater_status():
try:
out = subprocess.run(
[UPDATER_BIN, "status", "--json"],
capture_output=True, text=True, timeout=10,
)
if out.returncode == 0 and out.stdout.strip():
return json.loads(out.stdout.strip())
except Exception:
pass
return None
def _get_installed_desktop_version():
try:
out = subprocess.run(
["dpkg-query", "-W", "-f", "${Version}", "codex-desktop"],
capture_output=True, text=True, timeout=5,
)
if out.returncode == 0 and out.stdout.strip():
return out.stdout.strip()
except Exception:
pass
return None
def _get_upstream_info():
try:
req = urllib.request.Request(
f"https://api.github.com/repos/{UPSTREAM_REPO}/commits?per_page=1",
headers={"Accept": "application/vnd.github+json", "User-Agent": "codex-launcher"},
)
resp = urllib.request.urlopen(req, timeout=10)
commits = json.loads(resp.read())
if commits:
c = commits[0]
return {
"sha": c["sha"][:12],
"date": c["commit"]["committer"]["date"][:10],
"message": c["commit"]["message"].split("\n")[0][:80],
}
except Exception:
pass
return None
def _is_updater_service_active():
try:
out = subprocess.run(
["systemctl", "--user", "is-active", "codex-update-manager.service"],
capture_output=True, text=True, timeout=5,
)
return out.stdout.strip() == "active"
except Exception:
return False
class CodexUpdaterWindow(Gtk.Window):
def __init__(self):
super().__init__(title="Codex Desktop Updater")
self.set_default_size(580, 520)
self.set_border_width(10)
self.set_position(Gtk.WindowPosition.CENTER)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add(vbox)
hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0)
lbl = Gtk.Label()
lbl.set_markup("<b>Codex Desktop Updater</b>\n<small>Auto-update from github.com/ilysenko/codex-desktop-linux</small>")
lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0)
info_frame = Gtk.Frame(label="Current Installation")
vbox.pack_start(info_frame, False, False, 4)
info_grid = Gtk.Grid(column_spacing=12, row_spacing=4, margin=8)
info_frame.add(info_grid)
self._installed_lbl = Gtk.Label(label="Checking…", xalign=0)
self._service_lbl = Gtk.Label(label="Checking…", xalign=0)
self._upstream_lbl = Gtk.Label(label="Checking…", xalign=0)
self._candidate_lbl = Gtk.Label(label="—", xalign=0)
self._cli_lbl = Gtk.Label(label="Checking…", xalign=0)
labels = [
(0, "Installed:"), (1, self._installed_lbl),
(2, "Upstream:"), (3, self._upstream_lbl),
(4, "Service:"), (5, self._service_lbl),
(6, "Candidate:"), (7, self._candidate_lbl),
(8, "CLI:"), (9, self._cli_lbl),
]
for idx, widget in labels:
if isinstance(widget, str):
widget = Gtk.Label(label=widget, xalign=0)
info_grid.attach(widget, idx % 2, idx // 2, 1, 1)
btn_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(btn_box, False, False, 4)
self._check_btn = Gtk.Button(label="Check for Updates")
self._check_btn.connect("clicked", lambda b: self._check_updates())
btn_box.pack_start(self._check_btn, True, True, 0)
self._install_btn = Gtk.Button(label="Install Update")
self._install_btn.connect("clicked", lambda b: self._install_update())
self._install_btn.set_sensitive(False)
self._install_btn.get_style_context().add_class("suggested-action")
btn_box.pack_start(self._install_btn, True, True, 0)
self._rollback_btn = Gtk.Button(label="Rollback")
self._rollback_btn.connect("clicked", lambda b: self._rollback())
self._rollback_btn.set_sensitive(False)
btn_box.pack_start(self._rollback_btn, True, True, 0)
auto_note = Gtk.Label(xalign=0)
auto_note.set_markup("<small>↑ Auto-updater: only detects new upstream <i>Codex.dmg</i> from OpenAI. "
"For latest community patches, use Rebuild from Source below.</small>")
auto_note.set_use_markup(True)
vbox.pack_start(auto_note, False, False, 0)
svc_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(svc_box, False, False, 0)
self._svc_start_btn = Gtk.Button(label="Start Service")
self._svc_start_btn.connect("clicked", lambda b: self._toggle_service("start"))
svc_box.pack_start(self._svc_start_btn, True, True, 0)
self._svc_stop_btn = Gtk.Button(label="Stop Service")
self._svc_stop_btn.connect("clicked", lambda b: self._toggle_service("stop"))
svc_box.pack_start(self._svc_stop_btn, True, True, 0)
self._svc_enable_btn = Gtk.Button(label="Enable Autostart")
self._svc_enable_btn.connect("clicked", lambda b: self._toggle_service("enable"))
svc_box.pack_start(self._svc_enable_btn, True, True, 0)
rebuild_box = Gtk.Box(spacing=8)
vbox.pack_start(rebuild_box, False, False, 4)
rebuild_info = Gtk.Label(xalign=0)
rebuild_info.set_markup(
"<b>Rebuild from Source (Recommended)</b>\n"
"<small>The auto-updater only detects new upstream Codex DMGs from OpenAI's CDN.\n"
"To get the <i>latest community fixes</i> from ilysenko/codex-desktop-linux,\n"
"use Clone/Pull then Build &amp; Install to rebuild a fresh .deb from source.</small>"
)
rebuild_info.set_use_markup(True)
rebuild_box.pack_start(rebuild_info, True, True, 0)
rebuild_btn_box = Gtk.Box(spacing=8)
vbox.pack_start(rebuild_btn_box, False, False, 0)
self._clone_btn = Gtk.Button(label="Clone / Pull Repo")
self._clone_btn.connect("clicked", lambda b: self._clone_or_pull())
rebuild_btn_box.pack_start(self._clone_btn, True, True, 0)
self._build_btn = Gtk.Button(label="Build & Install .deb")
self._build_btn.connect("clicked", lambda b: self._build_and_install())
self._build_btn.set_sensitive(False)
self._build_btn.get_style_context().add_class("suggested-action")
rebuild_btn_box.pack_start(self._build_btn, True, True, 0)
self._rebuild_dir_lbl = Gtk.Label(label="", xalign=0)
vbox.pack_start(self._rebuild_dir_lbl, False, False, 0)
sw = Gtk.ScrolledWindow()
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
vbox.pack_start(sw, True, True, 0)
self._log_buf = Gtk.TextBuffer()
tv = Gtk.TextView(buffer=self._log_buf)
tv.set_editable(False)
tv.set_cursor_visible(False)
tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
sw.add(tv)
bb = Gtk.Box(spacing=8)
vbox.pack_start(bb, False, False, 0)
clear_btn = Gtk.Button(label="Clear Log")
clear_btn.connect("clicked", lambda b: self._log_buf.set_text(""))
bb.pack_start(clear_btn, False, False, 0)
view_log_btn = Gtk.Button(label="View Service Log")
view_log_btn.connect("clicked", lambda b: self._view_service_log())
bb.pack_start(view_log_btn, False, False, 0)
close_btn = Gtk.Button(label="Close")
close_btn.connect("clicked", lambda b: self.destroy())
bb.pack_end(close_btn, False, False, 0)
self.show_all()
self._rebuild_dir = Path.home() / ".cache/codex-launcher/codex-desktop-linux"
self._rebuild_dir_lbl.set_markup(f"<small>Build dir: {self._rebuild_dir}</small>")
self._rebuild_dir_lbl.set_use_markup(True)
self._log("Updater initialized")
threading.Thread(target=self._refresh_status, daemon=True).start()
def _log(self, msg):
def _append():
e = self._log_buf.get_end_iter()
self._log_buf.insert(e, msg + "\n")
GLib.idle_add(_append)
def _refresh_status(self):
installed = _get_installed_desktop_version()
upstream = _get_upstream_info()
status = _get_updater_status()
svc_active = _is_updater_service_active()
def _update():
if installed:
self._installed_lbl.set_markup(f"<span foreground='#2ea043'><b>{installed}</b></span>")
self._installed_lbl.set_use_markup(True)
else:
self._installed_lbl.set_text("Not installed via dpkg")
if upstream:
self._upstream_lbl.set_markup(
f"<span foreground='#2ea043'>{upstream['date']}</span>"
f" <small>({upstream['sha']}) {upstream['message']}</small>"
)
self._upstream_lbl.set_use_markup(True)
else:
self._upstream_lbl.set_text("Could not fetch")
if svc_active:
self._service_lbl.set_markup("<span foreground='#2ea043'>● active</span>")
self._service_lbl.set_use_markup(True)
else:
self._service_lbl.set_markup("<span foreground='#d29922'>● inactive</span>")
self._service_lbl.set_use_markup(True)
if status:
cand = status.get("candidate_version")
if cand:
self._candidate_lbl.set_markup(f"<span foreground='#58a6ff'><b>{cand}</b></span>")
self._candidate_lbl.set_use_markup(True)
self._install_btn.set_sensitive(True)
else:
self._candidate_lbl.set_text("No update pending")
self._install_btn.set_sensitive(False)
cli_ver = status.get("cli_installed_version", "")
cli_latest = status.get("cli_latest_version", "")
cli_status = status.get("cli_status", "")
if cli_ver:
color = "#2ea043" if cli_status == "up_to_date" else "#d29922"
self._cli_lbl.set_markup(
f"<span foreground='{color}'>{cli_ver}"
f"{' (up to date)' if cli_status == 'up_to_date' else f' → {cli_latest}'}"
f"</span>"
)
self._cli_lbl.set_use_markup(True)
has_rollback = bool(status.get("last_known_good_version"))
self._rollback_btn.set_sensitive(has_rollback)
else:
if not UPDATER_BIN:
self._candidate_lbl.set_text("codex-update-manager not found")
else:
self._candidate_lbl.set_text("Status unavailable")
if self._rebuild_dir.exists():
self._build_btn.set_sensitive(True)
GLib.idle_add(_update)
self._log(f"Status: installed={installed} svc={'active' if svc_active else 'inactive'}")
def _check_updates(self):
self._check_btn.set_sensitive(False)
self._log("Checking for updates…")
def _run():
try:
out = subprocess.run(
[UPDATER_BIN, "check-now"],
capture_output=True, text=True, timeout=120,
)
self._log(f"check-now: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
GLib.idle_add(lambda: self._check_btn.set_sensitive(True))
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _install_update(self):
self._install_btn.set_sensitive(False)
self._log("Installing update (may prompt for sudo)…")
def _run():
try:
desktop_running = False
try:
out = subprocess.run(
["pgrep", "-f", "/opt/codex-desktop/electron"],
capture_output=True, text=True, timeout=5,
)
desktop_running = out.returncode == 0
except Exception:
pass
if desktop_running:
self._log("⚠ Codex Desktop is running. Closing it to proceed with update…")
subprocess.run(["pkill", "-f", "/opt/codex-desktop/electron"], timeout=10)
import time; time.sleep(3)
self._log("Desktop closed.")
out = subprocess.run(
[UPDATER_BIN, "install-ready"],
capture_output=True, text=True, timeout=300,
)
self._log(f"install-ready: rc={out.returncode}")
combined = (out.stdout or "") + (out.stderr or "")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
if out.returncode == 0 and "successfully" in combined.lower():
self._log("Update installed successfully!")
elif "No Codex Desktop update is ready" in combined:
self._log("⚠ No update is ready. Run 'Check for Updates' first, or use 'Clone/Pull + Build & Install' for a manual update.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
elif "Close it to install" in combined:
self._log("⚠ Desktop was still running. Close Desktop manually and try again.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
elif out.returncode == 0:
self._log("⚠ install-ready returned OK but no confirmation of actual install. Output: " + combined[:200])
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
else:
self._log("Update may not have completed. Check the log above.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _rollback(self):
self._rollback_btn.set_sensitive(False)
self._log("Rolling back to previous version…")
def _run():
try:
out = subprocess.run(
[UPDATER_BIN, "rollback"],
capture_output=True, text=True, timeout=300,
)
self._log(f"rollback: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _toggle_service(self, action):
cmd_map = {
"start": ["systemctl", "--user", "start", "codex-update-manager.service"],
"stop": ["systemctl", "--user", "stop", "codex-update-manager.service"],
"enable": ["systemctl", "--user", "enable", "--now", "codex-update-manager.service"],
}
cmd = cmd_map.get(action)
if not cmd:
return
self._log(f"Running: {' '.join(cmd)}")
def _run():
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
self._log(f"{action}: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _clone_or_pull(self):
self._clone_btn.set_sensitive(False)
self._log(f"Clone/pull {UPSTREAM_REPO}…")
def _run():
try:
self._rebuild_dir.parent.mkdir(parents=True, exist_ok=True)
if self._rebuild_dir.exists():
self._log("Pulling latest changes…")
out = subprocess.run(
["git", "pull", "--ff-only"],
capture_output=True, text=True, timeout=60,
cwd=str(self._rebuild_dir),
)
else:
self._log("Cloning repository…")
out = subprocess.run(
["git", "clone", "--depth=1", f"https://github.com/{UPSTREAM_REPO}.git", str(self._rebuild_dir)],
capture_output=True, text=True, timeout=120,
)
self._log(f"git: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip()[:200])
if out.stderr:
self._log(out.stderr.strip()[:200])
if out.returncode == 0:
self._log("Repository ready.")
GLib.idle_add(lambda: self._build_btn.set_sensitive(True))
except Exception as e:
self._log(f"Error: {e}")
finally:
GLib.idle_add(lambda: self._clone_btn.set_sensitive(True))
threading.Thread(target=_run, daemon=True).start()
def _build_and_install(self):
self._build_btn.set_sensitive(False)
self._log("Building Codex Desktop from source (this may take several minutes)…")
def _run():
try:
self._log("Installing build dependencies…")
out = subprocess.run(
["bash", "-c", "bash scripts/install-deps.sh"],
capture_output=True, text=True, timeout=300,
cwd=str(self._rebuild_dir),
)
self._log(f"install-deps: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
self._log("Building app from upstream DMG…")
out = subprocess.run(
["make", "build-app-fresh"],
capture_output=True, text=True, timeout=600,
cwd=str(self._rebuild_dir),
)
self._log(f"build-app-fresh: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
if out.returncode != 0:
self._log("Build failed. Check log above.")
return
self._log("Building .deb package…")
out = subprocess.run(
["make", "deb"],
capture_output=True, text=True, timeout=120,
cwd=str(self._rebuild_dir),
)
self._log(f"deb: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
if out.returncode != 0:
self._log("Deb build failed.")
return
deb_files = list((self._rebuild_dir / "dist").glob("codex-desktop_*.deb"))
if not deb_files:
self._log("No .deb found in dist/")
return
deb_path = deb_files[-1]
self._log(f"Installing {deb_path.name}…")
out = subprocess.run(
["pkexec", "dpkg", "-i", str(deb_path)],
capture_output=True, text=True, timeout=120,
)
self._log(f"dpkg -i: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip()[:300])
if out.stderr:
self._log(out.stderr.strip()[:300])
if out.returncode == 0:
self._log("Codex Desktop updated successfully!")
else:
self._log("Installation failed. Try: sudo dpkg -i " + str(deb_path))
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _view_service_log(self):
if UPDATER_SERVICE_LOG.exists():
subprocess.Popen(["xdg-open", str(UPDATER_SERVICE_LOG)])
else:
self._log(f"Service log not found at {UPDATER_SERVICE_LOG}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -8,6 +8,7 @@ the tkinter GUI (Windows). No pip dependencies. No GTK/PyGObject imports.
import base64 import base64
import collections import collections
import contextlib import contextlib
import copy
import hashlib import hashlib
import json import json
import os import os
@@ -68,6 +69,9 @@ BGP_POOLS_FILE = CONFIG_DIR / "bgp-pools.json"
LAUNCH_LOG = LOG_DIR / "launcher.log" LAUNCH_LOG = LOG_DIR / "launcher.log"
OAUTH_SECRETS_PATH = HOME / ".config" / "codex-launcher" / "oauth-secrets.json" OAUTH_SECRETS_PATH = HOME / ".config" / "codex-launcher" / "oauth-secrets.json"
GEMINI_OAUTH_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
GEMINI_OAUTH_CLIENT_SECRET = "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"
if IS_WINDOWS: if IS_WINDOWS:
PROXY = BIN_DIR / "translate-proxy.py" PROXY = BIN_DIR / "translate-proxy.py"
CLEANUP = BIN_DIR / "cleanup-codex-stale.py" CLEANUP = BIN_DIR / "cleanup-codex-stale.py"
@@ -82,7 +86,159 @@ model_provider = ""
model_catalog_json = "" model_catalog_json = ""
""" """
_MODULE_DIR = Path(__file__).resolve().parent
if str(_MODULE_DIR) not in sys.path:
sys.path.insert(0, str(_MODULE_DIR))
try:
import universal_runtime as _universal_runtime
except (ImportError, ModuleNotFoundError):
_universal_runtime = None
def detect_runtime_environment():
if _universal_runtime is None:
return {"profile": "unknown", "fallback_mode": "builtin"}
return _universal_runtime.detect_environment()
def build_cross_platform_profile(mode="basic", overrides=None):
if _universal_runtime is None:
return {"profile": "legacy", "mode": mode, "overrides": overrides or {}}
return _universal_runtime.build_runtime_profile(mode=mode, overrides=overrides)
def run_doctor_plus():
if _universal_runtime is None:
return {"health": "unknown", "checks": []}
deps = ["python3" if not IS_WINDOWS else "python", "curl"]
return _universal_runtime.doctor_plus(deps, [CONFIG, ENDPOINTS_FILE, PROXY_CONFIG_DIR / "probe"])
def choose_policy_route(routes, policy=None):
if _universal_runtime is None:
return routes[0] if routes else {}
return _universal_runtime.select_policy_route(routes, policy=policy)
def create_session_portability_pack(destination, metadata=None, files=None):
if _universal_runtime is None:
raise RuntimeError("universal runtime unavailable")
return _universal_runtime.export_session_pack(Path(destination), metadata or {}, [Path(p) for p in (files or [])])
def restore_session_portability_pack(bundle_path, destination_dir):
if _universal_runtime is None:
raise RuntimeError("universal runtime unavailable")
return _universal_runtime.import_session_pack(Path(bundle_path), Path(destination_dir))
CHANGELOG = [ CHANGELOG = [
("10.13.8", "2026-05-27", [
"Fix: force_finalize skips Gemini call entirely (was hallucinating tool calls without tools)",
"Fix: _send_ag_finalize returns status=failed (was stored as valid history causing loops)",
"Fix: _forward_gemini_sse wrapped in try/except for TimeoutError/BrokenPipe",
"Fix: file tracker mutations inside lock scope (was racing in ThreadingHTTPServer)",
"Fix: compaction summary strips raw tool outputs (was re-triggering read loops)",
"Fix: post-compaction write directive when 10+ reads with 0 writes",
"Fix: detect get_goal/completion_budget null-tool loops (3+ → force finalize)",
"Fix: read-loop threshold raised to 8 same-file / 40 total (was too aggressive at 5/30)",
"Fix: strip timestamps from loop hash, base64 image data from normalizer",
]),
("3.12.1", "2026-05-27", [
"Fix Antigravity adapter (PR #15): simplify model resolution",
"Removed broken schema sanitization, restored correct headers",
"Expanded model alias map for all Antigravity variants",
"Re-enabled gRPC fallback by default",
]),
("3.12.0", "2026-05-27", [
"gRPC auto-fallback for Antigravity provider (PR #13)",
"New antigravity_grpc module with protobuf client",
"REST 404 triggers gRPC fallback using display names",
"gRPC supports streaming and unary generate",
"Dynamic version fetch with probe validation",
"Antigravity v2 handler rewrite (anti-api approach)",
"Safety settings, stopSequences, sessionId, requestType: agent",
]),
("3.11.11", "2026-05-26", [
"Final trimming only removes plain messages, never function_call_output",
]),
("3.11.10", "2026-05-26", [
"Fix Antigravity: interleave function_call/output pairs in correct sequence (PR #11)",
"Fix Gemini sanitizer: trim leading/trailing non-user turns for Google API compliance",
"Stricter function call/response isolation — no merging across role boundaries",
]),
("3.11.9", "2026-05-26", [
"Fix Antigravity: preserve functionCall/functionResponse in Gemini sanitizer (PR #10)",
"Prevents tool responses from being merged/dropped in multi-turn Antigravity sessions",
]),
("3.11.8", "2026-05-26", [
"Vision description cache persisted across requests (no redundant API calls for same image)",
"Merge PR #8: fix vision cache persistence across requests",
]),
("3.11.7", "2026-05-26", [
"Vision auto-detect: uses provider's own vision model (e.g. 0G-Qwen-VL) as fallback for image description",
"Vision preprocessing replaces image stripping: images described via API instead of just removed",
"Fix AttributeError in image_url handling when value is string not dict",
"Merge PR #6: vision/OCR preprocessing for text-only models",
"Merge PR #7: 177 unit tests for translate-proxy.py",
"Auth os error 2 fix: GUI shows config-missing message instead of raw error",
]),
("3.11.6", "2026-05-26", [
"Antigravity loop breakers: per-session tracking, edit-intent nudge (first turn only)",
"Loop breaker: same tool+args repeated 5+ times triggers force finalization",
"Latest user instruction appended exactly once per request",
"Detailed [antigravity-loop] logging for all tracking fields",
"has_content fix: function_call now counts as valid output (no more infinite loops)",
"Antigravity-only changes, no touch to other providers",
]),
("3.11.5", "2026-05-26", [
"Token-aware compaction: fixes context_length_exceeded on small-context models (25 items x 1600 tokens)",
"Proactive compaction triggers on token count (>80% model limit), not just item count",
"Universal adaptive compaction: removed crof.ai-only gates, all providers get compaction",
"Vision model detection: strips images for non-vision models, keeps for vision-capable ones",
"Per-model token limit learning from context_length_exceeded error messages",
"Compaction aggression levels: normal vs extreme when tokens > 1.5x model limit",
"Smart-continue text-tool detection: triggers on tool-call text patterns, not just function_call_output",
"Active endpoint sync: GUI auto-removes stale endpoint references on startup",
]),
("3.11.0", "2026-05-26", [
"Merge cobra PR: concurrency semaphore (max 3), auto-continue for truncated text",
"SO_REUSEADDR on sticky port, proxy-stderr.log, stream diagnostics logging",
"Timeout/OSError handler sends response.failed SSE instead of silent drop",
"Restart Proxy button: only restarts proxy without killing Codex Desktop",
"Tool call argument normalizer: fixes Arguments->arguments, strips markdown wrapping",
"Smart-continue loop (2x retries): escalating nudges when model stops text-only mid-task",
"XML tool call extraction: parses patterns from text, injects as real calls",
"Auto-continue + smart-continue ordered with skip guard to avoid double-firing",
"API key hot-reload with mtime tracking + /admin/reload + /admin/verify-key endpoints",
"GUI hot-reload: auto-refreshes proxy key on endpoint edit, verifies with upstream",
"Synthetic tool-results disabled: was causing deepseek-v4-pro truncation on opencode.ai",
]),
("3.10.12", "2026-05-26", [
"Sticky endpoint: caches last working endpoint, sequential fallback on failure",
"Endpoint order: cloudcode-pa first (matches agy CLI), daily-cloudcode-pa fallback",
"Anti-stall engine: kills stale proxy processes + clears pycache on startup",
"Smart error classification: quota vs capacity vs banned vs validation vs auth",
"Rate limit reset parsing: extracts cooldown from error body for accuracy",
"Missing headers: X-Client-Name, X-Client-Version, x-goog-api-client, sessionId",
"Guardrail skip: simple messages (hi) skip agent guardrail, no more tool-call loops",
"Claude fixes: preserve all tools, skip compaction/normalizer/sanitization for Claude",
"Normalizer model param: distinguishes Claude vs Gemini for correct behavior",
]),
("3.10.11", "2026-05-26", [
"Hybrid endpoint fallback: cloudcode-pa then daily-cloudcode-pa on 429",
"daily-cloudcode-pa.googleapis.com (same endpoint agy-core uses)",
"429 errors log full response body for debugging",
"Rate-limit marking only after ALL endpoints fail",
"Restored SERVICE_DISABLED (403) fallthrough",
]),
("3.10.10", "2026-05-25", [
"Fix normalizer stripping ALL context after compaction on resumed sessions",
"No auto-reset when compaction summary present (preserves 1925+ turn history)",
"Always preserve compaction summaries in normalizer output",
"Deduplicate consecutive identical goal_context messages",
"Emergency reset preserves compaction summaries",
"Fix hashlib NameError in _antigravity_normalize_context (string comparison instead)",
]),
("3.10.9", "2026-05-25", [ ("3.10.9", "2026-05-25", [
"Antigravity: production-only endpoints (cloudcode-pa.googleapis.com), sandbox blocked unless ALLOW_ANTIGRAVITY_STAGING=1", "Antigravity: production-only endpoints (cloudcode-pa.googleapis.com), sandbox blocked unless ALLOW_ANTIGRAVITY_STAGING=1",
"Antigravity: 403 SERVICE_DISABLED falls through, 429 returns to client (no sandbox fallback)", "Antigravity: 403 SERVICE_DISABLED falls through, 429 returns to client (no sandbox fallback)",
@@ -349,7 +505,7 @@ CHANGELOG = [
] ]
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# Provider presets (17 providers) # Provider presets (25+ providers)
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
PROVIDER_PRESETS = { PROVIDER_PRESETS = {
@@ -375,7 +531,9 @@ PROVIDER_PRESETS = {
"glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6", "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
"minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free", "minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free",
"deepseek-v4-flash-free", "nemotron-3-super-free", "deepseek-v4-flash-free", "nemotron-3-super-free",
"qwen3.6-plus", "qwen3.5-plus", "big-pickle", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free",
"gemini-3-flash", "gemini-3.1-pro", "gemini-3.5-flash",
"big-pickle", "grok-build-0.1",
], ],
}, },
"OpenCode Zen (Anthropic)": { "OpenCode Zen (Anthropic)": {
@@ -384,7 +542,7 @@ PROVIDER_PRESETS = {
"models": [ "models": [
"claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", "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-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5",
"claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku", "claude-sonnet-4", "claude-haiku-4-5",
], ],
}, },
"OpenCode Go (OpenAI-compatible)": { "OpenCode Go (OpenAI-compatible)": {
@@ -392,8 +550,10 @@ PROVIDER_PRESETS = {
"base_url": "https://opencode.ai/zen/go/v1", "base_url": "https://opencode.ai/zen/go/v1",
"models": [ "models": [
"glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6", "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
"mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m2.5", "mimo-v2-omni", "mimo-v2-pro", "mimo-v2.5", "mimo-v2.5-pro",
"qwen3.6-plus", "qwen3.5-plus", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m2.7", "minimax-m2.5",
"qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus",
"deepseek-v4-pro", "deepseek-v4-flash", "hy3-preview",
], ],
}, },
"OpenCode Go (Anthropic)": { "OpenCode Go (Anthropic)": {
@@ -406,6 +566,20 @@ PROVIDER_PRESETS = {
"base_url": "https://crof.ai/v1", "base_url": "https://crof.ai/v1",
"models": [], "models": [],
}, },
"Ocenza": {
"backend_type": "openai-compat",
"base_url": "https://global.ocenza.com/v1",
"models": [
"gpt-oss-120b", "mimo-v2-pro", "mimo-v2.5", "mimo-v2.5-pro",
],
},
"MiMo (Xiaomi)": {
"backend_type": "openai-compat",
"base_url": "https://token-plan-sgp.xiaomimimo.com/v1",
"models": [
"mimo-v2-omni", "mimo-v2-pro", "mimo-v2.5", "mimo-v2.5-pro",
],
},
"NVIDIA NIM": { "NVIDIA NIM": {
"backend_type": "openai-compat", "backend_type": "openai-compat",
"base_url": "https://integrate.api.nvidia.com/v1", "base_url": "https://integrate.api.nvidia.com/v1",
@@ -437,6 +611,41 @@ PROVIDER_PRESETS = {
"base_url": "https://openrouter.ai/api/v1", "base_url": "https://openrouter.ai/api/v1",
"models": [], "models": [],
}, },
"Perplexity": {
"backend_type": "openai-compat",
"base_url": "https://api.perplexity.ai",
"models": [
"sonar",
"sonar-pro",
"sonar-reasoning-pro",
"sonar-deep-research",
],
},
"Cohere": {
"backend_type": "openai-compat",
"base_url": "https://api.cohere.ai/compatibility/v1",
"models": [],
},
"Hugging Face": {
"backend_type": "openai-compat",
"base_url": "https://router.huggingface.co/v1",
"models": [],
},
"Together AI": {
"backend_type": "openai-compat",
"base_url": "https://api.together.xyz/v1",
"models": [],
},
"Groq": {
"backend_type": "openai-compat",
"base_url": "https://api.groq.com/openai/v1",
"models": [],
},
"Fireworks AI": {
"backend_type": "openai-compat",
"base_url": "https://api.fireworks.ai/inference/v1",
"models": [],
},
"Google Gemini (API Key)": { "Google Gemini (API Key)": {
"backend_type": "openai-compat", "backend_type": "openai-compat",
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
@@ -501,6 +710,16 @@ PROVIDER_PRESETS = {
"base_url": "http://localhost:11434/v1", "base_url": "http://localhost:11434/v1",
"models": [], "models": [],
}, },
"LM Studio (local)": {
"backend_type": "openai-compat",
"base_url": "http://127.0.0.1:1234/v1",
"models": [],
},
"vLLM / OpenAI-Compatible (self-hosted)": {
"backend_type": "openai-compat",
"base_url": "http://localhost:8000/v1",
"models": [],
},
} }
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
@@ -618,6 +837,9 @@ def safe_name(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}" return f"{base}-{digest}"
def _profile_slug(name):
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
def label_for_backend(backend_type): def label_for_backend(backend_type):
return { return {
@@ -733,29 +955,33 @@ def apply_provider_preset(endpoint, preset_name):
def load_endpoints(): def load_endpoints():
if ENDPOINTS_FILE.exists(): if ENDPOINTS_FILE.exists():
try: try:
return json.loads(ENDPOINTS_FILE.read_text()) return json.loads(ENDPOINTS_FILE.read_text(encoding="utf-8"))
except Exception: except Exception as exc:
pass print(f"[lib] failed to load endpoints: {exc}", file=sys.stderr)
return {"default": None, "endpoints": []} return {"default": None, "endpoints": []}
def save_endpoints(data): def save_endpoints(data):
ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True) ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True)
ENDPOINTS_FILE.write_text(json.dumps(data, indent=2)) tmp = ENDPOINTS_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2))
os.replace(str(tmp), str(ENDPOINTS_FILE))
def load_bgp_pools(): def load_bgp_pools():
if BGP_POOLS_FILE.exists(): if BGP_POOLS_FILE.exists():
try: try:
return json.loads(BGP_POOLS_FILE.read_text()) return json.loads(BGP_POOLS_FILE.read_text(encoding="utf-8"))
except Exception: except Exception as exc:
pass print(f"[lib] failed to load bgp pools: {exc}", file=sys.stderr)
return {"pools": []} return {"pools": []}
def save_bgp_pools(data): def save_bgp_pools(data):
BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True) BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True)
BGP_POOLS_FILE.write_text(json.dumps(data, indent=2)) tmp = BGP_POOLS_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2))
os.replace(str(tmp), str(BGP_POOLS_FILE))
def get_endpoint(name): def get_endpoint(name):
@@ -821,10 +1047,28 @@ def write_secure_text(path, text):
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
def backup_config(): def backup_config():
if CONFIG.exists(): if not CONFIG.exists():
return
tmp = CONFIG_BAK.with_suffix(".tmp") tmp = CONFIG_BAK.with_suffix(".tmp")
shutil.copy2(str(CONFIG), str(tmp)) shutil.copy2(str(CONFIG), str(tmp))
os.replace(str(tmp), str(CONFIG_BAK)) os.replace(str(tmp), str(CONFIG_BAK))
ts = time.strftime("%Y%m%d_%H%M%S")
rot = CONFIG.parent / f"config.toml.{ts}.bak"
try:
shutil.copy2(str(CONFIG), str(rot))
_rotate_backups(CONFIG.parent, "config.toml.*.bak", max_backups=10)
except Exception as exc:
print(f"[lib] backup rotation failed: {exc}", file=sys.stderr)
def _rotate_backups(directory, pattern, max_backups=10):
import glob as _glob
files = sorted(_glob.glob(str(directory / pattern)), key=os.path.getmtime, reverse=True)
for old in files[max_backups:]:
try:
os.remove(old)
except Exception:
pass
def restore_config(): def restore_config():
@@ -851,7 +1095,7 @@ def recover_config_if_needed(logfn=None):
if not CONFIG_TXN.exists(): if not CONFIG_TXN.exists():
return return
try: try:
txn = json.loads(CONFIG_TXN.read_text()) txn = json.loads(CONFIG_TXN.read_text(encoding="utf-8"))
if txn.get("config_existed") and CONFIG_BAK.exists(): if txn.get("config_existed") and CONFIG_BAK.exists():
restore_config() restore_config()
if logfn: if logfn:
@@ -1008,10 +1252,9 @@ def write_config_for_native(endpoint, selected_model):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True) mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2)) mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/") mc_str = str(mc_path).replace("\\", "/")
new_config = [
f'profile = "{_toml_safe(endpoint["name"])}"\n', main_config = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n', f'model_catalog_json = "{mc_str}"\n',
@@ -1019,16 +1262,21 @@ def write_config_for_native(endpoint, selected_model):
f'name = "{_toml_safe(endpoint["name"])}"\n', f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n', f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\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', existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(main_config))
write_secure_text(CONFIG, merged)
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n', f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "default"\n', f'service_tier = "default"\n',
f'approvals_reviewer = "user"\n', f'approvals_reviewer = "user"\n',
] ]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "" write_secure_text(profile_path, "".join(profile_lines))
merged = _merge_toml(existing, "".join(new_config))
write_secure_text(CONFIG, merged)
def write_config_for_translated(endpoint, selected_model, proxy_port=8080): def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
@@ -1037,10 +1285,9 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True) mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2)) mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/") mc_str = str(mc_path).replace("\\", "/")
new_config = [
f'profile = "{_toml_safe(endpoint["name"])}"\n', main_config = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n', f'model_catalog_json = "{mc_str}"\n',
@@ -1048,16 +1295,21 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
f'name = "{_toml_safe(endpoint["name"])}"\n', f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "http://127.0.0.1:{proxy_port}"\n', f'base_url = "http://127.0.0.1:{proxy_port}"\n',
f'experimental_bearer_token = "codex-launcher-local"\n', f'experimental_bearer_token = "codex-launcher-local"\n',
f'\n[profiles."{endpoint["name"]}"]\n', ]
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(main_config))
write_secure_text(CONFIG, merged)
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n', f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "fast"\n', f'service_tier = "fast"\n',
f'approvals_reviewer = "user"\n', f'approvals_reviewer = "user"\n',
] ]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "" write_secure_text(profile_path, "".join(profile_lines))
merged = _merge_toml(existing, "".join(new_config))
write_secure_text(CONFIG, merged)
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# Model fetching # Model fetching
@@ -1083,6 +1335,24 @@ def endpoint_model_headers(endpoint):
return headers return headers
def check_provider_latency(endpoint, timeout=5):
bt = endpoint.get("backend_type", "")
if bt in ("native", "codex-default", "gemini-oauth-antigravity"):
return None
base = endpoint.get("base_url", "").strip()
if not base:
return None
url = base.rstrip("/") + "/models"
try:
headers = endpoint_model_headers(endpoint)
req = urllib.request.Request(url, headers=headers, method="GET")
t0 = time.time()
urllib.request.urlopen(req, timeout=timeout)
return time.time() - t0
except Exception:
return None
def fetch_models_for_endpoint(endpoint, timeout=10): def fetch_models_for_endpoint(endpoint, timeout=10):
bt = endpoint.get("backend_type", "") bt = endpoint.get("backend_type", "")
if bt == "gemini-oauth-antigravity": if bt == "gemini-oauth-antigravity":
@@ -1140,9 +1410,16 @@ ANTIGRAVITY_MODELS = [
def load_oauth_secrets(): def load_oauth_secrets():
try: try:
with open(OAUTH_SECRETS_PATH, encoding="utf-8") as f: with open(OAUTH_SECRETS_PATH, encoding="utf-8") as f:
return json.load(f) data = json.load(f)
except Exception: except Exception:
return {} data = {}
for key in ("antigravity", "gemini_cli"):
sec = data.get(key, {})
if not sec.get("client_id"):
data.setdefault(key, {})["client_id"] = GEMINI_OAUTH_CLIENT_ID
if not sec.get("client_secret"):
data.setdefault(key, {})["client_secret"] = GEMINI_OAUTH_CLIENT_SECRET
return data
def save_oauth_secrets(data): def save_oauth_secrets(data):
@@ -1326,7 +1603,7 @@ def run_endpoint_doctor(endpoint):
token_path = PROXY_CONFIG_DIR / token_name token_path = PROXY_CONFIG_DIR / token_name
if token_path.exists(): if token_path.exists():
try: try:
td = json.loads(token_path.read_text()) td = json.loads(token_path.read_text(encoding="utf-8"))
exp = td.get("expires_at", 0) exp = td.get("expires_at", 0)
if exp > time.time(): if exp > time.time():
remaining = exp - time.time() remaining = exp - time.time()
@@ -1395,9 +1672,9 @@ def run_endpoint_doctor(endpoint):
def _load_pid_registry(): def _load_pid_registry():
if PID_REGISTRY.exists(): if PID_REGISTRY.exists():
try: try:
return json.loads(PID_REGISTRY.read_text()) return json.loads(PID_REGISTRY.read_text(encoding="utf-8"))
except Exception: except Exception as exc:
pass print(f"[lib] failed to load pid registry: {exc}", file=sys.stderr)
return {} return {}
@@ -1440,8 +1717,9 @@ _PROXY_PORT_FILE = PROXY_CONFIG_DIR / ".last-proxy-port"
def _pick_free_port(): def _pick_free_port():
saved = None saved = None
try: try:
saved = int(_PROXY_PORT_FILE.read_text().strip()) saved = int(_PROXY_PORT_FILE.read_text(encoding="utf-8").strip())
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", saved)) s.bind(("127.0.0.1", saved))
return saved return saved
except (ValueError, OSError, FileNotFoundError): except (ValueError, OSError, FileNotFoundError):
@@ -1489,8 +1767,8 @@ def start_proxy_for(endpoint, logfn):
discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", []) discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", [])
if discovered: if discovered:
model_list = discovered model_list = discovered
except Exception: except Exception as exc:
pass print(f"[lib] oauth token discovery: {exc}", file=sys.stderr)
pcfg = { pcfg = {
"port": port, "port": port,
@@ -1502,6 +1780,11 @@ def start_proxy_for(endpoint, logfn):
"reasoning_enabled": endpoint.get("reasoning_enabled", True), "reasoning_enabled": endpoint.get("reasoning_enabled", True),
"reasoning_effort": endpoint.get("reasoning_effort", "medium"), "reasoning_effort": endpoint.get("reasoning_effort", "medium"),
"force_model": endpoint.get("default_model") or "", "force_model": endpoint.get("default_model") or "",
"caveman_mode": endpoint.get("caveman_mode", False),
"rtk_compression": endpoint.get("rtk_compression", False),
"auto_compact": endpoint.get("auto_compact", False),
"adaptive_compact": endpoint.get("adaptive_compact", False),
"tool_output_truncation": endpoint.get("tool_output_truncation", True),
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]} "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
for m in model_list], for m in model_list],
} }
@@ -1533,11 +1816,19 @@ def _start_proxy_with_config(pcfg_path, port, logfn):
) )
_register_pgid_entry("proxy", _proxy_proc.pid) _register_pgid_entry("proxy", _proxy_proc.pid)
_proxy_log_path = PROXY_CONFIG_DIR / "proxy-stderr.log"
_proxy_log_file = open(_proxy_log_path, "a", encoding="utf-8")
def _pipe_stderr(): def _pipe_stderr():
if not _proxy_proc.stderr: if not _proxy_proc.stderr:
return return
for line in _proxy_proc.stderr: for line in _proxy_proc.stderr:
logfn(f"[proxy] {line.rstrip()}") logfn(f"[proxy] {line.rstrip()}")
try:
_proxy_log_file.write(line)
_proxy_log_file.flush()
except Exception:
pass
threading.Thread(target=_pipe_stderr, daemon=True).start() threading.Thread(target=_pipe_stderr, daemon=True).start()
@@ -1581,6 +1872,11 @@ def start_bgp_proxy(pool, model, logfn):
"target_url": "http://bgp.placeholder", "target_url": "http://bgp.placeholder",
"api_key": "", "api_key": "",
"bgp_routes": pool.get("routes", []), "bgp_routes": pool.get("routes", []),
"caveman_mode": endpoint.get("caveman_mode", False) if endpoint else False,
"rtk_compression": endpoint.get("rtk_compression", False) if endpoint else False,
"auto_compact": endpoint.get("auto_compact", False) if endpoint else False,
"adaptive_compact": endpoint.get("adaptive_compact", False) if endpoint else False,
"tool_output_truncation": endpoint.get("tool_output_truncation", True) if endpoint else True,
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]], "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 = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}-{port}.json"
@@ -1606,6 +1902,12 @@ def detect_codex_cli():
def detect_codex_desktop(): def detect_codex_desktop():
"""Detect Codex Desktop installation.
Returns (path_or_aumid, is_msix) tuple on Windows, path string on Linux.
For MSIX installs, returns the AppUserModelId since the exe cannot be
launched directly via subprocess from WindowsApps.
"""
if IS_WINDOWS: if IS_WINDOWS:
la = os.environ.get("LOCALAPPDATA", "") la = os.environ.get("LOCALAPPDATA", "")
pf = os.environ.get("PROGRAMFILES", "") pf = os.environ.get("PROGRAMFILES", "")
@@ -1618,8 +1920,8 @@ def detect_codex_desktop():
] ]
for p in desktop_paths: for p in desktop_paths:
if p.exists(): if p.exists():
return str(p) return str(p), False
# MSIX / Microsoft Store install: locate via Get-AppxPackage # MSIX / Microsoft Store install
try: try:
r = subprocess.run( r = subprocess.run(
["powershell", "-NoProfile", "-Command", ["powershell", "-NoProfile", "-Command",
@@ -1630,13 +1932,70 @@ def detect_codex_desktop():
if loc: if loc:
msix_exe = Path(loc) / "app" / "Codex.exe" msix_exe = Path(loc) / "app" / "Codex.exe"
if msix_exe.exists(): if msix_exe.exists():
return str(msix_exe) r2 = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-AppxPackage *OpenAI.Codex*).PackageFamilyName"],
capture_output=True, text=True, timeout=10,
)
family = r2.stdout.strip() if r2.returncode == 0 else ""
if family:
return f"{family}!App", True
except Exception: except Exception:
pass pass
return None return None, False
if START_SH and START_SH.exists(): if START_SH and START_SH.exists():
return str(START_SH) return str(START_SH), False
return None return None, False
def launch_codex_desktop(desktop_info):
"""Launch Codex Desktop process.
Args:
desktop_info: (path_or_aumid, is_msix) tuple from detect_codex_desktop()
Returns:
subprocess.Popen object or None
"""
path, is_msix = desktop_info
if IS_WINDOWS:
if is_msix:
return subprocess.Popen(
["cmd", "/c", "start", "", f"shell:AppsFolder\\{path}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
return subprocess.Popen(
[path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
else:
return subprocess.Popen(
[path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
def is_codex_desktop_running():
"""Check if Codex Desktop (or MSIX Codex) is currently running."""
if IS_WINDOWS:
try:
for name in ("Codex Desktop.exe", "Codex.exe"):
out = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {name}", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5,
)
for line in out.stdout.strip().splitlines():
parts = line.split(",")
if len(parts) >= 2 and parts[1].strip('"').isdigit():
return True
except Exception:
pass
return False
else:
try:
out = subprocess.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5)
return bool(out.stdout.strip())
except Exception:
return False
def check_codex_auth(): def check_codex_auth():
@@ -1655,6 +2014,10 @@ def check_codex_auth():
return ("unknown", "No output from codex login status") return ("unknown", "No output from codex login status")
except FileNotFoundError: except FileNotFoundError:
return ("not_installed", "codex not found") return ("not_installed", "codex not found")
except OSError as e:
if e.errno == 2:
return ("not_configured", "Config not found — launch Codex once to create it")
return ("error", str(e))
except Exception as e: except Exception as e:
return ("error", str(e)) return ("error", str(e))
@@ -1664,7 +2027,7 @@ def check_codex_auth():
def last_log_lines(n=15): def last_log_lines(n=15):
try: try:
t = LAUNCH_LOG.read_text() t = LAUNCH_LOG.read_text(encoding="utf-8")
return "\n".join(t.splitlines()[-n:]) return "\n".join(t.splitlines()[-n:])
except Exception: except Exception:
return "(no log file)" return "(no log file)"
@@ -1675,9 +2038,10 @@ def last_log_lines(n=15):
def kill_existing_desktop(logfn=None): def kill_existing_desktop(logfn=None):
if IS_WINDOWS: if IS_WINDOWS:
for img in ("Codex Desktop.exe", "Codex.exe"):
try: try:
out = subprocess.run( out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq Codex Desktop.exe", "/FO", "CSV", "/NH"], ["tasklist", "/FI", f"IMAGENAME eq {img}", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5, capture_output=True, text=True, timeout=5,
) )
for line in out.stdout.strip().splitlines(): for line in out.stdout.strip().splitlines():
@@ -1780,9 +2144,9 @@ _DIAGNOSTIC_SYSTEM_PROMPT = (
def load_monitoring_config(): def load_monitoring_config():
if MONITORING_FILE.exists(): if MONITORING_FILE.exists():
try: try:
return json.loads(MONITORING_FILE.read_text()) return json.loads(MONITORING_FILE.read_text(encoding="utf-8"))
except Exception: except Exception as exc:
pass print(f"[lib] failed to load monitoring config: {exc}", file=sys.stderr)
return { return {
"enabled": False, "enabled": False,
"provider_url": "", "provider_url": "",
@@ -1802,9 +2166,9 @@ def save_monitoring_config(cfg):
def load_incident_store(): def load_incident_store():
if INCIDENT_STORE_FILE.exists(): if INCIDENT_STORE_FILE.exists():
try: try:
return json.loads(INCIDENT_STORE_FILE.read_text()) return json.loads(INCIDENT_STORE_FILE.read_text(encoding="utf-8"))
except Exception: except Exception as exc:
pass print(f"[lib] failed to load incident store: {exc}", file=sys.stderr)
return {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}} return {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}
@@ -1817,16 +2181,18 @@ def monitoring_log(msg):
try: try:
with open(str(MONITORING_LOG), "a") as f: with open(str(MONITORING_LOG), "a") as f:
f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n") f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
except Exception: except Exception as exc:
pass print(f"[lib] monitoring_log write failed: {exc}", file=sys.stderr)
class IncidentStore: class IncidentStore:
def __init__(self): def __init__(self):
self._store = load_incident_store() self._store = load_incident_store()
self._dirty = False self._dirty = False
self._lock = threading.Lock()
def lookup(self, pattern): def lookup(self, pattern):
with self._lock:
inc = self._store.get("incidents", {}).get(pattern) inc = self._store.get("incidents", {}).get(pattern)
if inc and inc.get("success_count", 0) > 0: if inc and inc.get("success_count", 0) > 0:
rate = inc["success_count"] / max(inc["success_count"] + inc.get("fail_count", 0), 1) rate = inc["success_count"] / max(inc["success_count"] + inc.get("fail_count", 0), 1)
@@ -1835,34 +2201,45 @@ class IncidentStore:
return None return None
def record(self, pattern, fix, success=True): def record(self, pattern, fix, success=True):
incs = self._store.setdefault("incidents", {}) with self._lock:
new_store = copy.deepcopy(self._store)
incs = new_store.setdefault("incidents", {})
inc = incs.setdefault(pattern, { inc = incs.setdefault(pattern, {
"fix": fix, "success_count": 0, "fail_count": 0, "fix": fix, "success_count": 0, "fail_count": 0,
"last_seen": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "last_seen": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"occurrences": 0, "occurrences": 0,
}) })
inc = dict(inc)
inc["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) inc["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
inc["occurrences"] = inc.get("occurrences", 0) + 1 inc["occurrences"] = inc.get("occurrences", 0) + 1
if success: if success:
inc["success_count"] = inc.get("success_count", 0) + 1 inc["success_count"] = inc.get("success_count", 0) + 1
else: else:
inc["fail_count"] = inc.get("fail_count", 0) + 1 inc["fail_count"] = inc.get("fail_count", 0) + 1
incs[pattern] = inc
self._store = new_store
self._dirty = True self._dirty = True
def record_ai_call(self, tokens=0): def record_ai_call(self, tokens=0):
stats = self._store.setdefault("stats", {"ai_calls": 0, "tokens_used": 0}) with self._lock:
new_store = copy.deepcopy(self._store)
stats = dict(new_store.get("stats", {"ai_calls": 0, "tokens_used": 0}))
stats["ai_calls"] = stats.get("ai_calls", 0) + 1 stats["ai_calls"] = stats.get("ai_calls", 0) + 1
stats["tokens_used"] = stats.get("tokens_used", 0) + tokens stats["tokens_used"] = stats.get("tokens_used", 0) + tokens
new_store["stats"] = stats
self._store = new_store
self._dirty = True self._dirty = True
def flush(self): def flush(self):
with self._lock:
if self._dirty: if self._dirty:
save_incident_store(self._store) save_incident_store(self._store)
self._dirty = False self._dirty = False
@property @property
def stats(self): def stats(self):
return self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}) with self._lock:
return dict(self._store.get("stats", {"ai_calls": 0, "tokens_used": 0}))
class AIDiagnosticAgent: class AIDiagnosticAgent:
@@ -1983,10 +2360,10 @@ class HealthWatcher(threading.Thread):
try: try:
cfg_path = PROXY_CONFIG_DIR / "proxy-config.json" cfg_path = PROXY_CONFIG_DIR / "proxy-config.json"
if cfg_path.exists(): if cfg_path.exists():
d = json.loads(cfg_path.read_text()) d = json.loads(cfg_path.read_text(encoding="utf-8"))
return d.get("port") return d.get("port")
except Exception: except Exception as exc:
pass print(f"[lib] _get_proxy_port: {exc}", file=sys.stderr)
return None return None
def _check_health(self, port): def _check_health(self, port):
@@ -2051,7 +2428,7 @@ class HealthWatcher(threading.Thread):
for log_name in ["cc-debug.log", "proxy.log"]: for log_name in ["cc-debug.log", "proxy.log"]:
log_path = PROXY_CONFIG_DIR / log_name log_path = PROXY_CONFIG_DIR / log_name
try: try:
text = log_path.read_text() text = log_path.read_text(encoding="utf-8")
lines.extend(text.splitlines()[-20:]) lines.extend(text.splitlines()[-20:])
except Exception: except Exception:
pass pass
@@ -2101,9 +2478,9 @@ class _LogAnalyzerThread(threading.Thread):
def load_usage_stats(): def load_usage_stats():
try: try:
if _USAGE_STATS_FILE.exists(): if _USAGE_STATS_FILE.exists():
return json.loads(_USAGE_STATS_FILE.read_text()) return json.loads(_USAGE_STATS_FILE.read_text(encoding="utf-8"))
except Exception: except Exception as exc:
pass print(f"[lib] failed to load usage stats: {exc}", file=sys.stderr)
return {"providers": {}, "updated": None} return {"providers": {}, "updated": None}
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,24 @@
"""
antigravity_grpc — gRPC fallback client for Google CloudCode (Antigravity).
When the REST API rejects a request (404 model not found, 400 bad request due to
model ID mismatch, etc.), this module provides a gRPC fallback path that uses
Google's native PredictionService protocol — the same one the agy CLI uses.
This module is imported lazily and only when grpcio is installed. If grpcio is
not available, the fallback is silently skipped.
"""
from .client import (
GrpcFallbackResult,
AntigravityGrpcClient,
is_grpc_available,
get_client,
)
__all__ = [
"GrpcFallbackResult",
"AntigravityGrpcClient",
"is_grpc_available",
"get_client",
]

View File

@@ -0,0 +1,609 @@
"""
antigravity_grpc.client — gRPC fallback client for Google CloudCode (Antigravity).
This module provides a gRPC client that can be used as an automatic fallback when
the CloudCode REST API rejects requests. The gRPC path uses the same
PredictionService that the native agy CLI binary uses, giving access to models
that are unavailable via REST (e.g. models that return 404 on REST but work on gRPC).
Key design decisions:
- Lazy import: grpcio is only imported when actually needed. If not installed,
is_grpc_available() returns False and the fallback is silently skipped.
- Zero impact on other providers: this module is only called from
_handle_antigravity_v2() when REST returns a fallback-eligible error.
- Same output format as REST: the client returns structured dicts that match
the SSE/JSON response shapes the proxy already processes.
- Thread-safe: the gRPC channel is created once per endpoint and reused.
Usage from translate-proxy.py:
from antigravity_grpc import is_grpc_available, AntigravityGrpcClient
if is_grpc_available():
client = AntigravityGrpcClient()
result = client.try_generate(request_dict, stream=False)
if result.ok:
# Use result.response_data (dict matching REST response shape)
else:
# gRPC also failed, fall through to error
"""
import json
import os
import sys
import time
import threading
import collections
# ═══════════════════════════════════════════════════════════════════
# Lazy gRPC import — never crash if grpcio is missing
# ═══════════════════════════════════════════════════════════════════
_grpc = None
_pb2 = None
_pb2_grpc = None
_import_error = None
def _try_import():
global _grpc, _pb2, _pb2_grpc, _import_error
if _grpc is not None:
return _grpc is not False
try:
import grpc as _real_grpc
# Import the generated stubs relative to this package
from . import cloudcode_pb2 as _real_pb2
from . import cloudcode_pb2_grpc as _real_pb2_grpc
_grpc = _real_grpc
_pb2 = _real_pb2
_pb2_grpc = _real_pb2_grpc
return True
except Exception as e:
_import_error = str(e)
_grpc = False
return False
def is_grpc_available():
"""Return True if grpcio and the generated stubs are importable."""
return _try_import()
# ═══════════════════════════════════════════════════════════════════
# gRPC endpoints for Antigravity (same hosts, different port/path)
# ═══════════════════════════════════════════════════════════════════
# The CloudCode gRPC service runs on the same hosts as REST but uses
# the gRPC protocol. The agy CLI connects to:
# - cloudcode-pa.googleapis.com:443
# - daily-cloudcode-pa.googleapis.com:443
# - daily-cloudcode-pa.sandbox.googleapis.com:443
_GRPC_ENDPOINTS = [
"daily-cloudcode-pa.googleapis.com:443",
"cloudcode-pa.googleapis.com:443",
]
_ALLOW_STAGING_ENV = "ALLOW_ANTIGRAVITY_STAGING"
# ═══════════════════════════════════════════════════════════════════
# Result type
# ═══════════════════════════════════════════════════════════════════
class GrpcFallbackResult:
"""Result of a gRPC fallback attempt."""
__slots__ = ("ok", "response_data", "stream_chunks", "error_message",
"endpoint_used", "model_used", "elapsed_s")
def __init__(self, ok=False, response_data=None, stream_chunks=None,
error_message="", endpoint_used="", model_used="", elapsed_s=0.0):
self.ok = ok
self.response_data = response_data # dict (non-streaming)
self.stream_chunks = stream_chunks # list[dict] (streaming)
self.error_message = error_message
self.endpoint_used = endpoint_used
self.model_used = model_used
self.elapsed_s = elapsed_s
def __repr__(self):
if self.ok:
if self.stream_chunks is not None:
return f"<GrpcFallbackResult OK stream chunks={len(self.stream_chunks)}>"
return f"<GrpcFallbackResult OK data_keys={list(self.response_data.keys()) if self.response_data else None}>"
return f"<GrpcFallbackResult FAIL error={self.error_message!r}>"
# ═══════════════════════════════════════════════════════════════════
# JSON → Protobuf conversion helpers
# ═══════════════════════════════════════════════════════════════════
def _struct_to_protobuf(d, struct_obj=None):
"""Convert a Python dict to a google.protobuf.Struct."""
from google.protobuf.struct_pb2 import Struct, Value, NullValue, ListValue
if struct_obj is None:
struct_obj = Struct()
if isinstance(d, dict):
for k, v in d.items():
if isinstance(v, str):
struct_obj.fields[k].string_value = v
elif isinstance(v, bool):
struct_obj.fields[k].bool_value = v
elif isinstance(v, int):
struct_obj.fields[k].number_value = float(v)
elif isinstance(v, float):
struct_obj.fields[k].number_value = v
elif isinstance(v, dict):
_struct_to_protobuf(v, struct_obj.fields[k].struct_value)
elif isinstance(v, list):
lst = struct_obj.fields[k].list_value
for item in v:
if isinstance(item, str):
lst.values.add().string_value = item
elif isinstance(item, bool):
lst.values.add().bool_value = item
elif isinstance(item, (int, float)):
lst.values.add().number_value = float(item)
elif isinstance(item, dict):
_struct_to_protobuf(item, lst.values.add().struct_value)
elif item is None:
lst.values.add().null_value = 0
elif v is None:
struct_obj.fields[k].null_value = 0
return struct_obj
def _protobuf_struct_to_dict(struct):
"""Convert a google.protobuf.Struct to a Python dict."""
from google.protobuf.struct_pb2 import Value, NullValue
result = {}
for k, v in struct.fields.items():
kind = v.WhichOneof("kind")
if kind == "null_value":
result[k] = None
elif kind == "number_value":
result[k] = v.number_value
elif kind == "string_value":
result[k] = v.string_value
elif kind == "bool_value":
result[k] = v.bool_value
elif kind == "struct_value":
result[k] = _protobuf_struct_to_dict(v.struct_value)
elif kind == "list_value":
result[k] = [_value_to_python(item) for item in v.list_value.values]
else:
result[k] = None
return result
def _value_to_python(v):
"""Convert a google.protobuf.Value to a Python value."""
kind = v.WhichOneof("kind")
if kind == "null_value":
return None
elif kind == "number_value":
return v.number_value
elif kind == "string_value":
return v.string_value
elif kind == "bool_value":
return v.bool_value
elif kind == "struct_value":
return _protobuf_struct_to_dict(v.struct_value)
elif kind == "list_value":
return [_value_to_python(item) for item in v.list_value.values]
return None
def _json_parts_to_proto(parts_json):
"""Convert a list of JSON content parts to protobuf Part messages."""
result = []
for p in parts_json:
if not isinstance(p, dict):
continue
part = _pb2.Part()
# Thought signature
sig = p.get("thoughtSignature") or p.get("thought_signature")
if sig:
part.thought_signature = sig
if p.get("thought"):
part.thought = True
if "text" in p:
part.text = p["text"]
elif "text" in p and "functionCall" not in p:
part.text = p["text"]
elif "functionCall" in p:
fc = p["functionCall"]
part.function_call.name = fc.get("name", "")
part.function_call.id = fc.get("id", "")
args = fc.get("args", fc.get("arguments", {}))
if isinstance(args, dict):
_struct_to_protobuf(args, part.function_call.args)
elif isinstance(args, str):
try:
_struct_to_protobuf(json.loads(args), part.function_call.args)
except Exception:
pass
elif "functionResponse" in p:
fr = p["functionResponse"]
part.function_response.name = fr.get("name", "")
part.function_response.id = fr.get("id", "")
resp = fr.get("response", {})
if "result" in resp:
result_val = resp["result"]
if isinstance(result_val, (dict, list)):
_struct_to_protobuf({"result": result_val}, part.function_response.response)
else:
_struct_to_protobuf({"result": str(result_val)}, part.function_response.response)
elif isinstance(resp, dict):
_struct_to_protobuf(resp, part.function_response.response)
elif "inlineData" in p:
idata = p["inlineData"]
import base64
part.inline_data.mime_type = idata.get("mimeType", "image/png")
b64data = idata.get("data", "")
part.inline_data.data = base64.b64decode(b64data) if b64data else b""
result.append(part)
return result
def _json_contents_to_proto(contents_json):
"""Convert a list of JSON content objects to protobuf Content messages."""
result = []
for c in contents_json:
if not isinstance(c, dict):
continue
content = _pb2.Content()
content.role = c.get("role", "user")
for part in _json_parts_to_proto(c.get("parts", [])):
content.parts.append(part)
result.append(content)
return result
def _proto_candidate_to_json(candidate):
"""Convert a protobuf Candidate to a JSON-compatible dict."""
content_json = {"role": candidate.content.role, "parts": []}
for part in candidate.content.parts:
p = {}
if part.thought_signature:
p["thoughtSignature"] = part.thought_signature
if part.thought:
p["thought"] = True
if part.text:
p["text"] = part.text
elif part.text and not part.HasField("function_call"):
p["text"] = part.text
elif part.HasField("function_call"):
fc = part.function_call
args_dict = _protobuf_struct_to_dict(fc.args) if fc.HasField("args") else {}
p["functionCall"] = {
"name": fc.name,
"args": args_dict,
"id": fc.id,
}
elif part.HasField("function_response"):
fr = part.function_response
resp_dict = _protobuf_struct_to_dict(fr.response) if fr.HasField("response") else {}
p["functionResponse"] = {
"name": fr.name,
"response": resp_dict,
"id": fr.id,
}
elif part.HasField("inline_data"):
import base64
p["inlineData"] = {
"mimeType": part.inline_data.mime_type,
"data": base64.b64encode(part.inline_data.data).decode(),
}
if p:
content_json["parts"].append(p)
return {
"content": content_json,
"finishReason": candidate.finish_reason,
"index": candidate.index,
}
# ═══════════════════════════════════════════════════════════════════
# Client
# ═══════════════════════════════════════════════════════════════════
class AntigravityGrpcClient:
"""
gRPC fallback client for Google CloudCode Antigravity.
Thread-safe. Channels are cached per endpoint and reused.
"""
def __init__(self):
self._channels = {}
self._stubs = {}
self._lock = threading.Lock()
def _get_channel(self, endpoint):
"""Get or create a gRPC channel for the given endpoint."""
with self._lock:
if endpoint not in self._channels:
# Use secure channel with default SSL credentials
creds = _grpc.ssl_channel_credentials()
channel = _grpc.secure_channel(endpoint, creds)
self._channels[endpoint] = channel
self._stubs[endpoint] = _pb2_grpc.PredictionServiceStub(channel)
return self._channels[endpoint], self._stubs[endpoint]
def _build_request(self, wrapped_dict):
"""
Build a GenerateContentRequest protobuf from the same wrapped dict
that the REST API uses.
wrapped_dict shape:
{
"project": "...",
"model": "...",
"requestType": "agent",
"userAgent": "antigravity/...",
"requestId": "agent-...",
"request": {
"contents": [...],
"systemInstruction": {...},
"generationConfig": {...},
"tools": [...],
"safetySettings": [...],
"toolConfig": {...},
"sessionId": "..."
}
}
"""
req = _pb2.GenerateContentRequest()
req.project = wrapped_dict.get("project", "")
req.model = wrapped_dict.get("model", "")
req.request_type = wrapped_dict.get("requestType", "agent")
req.user_agent = wrapped_dict.get("userAgent", "")
req.request_id = wrapped_dict.get("requestId", "")
inner = wrapped_dict.get("request", {})
# Contents
for c in _json_contents_to_proto(inner.get("contents", [])):
req.request.contents.append(c)
# System instruction
si = inner.get("systemInstruction", {})
if si:
si_parts = si.get("parts", [])
if si.get("role"):
req.request.system_instruction.role = si.get("role", "user")
for part in _json_parts_to_proto(si_parts):
req.request.system_instruction.parts.append(part)
# Generation config
gc = inner.get("generationConfig", {})
if gc:
cfg = req.request.generation_config
if "maxOutputTokens" in gc:
cfg.max_output_tokens = int(gc["maxOutputTokens"])
if "temperature" in gc:
cfg.temperature = float(gc["temperature"])
if "topP" in gc:
cfg.top_p = float(gc["top_p" if "top_p" in gc else "topP"])
for ss in gc.get("stopSequences", []):
cfg.stop_sequences.append(ss)
# Thinking config (Gemini 3 native)
tc = gc.get("thinkingConfig", gc.get("thinking_config"))
if tc:
cfg.thinking_config.include_thoughts = tc.get("includeThoughts", tc.get("include_thoughts", False))
cfg.thinking_config.thinking_budget = int(tc.get("thinkingBudget", tc.get("thinking_budget", 8192)))
# Legacy thinking fields
if "includeThoughts" in gc and not tc:
cfg.thinking_config.include_thoughts = gc["includeThoughts"]
if "thinkingBudget" in gc and not tc:
cfg.thinking_config.thinking_budget = int(gc["thinkingBudget"])
# Tools
for tool_json in inner.get("tools", []):
tool = _pb2.Tool()
for fd_json in tool_json.get("functionDeclarations", []):
fd = tool.function_declarations.add()
fd.name = fd_json.get("name", "")
fd.description = fd_json.get("description", "")
params = fd_json.get("parameters", {})
if isinstance(params, dict) and params:
_struct_to_protobuf(params, fd.parameters)
req.request.tools.append(tool)
# Safety settings
for ss in inner.get("safetySettings", []):
ss_msg = _pb2.SafetySetting()
ss_msg.category = ss.get("category", "")
ss_msg.threshold = ss.get("threshold", "OFF")
req.request.safety_settings.append(ss_msg)
# Tool config
tcfg = inner.get("toolConfig", {})
if tcfg:
fcc = tcfg.get("functionCallingConfig", {})
if fcc:
req.request.tool_config.function_calling_config.mode = fcc.get("mode", "AUTO")
for afn in fcc.get("allowed_function_names", []):
req.request.tool_config.function_calling_config.allowed_function_names.append(afn)
# Session ID
sid = inner.get("sessionId", "")
if sid:
req.request.session_id = sid
return req
def try_generate(self, wrapped_dict, stream=False, access_token="",
timeout_s=180):
"""
Try a gRPC GenerateContent or StreamGenerateContent request.
Args:
wrapped_dict: The same wrapped dict used for REST requests.
stream: If True, use server-streaming RPC.
access_token: OAuth2 Bearer token for authentication.
timeout_s: Request timeout in seconds.
Returns:
GrpcFallbackResult with ok=True if successful.
For non-streaming: result.response_data is a dict matching
the REST JSON response shape.
For streaming: result.stream_chunks is a list of dicts matching
REST SSE chunk shapes.
"""
if not is_grpc_available():
return GrpcFallbackResult(ok=False, error_message="grpcio not installed")
t0 = time.time()
# Build metadata (gRPC uses metadata instead of HTTP headers)
metadata = []
if access_token:
metadata.append(("authorization", f"Bearer {access_token}"))
ua = wrapped_dict.get("userAgent", "")
if ua:
metadata.append(("user-agent", ua))
metadata.append(("x-client-name", "antigravity"))
# Required for Google's gRPC gateway
metadata.append(("x-goog-api-client", "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x"))
# Build endpoints list
endpoints = list(_GRPC_ENDPOINTS)
if os.environ.get(_ALLOW_STAGING_ENV, "0") == "1":
endpoints.append("daily-cloudcode-pa.sandbox.googleapis.com:443")
endpoints.append("autopush-cloudcode-pa.sandbox.googleapis.com:443")
model = wrapped_dict.get("model", "?")
last_error = ""
for ep in endpoints:
try:
channel, stub = self._get_channel(ep)
req = self._build_request(wrapped_dict)
if stream:
return self._do_stream(stub, req, metadata, ep, model,
timeout_s, t0)
else:
return self._do_unary(stub, req, metadata, ep, model,
timeout_s, t0)
except Exception as e:
last_error = str(e)
err_str = last_error.lower()
print(f"[antigravity-grpc] {ep} failed: {last_error[:300]}", file=sys.stderr)
# Don't retry on auth errors
if "unauthenticated" in err_str or "permission" in err_str:
break
# Don't retry on invalid argument (model truly doesn't exist)
if "not_found" in err_str or "not found" in err_str:
break
continue
elapsed = time.time() - t0
return GrpcFallbackResult(
ok=False,
error_message=f"All gRPC endpoints failed: {last_error}",
model_used=model,
elapsed_s=elapsed,
)
def _do_unary(self, stub, req, metadata, endpoint, model, timeout_s, t0):
"""Execute a unary (non-streaming) gRPC call."""
response = stub.GenerateContent(
req,
metadata=metadata,
timeout=timeout_s,
)
elapsed = time.time() - t0
# Convert protobuf response to REST-compatible JSON shape
candidates_json = []
for candidate in response.response.candidates:
candidates_json.append(_proto_candidate_to_json(candidate))
# Match the REST response envelope:
# { "response": { "candidates": [...] } }
rest_shape = {
"response": {
"candidates": candidates_json,
}
}
print(f"[antigravity-grpc] {endpoint} unary OK, candidates={len(candidates_json)}, elapsed={elapsed:.1f}s", file=sys.stderr)
return GrpcFallbackResult(
ok=True,
response_data=rest_shape,
endpoint_used=endpoint,
model_used=model,
elapsed_s=elapsed,
)
def _do_stream(self, stub, req, metadata, endpoint, model, timeout_s, t0):
"""Execute a server-streaming gRPC call."""
chunks = []
chunk_count = 0
response_iter = stub.StreamGenerateContent(
req,
metadata=metadata,
timeout=timeout_s,
)
for chunk_proto in response_iter:
chunk_count += 1
# Each chunk_proto is a StreamGenerateContentChunk
# which wraps a Response with candidates
candidates_json = []
for candidate in chunk_proto.response.candidates:
candidates_json.append(_proto_candidate_to_json(candidate))
# Match REST SSE chunk shape: { "response": { "candidates": [...] } }
chunk_json = {
"response": {
"candidates": candidates_json,
}
}
chunks.append(chunk_json)
elapsed = time.time() - t0
print(f"[antigravity-grpc] {endpoint} stream OK, chunks={chunk_count}, elapsed={elapsed:.1f}s", file=sys.stderr)
return GrpcFallbackResult(
ok=True,
stream_chunks=chunks,
endpoint_used=endpoint,
model_used=model,
elapsed_s=elapsed,
)
def close(self):
"""Close all gRPC channels."""
with self._lock:
for ep, channel in self._channels.items():
try:
channel.close()
except Exception:
pass
self._channels.clear()
self._stubs.clear()
# ═══════════════════════════════════════════════════════════════════
# Module-level singleton
# ═══════════════════════════════════════════════════════════════════
_client = None
_client_lock = threading.Lock()
def get_client():
"""Get the module-level AntigravityGrpcClient singleton."""
global _client
with _client_lock:
if _client is None:
_client = AntigravityGrpcClient()
return _client

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,275 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
from antigravity_grpc import cloudcode_pb2 as cloudcode__pb2
GRPC_GENERATED_VERSION = '1.80.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in cloudcode_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class PredictionServiceStub(object):
"""─── Service ──────────────────────────────────────────────────────────
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.GenerateContent = channel.unary_unary(
'/google.internal.cloud.code.v1internal.PredictionService/GenerateContent',
request_serializer=cloudcode__pb2.GenerateContentRequest.SerializeToString,
response_deserializer=cloudcode__pb2.GenerateContentResponse.FromString,
_registered_method=True)
self.StreamGenerateContent = channel.unary_stream(
'/google.internal.cloud.code.v1internal.PredictionService/StreamGenerateContent',
request_serializer=cloudcode__pb2.GenerateContentRequest.SerializeToString,
response_deserializer=cloudcode__pb2.StreamGenerateContentChunk.FromString,
_registered_method=True)
self.FetchAvailableModels = channel.unary_unary(
'/google.internal.cloud.code.v1internal.PredictionService/FetchAvailableModels',
request_serializer=cloudcode__pb2.FetchAvailableModelsRequest.SerializeToString,
response_deserializer=cloudcode__pb2.FetchAvailableModelsResponse.FromString,
_registered_method=True)
self.CountTokens = channel.unary_unary(
'/google.internal.cloud.code.v1internal.PredictionService/CountTokens',
request_serializer=cloudcode__pb2.CountTokensRequest.SerializeToString,
response_deserializer=cloudcode__pb2.CountTokensResponse.FromString,
_registered_method=True)
self.RetrieveUserQuota = channel.unary_unary(
'/google.internal.cloud.code.v1internal.PredictionService/RetrieveUserQuota',
request_serializer=cloudcode__pb2.RetrieveUserQuotaRequest.SerializeToString,
response_deserializer=cloudcode__pb2.RetrieveUserQuotaResponse.FromString,
_registered_method=True)
class PredictionServiceServicer(object):
"""─── Service ──────────────────────────────────────────────────────────
"""
def GenerateContent(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def StreamGenerateContent(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def FetchAvailableModels(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def CountTokens(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def RetrieveUserQuota(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_PredictionServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'GenerateContent': grpc.unary_unary_rpc_method_handler(
servicer.GenerateContent,
request_deserializer=cloudcode__pb2.GenerateContentRequest.FromString,
response_serializer=cloudcode__pb2.GenerateContentResponse.SerializeToString,
),
'StreamGenerateContent': grpc.unary_stream_rpc_method_handler(
servicer.StreamGenerateContent,
request_deserializer=cloudcode__pb2.GenerateContentRequest.FromString,
response_serializer=cloudcode__pb2.StreamGenerateContentChunk.SerializeToString,
),
'FetchAvailableModels': grpc.unary_unary_rpc_method_handler(
servicer.FetchAvailableModels,
request_deserializer=cloudcode__pb2.FetchAvailableModelsRequest.FromString,
response_serializer=cloudcode__pb2.FetchAvailableModelsResponse.SerializeToString,
),
'CountTokens': grpc.unary_unary_rpc_method_handler(
servicer.CountTokens,
request_deserializer=cloudcode__pb2.CountTokensRequest.FromString,
response_serializer=cloudcode__pb2.CountTokensResponse.SerializeToString,
),
'RetrieveUserQuota': grpc.unary_unary_rpc_method_handler(
servicer.RetrieveUserQuota,
request_deserializer=cloudcode__pb2.RetrieveUserQuotaRequest.FromString,
response_serializer=cloudcode__pb2.RetrieveUserQuotaResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'google.internal.cloud.code.v1internal.PredictionService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('google.internal.cloud.code.v1internal.PredictionService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class PredictionService(object):
"""─── Service ──────────────────────────────────────────────────────────
"""
@staticmethod
def GenerateContent(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/google.internal.cloud.code.v1internal.PredictionService/GenerateContent',
cloudcode__pb2.GenerateContentRequest.SerializeToString,
cloudcode__pb2.GenerateContentResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def StreamGenerateContent(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/google.internal.cloud.code.v1internal.PredictionService/StreamGenerateContent',
cloudcode__pb2.GenerateContentRequest.SerializeToString,
cloudcode__pb2.StreamGenerateContentChunk.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def FetchAvailableModels(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/google.internal.cloud.code.v1internal.PredictionService/FetchAvailableModels',
cloudcode__pb2.FetchAvailableModelsRequest.SerializeToString,
cloudcode__pb2.FetchAvailableModelsResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def CountTokens(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/google.internal.cloud.code.v1internal.PredictionService/CountTokens',
cloudcode__pb2.CountTokensRequest.SerializeToString,
cloudcode__pb2.CountTokensResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def RetrieveUserQuota(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/google.internal.cloud.code.v1internal.PredictionService/RetrieveUserQuota',
cloudcode__pb2.RetrieveUserQuotaRequest.SerializeToString,
cloudcode__pb2.RetrieveUserQuotaResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

View File

@@ -0,0 +1,183 @@
// Copyright 2026 Codex Launcher Contributors
// SPDX-License-Identifier: MIT
//
// CloudCode internal gRPC service definitions.
// Reverse-engineered from the agy-core binary for Antigravity proxy fallback.
// Service: google.internal.cloud.code.v1internal.PredictionService
//
// NOTE: google/api/annotations.proto is NOT imported here because it conflicts
// with the google namespace package at runtime. The HTTP annotations are only
// needed for Google's Envoy/gRPC-gateway and are unnecessary for our client.
syntax = "proto3";
package google.internal.cloud.code.v1internal;
import "google/protobuf/struct.proto";
option go_package = "google.golang.org/internal/cloud/code/v1internal";
// ─── Reused message types ───────────────────────────────────────────
message Content {
string role = 1;
repeated Part parts = 2;
}
message Part {
oneof data {
string text = 1;
InlineData inline_data = 2;
FunctionCall function_call = 3;
FunctionResponse function_response = 4;
}
// Thought signature for Gemini continuity
string thought_signature = 10;
// Thought part (reasoning)
bool thought = 11;
}
message InlineData {
string mime_type = 1;
bytes data = 2;
}
message FunctionCall {
string name = 1;
google.protobuf.Struct args = 2;
string id = 3;
}
message FunctionResponse {
string name = 1;
google.protobuf.Struct response = 2;
string id = 3;
}
message SafetySetting {
string category = 1;
string threshold = 2;
}
message GenerationConfig {
int32 max_output_tokens = 1;
float temperature = 2;
float top_p = 3;
int32 thinking_budget = 4;
bool include_thoughts = 5;
repeated string stop_sequences = 6;
message ThinkingConfig {
bool include_thoughts = 1;
int32 thinking_budget = 2;
}
ThinkingConfig thinking_config = 7;
}
message Tool {
repeated FunctionDeclaration function_declarations = 1;
}
message FunctionDeclaration {
string name = 1;
string description = 2;
google.protobuf.Struct parameters = 3;
}
message ToolConfig {
message FunctionCallingConfig {
string mode = 1; // "AUTO", "ANY", "NONE", "VALIDATED"
repeated string allowed_function_names = 2;
}
FunctionCallingConfig function_calling_config = 1;
}
message Candidate {
Content content = 1;
string finish_reason = 2;
int32 index = 3;
}
// ─── GenerateContent ─────────────────────────────────────────────────
message GenerateContentRequest {
string project = 1;
string model = 2;
string request_type = 3;
string user_agent = 4;
string request_id = 5;
message InnerRequest {
repeated Content contents = 1;
Content system_instruction = 2;
GenerationConfig generation_config = 3;
repeated Tool tools = 4;
repeated SafetySetting safety_settings = 5;
ToolConfig tool_config = 6;
string session_id = 7;
}
InnerRequest request = 10;
}
message GenerateContentResponse {
message Response {
repeated Candidate candidates = 1;
}
Response response = 1;
}
// ─── StreamGenerateContent ────────────────────────────────────────────
message StreamGenerateContentChunk {
GenerateContentResponse.Response response = 1;
}
// ─── FetchAvailableModels ────────────────────────────────────────────
message FetchAvailableModelsRequest {
string project = 1;
}
message FetchAvailableModelsResponse {
message ModelInfo {
string name = 1;
string display_name = 2;
string description = 3;
int64 context_window = 4;
}
repeated ModelInfo models = 1;
}
// ─── CountTokens ──────────────────────────────────────────────────────
message CountTokensRequest {
string project = 1;
string model = 2;
repeated Content contents = 3;
}
message CountTokensResponse {
int32 total_tokens = 1;
}
// ─── RetrieveUserQuota ───────────────────────────────────────────────
message RetrieveUserQuotaRequest {
string project = 1;
}
message RetrieveUserQuotaResponse {
int64 daily_limit = 1;
int64 daily_usage = 2;
int64 daily_remaining = 3;
}
// ─── Service ──────────────────────────────────────────────────────────
service PredictionService {
rpc GenerateContent(GenerateContentRequest) returns (GenerateContentResponse);
rpc StreamGenerateContent(GenerateContentRequest) returns (stream StreamGenerateContentChunk);
rpc FetchAvailableModels(FetchAvailableModelsRequest) returns (FetchAvailableModelsResponse);
rpc CountTokens(CountTokensRequest) returns (CountTokensResponse);
rpc RetrieveUserQuota(RetrieveUserQuotaRequest) returns (RetrieveUserQuotaResponse);
}

View File

@@ -0,0 +1,14 @@
// Minimal google/api/annotations.proto for code generation.
syntax = "proto3";
package google.api;
import "google/api/http.proto";
import "google/protobuf/descriptor.proto";
option go_package = "google.golang.org/genproto/googleapis/api/annotations";
extend google.protobuf.MethodOptions {
HttpRule http = 72295728;
}

View File

@@ -0,0 +1,18 @@
// Minimal google/api/http.proto for code generation.
syntax = "proto3";
package google.api;
option go_package = "google.golang.org/genproto/googleapis/api/annotations";
message HttpRule {
string get = 1;
string put = 2;
string post = 3;
string delete = 4;
string patch = 5;
repeated HttpRule additional_bindings = 11;
string body = 7;
string response_body = 12;
}

View File

@@ -27,6 +27,33 @@ model_catalog_json = ""
""" """
CHANGELOG = [ CHANGELOG = [
("3.13.0", "2026-05-27", [
"Codex Desktop Updater: auto-update from ilysenko/codex-desktop-linux",
"Check for updates, install, rollback, service management",
"Manual rebuild from source (clone → build → install .deb)",
"Fix Antigravity: prod endpoint first, skip sandbox service_disabled",
"Fix Antigravity: model resolution (gemini-3.5-flash-high → gemini-3-flash)",
"Fix OAUTH_PROVIDER derivation from BACKEND env var",
"Antigravity E2E test suite: bash ~/.local/bin/test-antigravity.sh",
]),
("3.12.1", "2026-05-27", [
"Fix Antigravity adapter (PR #15): simplified model resolution",
"Removed broken schema sanitization, restored headers",
"Re-enabled gRPC fallback by default",
]),
("3.12.0", "2026-05-27", [
"gRPC auto-fallback for Antigravity (PR #13)",
"Dynamic version fetch with probe validation",
"Antigravity v2 handler rewrite (anti-api)",
]),
("3.11.10", "2026-05-26", [
"Fix Antigravity: interleave function_call/output pairs (PR #11)",
"Gemini sanitizer: trim non-user turns for Google API compliance",
]),
("3.11.9", "2026-05-26", [
"Fix Antigravity: preserve functionCall/functionResponse (PR #10)",
"Prevents tool responses from being dropped in multi-turn sessions",
]),
("3.11.8", "2026-05-26", [ ("3.11.8", "2026-05-26", [
"Vision cache persisted across requests (PR #8 merge)", "Vision cache persisted across requests (PR #8 merge)",
"No redundant vision API calls for same image URL", "No redundant vision API calls for same image URL",
@@ -465,6 +492,9 @@ def safe_name(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}" return f"{base}-{digest}"
def _profile_slug(name):
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
def label_for_backend(backend_type): def label_for_backend(backend_type):
return { return {
"openai-compat": "OpenAI-compatible", "openai-compat": "OpenAI-compatible",
@@ -1014,23 +1044,29 @@ def write_config_for_native(endpoint, selected_model):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True) mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2)) mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
lines = [ main_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n', f'model_catalog_json = "{mc_str}"\n',
f'\n[model_providers."{endpoint["name"]}"]\n', f'\n[model_providers."{endpoint["name"]}"]\n',
f'name = "{_toml_safe(endpoint["name"])}"\n', f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n', f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\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', write_secure_text(CONFIG, "".join(main_lines))
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_catalog_json = "{mc_path}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "default"\n', f'service_tier = "default"\n',
f'approvals_reviewer = "user"\n', f'approvals_reviewer = "user"\n',
] ]
write_secure_text(CONFIG, "".join(lines)) write_secure_text(profile_path, "".join(profile_lines))
def _toml_safe(val): def _toml_safe(val):
val = str(val).replace('"', '\\"') val = str(val).replace('"', '\\"')
@@ -1049,12 +1085,12 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True) mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2)) mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/")
lines = [ main_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'review_model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n', f'model_catalog_json = "{mc_str}"\n',
f'\n[model_providers."{endpoint["name"]}"]\n', f'\n[model_providers."{endpoint["name"]}"]\n',
f'name = "{_toml_safe(endpoint["name"])}"\n', f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "http://127.0.0.1:{proxy_port}"\n', f'base_url = "http://127.0.0.1:{proxy_port}"\n',
@@ -1063,15 +1099,19 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
f'request_max_retries = 1\n', f'request_max_retries = 1\n',
f'stream_max_retries = 0\n', f'stream_max_retries = 0\n',
f'stream_idle_timeout_ms = 600000\n', f'stream_idle_timeout_ms = 600000\n',
f'\n[profiles."{endpoint["name"]}"]\n', ]
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', write_secure_text(CONFIG, "".join(main_lines))
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'review_model = "{_toml_safe(selected_model)}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_path}"\n', f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "fast"\n', f'service_tier = "fast"\n',
f'approvals_reviewer = "user"\n', f'approvals_reviewer = "user"\n',
] ]
write_secure_text(CONFIG, "".join(lines)) write_secure_text(profile_path, "".join(profile_lines))
def _gen_model_catalog(endpoint, selected_model=None): def _gen_model_catalog(endpoint, selected_model=None):
default_model = selected_model or endpoint.get("default_model") default_model = selected_model or endpoint.get("default_model")
@@ -1948,6 +1988,9 @@ class LauncherWin(Gtk.Window):
oauth_btn = Gtk.Button(label="OAuth Secrets") oauth_btn = Gtk.Button(label="OAuth Secrets")
oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets()) oauth_btn.connect("clicked", lambda b: self._edit_oauth_secrets())
hdr.pack_end(oauth_btn, False, False, 0) hdr.pack_end(oauth_btn, False, False, 0)
updater_btn = Gtk.Button(label="Update Desktop")
updater_btn.connect("clicked", lambda b: self._open_updater())
hdr.pack_end(updater_btn, False, False, 0)
# verification status bar # verification status bar
self._cli_info = _detect_codex_cli() self._cli_info = _detect_codex_cli()
@@ -2398,6 +2441,18 @@ class LauncherWin(Gtk.Window):
_py = str(Path(__file__).resolve().parent / "flet-codex-assist.py") _py = str(Path(__file__).resolve().parent / "flet-codex-assist.py")
subprocess.Popen([sys.executable, _py], start_new_session=True) subprocess.Popen([sys.executable, _py], start_new_session=True)
def _open_updater(self):
try:
if not UPDATER_BIN and not _detect_codex_desktop():
self.log("Codex Desktop not installed. Nothing to update.")
return
self._updater_window = CodexUpdaterWindow()
self._updater_window.connect("destroy", lambda *_: setattr(self, "_updater_window", None))
except Exception as e:
import traceback; traceback.print_exc()
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
d.run(); d.destroy()
def _backup_profile(self): def _backup_profile(self):
chooser = Gtk.FileChooserDialog( chooser = Gtk.FileChooserDialog(
title="Backup Codex Profile", title="Backup Codex Profile",
@@ -2841,7 +2896,7 @@ class LauncherWin(Gtk.Window):
cmd_parts.extend(["codex", "-c", f"model={model}", cmd_parts.extend(["codex", "-c", f"model={model}",
"-s", sandbox, "-a", approval]) "-s", sandbox, "-a", approval])
else: else:
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}", cmd_parts.extend(["codex", "--profile", _profile_slug(ep["name"]), "-c", f"model={model}",
"-s", sandbox, "-a", approval]) "-s", sandbox, "-a", approval])
self.log(f"Running: {' '.join(cmd_parts)}") self.log(f"Running: {' '.join(cmd_parts)}")
@@ -5836,5 +5891,510 @@ class BenchmarkWindow(Gtk.Window):
GLib.idle_add(_show) GLib.idle_add(_show)
# ═══════════════════════════════════════════════════════════════════
# Codex Desktop Updater — auto-update from ilysenko/codex-desktop-linux
# ═══════════════════════════════════════════════════════════════════
UPSTREAM_REPO = "ilysenko/codex-desktop-linux"
UPDATER_BIN = shutil.which("codex-update-manager") or ""
UPDATER_STATE_FILE = Path.home() / ".local/state/codex-update-manager/state.json"
UPDATER_SERVICE_LOG = Path.home() / ".local/state/codex-update-manager/service.log"
def _get_updater_status():
try:
out = subprocess.run(
[UPDATER_BIN, "status", "--json"],
capture_output=True, text=True, timeout=10,
)
if out.returncode == 0 and out.stdout.strip():
return json.loads(out.stdout.strip())
except Exception:
pass
return None
def _get_installed_desktop_version():
try:
out = subprocess.run(
["dpkg-query", "-W", "-f", "${Version}", "codex-desktop"],
capture_output=True, text=True, timeout=5,
)
if out.returncode == 0 and out.stdout.strip():
return out.stdout.strip()
except Exception:
pass
return None
def _get_upstream_info():
try:
req = urllib.request.Request(
f"https://api.github.com/repos/{UPSTREAM_REPO}/commits?per_page=1",
headers={"Accept": "application/vnd.github+json", "User-Agent": "codex-launcher"},
)
resp = urllib.request.urlopen(req, timeout=10)
commits = json.loads(resp.read())
if commits:
c = commits[0]
return {
"sha": c["sha"][:12],
"date": c["commit"]["committer"]["date"][:10],
"message": c["commit"]["message"].split("\n")[0][:80],
}
except Exception:
pass
return None
def _is_updater_service_active():
try:
out = subprocess.run(
["systemctl", "--user", "is-active", "codex-update-manager.service"],
capture_output=True, text=True, timeout=5,
)
return out.stdout.strip() == "active"
except Exception:
return False
class CodexUpdaterWindow(Gtk.Window):
def __init__(self):
super().__init__(title="Codex Desktop Updater")
self.set_default_size(580, 520)
self.set_border_width(10)
self.set_position(Gtk.WindowPosition.CENTER)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add(vbox)
hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0)
lbl = Gtk.Label()
lbl.set_markup("<b>Codex Desktop Updater</b>\n<small>Auto-update from github.com/ilysenko/codex-desktop-linux</small>")
lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0)
info_frame = Gtk.Frame(label="Current Installation")
vbox.pack_start(info_frame, False, False, 4)
info_grid = Gtk.Grid(column_spacing=12, row_spacing=4, margin=8)
info_frame.add(info_grid)
self._installed_lbl = Gtk.Label(label="Checking…", xalign=0)
self._service_lbl = Gtk.Label(label="Checking…", xalign=0)
self._upstream_lbl = Gtk.Label(label="Checking…", xalign=0)
self._candidate_lbl = Gtk.Label(label="—", xalign=0)
self._cli_lbl = Gtk.Label(label="Checking…", xalign=0)
labels = [
(0, "Installed:"), (1, self._installed_lbl),
(2, "Upstream:"), (3, self._upstream_lbl),
(4, "Service:"), (5, self._service_lbl),
(6, "Candidate:"), (7, self._candidate_lbl),
(8, "CLI:"), (9, self._cli_lbl),
]
for idx, widget in labels:
if isinstance(widget, str):
widget = Gtk.Label(label=widget, xalign=0)
info_grid.attach(widget, idx % 2, idx // 2, 1, 1)
btn_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(btn_box, False, False, 4)
self._check_btn = Gtk.Button(label="Check for Updates")
self._check_btn.connect("clicked", lambda b: self._check_updates())
btn_box.pack_start(self._check_btn, True, True, 0)
self._install_btn = Gtk.Button(label="Install Update")
self._install_btn.connect("clicked", lambda b: self._install_update())
self._install_btn.set_sensitive(False)
self._install_btn.get_style_context().add_class("suggested-action")
btn_box.pack_start(self._install_btn, True, True, 0)
self._rollback_btn = Gtk.Button(label="Rollback")
self._rollback_btn.connect("clicked", lambda b: self._rollback())
self._rollback_btn.set_sensitive(False)
btn_box.pack_start(self._rollback_btn, True, True, 0)
auto_note = Gtk.Label(xalign=0)
auto_note.set_markup("<small>↑ Auto-updater: only detects new upstream <i>Codex.dmg</i> from OpenAI. "
"For latest community patches, use Rebuild from Source below.</small>")
auto_note.set_use_markup(True)
vbox.pack_start(auto_note, False, False, 0)
svc_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(svc_box, False, False, 0)
self._svc_start_btn = Gtk.Button(label="Start Service")
self._svc_start_btn.connect("clicked", lambda b: self._toggle_service("start"))
svc_box.pack_start(self._svc_start_btn, True, True, 0)
self._svc_stop_btn = Gtk.Button(label="Stop Service")
self._svc_stop_btn.connect("clicked", lambda b: self._toggle_service("stop"))
svc_box.pack_start(self._svc_stop_btn, True, True, 0)
self._svc_enable_btn = Gtk.Button(label="Enable Autostart")
self._svc_enable_btn.connect("clicked", lambda b: self._toggle_service("enable"))
svc_box.pack_start(self._svc_enable_btn, True, True, 0)
rebuild_box = Gtk.Box(spacing=8)
vbox.pack_start(rebuild_box, False, False, 4)
rebuild_info = Gtk.Label(xalign=0)
rebuild_info.set_markup(
"<b>Rebuild from Source (Recommended)</b>\n"
"<small>The auto-updater only detects new upstream Codex DMGs from OpenAI's CDN.\n"
"To get the <i>latest community fixes</i> from ilysenko/codex-desktop-linux,\n"
"use Clone/Pull then Build &amp; Install to rebuild a fresh .deb from source.</small>"
)
rebuild_info.set_use_markup(True)
rebuild_box.pack_start(rebuild_info, True, True, 0)
rebuild_btn_box = Gtk.Box(spacing=8)
vbox.pack_start(rebuild_btn_box, False, False, 0)
self._clone_btn = Gtk.Button(label="Clone / Pull Repo")
self._clone_btn.connect("clicked", lambda b: self._clone_or_pull())
rebuild_btn_box.pack_start(self._clone_btn, True, True, 0)
self._build_btn = Gtk.Button(label="Build & Install .deb")
self._build_btn.connect("clicked", lambda b: self._build_and_install())
self._build_btn.set_sensitive(False)
self._build_btn.get_style_context().add_class("suggested-action")
rebuild_btn_box.pack_start(self._build_btn, True, True, 0)
self._rebuild_dir_lbl = Gtk.Label(label="", xalign=0)
vbox.pack_start(self._rebuild_dir_lbl, False, False, 0)
sw = Gtk.ScrolledWindow()
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
vbox.pack_start(sw, True, True, 0)
self._log_buf = Gtk.TextBuffer()
tv = Gtk.TextView(buffer=self._log_buf)
tv.set_editable(False)
tv.set_cursor_visible(False)
tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
sw.add(tv)
bb = Gtk.Box(spacing=8)
vbox.pack_start(bb, False, False, 0)
clear_btn = Gtk.Button(label="Clear Log")
clear_btn.connect("clicked", lambda b: self._log_buf.set_text(""))
bb.pack_start(clear_btn, False, False, 0)
view_log_btn = Gtk.Button(label="View Service Log")
view_log_btn.connect("clicked", lambda b: self._view_service_log())
bb.pack_start(view_log_btn, False, False, 0)
close_btn = Gtk.Button(label="Close")
close_btn.connect("clicked", lambda b: self.destroy())
bb.pack_end(close_btn, False, False, 0)
self.show_all()
self._rebuild_dir = Path.home() / ".cache/codex-launcher/codex-desktop-linux"
self._rebuild_dir_lbl.set_markup(f"<small>Build dir: {self._rebuild_dir}</small>")
self._rebuild_dir_lbl.set_use_markup(True)
self._log("Updater initialized")
threading.Thread(target=self._refresh_status, daemon=True).start()
def _log(self, msg):
def _append():
e = self._log_buf.get_end_iter()
self._log_buf.insert(e, msg + "\n")
GLib.idle_add(_append)
def _refresh_status(self):
installed = _get_installed_desktop_version()
upstream = _get_upstream_info()
status = _get_updater_status()
svc_active = _is_updater_service_active()
def _update():
if installed:
self._installed_lbl.set_markup(f"<span foreground='#2ea043'><b>{installed}</b></span>")
self._installed_lbl.set_use_markup(True)
else:
self._installed_lbl.set_text("Not installed via dpkg")
if upstream:
self._upstream_lbl.set_markup(
f"<span foreground='#2ea043'>{upstream['date']}</span>"
f" <small>({upstream['sha']}) {upstream['message']}</small>"
)
self._upstream_lbl.set_use_markup(True)
else:
self._upstream_lbl.set_text("Could not fetch")
if svc_active:
self._service_lbl.set_markup("<span foreground='#2ea043'>● active</span>")
self._service_lbl.set_use_markup(True)
else:
self._service_lbl.set_markup("<span foreground='#d29922'>● inactive</span>")
self._service_lbl.set_use_markup(True)
if status:
cand = status.get("candidate_version")
if cand:
self._candidate_lbl.set_markup(f"<span foreground='#58a6ff'><b>{cand}</b></span>")
self._candidate_lbl.set_use_markup(True)
self._install_btn.set_sensitive(True)
else:
self._candidate_lbl.set_text("No update pending")
self._install_btn.set_sensitive(False)
cli_ver = status.get("cli_installed_version", "")
cli_latest = status.get("cli_latest_version", "")
cli_status = status.get("cli_status", "")
if cli_ver:
color = "#2ea043" if cli_status == "up_to_date" else "#d29922"
self._cli_lbl.set_markup(
f"<span foreground='{color}'>{cli_ver}"
f"{' (up to date)' if cli_status == 'up_to_date' else f' → {cli_latest}'}"
f"</span>"
)
self._cli_lbl.set_use_markup(True)
has_rollback = bool(status.get("last_known_good_version"))
self._rollback_btn.set_sensitive(has_rollback)
else:
if not UPDATER_BIN:
self._candidate_lbl.set_text("codex-update-manager not found")
else:
self._candidate_lbl.set_text("Status unavailable")
if self._rebuild_dir.exists():
self._build_btn.set_sensitive(True)
GLib.idle_add(_update)
self._log(f"Status: installed={installed} svc={'active' if svc_active else 'inactive'}")
def _check_updates(self):
self._check_btn.set_sensitive(False)
self._log("Checking for updates…")
def _run():
try:
out = subprocess.run(
[UPDATER_BIN, "check-now"],
capture_output=True, text=True, timeout=120,
)
self._log(f"check-now: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
GLib.idle_add(lambda: self._check_btn.set_sensitive(True))
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _install_update(self):
self._install_btn.set_sensitive(False)
self._log("Installing update (may prompt for sudo)…")
def _run():
try:
desktop_running = False
try:
out = subprocess.run(
["pgrep", "-f", "/opt/codex-desktop/electron"],
capture_output=True, text=True, timeout=5,
)
desktop_running = out.returncode == 0
except Exception:
pass
if desktop_running:
self._log("⚠ Codex Desktop is running. Closing it to proceed with update…")
subprocess.run(["pkill", "-f", "/opt/codex-desktop/electron"], timeout=10)
import time; time.sleep(3)
self._log("Desktop closed.")
out = subprocess.run(
[UPDATER_BIN, "install-ready"],
capture_output=True, text=True, timeout=300,
)
self._log(f"install-ready: rc={out.returncode}")
combined = (out.stdout or "") + (out.stderr or "")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
if out.returncode == 0 and "successfully" in combined.lower():
self._log("Update installed successfully!")
elif "No Codex Desktop update is ready" in combined:
self._log("⚠ No update is ready. Run 'Check for Updates' first, or use 'Clone/Pull + Build & Install' for a manual update.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
elif "Close it to install" in combined:
self._log("⚠ Desktop was still running. Close Desktop manually and try again.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
elif out.returncode == 0:
self._log("⚠ install-ready returned OK but no confirmation of actual install. Output: " + combined[:200])
GLib.idle_add(lambda: self._install_btn.set_sensitive(False))
else:
self._log("Update may not have completed. Check the log above.")
GLib.idle_add(lambda: self._install_btn.set_sensitive(True))
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _rollback(self):
self._rollback_btn.set_sensitive(False)
self._log("Rolling back to previous version…")
def _run():
try:
out = subprocess.run(
[UPDATER_BIN, "rollback"],
capture_output=True, text=True, timeout=300,
)
self._log(f"rollback: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip())
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _toggle_service(self, action):
cmd_map = {
"start": ["systemctl", "--user", "start", "codex-update-manager.service"],
"stop": ["systemctl", "--user", "stop", "codex-update-manager.service"],
"enable": ["systemctl", "--user", "enable", "--now", "codex-update-manager.service"],
}
cmd = cmd_map.get(action)
if not cmd:
return
self._log(f"Running: {' '.join(cmd)}")
def _run():
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
self._log(f"{action}: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip())
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _clone_or_pull(self):
self._clone_btn.set_sensitive(False)
self._log(f"Clone/pull {UPSTREAM_REPO}…")
def _run():
try:
self._rebuild_dir.parent.mkdir(parents=True, exist_ok=True)
if self._rebuild_dir.exists():
self._log("Pulling latest changes…")
out = subprocess.run(
["git", "pull", "--ff-only"],
capture_output=True, text=True, timeout=60,
cwd=str(self._rebuild_dir),
)
else:
self._log("Cloning repository…")
out = subprocess.run(
["git", "clone", "--depth=1", f"https://github.com/{UPSTREAM_REPO}.git", str(self._rebuild_dir)],
capture_output=True, text=True, timeout=120,
)
self._log(f"git: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip()[:200])
if out.stderr:
self._log(out.stderr.strip()[:200])
if out.returncode == 0:
self._log("Repository ready.")
GLib.idle_add(lambda: self._build_btn.set_sensitive(True))
except Exception as e:
self._log(f"Error: {e}")
finally:
GLib.idle_add(lambda: self._clone_btn.set_sensitive(True))
threading.Thread(target=_run, daemon=True).start()
def _build_and_install(self):
self._build_btn.set_sensitive(False)
self._log("Building Codex Desktop from source (this may take several minutes)…")
def _run():
try:
self._log("Installing build dependencies…")
out = subprocess.run(
["bash", "-c", "bash scripts/install-deps.sh"],
capture_output=True, text=True, timeout=300,
cwd=str(self._rebuild_dir),
)
self._log(f"install-deps: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
self._log("Building app from upstream DMG…")
out = subprocess.run(
["make", "build-app-fresh"],
capture_output=True, text=True, timeout=600,
cwd=str(self._rebuild_dir),
)
self._log(f"build-app-fresh: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
if out.returncode != 0:
self._log("Build failed. Check log above.")
return
self._log("Building .deb package…")
out = subprocess.run(
["make", "deb"],
capture_output=True, text=True, timeout=120,
cwd=str(self._rebuild_dir),
)
self._log(f"deb: rc={out.returncode}")
if out.stderr:
self._log(out.stderr.strip()[-300:])
if out.returncode != 0:
self._log("Deb build failed.")
return
deb_files = list((self._rebuild_dir / "dist").glob("codex-desktop_*.deb"))
if not deb_files:
self._log("No .deb found in dist/")
return
deb_path = deb_files[-1]
self._log(f"Installing {deb_path.name}…")
out = subprocess.run(
["pkexec", "dpkg", "-i", str(deb_path)],
capture_output=True, text=True, timeout=120,
)
self._log(f"dpkg -i: rc={out.returncode}")
if out.stdout:
self._log(out.stdout.strip()[:300])
if out.stderr:
self._log(out.stderr.strip()[:300])
if out.returncode == 0:
self._log("Codex Desktop updated successfully!")
else:
self._log("Installation failed. Try: sudo dpkg -i " + str(deb_path))
except Exception as e:
self._log(f"Error: {e}")
finally:
self._refresh_status()
threading.Thread(target=_run, daemon=True).start()
def _view_service_log(self):
if UPDATER_SERVICE_LOG.exists():
subprocess.Popen(["xdg-open", str(UPDATER_SERVICE_LOG)])
else:
self._log(f"Service log not found at {UPDATER_SERVICE_LOG}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -33,6 +33,7 @@ from codex_launcher_lib import (
PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH, PROVIDER_PRESETS, CHANGELOG, DEFAULT_CONFIG, OAUTH_SECRETS_PATH,
ANTIGRAVITY_MODELS, ANTIGRAVITY_MODELS,
safe_name, label_for_backend, normalize_model_id, normalize_base_url, safe_name, label_for_backend, normalize_model_id, normalize_base_url,
_profile_slug,
parse_model_list, now_utc_iso, apply_provider_preset, parse_model_list, now_utc_iso, apply_provider_preset,
load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools, load_endpoints, save_endpoints, load_bgp_pools, save_bgp_pools,
get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle, get_endpoint, build_profile_bundle, save_profile_bundle, import_profile_bundle,
@@ -40,7 +41,7 @@ from codex_launcher_lib import (
recover_config_if_needed, write_config_for_native, write_config_for_translated, recover_config_if_needed, write_config_for_native, write_config_for_translated,
endpoint_models_url, endpoint_model_headers, fetch_models_for_endpoint, endpoint_models_url, endpoint_model_headers, fetch_models_for_endpoint,
refresh_endpoint_models, run_endpoint_doctor, refresh_endpoint_models, run_endpoint_doctor,
detect_codex_cli, detect_codex_desktop, check_codex_auth, detect_codex_cli, detect_codex_desktop, launch_codex_desktop, is_codex_desktop_running, check_codex_auth,
last_log_lines, kill_existing_desktop, safe_cleanup_owned, last_log_lines, kill_existing_desktop, safe_cleanup_owned,
start_proxy_for, stop_proxy, start_bgp_proxy, get_proxy_state, set_proxy_state, start_proxy_for, stop_proxy, start_bgp_proxy, get_proxy_state, set_proxy_state,
detect_terminal, open_url, open_file, write_secure_text, detect_terminal, open_url, open_file, write_secure_text,
@@ -2073,6 +2074,164 @@ class BenchmarkWindow:
self._dlg.after(0, _show) self._dlg.after(0, _show)
# ═══════════════════════════════════════════════════════════════════════
# Codex Desktop Updater Window
# ═══════════════════════════════════════════════════════════════════════
class UpdateDesktopWindow:
def __init__(self, parent):
self._dlg = tk.Toplevel(parent)
self._dlg.title("Codex Desktop Updater")
self._dlg.geometry("580x520")
self._dlg.transient(parent)
main = ttk.Frame(self._dlg, padding=12)
main.pack(fill="both", expand=True)
ttk.Label(main, text="Codex Desktop Updater",
font=("Segoe UI", 11, "bold")).pack(anchor="w")
if IS_WINDOWS:
info_frame = ttk.LabelFrame(main, text="Status", padding=8)
info_frame.pack(fill="x", pady=(8, 0))
ttk.Label(info_frame,
text="Update feature available on Linux only.\n"
"On Windows, use the official Codex Desktop installer\n"
"or download updates from https://codex.desktop.openai.com",
foreground="#d29922").pack(anchor="w")
ttk.Button(self._dlg, text="Close",
command=self._dlg.destroy).pack(pady=(8, 0))
return
self._status_text = scrolledtext.ScrolledText(main, height=14, state="disabled",
wrap="word", font=("Consolas", 9))
self._status_text.pack(fill="both", expand=True, pady=(8, 0))
btn_frame = ttk.Frame(main)
btn_frame.pack(fill="x", pady=(8, 0))
ttk.Button(btn_frame, text="Refresh Status",
command=self._refresh_status).pack(side="left", padx=(0, 4))
ttk.Button(btn_frame, text="Check for Updates",
command=self._check_updates).pack(side="left", padx=(0, 4))
ttk.Button(btn_frame, text="Install Update",
command=self._install_update).pack(side="left", padx=(0, 4))
ttk.Button(btn_frame, text="Rollback",
command=self._rollback).pack(side="left", padx=(0, 4))
svc_frame = ttk.Frame(main)
svc_frame.pack(fill="x", pady=(4, 0))
ttk.Button(svc_frame, text="Start Service",
command=lambda: self._svc_cmd("start")).pack(side="left", padx=(0, 4))
ttk.Button(svc_frame, text="Stop Service",
command=lambda: self._svc_cmd("stop")).pack(side="left", padx=(0, 4))
ttk.Button(svc_frame, text="Enable Service",
command=lambda: self._svc_cmd("enable")).pack(side="left", padx=(0, 4))
manual_frame = ttk.LabelFrame(main, text="Manual Rebuild (ilysenko/codex-desktop-linux)", padding=4)
manual_frame.pack(fill="x", pady=(8, 0))
ttk.Button(manual_frame, text="Clone/Pull Repo",
command=self._clone_pull).pack(side="left", padx=(0, 4))
ttk.Button(manual_frame, text="Build & Install .deb",
command=self._build_install).pack(side="left", padx=(0, 4))
ttk.Button(main, text="Close",
command=self._dlg.destroy).pack(side="right", pady=(8, 0))
self._refresh_status()
def _set_status(self, text):
self._status_text.configure(state="normal")
self._status_text.delete("1.0", "end")
self._status_text.insert("end", text)
self._status_text.configure(state="disabled")
def _run_cmd(self, cmd, label):
def _thread():
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
out = r.stdout.strip() or r.stderr.strip() or "(no output)"
self._dlg.after(0, lambda: self._set_status(f"[{label}]\n{out}"))
except FileNotFoundError:
self._dlg.after(0, lambda: self._set_status(
f"[{label}] codex-update-manager not found.\n"
"Install from ilysenko/codex-desktop-linux"))
except Exception as e:
self._dlg.after(0, lambda: self._set_status(f"[{label}] Error: {e}"))
threading.Thread(target=_thread, daemon=True).start()
def _refresh_status(self):
if IS_WINDOWS:
return
def _thread():
try:
r = subprocess.run(["codex-update-manager", "status", "--json"],
capture_output=True, text=True, timeout=30)
if r.returncode == 0 and r.stdout.strip():
data = json.loads(r.stdout.strip())
lines = []
lines.append(f"Installed version: {data.get('installed_version', 'unknown')}")
lines.append(f"Upstream version: {data.get('upstream_version', 'unknown')}")
lines.append(f"Upstream date: {data.get('upstream_date', 'unknown')}")
lines.append(f"Update available: {data.get('update_available', False)}")
lines.append(f"Service status: {data.get('service_status', 'unknown')}")
lines.append(f"Service enabled: {data.get('service_enabled', False)}")
if data.get("last_check"):
lines.append(f"Last check: {data['last_check']}")
if data.get("error"):
lines.append(f"Error: {data['error']}")
self._dlg.after(0, lambda: self._set_status("\n".join(lines)))
else:
err = r.stderr.strip() or r.stdout.strip() or "unknown error"
self._dlg.after(0, lambda: self._set_status(f"Status error:\n{err}"))
except FileNotFoundError:
self._dlg.after(0, lambda: self._set_status(
"codex-update-manager not found.\n"
"Install from ilysenko/codex-desktop-linux"))
except Exception as e:
self._dlg.after(0, lambda: self._set_status(f"Status error: {e}"))
threading.Thread(target=_thread, daemon=True).start()
def _check_updates(self):
if IS_WINDOWS:
return
self._run_cmd(["codex-update-manager", "check-now"], "Check for Updates")
def _install_update(self):
if IS_WINDOWS:
return
self._run_cmd(["codex-update-manager", "install-ready"], "Install Update")
def _rollback(self):
if IS_WINDOWS:
return
self._run_cmd(["codex-update-manager", "rollback"], "Rollback")
def _svc_cmd(self, action):
if IS_WINDOWS:
return
self._run_cmd(["systemctl", "--user", action, "codex-update-manager"],
f"Service {action}")
def _clone_pull(self):
if IS_WINDOWS:
return
repo_dir = str(Path.home() / "codex-desktop-linux")
if Path(repo_dir).exists():
self._run_cmd(["git", "-C", repo_dir, "pull"], "Pull Repo")
else:
self._run_cmd(["git", "clone",
"https://github.com/ilysenko/codex-desktop-linux",
repo_dir], "Clone Repo")
def _build_install(self):
if IS_WINDOWS:
return
repo_dir = str(Path.home() / "codex-desktop-linux")
self._run_cmd(["bash", "-c",
f"cd {repo_dir} && bash build-install.sh"],
"Build & Install .deb")
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# Main Launcher Window # Main Launcher Window
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
@@ -2161,6 +2320,7 @@ class LauncherWin:
ttk.Button(tb1, text="History", command=self._open_history).pack(side="left", padx=(6, 0)) ttk.Button(tb1, text="History", command=self._open_history).pack(side="left", padx=(6, 0))
ttk.Button(tb1, text="OAuth Secrets", command=self._edit_oauth_secrets).pack(side="left", padx=(6, 0)) ttk.Button(tb1, text="OAuth Secrets", command=self._edit_oauth_secrets).pack(side="left", padx=(6, 0))
ttk.Button(tb1, text="Changelog", command=self._show_changelog).pack(side="right") ttk.Button(tb1, text="Changelog", command=self._show_changelog).pack(side="right")
ttk.Button(tb1, text="Update Desktop", command=self._open_updater).pack(side="right", padx=(0, 6))
# Detection status — one row per item so long paths don't truncate # Detection status — one row per item so long paths don't truncate
self._cli_info = detect_codex_cli() self._cli_info = detect_codex_cli()
@@ -2178,9 +2338,10 @@ class LauncherWin:
desk_row = ttk.Frame(main) desk_row = ttk.Frame(main)
desk_row.pack(fill="x", pady=(2, 0)) desk_row.pack(fill="x", pady=(2, 0))
if self._desktop_info: if self._desktop_info[0]:
label = "MSIX (Store)" if self._desktop_info[1] else self._desktop_info[0]
ttk.Label(desk_row, text="✓ Codex Desktop", foreground="#2ea043").pack(side="left") ttk.Label(desk_row, text="✓ Codex Desktop", foreground="#2ea043").pack(side="left")
ttk.Label(desk_row, text=f" ({self._desktop_info})", foreground="gray").pack(side="left") ttk.Label(desk_row, text=f" ({label})", foreground="gray").pack(side="left")
else: else:
ttk.Label(desk_row, text="✗ Codex Desktop -- not found", foreground="#d29922").pack(side="left") ttk.Label(desk_row, text="✗ Codex Desktop -- not found", foreground="#d29922").pack(side="left")
ttk.Button(desk_row, text="Install", command=lambda: self._show_install_guide("desktop")).pack(side="left", padx=(6, 0)) ttk.Button(desk_row, text="Install", command=lambda: self._show_install_guide("desktop")).pack(side="left", padx=(6, 0))
@@ -2188,7 +2349,7 @@ class LauncherWin:
self._missing = [] self._missing = []
if not self._cli_info: if not self._cli_info:
self._missing.append("cli") self._missing.append("cli")
if not self._desktop_info: if not self._desktop_info[0]:
self._missing.append("desktop") self._missing.append("desktop")
# Auth status # Auth status
@@ -2301,8 +2462,9 @@ class LauncherWin:
self.log(f"✓ Codex CLI detected ({ver})") self.log(f"✓ Codex CLI detected ({ver})")
else: else:
self.log("✗ Codex CLI NOT found -- CLI launch disabled.") self.log("✗ Codex CLI NOT found -- CLI launch disabled.")
if self._desktop_info: if self._desktop_info[0]:
self.log(f"✓ Codex Desktop detected ({self._desktop_info})") label = "MSIX (Store)" if self._desktop_info[1] else self._desktop_info[0]
self.log(f"✓ Codex Desktop detected ({label})")
else: else:
self.log("✗ Codex Desktop NOT found -- Desktop launch disabled.") self.log("✗ Codex Desktop NOT found -- Desktop launch disabled.")
if self._missing: if self._missing:
@@ -2407,6 +2569,9 @@ class LauncherWin:
def _open_benchmark(self): def _open_benchmark(self):
BenchmarkWindow(self._root) BenchmarkWindow(self._root)
def _open_updater(self):
UpdateDesktopWindow(self._root)
def _open_proxy_log_dir(self): def _open_proxy_log_dir(self):
log_dir = str(PROXY_CONFIG_DIR) log_dir = str(PROXY_CONFIG_DIR)
req_log = PROXY_CONFIG_DIR / "requests.log" req_log = PROXY_CONFIG_DIR / "requests.log"
@@ -2969,7 +3134,7 @@ class LauncherWin:
# ── Launch ─────────────────────────────────────────────────────── # ── Launch ───────────────────────────────────────────────────────
def _set_busy(self, busy): def _set_busy(self, busy, proxy_alive=False):
has_cli = "cli" not in self._missing has_cli = "cli" not in self._missing
has_desk = "desktop" not in self._missing has_desk = "desktop" not in self._missing
def _update(): def _update():
@@ -2977,8 +3142,8 @@ class LauncherWin:
self._btn_cli.configure(state="disabled" if busy or not has_cli else "normal") self._btn_cli.configure(state="disabled" if busy or not has_cli else "normal")
self._btn_codex_desktop.configure(state="disabled" if busy or not has_desk else "normal") self._btn_codex_desktop.configure(state="disabled" if busy or not has_desk else "normal")
self._btn_codex_cli.configure(state="disabled" if busy or not has_cli else "normal") self._btn_codex_cli.configure(state="disabled" if busy or not has_cli else "normal")
self._kill_btn.configure(state="normal" if busy else "disabled") self._kill_btn.configure(state="normal" if busy or proxy_alive else "disabled")
self._restart_btn.configure(state="normal" if busy else "disabled") self._restart_btn.configure(state="normal" if busy or proxy_alive else "disabled")
self._root.after(0, _update) self._root.after(0, _update)
def _launch(self, target): def _launch(self, target):
@@ -3065,7 +3230,7 @@ class LauncherWin:
finally: finally:
if keep_session_alive: if keep_session_alive:
self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.") self.log("Warm-start handoff detected; keeping proxy/config active for running Desktop.")
self._set_busy(False) self._set_busy(False, proxy_alive=True)
self.log("Ready. Use Kill && Cleanup when finished.") self.log("Ready. Use Kill && Cleanup when finished.")
else: else:
stop_proxy() stop_proxy()
@@ -3129,25 +3294,31 @@ class LauncherWin:
self.log("Ready.") self.log("Ready.")
def _launch_desktop(self, ep, model): def _launch_desktop(self, ep, model):
desktop_path = self._desktop_info if not self._desktop_info[0]:
if not desktop_path:
self.log("ERROR: Codex Desktop not found") self.log("ERROR: Codex Desktop not found")
return False return False
if IS_WINDOWS: _, is_msix = self._desktop_info
self._proc = subprocess.Popen( self._proc = launch_codex_desktop(self._desktop_info)
[desktop_path], if not self._proc:
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, self.log("ERROR: Failed to launch Codex Desktop")
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) return False
else:
self._proc = subprocess.Popen(
[desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
pid = self._proc.pid pid = self._proc.pid
self.log(f"Desktop started (PID {pid})") self.log(f"Desktop started (PID {pid})")
self.log(f"Log: {LAUNCH_LOG}") self.log(f"Log: {LAUNCH_LOG}")
# MSIX: cmd.exe exits immediately, monitor via tasklist instead
if is_msix and IS_WINDOWS:
time.sleep(3)
if not is_codex_desktop_running():
self.log("ERROR: Codex Desktop did not start")
self._proc = None
return False
self.log("Codex Desktop is running (MSIX)")
self._proc = None
return True
t0 = time.time() t0 = time.time()
stall_warned = False stall_warned = False
while self._proc and self._proc.poll() is None: while self._proc and self._proc.poll() is None:
@@ -3184,7 +3355,7 @@ class LauncherWin:
if ep["backend_type"] == "native": if ep["backend_type"] == "native":
cmd_parts.extend(["codex", "-c", f"model={model}"]) cmd_parts.extend(["codex", "-c", f"model={model}"])
else: else:
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"]) cmd_parts.extend(["codex", "--profile", _profile_slug(ep["name"]), "-c", f"model={model}"])
self.log(f"Running: {' '.join(cmd_parts)}") self.log(f"Running: {' '.join(cmd_parts)}")
if IS_WINDOWS: if IS_WINDOWS:
@@ -3203,18 +3374,13 @@ class LauncherWin:
def _launch_desktop_direct(self): def _launch_desktop_direct(self):
self.log("Launching Codex Desktop (default OAuth)...") self.log("Launching Codex Desktop (default OAuth)...")
desktop_path = self._desktop_info if not self._desktop_info[0]:
if not desktop_path:
self.log("ERROR: Codex Desktop not found") self.log("ERROR: Codex Desktop not found")
return return
if IS_WINDOWS: self._proc = launch_codex_desktop(self._desktop_info)
self._proc = subprocess.Popen( if not self._proc:
[desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, self.log("ERROR: Failed to launch Codex Desktop")
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) return
else:
self._proc = subprocess.Popen(
[desktop_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
pid = self._proc.pid pid = self._proc.pid
self.log(f"Desktop started (PID {pid})") self.log(f"Desktop started (PID {pid})")

View File

@@ -83,6 +83,46 @@ model_catalog_json = ""
""" """
CHANGELOG = [ CHANGELOG = [
("10.13.6", "2026-05-27", [
"Anti-loop: cross-session tracker, tool-call budget (150), file read-loop detection",
"Auto 401 token refresh with retry",
"Model-aware idle timeout: flash 120s, pro 300s",
"Smart compaction: directive summary on read-loops",
"Default provider policy for unrecognized backends",
"Anti-stall fix: no longer kills own parent/process group",
"Codex Desktop Updater: check/install/rollback/manual rebuild",
"Fix Codex CLI 0.134.0 profiles: separate .config.toml files",
"Fix compaction: max_input_items 60->200 for 1M-token models",
"E2E test suite: bash test-antigravity.sh [--task]",
"Merge cobra91 PR #17: MSIX Desktop launch, button state",
]),
("3.12.1", "2026-05-27", [
"Fix Antigravity adapter (PR #15): simplify model resolution",
"Removed broken schema sanitization, restored correct headers",
"Expanded model alias map for all Antigravity variants",
"Re-enabled gRPC fallback by default",
]),
("3.12.0", "2026-05-27", [
"gRPC auto-fallback for Antigravity provider (PR #13)",
"New antigravity_grpc module with protobuf client",
"REST 404 triggers gRPC fallback using display names",
"gRPC supports streaming and unary generate",
"Dynamic version fetch with probe validation",
"Antigravity v2 handler rewrite (anti-api approach)",
"Safety settings, stopSequences, sessionId, requestType: agent",
]),
("3.11.11", "2026-05-26", [
"Final trimming only removes plain messages, never function_call_output",
]),
("3.11.10", "2026-05-26", [
"Fix Antigravity: interleave function_call/output pairs in correct sequence (PR #11)",
"Fix Gemini sanitizer: trim leading/trailing non-user turns for Google API compliance",
"Stricter function call/response isolation — no merging across role boundaries",
]),
("3.11.9", "2026-05-26", [
"Fix Antigravity: preserve functionCall/functionResponse in Gemini sanitizer (PR #10)",
"Prevents tool responses from being merged/dropped in multi-turn Antigravity sessions",
]),
("3.11.8", "2026-05-26", [ ("3.11.8", "2026-05-26", [
"Vision description cache persisted across requests (no redundant API calls for same image)", "Vision description cache persisted across requests (no redundant API calls for same image)",
"Merge PR #8: fix vision cache persistence across requests", "Merge PR #8: fix vision cache persistence across requests",
@@ -687,6 +727,9 @@ def safe_name(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}" return f"{base}-{digest}"
def _profile_slug(name):
return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"
def label_for_backend(backend_type): def label_for_backend(backend_type):
return { return {
@@ -1077,10 +1120,9 @@ def write_config_for_native(endpoint, selected_model):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True) mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2)) mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/") mc_str = str(mc_path).replace("\\", "/")
new_config = [
f'profile = "{_toml_safe(endpoint["name"])}"\n', main_config = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n', f'model_catalog_json = "{mc_str}"\n',
@@ -1088,16 +1130,21 @@ def write_config_for_native(endpoint, selected_model):
f'name = "{_toml_safe(endpoint["name"])}"\n', f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "{_toml_safe(endpoint["base_url"])}"\n', f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\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', existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(main_config))
write_secure_text(CONFIG, merged)
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n', f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "default"\n', f'service_tier = "default"\n',
f'approvals_reviewer = "user"\n', f'approvals_reviewer = "user"\n',
] ]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "" write_secure_text(profile_path, "".join(profile_lines))
merged = _merge_toml(existing, "".join(new_config))
write_secure_text(CONFIG, merged)
def write_config_for_translated(endpoint, selected_model, proxy_port=8080): def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
@@ -1106,10 +1153,9 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json" mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
mc_path.parent.mkdir(parents=True, exist_ok=True) mc_path.parent.mkdir(parents=True, exist_ok=True)
mc_path.write_text(json.dumps(model_catalog, indent=2)) mc_path.write_text(json.dumps(model_catalog, indent=2))
mc_str = str(mc_path).replace("\\", "/") mc_str = str(mc_path).replace("\\", "/")
new_config = [
f'profile = "{_toml_safe(endpoint["name"])}"\n', main_config = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n', f'model_catalog_json = "{mc_str}"\n',
@@ -1117,16 +1163,21 @@ def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
f'name = "{_toml_safe(endpoint["name"])}"\n', f'name = "{_toml_safe(endpoint["name"])}"\n',
f'base_url = "http://127.0.0.1:{proxy_port}"\n', f'base_url = "http://127.0.0.1:{proxy_port}"\n',
f'experimental_bearer_token = "codex-launcher-local"\n', f'experimental_bearer_token = "codex-launcher-local"\n',
f'\n[profiles."{endpoint["name"]}"]\n', ]
f'model_provider = "{_toml_safe(endpoint["name"])}"\n', existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
merged = _merge_toml(existing, "".join(main_config))
write_secure_text(CONFIG, merged)
profile_slug = _profile_slug(endpoint["name"])
profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
profile_lines = [
f'model = "{_toml_safe(selected_model)}"\n', f'model = "{_toml_safe(selected_model)}"\n',
f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
f'model_catalog_json = "{mc_str}"\n', f'model_catalog_json = "{mc_str}"\n',
f'service_tier = "fast"\n', f'service_tier = "fast"\n',
f'approvals_reviewer = "user"\n', f'approvals_reviewer = "user"\n',
] ]
existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "" write_secure_text(profile_path, "".join(profile_lines))
merged = _merge_toml(existing, "".join(new_config))
write_secure_text(CONFIG, merged)
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# Model fetching # Model fetching
@@ -1684,6 +1735,12 @@ def detect_codex_cli():
def detect_codex_desktop(): def detect_codex_desktop():
"""Detect Codex Desktop installation.
Returns (path_or_aumid, is_msix) tuple on Windows, path string on Linux.
For MSIX installs, returns the AppUserModelId since the exe cannot be
launched directly via subprocess from WindowsApps.
"""
if IS_WINDOWS: if IS_WINDOWS:
la = os.environ.get("LOCALAPPDATA", "") la = os.environ.get("LOCALAPPDATA", "")
pf = os.environ.get("PROGRAMFILES", "") pf = os.environ.get("PROGRAMFILES", "")
@@ -1696,8 +1753,8 @@ def detect_codex_desktop():
] ]
for p in desktop_paths: for p in desktop_paths:
if p.exists(): if p.exists():
return str(p) return str(p), False
# MSIX / Microsoft Store install: locate via Get-AppxPackage # MSIX / Microsoft Store install
try: try:
r = subprocess.run( r = subprocess.run(
["powershell", "-NoProfile", "-Command", ["powershell", "-NoProfile", "-Command",
@@ -1708,13 +1765,70 @@ def detect_codex_desktop():
if loc: if loc:
msix_exe = Path(loc) / "app" / "Codex.exe" msix_exe = Path(loc) / "app" / "Codex.exe"
if msix_exe.exists(): if msix_exe.exists():
return str(msix_exe) r2 = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-AppxPackage *OpenAI.Codex*).PackageFamilyName"],
capture_output=True, text=True, timeout=10,
)
family = r2.stdout.strip() if r2.returncode == 0 else ""
if family:
return f"{family}!App", True
except Exception: except Exception:
pass pass
return None return None, False
if START_SH and START_SH.exists(): if START_SH and START_SH.exists():
return str(START_SH) return str(START_SH), False
return None return None, False
def launch_codex_desktop(desktop_info):
"""Launch Codex Desktop process.
Args:
desktop_info: (path_or_aumid, is_msix) tuple from detect_codex_desktop()
Returns:
subprocess.Popen object or None
"""
path, is_msix = desktop_info
if IS_WINDOWS:
if is_msix:
return subprocess.Popen(
["cmd", "/c", "start", "", f"shell:AppsFolder\\{path}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
return subprocess.Popen(
[path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
else:
return subprocess.Popen(
[path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
def is_codex_desktop_running():
"""Check if Codex Desktop (or MSIX Codex) is currently running."""
if IS_WINDOWS:
try:
for name in ("Codex Desktop.exe", "Codex.exe"):
out = subprocess.run(
["tasklist", "/FI", f"IMAGENAME eq {name}", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5,
)
for line in out.stdout.strip().splitlines():
parts = line.split(",")
if len(parts) >= 2 and parts[1].strip('"').isdigit():
return True
except Exception:
pass
return False
else:
try:
out = subprocess.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5)
return bool(out.stdout.strip())
except Exception:
return False
def check_codex_auth(): def check_codex_auth():
@@ -1757,9 +1871,10 @@ def last_log_lines(n=15):
def kill_existing_desktop(logfn=None): def kill_existing_desktop(logfn=None):
if IS_WINDOWS: if IS_WINDOWS:
for img in ("Codex Desktop.exe", "Codex.exe"):
try: try:
out = subprocess.run( out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq Codex Desktop.exe", "/FO", "CSV", "/NH"], ["tasklist", "/FI", f"IMAGENAME eq {img}", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5, capture_output=True, text=True, timeout=5,
) )
for line in out.stdout.strip().splitlines(): for line in out.stdout.strip().splitlines():

File diff suppressed because it is too large Load Diff

482
test-antigravity.sh Normal file
View File

@@ -0,0 +1,482 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════
# test-antigravity.sh — End-to-end Antigravity proxy test + real task
#
# Phases:
# 1. Token validity
# 2. Direct REST endpoint probe
# 3. Proxy adapter (start proxy, test /responses)
# 4. Real Codex CLI task (if --task flag given)
# 5. Anomaly detection + analysis
#
# Usage:
# bash ~/.local/bin/test-antigravity.sh # quick tests
# bash ~/.local/bin/test-antigravity.sh --task # + real CLI task
# bash ~/.local/bin/test-antigravity.sh --verbose # show all logs
# Exit: 0 = all pass, 1 = some fail
# ═══════════════════════════════════════════════════════════════════
set -uo pipefail
VERBOSE=0; RUN_TASK=0
for arg in "$@"; do
case "$arg" in
--verbose|-v) VERBOSE=1 ;;
--task|-t) RUN_TASK=1 ;;
esac
done
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
PASS=0; FAIL=0; SKIP=0; RESULTS=()
log_pass() { echo -e " ${GREEN}PASS${NC} $1"; ((PASS++)); RESULTS+=("PASS $1"); }
log_fail() { echo -e " ${RED}FAIL${NC} $1"; ((FAIL++)); RESULTS+=("FAIL $1"); }
log_skip() { echo -e " ${YELLOW}SKIP${NC} $1"; ((SKIP++)); RESULTS+=("SKIP $1"); }
log_info() { echo -e " ${CYAN}INFO${NC} $1"; }
TOKEN_PATH="$HOME/.cache/codex-proxy/google-antigravity-oauth-token.json"
[ ! -f "$TOKEN_PATH" ] && { echo "ERROR: No token file. Login via GUI first."; exit 1; }
ACCESS_TOKEN=$(python3 -c "
import json, os, sys, time, urllib.request, urllib.parse
tp = os.path.expanduser('~/.cache/codex-proxy/google-antigravity-oauth-token.json')
d = json.load(open(tp))
if d.get('expires_at', 0) > time.time(): print(d['access_token']); sys.exit(0)
cid, cs, rt = d.get('client_id',''), d.get('client_secret',''), d.get('refresh_token','')
if not all([cid, cs, rt]): print('ERROR'); sys.exit(1)
data = urllib.parse.urlencode({'client_id':cid,'client_secret':cs,'refresh_token':rt,'grant_type':'refresh_token'}).encode()
resp = urllib.request.urlopen(urllib.request.Request('https://oauth2.googleapis.com/token', data=data), timeout=15)
tok = json.loads(resp.read()); d.update(tok); d['expires_at'] = time.time() + tok.get('expires_in',3600)
json.dump(d, open(tp,'w')); print(tok.get('access_token','ERROR'))
" 2>&1) || true
[[ "$ACCESS_TOKEN" == ERROR* ]] || [ -z "$ACCESS_TOKEN" ] && { echo "ERROR: Token refresh failed: $ACCESS_TOKEN"; exit 1; }
PROJECT_ID=$(python3 -c "import json; print(json.load(open('$TOKEN_PATH')).get('project_id',''))")
[ -z "$PROJECT_ID" ] && { echo "ERROR: No project_id"; exit 1; }
echo "═══════════════════════════════════════════════════════════════"
echo " Antigravity E2E Test Suite"
echo "═══════════════════════════════════════════════════════════════"
echo " Project: $PROJECT_ID Token: ${ACCESS_TOKEN:0:20}..."
# ── Test 1: Token validity ────────────────────────────────────────
echo ""; echo "─── Test 1: Token Validity ───"
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://www.googleapis.com/oauth2/v1/userinfo" --max-time 5)
[ "$HTTP" = "200" ] && log_pass "Token valid" || log_fail "Token invalid (HTTP $HTTP)"
# ── Test 2: Direct REST probe (prod first, fast timeout) ─────────
echo ""; echo "─── Test 2: Direct REST Endpoint Probe ───"
ENDPOINTS=(
"https://cloudcode-pa.googleapis.com"
"https://daily-cloudcode-pa.sandbox.googleapis.com"
"https://autopush-cloudcode-pa.sandbox.googleapis.com"
)
MODELS=("gemini-3-flash")
BEST_EP=""; BEST_MODEL=""
for model in "${MODELS[@]}"; do
for ep in "${ENDPOINTS[@]}"; do
ep_s=$(echo "$ep" | sed 's|https://||;s|.googleapis.com||')
RESP=$(curl -s -w "\n%{http_code}" -X POST "${ep}/v1internal:generateContent" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/2.0.6 Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36" \
-H 'Client-Metadata: {"ideType":"ANTIGRAVITY","platform":"LINUX","pluginType":"GEMINI"}' \
-d "{\"project\":\"$PROJECT_ID\",\"model\":\"$model\",\"requestType\":\"agent\",\"userAgent\":\"antigravity/2.0.6 linux/x64\",\"requestId\":\"t$(date +%s)\",\"request\":{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Say hi\"}]}],\"sessionId\":\"t$(date +%s%N)\",\"generationConfig\":{\"maxOutputTokens\":256}}}" \
--connect-timeout 5 --max-time 20 2>&1)
HTTP=$(echo "$RESP" | tail -1); BODY=$(echo "$RESP" | sed '$d')
if [ "$HTTP" = "200" ]; then
TEXT=$(echo "$BODY" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
parts = d.get('response',{}).get('candidates',[{}])[0].get('content',{}).get('parts',[])
texts = [p['text'] for p in parts if 'text' in p and p['text']]
print(' '.join(texts)[:80] if texts else 'EMPTY')
except: print('EMPTY')" 2>/dev/null)
if [ "$TEXT" != "EMPTY" ] && ! echo "$TEXT" | grep -qi "no longer supported"; then
log_pass "$model @ ${ep_s} → \"$TEXT\""
[ -z "$BEST_EP" ] && BEST_EP="$ep" && BEST_MODEL="$model"
else
log_fail "$model @ ${ep_s} → 200 but empty/deprecated"
fi
else
ERR=$(echo "$BODY" | python3 -c "
import sys, json
try: print(json.load(sys.stdin).get('error',{}).get('status','')[:50])
except: pass" 2>/dev/null)
log_skip "$model @ ${ep_s}$HTTP $ERR"
fi
done
done
# ── Test 3: Proxy adapter (start proxy, test /responses) ──────────
echo ""; echo "─── Test 3: Proxy Adapter (end-to-end) ───"
set +e
TEST_PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()")
PROXY_API_KEY="test-$RANDOM"
find /home/roman/.local/bin -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null; true
PROXY_PID=""
export PROXY_PORT=$TEST_PORT
export PROXY_API_KEY=$PROXY_API_KEY
export PROXY_BACKEND=gemini-oauth-antigravity
export PROXY_TARGET_URL=https://cloudcode-pa.googleapis.com
python3 /home/roman/.local/bin/translate-proxy.py >/tmp/antigravity-test-proxy.log 2>&1 &
PROXY_PID=$!
cleanup() { kill $PROXY_PID 2>/dev/null || true; wait $PROXY_PID 2>/dev/null || true; }
trap cleanup EXIT
sleep 3
if ! kill -0 $PROXY_PID 2>/dev/null; then
log_fail "Proxy failed to start (port $TEST_PORT)"
cat /tmp/antigravity-test-proxy.log 2>/dev/null | tail -5
else
log_pass "Proxy started on :$TEST_PORT"
# /v1/models
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $PROXY_API_KEY" \
"http://127.0.0.1:$TEST_PORT/v1/models" --max-time 5)
[ "$HTTP" = "200" ] && log_pass "/v1/models → 200" || log_fail "/v1/models → $HTTP"
# /responses (non-stream)
RESP_HTTP=$(curl -s -w "%{http_code}" -o /tmp/antigravity-test-response.json \
-X POST "http://127.0.0.1:$TEST_PORT/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $PROXY_API_KEY" \
-d '{
"model":"gemini-3.5-flash-high",
"stream":false,
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Say hello in exactly 3 words"}]}],
"tools":[{"type":"function","name":"test_tool","description":"test","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}],
"instructions":"You are a helpful assistant.",
"max_output_tokens":256
}' --connect-timeout 10 --max-time 60 2>&1)
if [ "$RESP_HTTP" = "200" ]; then
TEXT=$(python3 -c "
import json
d = json.load(open('/tmp/antigravity-test-response.json'))
out = d.get('output', [])
texts = []
for item in out:
for p in (item.get('content', []) if isinstance(item, dict) else []):
if isinstance(p, dict): texts.append(p.get('text', ''))
print(' '.join(t for t in texts if t).strip()[:120] or 'EMPTY')
" 2>/dev/null)
if [ "$TEXT" = "EMPTY" ]; then
log_fail "Proxy /responses → 200 but EMPTY"
else
log_pass "Proxy /responses → 200: \"$TEXT\""
fi
else
ERR=$(python3 -c "
import json; d = json.load(open('/tmp/antigravity-test-response.json'))
print(d.get('error',{}).get('message','')[:120])" 2>/dev/null || echo "unknown")
log_fail "Proxy /responses → $RESP_HTTP: $ERR"
fi
# Verify model resolution in logs
if grep -q "model resolved: gemini-3.5-flash-high -> gemini-3-flash" /tmp/antigravity-test-proxy.log; then
log_pass "Model resolution: gemini-3.5-flash-high → gemini-3-flash"
else
log_fail "Model resolution not found in proxy logs"
fi
[ "$VERBOSE" = "1" ] && cat /tmp/antigravity-test-proxy.log
fi
# ── Test 4: Real Codex CLI Task ────────────────────────────────────
if [ "$RUN_TASK" = "1" ]; then
echo ""; echo "─── Test 4: Real Codex CLI Task ───"
if ! command -v codex &>/dev/null; then
log_skip "Codex CLI not found"
else
CLI_VERSION=$(codex --version 2>/dev/null || echo "unknown")
log_info "Codex CLI: $CLI_VERSION"
TASK_PROMPT='Create a file /tmp/e2e-test-output.txt with the text "Hello from Codex CLI E2E test" followed by the current date. Then read it back and confirm the content is correct. This is a simple smoke test.'
TASK_WORKSPACE="/tmp/e2e-test-workspace"
mkdir -p "$TASK_WORKSPACE"
mkdir -p /tmp/antigravity-task-logs
TASK_PROXY_LOG="/tmp/antigravity-task-logs/proxy-$(date +%s).log"
TASK_CLI_LOG="/tmp/antigravity-task-logs/cli-$(date +%s).log"
TASK_MONITOR_LOG="/tmp/antigravity-task-logs/monitor-$(date +%s).log"
# Set up proxy for CLI task (use the one already running on TEST_PORT)
# Write codex profile + config pointing to our test proxy
CONFIG_DIR="$HOME/.codex"
CONFIG_FILE="$CONFIG_DIR/config.toml"
CONFIG_BACKUP="$CONFIG_DIR/config.toml.task-backup"
[ -f "$CONFIG_FILE" ] && cp "$CONFIG_FILE" "$CONFIG_BACKUP"
# Generate model catalog
CATALOG_PATH="$HOME/.cache/codex-proxy/models-Antigravity-Test.json"
python3 -c "
import json, os
models = ['gemini-3.5-flash-high', 'gemini-3.5-flash-medium', 'gemini-3.5-flash-low',
'gemini-3.1-pro-high', 'gemini-3.1-pro-low',
'claude-sonnet-4-6', 'claude-opus-4-6-thinking', 'gpt-oss-120b-medium']
catalog = []
for m in models:
catalog.append({'slug':m,'model':m,'display_name':m,'description':'Antigravity '+m,'hidden':False,'isDefault':m=='gemini-3.5-flash-high','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'}]})
os.makedirs(os.path.dirname('$CATALOG_PATH'), exist_ok=True)
json.dump(catalog, open('$CATALOG_PATH','w'), indent=2)
" || log_fail "Failed to create model catalog"
# Write main config
cat > "$CONFIG_FILE" <<CONFEOF
model = "gemini-3.5-flash-high"
model_provider = "Antigravity Test"
model_catalog_json = "$CATALOG_PATH"
[model_providers."Antigravity Test"]
name = "Antigravity Test"
base_url = "http://127.0.0.1:$TEST_PORT"
experimental_bearer_token = "$PROXY_API_KEY"
wire_api = "responses"
request_max_retries = 1
stream_max_retries = 0
stream_idle_timeout_ms = 600000
[projects."/home/roman/Codex-Launcher-Any-AI-Provider"]
trust_level = "trusted"
CONFEOF
# Write profile file for Codex CLI 0.134.0+
PROFILE_FILE="$CONFIG_DIR/Antigravity-Test.config.toml"
cat > "$PROFILE_FILE" <<PROFEOF
model = "gemini-3.5-flash-high"
model_provider = "Antigravity Test"
model_catalog_json = "$CATALOG_PATH"
service_tier = "fast"
approvals_reviewer = "user"
PROFEOF
log_info "Config written: profile=Antigravity-Test, port=$TEST_PORT"
# ── Anomaly monitor (background) ──
ANOMALY_FOUND=0
(
PROXY_LOG="/tmp/antigravity-test-proxy.log"
START_TIME=$(date +%s)
TIMEOUT_SEC=600
PREV_LINE_COUNT=0
STALL_COUNT=0
LOOP_DETECTOR=""
LOOP_COUNT=0
while true; do
sleep 10
[ ! -f "$PROXY_LOG" ] && continue
NOW=$(date +%s)
ELAPSED=$(( NOW - START_TIME ))
[ "$ELAPSED" -gt "$TIMEOUT_SEC" ] && {
echo "[MONITOR] TIMEOUT: Task exceeded ${TIMEOUT_SEC}s" >> "$TASK_MONITOR_LOG"
break
}
# Check proxy is alive
if ! kill -0 $PROXY_PID 2>/dev/null; then
echo "[MONITOR] FATAL: Proxy process died" >> "$TASK_MONITOR_LOG"
break
fi
# Count lines in proxy log
LINE_COUNT=$(wc -l < "$PROXY_LOG" 2>/dev/null || echo 0)
NEW_LINES=$(( LINE_COUNT - PREV_LINE_COUNT ))
PREV_LINE_COUNT=$LINE_COUNT
# Stall detection: no new log lines for 3 consecutive checks = stalled
if [ "$NEW_LINES" -eq 0 ]; then
STALL_COUNT=$(( STALL_COUNT + 1 ))
if [ "$STALL_COUNT" -ge 18 ]; then
echo "[MONITOR] STALL: No proxy activity for 180s" >> "$TASK_MONITOR_LOG"
fi
else
STALL_COUNT=0
fi
# Loop detection: check if same tool call repeats
RECENT=$(tail -50 "$PROXY_LOG" 2>/dev/null | grep "exec_command" | tail -5 | md5sum | cut -c1-8)
if [ -n "$RECENT" ] && [ "$RECENT" = "$LOOP_DETECTOR" ]; then
LOOP_COUNT=$(( LOOP_COUNT + 1 ))
if [ "$LOOP_COUNT" -ge 6 ]; then
echo "[MONITOR] LOOP: Same tool calls repeating ($LOOP_COUNT times)" >> "$TASK_MONITOR_LOG"
fi
else
LOOP_DETECTOR="$RECENT"
LOOP_COUNT=0
fi
# Check for error patterns
ERRORS=$(tail -100 "$PROXY_LOG" 2>/dev/null | grep -ciE "error|failed|timeout|500|502|503|429" || echo 0)
if [ "$ERRORS" -gt 10 ]; then
echo "[MONITOR] ERRORS: $ERRORS error lines in last 100 log lines" >> "$TASK_MONITOR_LOG"
fi
# Check for compaction issues
COMPACT_LINES=$(tail -200 "$PROXY_LOG" 2>/dev/null | grep -c "compacted\|compaction\|trimming" || echo 0)
if [ "$COMPACT_LINES" -gt 20 ]; then
echo "[MONITOR] COMPACTION: Excessive compaction ($COMPACT_LINES events)" >> "$TASK_MONITOR_LOG"
fi
# Check context item count
HIGH_ITEM=$(tail -200 "$PROXY_LOG" 2>/dev/null | grep -oP '\[\d+\]' | grep -oP '\d+' | sort -rn | head -1 || echo 0)
if [ -n "$HIGH_ITEM" ] && [ "$HIGH_ITEM" -gt 100 ]; then
echo "[MONITOR] CONTEXT: High item count detected: [$HIGH_ITEM]" >> "$TASK_MONITOR_LOG"
fi
# Log heartbeat
echo "[MONITOR] ${ELAPSED}s elapsed, ${LINE_COUNT} log lines, ${NEW_LINES} new, ${ERRORS} errors" >> "$TASK_MONITOR_LOG"
done
) &
MONITOR_PID=$!
# ── Launch Codex CLI with the task ──
log_info "Launching Codex CLI with real task..."
log_info "Task: Create and verify a simple test file"
log_info "Monitor log: $TASK_MONITOR_LOG"
cd "$TASK_WORKSPACE"
set +e
codex exec --profile Antigravity-Test -c "model=gemini-3.5-flash-high" \
-c 'sandbox_permissions=["disk-full-read-access","disk-full-write-access"]' \
"$TASK_PROMPT" \
> "$TASK_CLI_LOG" 2>&1
CLI_EXIT=$?
set -e
# Stop monitor
kill $MONITOR_PID 2>/dev/null || true
wait $MONITOR_PID 2>/dev/null || true
CLI_DURATION=$(wc -l < "$TASK_CLI_LOG" 2>/dev/null || echo 0)
log_info "CLI exited (code $CLI_EXIT, $CLI_DURATION output lines)"
# ── Analyze results ──
echo ""; echo "─── Test 4a: CLI Task Results ───"
if [ "$CLI_EXIT" -eq 0 ]; then
log_pass "CLI task completed successfully"
else
log_fail "CLI task failed (exit code $CLI_EXIT)"
echo " Last 10 lines of CLI output:"
tail -10 "$TASK_CLI_LOG" 2>/dev/null | sed 's/^/ /'
fi
# Check monitor log for anomalies
echo ""; echo "─── Test 4b: Anomaly Analysis ───"
if [ -f "$TASK_MONITOR_LOG" ]; then
ANOMALIES=$(grep -c "\[MONITOR\]" "$TASK_MONITOR_LOG" 2>/dev/null || echo 0)
CRITICAL=$(grep -cE "FATAL|LOOP|TIMEOUT|STALL|ERRORS|COMPACTION|CONTEXT" "$TASK_MONITOR_LOG" 2>/dev/null || echo 0)
log_info "Monitor: $ANOMALIES checks, $CRITICAL anomalies detected"
if [ "$CRITICAL" -gt 0 ]; then
echo -e " ${RED}ANOMALIES FOUND:${NC}"
grep -E "FATAL|LOOP|TIMEOUT|STALL|ERRORS|COMPACTION|CONTEXT" "$TASK_MONITOR_LOG" | while read line; do
echo -e " ${RED}$line${NC}"
done
log_fail "$CRITICAL anomalies detected during task"
else
log_pass "No anomalies detected during task"
fi
[ "$VERBOSE" = "1" ] && cat "$TASK_MONITOR_LOG"
else
log_skip "No monitor log produced"
fi
# Check proxy log for issues
echo ""; echo "─── Test 4c: Proxy Health ───"
if [ -f "/tmp/antigravity-test-proxy.log" ]; then
ERROR_COUNT=$(grep -ciE "error|failed|exception|traceback" /tmp/antigravity-test-proxy.log || echo 0)
TIMEOUT_COUNT=$(grep -ci "timeout\|timed.out" /tmp/antigravity-test-proxy.log || echo 0)
COMPACT_COUNT=$(grep -c "compacted\|compaction" /tmp/antigravity-test-proxy.log || echo 0)
ITEM_COUNT=$(grep -oP '\[\d+\]' /tmp/antigravity-test-proxy.log | grep -oP '\d+' | sort -rn | head -1 || echo 0)
log_info "Proxy errors: $ERROR_COUNT, timeouts: $TIMEOUT_COUNT, compactions: $COMPACT_COUNT, max context items: $ITEM_COUNT"
[ "$ERROR_COUNT" -gt 20 ] && log_fail "High error count: $ERROR_COUNT"
[ "$TIMEOUT_COUNT" -gt 5 ] && log_fail "Timeout count: $TIMEOUT_COUNT"
[ "$ITEM_COUNT" -gt 100 ] && log_fail "Context items grew to: $ITEM_COUNT (compaction may be failing)"
[ "$ITEM_COUNT" -le 100 ] && [ "$ITEM_COUNT" -gt 0 ] && log_pass "Context items stayed under 100 (max: $ITEM_COUNT)"
# Check for repeated identical tool calls (loop detection)
DUPE_CALLS=$(grep "exec_command" /tmp/antigravity-test-proxy.log | sed 's/.*args=//' | sort | uniq -c | sort -rn | head -1 | awk '{print $1}' || echo 0)
if [ "$DUPE_CALLS" -gt 10 ]; then
log_fail "Loop detected: same tool call repeated $DUPE_CALLS times"
else
log_pass "No tool call loops (max repeat: $DUPE_CALLS)"
fi
fi
# Check if the file was actually created
echo ""; echo "─── Test 4d: Task Output Quality ───"
if [ -f "/tmp/e2e-test-output.txt" ]; then
CONTENT=$(cat /tmp/e2e-test-output.txt 2>/dev/null)
if echo "$CONTENT" | grep -q "Hello from Codex CLI E2E test"; then
log_pass "Task output file created with correct content"
else
log_fail "Task output file exists but content is wrong: $CONTENT"
fi
else
log_fail "Task output file /tmp/e2e-test-output.txt was NOT created"
fi
# Check proxy log for tool-strip events (budget cap defense)
echo ""; echo "─── Test 4e: Anti-Loop Defense Verification ───"
if [ -f "/tmp/antigravity-test-proxy.log" ]; then
NULL_TOOL_LOOPS=$(grep -c "NULL-TOOL LOOP" /tmp/antigravity-test-proxy.log || echo 0)
TOOL_STRIPPED=$(grep -c "TOOLS STRIPPED" /tmp/antigravity-test-proxy.log || echo 0)
BUDGET_HIT=$(grep -c "HARD CAP" /tmp/antigravity-test-proxy.log || echo 0)
READ_LOOP=$(grep -c "FILE READ LOOP" /tmp/antigravity-test-proxy.log || echo 0)
FORCE_FINALIZE=$(grep -c "force_finalize" /tmp/antigravity-test-proxy.log || echo 0)
log_info "Anti-loop events: null-tool=$NULL_TOOL_LOOPS stripped=$TOOL_STRIPPED budget=$BUDGET_HIT read-loop=$READ_LOOP finalize=$FORCE_FINALIZE"
# For a simple task, none of these should fire
if [ "$BUDGET_HIT" -gt 0 ]; then
log_fail "Budget cap hit on simple task — model looping"
else
log_pass "No budget cap triggered (task completed cleanly)"
fi
if [ "$TOOL_STRIPPED" -gt 0 ]; then
log_fail "Tools were stripped — model hit hard limit"
else
log_pass "No tool stripping needed (model behaved)"
fi
fi
# Restore original config
[ -f "$CONFIG_BACKUP" ] && mv "$CONFIG_BACKUP" "$CONFIG_FILE"
rm -f "$PROFILE_FILE"
log_info "Config restored"
fi
fi
# ── Summary ───────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo " Results: $PASS passed, $FAIL failed, $SKIP skipped"
echo "═══════════════════════════════════════════════════════════════"
[ -n "$BEST_EP" ] && echo -e " ${GREEN}Best direct:${NC} $BEST_MODEL @ $BEST_EP"
if [ "$FAIL" -gt 0 ]; then
echo -e "\n${RED}FAILED — Do NOT push until all tests pass${NC}"
for r in "${RESULTS[@]}"; do echo "$r" | grep -q "^FAIL" && echo " $r"; done
exit 1
else
echo -e "\n${GREEN}ALL TESTS PASSED — Safe to push${NC}"
exit 0
fi

View File

@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""
Unit tests for the Antigravity gRPC fallback module.
Tests cover:
1. Module import and availability detection
2. Protobuf conversion helpers (JSON <-> protobuf)
3. Request building from wrapped REST dict
4. Reverse alias map correctness
5. GrpcFallbackResult type
6. Integration: _try_grpc_fallback triggers correctly on REST 404
"""
import json
import os
import sys
import unittest
from unittest.mock import patch, MagicMock
# Add src to path so we can import the antigravity_grpc package
_src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")
if _src_dir not in sys.path:
sys.path.insert(0, _src_dir)
class TestGrpcModuleAvailability(unittest.TestCase):
"""Tests for is_grpc_available() and module loading."""
def test_is_grpc_available_returns_bool(self):
"""is_grpc_available should return a boolean."""
from antigravity_grpc import is_grpc_available
result = is_grpc_available()
self.assertIsInstance(result, bool)
def test_is_grpc_available_true_when_installed(self):
"""If grpcio is installed and stubs are loadable, should return True."""
from antigravity_grpc import is_grpc_available
# grpcio was installed at test time, so this should be True
self.assertTrue(is_grpc_available())
def test_client_instantiation(self):
"""AntigravityGrpcClient should be instantiatable."""
from antigravity_grpc import AntigravityGrpcClient
client = AntigravityGrpcClient()
self.assertIsNotNone(client)
def test_get_client_singleton(self):
"""get_client should return the same singleton."""
from antigravity_grpc import get_client
c1 = get_client()
c2 = get_client()
self.assertIs(c1, c2)
class TestGrpcFallbackResult(unittest.TestCase):
"""Tests for GrpcFallbackResult type."""
def test_default_values(self):
from antigravity_grpc import GrpcFallbackResult
r = GrpcFallbackResult()
self.assertFalse(r.ok)
self.assertIsNone(r.response_data)
self.assertIsNone(r.stream_chunks)
self.assertEqual(r.error_message, "")
self.assertEqual(r.endpoint_used, "")
self.assertEqual(r.model_used, "")
self.assertEqual(r.elapsed_s, 0.0)
def test_success_result(self):
from antigravity_grpc import GrpcFallbackResult
r = GrpcFallbackResult(ok=True, response_data={"response": {"candidates": []}},
endpoint_used="daily-cloudcode-pa.googleapis.com:443",
model_used="Gemini 3.5 Flash (High)",
elapsed_s=2.5)
self.assertTrue(r.ok)
self.assertIsNotNone(r.response_data)
self.assertEqual(r.elapsed_s, 2.5)
def test_failure_result(self):
from antigravity_grpc import GrpcFallbackResult
r = GrpcFallbackResult(ok=False, error_message="All gRPC endpoints failed")
self.assertFalse(r.ok)
self.assertIn("failed", r.error_message)
def test_repr(self):
from antigravity_grpc import GrpcFallbackResult
r_ok = GrpcFallbackResult(ok=True, response_data={"response": {"candidates": []}})
self.assertIn("OK", repr(r_ok))
r_fail = GrpcFallbackResult(ok=False, error_message="timeout")
self.assertIn("FAIL", repr(r_fail))
class TestReverseAliasMap(unittest.TestCase):
"""Tests for the _GRPC_REVERSE_ALIAS map in translate-proxy.py."""
def test_import_reverse_alias(self):
"""The reverse alias map should be importable from the proxy module."""
import importlib
_spec = importlib.util.spec_from_file_location(
"translate_proxy",
os.path.join(_src_dir, "translate-proxy.py"),
)
tp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tp)
self.assertIsInstance(tp._GRPC_REVERSE_ALIAS, dict)
def test_key_models_have_reverse_aliases(self):
"""All key REST model slugs should have gRPC display name mappings."""
import importlib
_spec = importlib.util.spec_from_file_location(
"translate_proxy",
os.path.join(_src_dir, "translate-proxy.py"),
)
tp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tp)
required_slugs = [
"gemini-3-flash",
"gemini-3.5-flash-low",
"gemini-3.1-pro-low",
"claude-sonnet-4-6",
"claude-opus-4-6-thinking",
"gemini-2.5-flash",
]
for slug in required_slugs:
self.assertIn(slug, tp._GRPC_REVERSE_ALIAS,
f"Missing reverse alias for REST slug '{slug}'")
def test_reverse_alias_values_are_display_names(self):
"""gRPC display names should contain spaces and parentheses, not hyphens."""
import importlib
_spec = importlib.util.spec_from_file_location(
"translate_proxy",
os.path.join(_src_dir, "translate-proxy.py"),
)
tp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tp)
for slug, display_name in tp._GRPC_REVERSE_ALIAS.items():
# Display names typically have spaces (e.g. "Gemini 3.5 Flash (High)")
# while slugs use hyphens (e.g. "gemini-3-flash")
self.assertNotEqual(slug, display_name,
f"Reverse alias for '{slug}' should differ from slug (gRPC uses display names)")
class TestProtobufConversion(unittest.TestCase):
"""Tests for JSON -> protobuf conversion helpers."""
def test_struct_to_protobuf(self):
"""_struct_to_protobuf should convert a simple dict to Struct."""
from antigravity_grpc.client import _struct_to_protobuf
result = _struct_to_protobuf({"key": "value", "num": 42})
self.assertIsNotNone(result)
# Verify round-trip
from antigravity_grpc.client import _protobuf_struct_to_dict
d = _protobuf_struct_to_dict(result)
self.assertEqual(d["key"], "value")
self.assertEqual(d["num"], 42.0)
def test_struct_round_trip_nested(self):
"""Nested dicts should survive a round-trip through protobuf."""
from antigravity_grpc.client import _struct_to_protobuf, _protobuf_struct_to_dict
original = {"outer": {"inner": "hello"}, "list_val": [1, 2, 3]}
proto = _struct_to_protobuf(original)
result = _protobuf_struct_to_dict(proto)
self.assertEqual(result["outer"]["inner"], "hello")
self.assertEqual(result["list_val"], [1.0, 2.0, 3.0])
def test_json_parts_to_proto_text(self):
"""Text parts should convert to protobuf Part with text field."""
from antigravity_grpc.client import _json_parts_to_proto
parts = _json_parts_to_proto([{"text": "Hello world"}])
self.assertEqual(len(parts), 1)
self.assertEqual(parts[0].text, "Hello world")
def test_json_parts_to_proto_function_call(self):
"""FunctionCall parts should convert correctly."""
from antigravity_grpc.client import _json_parts_to_proto
parts = _json_parts_to_proto([{
"functionCall": {
"name": "exec_command",
"args": {"cmd": "ls -la"},
"id": "call_123"
}
}])
self.assertEqual(len(parts), 1)
self.assertTrue(parts[0].HasField("function_call"))
self.assertEqual(parts[0].function_call.name, "exec_command")
self.assertEqual(parts[0].function_call.id, "call_123")
def test_json_parts_to_proto_function_response(self):
"""FunctionResponse parts should convert correctly."""
from antigravity_grpc.client import _json_parts_to_proto
parts = _json_parts_to_proto([{
"functionResponse": {
"name": "exec_command",
"response": {"result": "file1.txt"},
"id": "call_123"
}
}])
self.assertEqual(len(parts), 1)
self.assertTrue(parts[0].HasField("function_response"))
self.assertEqual(parts[0].function_response.name, "exec_command")
def test_json_contents_to_proto(self):
"""Content objects should convert correctly."""
from antigravity_grpc.client import _json_contents_to_proto
contents = _json_contents_to_proto([
{"role": "user", "parts": [{"text": "Hello"}]},
{"role": "model", "parts": [{"text": "Hi there"}]},
])
self.assertEqual(len(contents), 2)
self.assertEqual(contents[0].role, "user")
self.assertEqual(contents[1].role, "model")
def test_proto_candidate_to_json(self):
"""Protobuf candidates should convert back to JSON-compatible dicts."""
from antigravity_grpc.client import _json_contents_to_proto, _proto_candidate_to_json
from antigravity_grpc import cloudcode_pb2 as pb2
# Build a candidate manually
candidate = pb2.Candidate()
candidate.content.role = "model"
candidate.content.parts.add().text = "Hello from gRPC"
candidate.finish_reason = "STOP"
candidate.index = 0
result = _proto_candidate_to_json(candidate)
self.assertEqual(result["finishReason"], "STOP")
self.assertEqual(result["content"]["role"], "model")
self.assertEqual(result["content"]["parts"][0]["text"], "Hello from gRPC")
class TestGrpcRequestBuilding(unittest.TestCase):
"""Tests for _build_request (wrapped REST dict → protobuf)."""
def _get_client(self):
from antigravity_grpc import AntigravityGrpcClient
return AntigravityGrpcClient()
def test_build_request_basic(self):
"""Basic request fields should be populated correctly."""
client = self._get_client()
wrapped = {
"project": "test-project-123",
"model": "Gemini 3.5 Flash (High)",
"requestType": "agent",
"userAgent": "antigravity/2.0.6",
"requestId": "agent-test123",
"request": {
"contents": [
{"role": "user", "parts": [{"text": "Say hello"}]}
],
"safetySettings": [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
],
}
}
req = client._build_request(wrapped)
self.assertEqual(req.project, "test-project-123")
self.assertEqual(req.model, "Gemini 3.5 Flash (High)")
self.assertEqual(req.request_type, "agent")
self.assertEqual(len(req.request.contents), 1)
self.assertEqual(req.request.contents[0].role, "user")
def test_build_request_with_tools(self):
"""Tools should be converted to function declarations."""
client = self._get_client()
wrapped = {
"project": "test-project",
"model": "gemini-3-flash",
"request": {
"contents": [],
"tools": [{
"functionDeclarations": [{
"name": "exec_command",
"description": "Run a shell command",
"parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}
}]
}],
}
}
req = client._build_request(wrapped)
self.assertEqual(len(req.request.tools), 1)
self.assertEqual(req.request.tools[0].function_declarations[0].name, "exec_command")
def test_build_request_with_generation_config(self):
"""Generation config should be populated correctly."""
client = self._get_client()
wrapped = {
"project": "test-project",
"model": "gemini-3-flash",
"request": {
"contents": [],
"generationConfig": {
"maxOutputTokens": 64000,
"temperature": 0.7,
"stopSequences": ["\n\nHuman:"],
"thinkingConfig": {
"includeThoughts": True,
"thinkingBudget": 8192,
}
}
}
}
req = client._build_request(wrapped)
self.assertEqual(req.request.generation_config.max_output_tokens, 64000)
self.assertAlmostEqual(req.request.generation_config.temperature, 0.7, places=2)
self.assertTrue(req.request.generation_config.thinking_config.include_thoughts)
self.assertEqual(req.request.generation_config.thinking_config.thinking_budget, 8192)
def test_build_request_with_function_call_history(self):
"""Function call/response pairs in contents should be preserved."""
client = self._get_client()
wrapped = {
"project": "test-project",
"model": "gemini-3-flash",
"request": {
"contents": [
{"role": "user", "parts": [{"text": "List files"}]},
{"role": "model", "parts": [{
"functionCall": {"name": "exec_command", "args": {"cmd": "ls"}, "id": "call_1"}
}]},
{"role": "user", "parts": [{
"functionResponse": {"name": "exec_command", "response": {"result": "file.txt"}, "id": "call_1"}
}]},
]
}
}
req = client._build_request(wrapped)
self.assertEqual(len(req.request.contents), 3)
# Verify function call preserved
self.assertTrue(req.request.contents[1].parts[0].HasField("function_call"))
self.assertEqual(req.request.contents[1].parts[0].function_call.name, "exec_command")
# Verify function response preserved
self.assertTrue(req.request.contents[2].parts[0].HasField("function_response"))
self.assertEqual(req.request.contents[2].parts[0].function_response.name, "exec_command")
class TestGrpcEndpointsConfig(unittest.TestCase):
"""Tests for gRPC endpoint configuration."""
def test_default_endpoints(self):
"""Default endpoints should include production and daily."""
from antigravity_grpc.client import _GRPC_ENDPOINTS
self.assertGreaterEqual(len(_GRPC_ENDPOINTS), 2)
hostnames = [ep.split(":")[0] for ep in _GRPC_ENDPOINTS]
self.assertIn("daily-cloudcode-pa.googleapis.com", hostnames)
self.assertIn("cloudcode-pa.googleapis.com", hostnames)
def test_staging_env_var(self):
"""Staging endpoints should be controlled by env var."""
from antigravity_grpc.client import _ALLOW_STAGING_ENV
self.assertEqual(_ALLOW_STAGING_ENV, "ALLOW_ANTIGRAVITY_STAGING")
class TestProxyIntegration(unittest.TestCase):
"""Tests for the proxy's gRPC fallback integration."""
def _load_proxy_module(self):
import importlib
_spec = importlib.util.spec_from_file_location(
"translate_proxy",
os.path.join(_src_dir, "translate-proxy.py"),
)
tp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tp)
return tp
def test_get_grpc_client_function_exists(self):
"""_get_grpc_client should exist as a module-level function."""
tp = self._load_proxy_module()
self.assertTrue(callable(tp._get_grpc_client))
def test_grpc_fallback_errors_set(self):
"""_GRPC_FALLBACK_REST_ERRORS should include 404."""
tp = self._load_proxy_module()
self.assertIn(404, tp._GRPC_FALLBACK_REST_ERRORS)
def test_versions_bug_fixed(self):
"""The _versions[0] NameError should be fixed (should be _fetched_ver)."""
# Read the source file and verify _versions is not used incorrectly
with open(os.path.join(_src_dir, "translate-proxy.py")) as f:
source = f.read()
# The bug was: ver={_versions[0]} -- should be ver={_fetched_ver}
self.assertNotIn("_versions[0]", source,
"Bug: _versions[0] should have been replaced with _fetched_ver")
if __name__ == "__main__":
print("=" * 70)
print("Antigravity gRPC Fallback - Unit Tests")
print("=" * 70)
print()
unittest.main(verbosity=2)

9323
translate-proxy.py Executable file

File diff suppressed because it is too large Load Diff