Compare commits
159 Commits
850c7d1e82
...
main
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,3 +11,7 @@ config.toml
|
||||
.DS_Store
|
||||
DEBIAN/
|
||||
usr/
|
||||
oauth-secrets.json
|
||||
secrets/
|
||||
*.secret
|
||||
.env
|
||||
|
||||
83
AGENTS.md
Normal file
83
AGENTS.md
Normal 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`
|
||||
508
CHANGELOG.md
508
CHANGELOG.md
@@ -1,5 +1,513 @@
|
||||
# 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)
|
||||
|
||||
**Vision Cache Persistence, PR #8 Merge**
|
||||
|
||||
### New Features
|
||||
|
||||
- **Vision description cache persisted across requests**: Image descriptions from the vision fallback API are now cached in a file (`~/.cache/codex-proxy/vision-cache.json`) so the same image URL is never described twice — saves API calls and latency
|
||||
- **Merge PR #8**: `fix: persist vision description cache across requests` (cobra91)
|
||||
|
||||
## v3.11.7 (2026-05-26)
|
||||
|
||||
**Vision Auto-Detect, Proactive Non-Vision Model Detection, Unit Tests, Bug Fixes**
|
||||
|
||||
### New Features
|
||||
|
||||
- **Vision auto-detect fallback**: When no explicit vision fallback is configured, automatically uses the current provider's own vision model (e.g., `0G-Qwen-VL` for OpenAdapter) as the image description API — no separate API key needed
|
||||
- **Proactive non-vision model detection**: Models matching name patterns (`glm`, `deepseek`, `llama`, `qwen` without `vl`, etc.) are detected as non-vision on first request without waiting for an error from the provider
|
||||
- **Vision preprocessing is now the primary image handling solution**: Replaces old `_strip_images_from_input()` (which just removed images with a placeholder). Images are now described via API and sent as rich text descriptions to text-only models
|
||||
- **Merge PR #6**: Vision/OCR preprocessing for text-only models (cobra91)
|
||||
- **Merge PR #7**: 177 unit tests for translate-proxy.py (cobra91)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **AttributeError fix**: `image_url` field can be a string (bare URL) not always a dict — fixed in both `_preprocess_vision_input()` and old strip function
|
||||
- **Auth os error 2 fix**: GUI shows "Config missing" message instead of raw OSError when `~/.codex/` directory doesn't exist
|
||||
- **Removed duplicate vision functions**: Cleaned up duplicate `_vision_describe_image()`, `_preprocess_vision()`, `_preprocess_vision_input()` from merge
|
||||
|
||||
## v3.11.6 (2026-05-26)
|
||||
|
||||
**Antigravity Loop Breakers, Vision/OCR Preprocessing, has_content Fix, Auth Error Fix**
|
||||
|
||||
### New Features (Antigravity-only, no other providers affected)
|
||||
|
||||
- **Per-session loop tracking**: `_ANTIGRAVITY_LOOP_TRACKER` global dict with `_antigravity_loop_key()` function tracks state per session: `latest_user_hash`, `nudge_injected`, `latest_user_appended`, `tool_calls_for_request`, `repeated_tool`, `force_finalize`, `last_tool`, `last_tool_count`
|
||||
- **Edit-intent nudge injection**: Injected only on the first turn per request, preventing duplicate nudges across retries
|
||||
- **Latest user instruction append**: Appended exactly once per request to prevent redundant instruction stacking
|
||||
- **Loop breaker**: If the same tool + arguments is repeated ≥ 5 times in a session, `force_finalize` is triggered to break the infinite loop
|
||||
- **Detailed `[antigravity-loop]` logging**: All tracking fields logged on every Antigravity request for debugging
|
||||
|
||||
### New Features (All OpenAI-compatible providers)
|
||||
|
||||
- **Vision/OCR preprocessing**: When a provider doesn't support images (detected via error messages like "unknown variant image_url", "does not support image"), the proxy automatically calls a configurable vision fallback API (default: Kilo.ai) to describe images as text, then replaces image blocks with text descriptions before sending to text-only models
|
||||
- **`_vision_describe_image()`**: Calls vision fallback model to describe a single image, with MD5-based caching to avoid re-describing same URL
|
||||
- **`_preprocess_vision()`**: Replaces `image_url`/`input_image` blocks in Chat Completions message format with text descriptions when provider lacks vision support
|
||||
- **`_preprocess_vision_input()`**: Same for Responses API input format — runs BEFORE adapter conversion so images are replaced early
|
||||
- **Vision error retry**: On HTTP 4xx errors containing image-related keywords, automatically retries with images preprocessed instead of failing
|
||||
- **Configurable via env vars**: `VISION_FALLBACK_URL`, `VISION_FALLBACK_MODEL`, `VISION_FALLBACK_KEY`
|
||||
- **ProviderSchema `supports_vision` field**: Auto-detected from error responses and persisted in provider-caps.json
|
||||
|
||||
### Critical Fixes
|
||||
|
||||
- **`has_content` now includes `function_call`** (v3.11.5 fix): `_observe_event` only checked for `"type": "message"` — when models return only tool calls (no text), `has_content` was `False`, causing Codex to loop infinitely and build context until `context_length_exceeded`. Now checks both `"message"` and `"function_call"`.
|
||||
- **`has_message`/`has_tool_call` initialized in all 5 locations**: Previous fix added variables inside `_observe_event` closure but missed 4 other `has_content = False` locations, causing `NameError: name 'has_message' is not defined` crashes.
|
||||
- **Auth config-not-found error handling**: When Codex's `config.toml` is missing or deleted, `codex login status` returns "Error loading configuration: No such file or directory (os error 2)". Now caught specifically (`OSError errno==2`) and returns ("not_configured", "Config missing — launch once to create") with clear GUI guidance.
|
||||
|
||||
### Bug Fixes (GUI)
|
||||
|
||||
- **Active endpoint sync**: GUI auto-removes stale endpoint references on startup
|
||||
|
||||
## v3.11.5 (2026-05-26)
|
||||
|
||||
**Vision Filter, Token-Aware Compaction, Universal Adaptive Compaction, Smart-Continue Text Detection**
|
||||
|
||||
### Critical Fixes
|
||||
|
||||
- **Token-aware compaction for small-context models (FIX)**: `_crof_compact_for_retry()` had an early return at `len(input_data) <= limit` (item count) — if you had 25 items × 1600 tokens = 40K tokens, it skipped compaction entirely because 25 < 30 (the default item limit). Now also checks estimated token count vs learned model max, and compacts when either item count OR token count exceeds limits. Fixes repeated `context_length_exceeded` errors on models like 0G-GLM-5.1 (~35K token context).
|
||||
- **Proactive compaction now token-aware**: Previously only triggered when item count > 30. Now also triggers when estimated tokens exceed 80% of the model's learned token limit, even if item count is below the threshold. Prevents the first-request failure pattern on small-context models.
|
||||
- **Compaction aggression threshold**: Changed `est > max_tok` to `est >= max_tok * 0.9` to avoid edge case where estimated tokens exactly equal the limit and compaction is skipped.
|
||||
- **Removed all `crof.ai` gates from adaptive compaction**: Proactive compaction, `finish_reason=length` retry, `_crof_record`, and compaction logging were gated behind `"crof.ai" in TARGET_URL`. These gates prevented OpenAdapter and other providers from getting proactive/retry compaction, causing repeated `context_length_exceeded` failures. Now applies universally to ALL providers.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Vision model detection + image stripping**: `_strip_images_from_input()` and `_model_supports_vision()` detect vision capability by model name pattern. Non-vision models (deepseek, glm, mixtral, llama, command, dbrx, qwen, phi-3) have `input_image`/`image_url` parts stripped and replaced with `[User attached image: filename — this model does not support vision]` text notice. Vision models (gpt-4o, gemini, claude, qwen-vl, glm-5v) keep images intact. Applied in 3 paths: main request, context_length_exceeded retry, smart-continue nudge.
|
||||
- **Token estimation and per-model limit learning**: `_estimate_tokens()`, `_estimate_input_tokens()`, `_get_model_max_tokens()`, `_set_model_max_tokens()`. Extracts `~N tokens` from `context_length_exceeded` error messages and stores per-model token limits. Used by proactive compaction and retry compaction to adjust `keep` count dynamically.
|
||||
- **Compaction aggression levels**: `_crof_compact_for_retry()` accepts `aggression` parameter (0=normal, 1=extreme). Extreme mode kicks in when estimated tokens > 1.5× the learned limit or on 2nd+ retry attempt. Reduces `keep` count to minimum, ensuring the compacted request fits within model limits.
|
||||
- **Smart-continue text-tool detection**: Removed hard requirement for `has_function_call_output(input_data)`. Added `_TOOL_CALL_TEXT_PATTERNS` and `_text_looks_like_tool_calls()` to trigger nudging when model outputs text matching tool-call patterns (e.g., `• (exec_command cmd ...)`, `write_to_file`, `exec_command`) even without prior `function_call_output` in context. Essential for models like 0G-GLM-5.1 that never emit real `function_call_output` items.
|
||||
- **Parenthesized tool call regex**: `_PAREN_TC_RE` pattern to match `• (name args...)` format from non-vision models that output tool calls as parenthesized text.
|
||||
|
||||
### GUI Fixes
|
||||
|
||||
- **Active endpoint sync**: Added `set_active_endpoint()` and `validate_active_endpoint()` to Linux GTK GUI. Syncs `.active-endpoint.json` with `config.toml` on every launch; auto-removes stale references to deleted providers. Fixed `"Error loading configuration: No such file or directory (os error 2)"` crash when active endpoint referenced a deleted provider.
|
||||
- **Config state**: `~/.codex/.active-endpoint.json` and `config.toml` model catalog path validated and auto-corrected on GUI startup.
|
||||
|
||||
## v3.11.0 (2026-05-26)
|
||||
|
||||
**Cobra PR Merge + Smart Continuation + API Key Hot-Reload**
|
||||
|
||||
### New Features
|
||||
- **Concurrency semaphore (max 3)**: limits parallel upstream requests to prevent rate-limiting
|
||||
- **Auto-continue for truncated text**: detects text ending in `:`, `(`, `;`, `…` or `finish_reason=length`, continues seamlessly
|
||||
- **SO_REUSEADDR on sticky port**: prevents `TIME_WAIT` from changing port on restart
|
||||
- **proxy-stderr.log**: persistent log file for proxy errors
|
||||
- **Stream diagnostics**: logs event count, finish reason, content flag, elapsed time after each stream
|
||||
- **Timeout/OSError handler**: sends proper `response.failed` SSE event instead of silently dropping connection
|
||||
- **Restart Proxy button**: now only restarts proxy without killing Codex Desktop
|
||||
- **Tool call argument normalizer**: fixes capital-A `Arguments` key, strips markdown/JSON code block wrapping from tool call arguments
|
||||
- **Smart-continue loop (2× retries)**: escalating nudge messages when model returns text-only stop mid-task
|
||||
- **XML tool call extraction**: parses `<tool_call>name{args}</tool_call>` from model text output, injects as real `function_call` items
|
||||
- **Auto-continue + smart-continue ordered with skip guard**: prevents both from double-firing on the same response
|
||||
- **API key hot-reload**: mtime tracking detects config changes, `/admin/reload` endpoint triggers hot-reload, `/admin/verify-key` tests key against upstream
|
||||
- **GUI hot-reload**: auto-refreshes proxy key on endpoint edit, verifies with upstream — no proxy restart needed
|
||||
- **Synthetic tool-results disabled**: was causing deepseek-v4-pro truncation on opencode.ai
|
||||
|
||||
## v3.10.12 (2026-05-26)
|
||||
|
||||
**Sticky Endpoint, Claude Fixes, Guardrail Skip, Anti-Stall**
|
||||
|
||||
### New Features
|
||||
- **Sticky endpoint caching**: remembers which endpoint last succeeded, reuses it on every subsequent request (zero overhead)
|
||||
- **Sequential fallback**: if sticky endpoint fails (429/502/503), tries next endpoint in order — no parallel probing, no wasted requests
|
||||
- **Endpoint order**: `cloudcode-pa.googleapis.com` first (matches agy CLI), `daily-cloudcode-pa.googleapis.com` as fallback
|
||||
- **Anti-stall engine**: kills stale proxy processes and clears `__pycache__` on every new session start
|
||||
- **Smart error classification**: distinguishes `quota_exhausted` vs `capacity_exhausted` vs `account_banned` vs `validation_required` vs `service_disabled` vs `auth_permanent`
|
||||
- **Rate limit reset time parsing**: extracts cooldown from error body (`quotaResetDelay`, `Resets in ~1h27m`, etc.) for accurate cooldown
|
||||
- **Missing Antigravity headers**: `X-Client-Name`, `X-Client-Version`, `x-goog-api-client`, platform-aware `User-Agent`
|
||||
- **Session ID**: added `sessionId` to request wrapper for proper session tracking
|
||||
|
||||
### Bug Fixes (TRAE Agent)
|
||||
- **Guardrail skip for simple messages**: when user sends simple messages (e.g. "hi"), skip injecting `_GEMINI_AGENT_GUARDRAIL` — prevents model from aggressively calling tools and looping `ls -la` 50+ times
|
||||
- **Claude tool preservation**: Claude models through Antigravity now keep ALL tool outputs in normalizer (no summarization/truncation) — prevents context loss that broke Claude sessions
|
||||
- **Claude compaction guard**: `_adaptive_compact` skipped for Claude models — Claude handles its own context, no forced compaction
|
||||
- **Claude normalizer guard**: `_antigravity_normalize_context` skipped for Claude models — avoids stripping Claude-specific message structure
|
||||
- **Claude sanitization guard**: Google content sanitization loop skipped for Claude models — prevents mangling Claude's response format
|
||||
- **Normalizer model parameter**: `_antigravity_normalize_context` now receives `model` param to distinguish Claude vs Gemini behavior
|
||||
|
||||
## v3.10.11 (2026-05-26)
|
||||
|
||||
**Hybrid Endpoint Fallback — Redundant Antigravity Endpoints**
|
||||
|
||||
### New Features
|
||||
- Hybrid endpoint fallback: tries `cloudcode-pa.googleapis.com` then `daily-cloudcode-pa.googleapis.com` on 429
|
||||
- `daily-cloudcode-pa.googleapis.com` is the same production endpoint agy-core uses (separate rate limit bucket)
|
||||
- 429 errors now log full response body for debugging
|
||||
- SERVICE_DISABLED (403) still falls through to next endpoint
|
||||
- Rate-limit marking only happens after ALL endpoints fail
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed 429 on one endpoint immediately failing — now tries fallback before giving up
|
||||
- Restored SERVICE_DISABLED fallthrough (was accidentally removed)
|
||||
|
||||
## v3.10.10 (2026-05-25)
|
||||
|
||||
**Context Normalizer Fix — Compaction Summary Preservation**
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed normalizer stripping ALL context on resumed sessions after compaction
|
||||
- Normalizer no longer auto-resets when compaction summary is present
|
||||
- Compaction summaries ("Auto-compacted: N earlier turns") are always preserved
|
||||
- Deduplicates consecutive identical `<goal_context>` messages (10→1)
|
||||
- Emergency reset now preserves compaction summaries
|
||||
- Previous behavior: after compaction reduced 1925→185 items, normalizer saw `n_tool_outputs == 0` and stripped to just `system + latest_user`, losing all context — model responded with "I don't have context"
|
||||
|
||||
### hashlib Fix (v3.10.9 hotfix)
|
||||
- `_antigravity_normalize_context` crashed with `NameError: hashlib` on resumed sessions
|
||||
- Replaced SHA256 duplicate detection with string comparison
|
||||
|
||||
## v3.10.9 (2026-05-25)
|
||||
|
||||
**Antigravity Overhaul — Context Normalizer, Claude Thinking Fix, Endpoint Lockdown**
|
||||
|
||||
### Antigravity Endpoint Lockdown
|
||||
- Production-only: `cloudcode-pa.googleapis.com` by default
|
||||
- Sandbox/staging blocked unless `ALLOW_ANTIGRAVITY_STAGING=1`
|
||||
- 403 SERVICE_DISABLED falls through, 429 returns to client
|
||||
|
||||
### AntigravityContextNormalizer
|
||||
- Bounded context — no more 136-item polluted requests for "hi"
|
||||
- Simple message detector, auto-reset polluted context
|
||||
- Duplicate removal, tool output budget, hard char limits
|
||||
|
||||
### Claude Thinking Fix (Antigravity-only)
|
||||
- Fixed 400 error: `maxOutputTokens=64000` when thinking enabled
|
||||
- Snake_case config, VALIDATED toolConfig, proper budgets
|
||||
|
||||
### z.ai / OpenRouter (cobra91 PR #4)
|
||||
- Full OpenClaw attribution headers, OpenRouter caching
|
||||
|
||||
## v3.10.8 (2026-05-25)
|
||||
|
||||
**OAuth & Antigravity Endpoint Fixes**
|
||||
|
||||
### Re-OAuth Buttons Fixed
|
||||
- Linux GUI: `load_oauth_secrets()` was undefined — buttons crashed silently on click
|
||||
- Now loads OAuth secrets inline from `~/.config/codex-launcher/oauth-secrets.json`
|
||||
- Both Linux and Windows Re-OAuth use PKCE + localhost callback (was deprecated OOB paste)
|
||||
|
||||
### Antigravity Staging/Sandbox Blocked by Default
|
||||
- Proxy: production `cloudcode-pa.googleapis.com` tried FIRST, sandbox/daily/autopush as fallback only
|
||||
- Proxy: 403 SERVICE_DISABLED now falls through to next endpoint instead of returning error immediately
|
||||
- Project discovery: validates against production endpoint, not staging-cloudaicompanion.sandbox
|
||||
- Antigravity preset `base_url` changed to production (was `daily-cloudcode-pa.sandbox.googleapis.com`)
|
||||
- `[antigravity-endpoint]` log line shows which endpoints are being tried
|
||||
|
||||
### Other Fixes
|
||||
- GLib.idle_add lambda returning truthy tuple fixed (caused repeated callbacks)
|
||||
- Windows GUI project discovery also uses production endpoint
|
||||
|
||||
## v3.10.7 (2026-05-25)
|
||||
|
||||
**Prompt Enhancer — Fix Lost Context After Compaction**
|
||||
|
||||
### Prompt Enhancer (Per-Provider Toggle)
|
||||
- **Offline mode**: Injects structured XML instructions before every user prompt to keep the model focused, decisive, and context-aware after compaction strips conversation history
|
||||
- **AI-powered mode**: Optionally calls an external LLM (configurable model/URL/key) to rewrite vague prompts into clear, actionable instructions
|
||||
- Prevents the "had to resend and reword" problem in long sessions where compaction summarizes hundreds of turns
|
||||
- **Per-endpoint setting** — enable/disable for each provider independently
|
||||
- Configurable in both Linux and Windows GUI: toggle switch, mode selector, enhancer model, URL, API key fields
|
||||
|
||||
### How It Works
|
||||
- **Offline**: Prepends a `<prompt-enhancer>` block with rules like "never ask for clarification, infer from compacted context, execute decisively"
|
||||
- **AI-powered**: Sends the user's prompt + compaction summary to a separate model (e.g. DeepSeek V4 Flash via Freebuff) which rewrites it for clarity, then prepends the offline instructions too
|
||||
- Both modes run after compaction but before the request is sent upstream
|
||||
|
||||
## v3.10.6 (2026-05-25)
|
||||
|
||||
**Freebuff Integration + Codebuff OAuth Fix + Windows Consolidation**
|
||||
|
||||
### Freebuff (Free DeepSeek/Kimi)
|
||||
- **Freebuff integration**: Free DeepSeek/Kimi models via codebuff.com API
|
||||
- Fixed User-Agent to match official SDK: `ai-sdk/openai-compatible/1.0.25/codebuff`
|
||||
- Fixed metadata fields: `freebuff_instance_id` + `client_id` (base36 random) + `cost_mode: "free"`
|
||||
- Fixed session endpoint: POST empty `{}` body (not `{"model": model}`)
|
||||
- GUI preset aliases: "Freebuff (Free DeepSeek/Kimi)", "FreeBuff", "Codebuff (Free DeepSeek/Kimi)" all map to same backend
|
||||
|
||||
### Codebuff Fix
|
||||
- Fixed Codebuff OAuth: use `www.codebuff.com` (bare `codebuff.com` returns 307 redirect)
|
||||
|
||||
### OAuth Secrets & Credentials (All Providers)
|
||||
- **OAuth Secrets dialog now shows ALL providers**: Google (Antigravity + Gemini CLI) AND Freebuff/Codebuff
|
||||
- **Re-OAuth buttons** for each provider: instantly re-authenticate Google or GitHub/Codebuff
|
||||
- Token status indicators (valid/missing) for each Google provider
|
||||
- Shows logged-in email and auth status for Freebuff/Codebuff
|
||||
- Editable auth token and fingerprint fields for Freebuff/Codebuff
|
||||
|
||||
### Windows
|
||||
- Windows GUI files consolidated into `src/` (merged by cobra91 via PR #1 and PR #2)
|
||||
|
||||
### Proxy & GUI Improvements (cobra91 PR #3)
|
||||
- CROF adaptive logic gated to `crof.ai` only — no more log pollution for other providers
|
||||
- Data directory consolidation: all data now in `codex-proxy/` (was split across `codex-desktop/`, `codex-launcher/`, `codex-proxy/`)
|
||||
- Sticky proxy port: persists in `.last-proxy-port`, reused on restart so Codex Desktop keeps connection
|
||||
- Adaptive compact budget raised from 60% to 80% — avoids premature compaction on large-context models (DeepSeek v4 Pro 1M)
|
||||
- Config cleanup fix: stale `proxy-*.json` cleanup moved after `_init_runtime()` to avoid deleting active config
|
||||
- Windows GUI: added Clear Log, Restart Proxy, View Log buttons
|
||||
- **Linux/Windows feature parity**: both GUIs now have identical features
|
||||
- Windows GUI: ported OAuth Secrets all-providers dialog (Google + Freebuff/Codebuff with Re-OAuth buttons, token status)
|
||||
- Windows GUI: added Codebuff/Freebuff OAuth login flow (GitHub browser-based)
|
||||
- Windows GUI: added Sync from Preset button in endpoint editor
|
||||
- Linux GUI: added Clear Log + Restart Proxy buttons (matching Windows)
|
||||
|
||||
## v3.10.5 (2026-05-25)
|
||||
|
||||
**Windows GUI + Context Compaction for Antigravity/Gemini OAuth**
|
||||
|
||||
### Windows Native GUI (tkinter)
|
||||
- **Windows GUI** in `windows/` folder — full tkinter port by cobra91
|
||||
- OAuth Secrets editor, Import JSON, Antigravity model list
|
||||
- Shared backend with Linux (same translate-proxy.py)
|
||||
- See README for Windows installation and usage
|
||||
|
||||
**Context Compaction for Antigravity/Gemini OAuth**
|
||||
|
||||
### Fix
|
||||
- **Prevent `input token count exceeds maximum` errors** during long conversations
|
||||
- Added aggressive compaction policies for Antigravity (`cloudcode-pa`) and Gemini CLI (`googleapis`)
|
||||
- Auto-trims old turns when approaching 60% of model context limit (1M tokens for Gemini, 200K for Claude, 128K for GPT-OSS)
|
||||
- Added REST model IDs to context size map (`gemini-3-flash`, `gemini-3.1-pro-low`, `claude-sonnet-4-6`, etc.)
|
||||
|
||||
## v3.10.4 (2026-05-25)
|
||||
|
||||
**Security: OAuth Secrets Editor + Import JSON**
|
||||
|
||||
### Security
|
||||
- **All hardcoded OAuth secrets removed from source code and git history**
|
||||
- OAuth client IDs and secrets now stored locally in `~/.config/codex-launcher/oauth-secrets.json`
|
||||
- Git history rewritten to scrub all leaked credentials (0 matches verified)
|
||||
- Pre-push hook blocks any future commit containing secrets
|
||||
- All old Gitea releases deleted (contained leaked secrets in .deb files)
|
||||
|
||||
### New Features
|
||||
- **OAuth Secrets editor** in GUI — "OAuth Secrets" button in header bar
|
||||
- **Import JSON** button — import `client_secret_*.json` downloaded from Google Cloud Console
|
||||
- Supports both `"installed"` and `"web"` JSON formats from Google
|
||||
|
||||
### Antigravity Fix (from v3.10.3)
|
||||
- Antigravity REST API uses slug IDs, not display names
|
||||
- Verified all model IDs with live API testing
|
||||
|
||||
## v3.10.3 (2026-05-25)
|
||||
|
||||
**Fix Antigravity 404 Errors — Verified REST Model IDs**
|
||||
|
||||
### Critical Fix
|
||||
- Antigravity REST API (`v1internal:generateContent`) uses slug IDs, not display names
|
||||
- Verified all model IDs with live API testing against `daily-cloudcode-pa.sandbox.googleapis.com`
|
||||
- Display names map to closest working REST model (e.g. `Gemini 3.5 Flash (High)` → `gemini-3-flash`)
|
||||
- Model list now matches agy CLI: Gemini 3.5 Flash (H/M/L), Gemini 3.1 Pro (H/L), Claude Sonnet/Opus 4.6, GPT-OSS 120B
|
||||
|
||||
### Working REST Model IDs
|
||||
| Display Name | REST ID |
|
||||
|---|---|
|
||||
| Gemini 3.5 Flash (High) | gemini-3-flash |
|
||||
| Gemini 3.5 Flash (Medium) | gemini-3-flash |
|
||||
| Gemini 3.5 Flash (Low) | gemini-3.5-flash-low |
|
||||
| Gemini 3.1 Pro (High) | gemini-3.1-pro-low |
|
||||
| Gemini 3.1 Pro (Low) | gemini-3.1-pro-low |
|
||||
| Claude Sonnet 4.6 (Thinking) | claude-sonnet-4-6 |
|
||||
| Claude Opus 4.6 (Thinking) | claude-opus-4-6-thinking |
|
||||
| GPT-OSS 120B (Medium) | gpt-oss-120b-medium |
|
||||
|
||||
## v3.10.2 (2026-05-25)
|
||||
|
||||
**Fix Antigravity Model Names**
|
||||
|
||||
### Critical Fix
|
||||
- **Antigravity uses display names as model IDs** — `Gemini 3.5 Flash (High)` not `gemini-3.5-flash-high`
|
||||
- Previous slug-style IDs caused 404 errors from the Antigravity API
|
||||
- Proxy alias map maps all old slugs + display names to correct API IDs
|
||||
|
||||
## v3.10.0 (2026-05-25)
|
||||
|
||||
**Provider Model Editor + Antigravity Model Refresh**
|
||||
|
||||
### Provider Editor
|
||||
- **Remove Selected** button to remove highlighted model(s) from provider
|
||||
- **Clear All** button to empty model list
|
||||
- **Sync from Preset** button to refresh model list from current preset definition
|
||||
- Preset sync now replaces (not appends) models — fixes stale saved model lists
|
||||
|
||||
### Antigravity Models Updated
|
||||
- **Gemini 3.5 Flash** (High / Medium)
|
||||
- **Gemini 3.1 Pro** (High / Low)
|
||||
- **Claude Sonnet 4.6 Thinking**
|
||||
- **Claude Opus 4.6 Thinking**
|
||||
- **GPT-OSS 120B Medium**
|
||||
|
||||
## v3.9.9 (2026-05-25)
|
||||
|
||||
**Antigravity Model Refresh**
|
||||
|
||||
### Updated Models
|
||||
- **Gemini 3.5 Flash** (High / Medium) — new flagship flash model
|
||||
- **Gemini 3.1 Pro** (High / Low) — tiered reasoning control
|
||||
- **Claude Sonnet 4.6 Thinking** — Anthropic partner model via Antigravity
|
||||
- **Claude Opus 4.6 Thinking** — Anthropic partner model via Antigravity
|
||||
- **GPT-OSS 120B Medium** — open-weight GPT model via Antigravity
|
||||
- Removed stale `antigravity-*` prefixed IDs and old preview models
|
||||
|
||||
### Proxy Updates
|
||||
- Alias map updated for tiered model IDs (high/medium/low/thinking)
|
||||
- Context sizes added for all new Antigravity models
|
||||
|
||||
## v3.9.8 (2026-05-25)
|
||||
|
||||
**Codex Desktop Model Fix & Global BrokenPipeError Protection**
|
||||
|
||||
### Desktop Model Fix
|
||||
- **Codex Desktop sending wrong model** (gpt-5.4-mini) instead of user-selected model — now remapped via `CODEX_LAUNCHER_MODEL` env var
|
||||
- **Config.toml** now writes `review_model`, `wire_api`, `request_max_retries`, `stream_max_retries`, `stream_idle_timeout_ms` for Desktop compatibility
|
||||
- **Proxy model remap** intercepts Desktop forced models (`gpt-5.4-mini`, `gpt-5.5`, etc.) and routes to the user's selected model
|
||||
|
||||
### Global Crash Fix
|
||||
- **`send_json()` globally catches BrokenPipeError** — no more crashes on client disconnect across all backends
|
||||
|
||||
## v3.9.7 (2026-05-25)
|
||||
|
||||
**Codebuff Error Forwarding & Crash Fixes**
|
||||
|
||||
32
CLAUDE.md
Normal file
32
CLAUDE.md
Normal 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.
|
||||
111
README.md
111
README.md
@@ -9,13 +9,28 @@
|
||||
<a href="https://z.ai/subscribe?ic=ROK78RJKNW">z.ai/subscribe</a>
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
---
|
||||
If you want fork it, use the Github copy, here it is:
|
||||
<a href="https://github.com/roman-ryzenadvanced/Codex-Launcher-Any-AI-Provider">Codex-Any-AI-Provider on Github (Official)</a>
|
||||
---
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<h1 align="center">Codex Launcher — Any AI Provider</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Run OpenAI Codex CLI & Desktop with <em>any</em> AI provider.</strong><br/>
|
||||
Google Antigravity • Gemini CLI • OpenCode • Z.AI • Anthropic • Command Code • Codebuff • OpenRouter • Crof.ai • NVIDIA NIM • OpenAdapter • Kilo.ai • DeepSeek • and more
|
||||
Google Antigravity • Gemini CLI • OpenCode • Z.AI • Anthropic • Command Code • Freebuff • OpenRouter • Crof.ai • NVIDIA NIM • OpenAdapter • Kilo.ai • DeepSeek • and more
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>
|
||||
Windows version by <a href="https://github.com/cobra91">cobra91</a> •
|
||||
Original Linux development by <a href="https://github.com/roman-ryzenadvanced">roman-ryzenadvanced</a>
|
||||
</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -115,6 +130,20 @@ A three-component system:
|
||||
- **Response store TTL** — evicts stored responses older than 10 minutes, prevents memory leaks
|
||||
- **Bounded stream buffers** — 8MB cap prevents OOM on pathological responses
|
||||
- **Dual logging** — all proxy messages written to both stderr and `~/.cache/codex-proxy/proxy.log`
|
||||
- **Vision model detection** (v3.11.5) — automatically strips images for non-vision models (DeepSeek, GLM, Qwen, etc.) and replaces with text notice; vision-capable models (GPT-4o, Gemini, Claude, Qwen-VL) keep images intact
|
||||
- **Token-aware compaction** (v3.11.5) — learns per-model token limits from `context_length_exceeded` errors; proactively compacts when estimated tokens exceed 80% of limit; prevents repeated context overflow on small-context models (~35K tokens)
|
||||
- **Universal adaptive compaction** (v3.11.5) — compaction now works for ALL providers (was Crof.ai-only); proactive + retry compaction with aggression levels (normal/extreme)
|
||||
- **Smart-continue text detection** (v3.11.5) — triggers continuation nudging when model outputs text matching tool-call patterns, essential for text-only models that never emit real `function_call_output` items
|
||||
- **Antigravity loop breakers** (v3.11.6) — per-session tracking with automatic finalization when same tool+args repeats 5+ times; edit-intent nudge injected only on first turn; latest user instruction appended exactly once per request
|
||||
- **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
|
||||
- **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
|
||||
|
||||
### Command Code Adapter
|
||||
@@ -539,6 +568,7 @@ The launcher generates model catalog JSON with dual field naming to satisfy both
|
||||
|
||||
Codex Launcher includes special handling for Gemini 3 / Antigravity OAuth:
|
||||
|
||||
- **Sticky endpoint with parallel discovery**: First request probes `cloudcode-pa.googleapis.com` and `daily-cloudcode-pa.googleapis.com` simultaneously — first 200 wins and is cached. All subsequent requests go straight to the cached endpoint. If it fails (429/502/503), cache is cleared and all endpoints are re-probed in parallel. Zero wasted time on rate-limited endpoints.
|
||||
- **Thought signature preservation**: Captures `thoughtSignature` from Gemini responses
|
||||
and reattaches them on follow-up requests to maintain tool-call continuity.
|
||||
- **Edit-intent detection**: When follow-up requests contain edit keywords, a tool-use
|
||||
@@ -546,6 +576,16 @@ Codex Launcher includes special handling for Gemini 3 / Antigravity OAuth:
|
||||
- **User instruction enforcement**: The latest user message is guaranteed to be the
|
||||
final content turn sent to Gemini, even after compaction.
|
||||
- **Smart compaction**: Old tool outputs capped at 3000 chars, recent 6 at 20000 chars.
|
||||
- **Context compaction**: Aggressive auto-trimming when approaching 80% of model context
|
||||
limit (1M tokens Gemini, 200K Claude, 128K GPT-OSS). Prevents token limit errors.
|
||||
- **Model ID mapping**: Display names (e.g. `Gemini 3.5 Flash (High)`) mapped to REST API
|
||||
slugs (e.g. `gemini-3-flash`). See `docs/ANTIGRAVITY.md` for details.
|
||||
|
||||
### OAuth Secrets
|
||||
|
||||
Google OAuth credentials are stored locally in `~/.config/codex-launcher/oauth-secrets.json`
|
||||
and never committed to the repository. Use the **OAuth Secrets** button in the launcher
|
||||
header to edit or import `client_secret_*.json` files from Google Cloud Console.
|
||||
|
||||
---
|
||||
|
||||
@@ -605,7 +645,7 @@ curl http://127.0.0.1:PORT/v1/accounts
|
||||
| OpenCode Zen | OpenAI-compat | `https://opencode.ai/zen/v1` |
|
||||
| OpenCode Go | OpenAI-compat | `https://opencode.ai/zen/go/v1` |
|
||||
| Command Code | Command Code | `https://api.commandcode.ai` |
|
||||
| **Codebuff** | **Codebuff** | `https://codebuff.com` *(free DeepSeek/Kimi — OAuth login built-in)* |
|
||||
| **Codebuff / Freebuff** | **Codebuff** | `https://www.codebuff.com` *(free DeepSeek/Kimi — OAuth login built-in)* |
|
||||
| Crof.ai | OpenAI-compat | `https://crof.ai/v1` |
|
||||
| OpenAdapter | OpenAI-compat | `https://api.openadapter.in/v1` |
|
||||
| Z.ai Coding | OpenAI-compat | `https://api.z.ai/api/coding/paas/v4` |
|
||||
@@ -618,14 +658,14 @@ curl http://127.0.0.1:PORT/v1/accounts
|
||||
| Google Antigravity (OAuth) | Antigravity OAuth | `daily-cloudcode-pa.sandbox.googleapis.com` |
|
||||
| Custom | Any | User-defined |
|
||||
|
||||
### Free Models (via Codebuff)
|
||||
Codebuff provides free access to these models — no API key needed:
|
||||
### Free Models (via Codebuff/Freebuff)
|
||||
Codebuff/Freebuff provides free access to these models — no API key needed:
|
||||
- **DeepSeek V4 Pro** — Smartest model
|
||||
- **DeepSeek V4 Flash** — Most efficient
|
||||
- **Kimi K2.6** — Balanced
|
||||
- **MiniMax M2.7** — Fastest
|
||||
|
||||
*Requires: `codebuff login` via GUI OAuth button, or `npm install -g codebuff && codebuff login` (GitHub OAuth)*
|
||||
*Requires: `freebuff login` via GUI OAuth button, or `npm install -g freebuff && freebuff login` (GitHub OAuth)*
|
||||
|
||||
---
|
||||
|
||||
@@ -762,15 +802,70 @@ codex --profile my-profile -c model=my-model
|
||||
|
||||
---
|
||||
|
||||
## Windows Version
|
||||
|
||||
A native **Windows GUI** (tkinter) is available in the `src/` folder alongside the Linux version. Both GUIs have **full feature parity**.
|
||||
|
||||
<p align="center">
|
||||
<sub>
|
||||
Windows version by <a href="https://github.com/cobra91">cobra91</a> •
|
||||
Original Linux development by <a href="https://github.com/roman-ryzenadvanced">roman-ryzenadvanced</a>
|
||||
</sub>
|
||||
</p>
|
||||
|
||||
### Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/codex-launcher-gui.py` | tkinter GUI (Windows) — manage endpoints, launch Codex CLI/Desktop |
|
||||
| `src/codex-launcher-gui` | GTK GUI (Linux) — same features, native GTK look |
|
||||
| `src/codex_launcher_lib.py` | Shared library — proxy lifecycle, config, OAuth, diagnostics |
|
||||
| `src/translate-proxy.py` | Proxy — translates Responses API for any provider |
|
||||
|
||||
### How to Run (Windows)
|
||||
|
||||
Python ≥ 3.8 with tkinter is required (comes with the official Python installer).
|
||||
|
||||
```powershell
|
||||
# From repo root
|
||||
cd src
|
||||
python codex-launcher-gui.py
|
||||
```
|
||||
|
||||
The GUI will:
|
||||
1. Auto-create default endpoints on first run
|
||||
2. Show a toolbar with Endpoints, OAuth Secrets, AI Monitor, and more
|
||||
3. Launch Codex CLI/Desktop with your chosen provider
|
||||
|
||||
### OAuth Credentials
|
||||
|
||||
Google OAuth (Antigravity / Gemini CLI) requires a `client_secret_*.json` from [Google Cloud Console](https://console.cloud.google.com/apis/credentials). Use the **OAuth Secrets** button in the GUI to import it — credentials are stored locally in `~/.config/codex-launcher/oauth-secrets.json`, never in the repo.
|
||||
|
||||
The **OAuth Secrets** dialog shows all providers (Google + Freebuff/Codebuff) with **Re-OAuth buttons** to instantly re-authenticate any provider.
|
||||
|
||||
### Feature Parity
|
||||
|
||||
Both Linux (GTK) and Windows (tkinter) GUIs have identical features:
|
||||
- All provider presets, endpoint management, BGP routing
|
||||
- OAuth Secrets with all providers + Re-OAuth buttons
|
||||
- AI Monitor, Usage Dashboard, Request History, Benchmark
|
||||
- Clear Log, Restart Proxy, View Log
|
||||
- Doctor, Diagnostic Agent, Profile Backup/Import
|
||||
- Antigravity model mapping, context compaction (80% budget)
|
||||
- Multi-account rotation, rate limit handling
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python ≥ 3.8
|
||||
- python3-gi (`sudo apt install python3-gi`)
|
||||
- python3-gi (`sudo apt install python3-gi`) — Linux only
|
||||
- tkinter (`python3-tk`) — Windows / Linux GUI
|
||||
- Codex CLI ≥ 2.0
|
||||
- Codex Desktop (optional, for Desktop mode)
|
||||
- bash, curl, lsof
|
||||
- bash, curl, lsof — Linux only
|
||||
|
||||
**No pip dependencies.** Zero. Pure stdlib + system GTK.
|
||||
**No pip dependencies.** Zero. Pure stdlib.
|
||||
|
||||
---
|
||||
|
||||
|
||||
1545
codex-launcher-gui
1545
codex-launcher-gui
File diff suppressed because it is too large
Load Diff
6515
codex-launcher-gui.py
Normal file
6515
codex-launcher-gui.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
codex-launcher_10.13.6_all.deb
Normal file
BIN
codex-launcher_10.13.6_all.deb
Normal file
Binary file not shown.
BIN
codex-launcher_10.13.8_all.deb
Normal file
BIN
codex-launcher_10.13.8_all.deb
Normal file
Binary file not shown.
Binary file not shown.
BIN
codex-launcher_3.10.10_all.deb
Normal file
BIN
codex-launcher_3.10.10_all.deb
Normal file
Binary file not shown.
BIN
codex-launcher_3.10.11_all.deb
Normal file
BIN
codex-launcher_3.10.11_all.deb
Normal file
Binary file not shown.
BIN
codex-launcher_3.10.12_all.deb
Normal file
BIN
codex-launcher_3.10.12_all.deb
Normal file
Binary file not shown.
BIN
codex-launcher_3.10.9_all.deb
Normal file
BIN
codex-launcher_3.10.9_all.deb
Normal file
Binary file not shown.
BIN
codex-launcher_3.12.1_all.deb
Normal file
BIN
codex-launcher_3.12.1_all.deb
Normal file
Binary file not shown.
BIN
codex-launcher_3.13.0_all.deb
Normal file
BIN
codex-launcher_3.13.0_all.deb
Normal file
Binary file not shown.
BIN
codex-launcher_3.13.5_all.deb
Normal file
BIN
codex-launcher_3.13.5_all.deb
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2509
codex_launcher_lib.py
Normal file
2509
codex_launcher_lib.py
Normal file
File diff suppressed because it is too large
Load Diff
335
docs/ANTIGRAVITY.md
Normal file
335
docs/ANTIGRAVITY.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# Antigravity (Google CloudCode) — Technical Reference
|
||||
|
||||
Everything needed to understand, maintain, and debug the Antigravity OAuth provider integration in Codex Launcher.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Is Antigravity?
|
||||
|
||||
Antigravity is Google's internal codename for **Google CloudCode** — a cloud-based AI coding agent powered by Gemini and other models. The CLI tool (`agy`) is a native Go binary that uses gRPC to communicate with Google's CloudCode backend.
|
||||
|
||||
- **Official CLI binary**: `~/.local/bin/agy-core` (ELF x86-64 Go binary, ~183MB)
|
||||
- **Wrapper script**: `~/.local/bin/agy` (Python, manages provider switching)
|
||||
- **CLI settings**: `~/.gemini/antigravity-cli/settings.json`
|
||||
- **Provider state**: `~/.gemini/antigravity-cli/agy_provider.json`
|
||||
|
||||
---
|
||||
|
||||
## 2. Two API Protocols — REST vs gRPC
|
||||
|
||||
### 2.1 What the agy CLI uses (gRPC)
|
||||
|
||||
The native `agy-core` binary uses **gRPC** to communicate with the CloudCode backend:
|
||||
|
||||
- **Service**: `google.internal.cloud.code.v1internal.PredictionService`
|
||||
- **Methods**:
|
||||
- `GenerateContent` — main inference
|
||||
- `FetchAvailableModels` — list available models
|
||||
- `CountTokens` — token counting
|
||||
- `RetrieveUserQuota` — quota check
|
||||
- **Other services**: `CloudCode`, `JetskiService` (settings, plugins, etc.)
|
||||
- **Proto files**: `google/internal/cloud/code/v1internal/prediction_service.proto`, `cloudcode.proto`
|
||||
- **Model IDs in gRPC**: Display names like `"Gemini 3.5 Flash (High)"` — verified from `settings.json`
|
||||
|
||||
### 2.2 What our proxy uses (REST)
|
||||
|
||||
Our Codex Launcher proxy does NOT use gRPC. It uses the **REST API** that the CloudCode backend also exposes:
|
||||
|
||||
- **Endpoint path**: `v1internal:generateContent` (non-streaming) / `v1internal:streamGenerateContent?alt=sse` (streaming SSE)
|
||||
- **This is NOT the standard Gemini REST API** — it's the CloudCode-internal REST gateway
|
||||
- **Model IDs in REST**: Slug-style IDs like `gemini-3-flash` — NOT display names
|
||||
- **The REST API is more limited** — fewer model variants available than gRPC
|
||||
|
||||
### 2.3 Why not gRPC?
|
||||
|
||||
The agy binary uses gRPC with protobuf serialization. Using gRPC from the proxy would require:
|
||||
- Maintaining proto definitions (compiled from the binary)
|
||||
- More complex streaming
|
||||
- The `grpcio` Python library (not installed by default)
|
||||
|
||||
The REST API works well enough for our use case.
|
||||
|
||||
---
|
||||
|
||||
## 3. Endpoints
|
||||
|
||||
The proxy tries these endpoints in order for Antigravity:
|
||||
|
||||
```
|
||||
1. https://daily-cloudcode-pa.sandbox.googleapis.com (primary)
|
||||
2. https://autopush-cloudcode-pa.sandbox.googleapis.com (fallback)
|
||||
3. https://cloudcode-pa.googleapis.com (production fallback)
|
||||
```
|
||||
|
||||
For regular Gemini CLI OAuth, only `cloudcode-pa.googleapis.com` is used.
|
||||
|
||||
---
|
||||
|
||||
## 4. Authentication
|
||||
|
||||
### 4.1 OAuth Flow
|
||||
|
||||
- **Client IDs**: Stored locally in `~/.config/codex-launcher/oauth-secrets.json` (not in repo)
|
||||
- **OAuth callback**: `https://antigravity.google/oauth-callback`
|
||||
- **Token storage**: `~/.cache/codex-proxy/google-antigravity-oauth-token.json`
|
||||
- **Token refresh**: via `https://oauth2.googleapis.com/token`
|
||||
- **Scopes**: `email profile openid cloud-platform cclog experimentsandconfigs userinfo.email userinfo.profile`
|
||||
- **Note**: The token does NOT have `auth/aicode` scope — it uses `cloud-platform` instead
|
||||
|
||||
### 4.2 Multi-Account Support
|
||||
|
||||
- `GoogleAccountPool("antigravity")` manages multiple Google accounts
|
||||
- Token files: `google-antigravity-oauth-token.json`, `google-antigravity-oauth-token-2.json`, etc.
|
||||
- Round-robin rotation across accounts
|
||||
|
||||
---
|
||||
|
||||
## 5. Request Format
|
||||
|
||||
### 5.1 REST Request Wrapper
|
||||
|
||||
The proxy wraps the Gemini-format request body in an outer envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<gcp-project-id>",
|
||||
"model": "<rest-model-id>",
|
||||
"requestType": "agent",
|
||||
"userAgent": "antigravity",
|
||||
"requestId": "agent-<uuid>",
|
||||
"request": {
|
||||
"contents": [...],
|
||||
"systemInstruction": {...},
|
||||
"generationConfig": {...},
|
||||
"tools": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Required Headers
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <access_token>
|
||||
User-Agent: antigravity/<version> darwin/arm64
|
||||
```
|
||||
|
||||
The User-Agent version is auto-fetched from:
|
||||
- `https://antigravity-auto-updater-974169037036.us-central1.run.app`
|
||||
- Fallback: `https://antigravity.google/changelog`
|
||||
- Cached in `~/.cache/codex-proxy/antigravity-version.json`
|
||||
- Default: `1.18.3`
|
||||
|
||||
---
|
||||
|
||||
## 6. Model ID Mapping (CRITICAL)
|
||||
|
||||
### 6.1 The Problem
|
||||
|
||||
The agy CLI shows models with display names:
|
||||
- `Gemini 3.5 Flash (High)`
|
||||
- `Claude Sonnet 4.6 (Thinking)`
|
||||
|
||||
But the **REST API only accepts slug IDs**:
|
||||
- `gemini-3-flash`
|
||||
- `claude-sonnet-4-6`
|
||||
|
||||
Sending display names to the REST API returns **HTTP 404 "Requested entity was not found"**.
|
||||
|
||||
### 6.2 Verified Working Model IDs
|
||||
|
||||
All tested with live API calls to `daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent` on 2026-05-25:
|
||||
|
||||
| Display Name (agy CLI / GUI) | REST API Model ID | Status |
|
||||
|---|---|---|
|
||||
| Gemini 3.5 Flash (High) | `gemini-3-flash` | OK |
|
||||
| Gemini 3.5 Flash (Medium) | `gemini-3-flash` | OK |
|
||||
| Gemini 3.5 Flash (Low) | `gemini-3.5-flash-low` | OK |
|
||||
| Gemini 3.1 Pro (High) | `gemini-3.1-pro-low` | OK (only low tier works via REST) |
|
||||
| Gemini 3.1 Pro (Low) | `gemini-3.1-pro-low` | OK |
|
||||
| Claude Sonnet 4.6 (Thinking) | `claude-sonnet-4-6` | OK |
|
||||
| Claude Opus 4.6 (Thinking) | `claude-opus-4-6-thinking` | OK |
|
||||
| GPT-OSS 120B (Medium) | `gpt-oss-120b-medium` | OK |
|
||||
| Gemini 2.5 Flash | `gemini-2.5-flash` | OK |
|
||||
| Gemini 2.5 Flash Lite | `gemini-2.5-flash-lite` | OK |
|
||||
| Gemini 2.5 Pro | `gemini-2.5-pro` | 503 (exists, no capacity) |
|
||||
|
||||
### 6.3 Models That Return 404 via REST
|
||||
|
||||
These exist in gRPC but NOT in the REST API:
|
||||
|
||||
```
|
||||
gemini-3-flash-high, gemini-3-flash-medium, gemini-3-flash-low
|
||||
gemini-3.5-flash, gemini-3.5-flash-high, gemini-3.5-flash-medium
|
||||
gemini-3.1-pro-high (400, not 404, but doesn't work)
|
||||
gemini-3-pro, gemini-3-pro-high, gemini-3-pro-low (500)
|
||||
gemini-3.1-flash, gemini-3.1-flash-high
|
||||
claude-sonnet-4, claude-sonnet-4-5, claude-sonnet-4-6-thinking
|
||||
claude-opus-4, claude-opus-4-5
|
||||
claude-haiku-4-5
|
||||
gpt-oss-120b, gpt-oss-120b-maas, gpt-oss-20b-maas
|
||||
```
|
||||
|
||||
### 6.4 How the Mapping Works
|
||||
|
||||
1. GUI shows display names (matching agy CLI): `Gemini 3.5 Flash (High)`
|
||||
2. Codex CLI sends whatever model ID the user selected
|
||||
3. Proxy `alias_map` translates: `"Gemini 3.5 Flash (High)" → "gemini-3-flash"`
|
||||
4. Proxy sends REST request with `"model": "gemini-3-flash"`
|
||||
|
||||
The alias map is in `_handle_gemini_oauth()` around line 4316 of `translate-proxy.py`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Response Format
|
||||
|
||||
### 7.1 Non-Streaming
|
||||
|
||||
```json
|
||||
{
|
||||
"response": {
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [{"text": "..."}]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Streaming (SSE)
|
||||
|
||||
Content-Type: `text/event-stream`
|
||||
|
||||
Each SSE event contains a JSON chunk with the same structure. The proxy converts these to OpenAI Responses API format for Codex CLI.
|
||||
|
||||
---
|
||||
|
||||
## 8. Context Sizes
|
||||
|
||||
```python
|
||||
"Gemini 3.5 Flash": 1000000, "Gemini 3.1 Pro": 2000000,
|
||||
"gemini-3-flash": 1000000, "gemini-3.1-pro-low": 2000000,
|
||||
"gemini-3.5-flash-low": 1000000,
|
||||
"Claude Sonnet 4.6": 200000, "Claude Opus 4.6": 200000,
|
||||
"claude-sonnet-4-6": 200000, "claude-opus-4-6-thinking": 200000,
|
||||
"GPT-OSS 120B": 128000, "gpt-oss-120b-medium": 128000,
|
||||
"gemini-2.5-flash": 1000000, "gemini-2.5-pro": 2000000,
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Key Proxy Code Locations
|
||||
|
||||
| Component | File | Line (approx) |
|
||||
|---|---|---|
|
||||
| Antigravity version | translate-proxy.py | 287-288 |
|
||||
| Version fetcher | translate-proxy.py | 705-748 |
|
||||
| Model alias map | translate-proxy.py | ~4316 |
|
||||
| REST request building | translate-proxy.py | ~4563-4602 |
|
||||
| Endpoint fallback loop | translate-proxy.py | ~4610 |
|
||||
| SSE streaming handler | translate-proxy.py | `_forward_gemini_sse()` |
|
||||
| Auto-continue for MAX_TOKENS | translate-proxy.py | `_auto_continue_gemini()` |
|
||||
| OAuth token refresh | translate-proxy.py | `_refresh_oauth_token_for()` |
|
||||
| Google account pool | translate-proxy.py | `_google_antigravity_pool` |
|
||||
| GUI preset models | codex-launcher-gui | ~358 |
|
||||
| GUI static model list | codex-launcher-gui | ~760 `_ANTIGRAVITY_MODELS` |
|
||||
| GUI fetch_models shortcut | codex-launcher-gui | ~770 `fetch_models_for_endpoint()` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Debugging
|
||||
|
||||
### 10.1 Debug Logs
|
||||
|
||||
- **Proxy stderr**: Shows model mapping, request details, errors
|
||||
- **400 error dump**: `~/.cache/codex-proxy/gemini-last-400-request.json`
|
||||
- **Long context dump**: `~/.cache/codex-proxy/gemini-long-ctx-<session>.json`
|
||||
|
||||
### 10.2 Quick API Test
|
||||
|
||||
```bash
|
||||
TOKEN=$(python3 -c "import json; print(json.load(open('$HOME/.cache/codex-proxy/google-antigravity-oauth-token.json'))['access_token'])")
|
||||
|
||||
curl -s "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "User-Agent: antigravity/2.0.1 darwin/arm64" \
|
||||
-d '{
|
||||
"project": "voltaic-hangout-z1qhf",
|
||||
"model": "gemini-3-flash",
|
||||
"requestType": "agent",
|
||||
"userAgent": "antigravity",
|
||||
"requestId": "test-123",
|
||||
"request": {
|
||||
"contents": [{"role": "user", "parts": [{"text": "say hi"}]}]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 10.3 Token Info
|
||||
|
||||
```bash
|
||||
TOKEN=$(python3 -c "import json; print(json.load(open('$HOME/.cache/codex-proxy/google-antigravity-oauth-token.json'))['access_token'])")
|
||||
curl -s "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=$TOKEN" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### 10.4 Common Errors
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|---|---|---|
|
||||
| 404 "Requested entity was not found" | Wrong model ID (display name instead of slug) | Check alias_map |
|
||||
| 404 on `/v1/models` | Antigravity has no models REST endpoint | Proxy returns static list |
|
||||
| 404 on POST /responses | Codex CLI routing issue, not Antigravity | Check proxy is running |
|
||||
| 503 "No capacity" | Model exists but overloaded | Try another model or endpoint |
|
||||
| 500 "Unknown Error" | Model ID exists but broken on server | Known for gemini-3-pro-low |
|
||||
| PERMISSION_DENIED (gRPC) | Token lacks scope or empty request body | Use REST API instead |
|
||||
|
||||
---
|
||||
|
||||
## 11. Version History (Antigravity-specific)
|
||||
|
||||
| Version | Date | Change |
|
||||
|---|---|---|
|
||||
| v3.10.3 | 2026-05-25 | **Fix 404**: Verified REST model IDs, display→slug mapping |
|
||||
| v3.10.2 | 2026-05-25 | Wrong fix: tried display names (didn't work) |
|
||||
| v3.10.0 | 2026-05-25 | Provider model editor, static Antigravity model list |
|
||||
| v3.9.9 | 2026-05-25 | Refreshed Antigravity models (slugs were wrong) |
|
||||
| v3.3.0 | Earlier | Initial Antigravity OAuth + tool calls + SSE streaming |
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing a New Model ID
|
||||
|
||||
If new models appear in the agy CLI, verify them against the REST API before adding:
|
||||
|
||||
```python
|
||||
# Test a candidate model ID
|
||||
import urllib.request, json, os
|
||||
|
||||
token = json.load(open(os.path.expanduser("~/.cache/codex-proxy/google-antigravity-oauth-token.json")))["access_token"]
|
||||
wrapped = {
|
||||
"project": "voltaic-hangout-z1qhf", "model": "NEW-MODEL-ID",
|
||||
"requestType": "agent", "userAgent": "antigravity",
|
||||
"requestId": "test-123",
|
||||
"request": {"contents": [{"role": "user", "parts": [{"text": "say hi"}]}]},
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent",
|
||||
data=json.dumps(wrapped).encode(),
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "antigravity/2.0.1 darwin/arm64"},
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
print("OK:", resp.read().decode()[:200])
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"{e.code}:", e.read().decode()[:200])
|
||||
```
|
||||
|
||||
Then update:
|
||||
1. `alias_map` in `translate-proxy.py` — add display name → REST slug mapping
|
||||
2. `_ANTIGRAVITY_MODELS` in `codex-launcher-gui` — add display name to list
|
||||
3. Preset in `codex-launcher-gui` — add display name to `"Google Antigravity (OAuth)"` models
|
||||
4. Context sizes in `translate-proxy.py` — add model ID to `_MODEL_CTX` dict
|
||||
127
install.ps1
Normal file
127
install.ps1
Normal file
@@ -0,0 +1,127 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Codex Launcher Windows Installer
|
||||
.DESCRIPTION
|
||||
Installs Codex Launcher for the current user.
|
||||
.NOTES
|
||||
Requires: Python 3.8+ (stdlib only, zero pip dependencies).
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$Uninstall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$BinDir = Join-Path $env:LOCALAPPDATA 'Programs\Codex-Launcher'
|
||||
$StartMenu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
|
||||
|
||||
if ($Uninstall) {
|
||||
Write-Host 'Uninstalling Codex Launcher...' -ForegroundColor Yellow
|
||||
|
||||
if (Test-Path $BinDir) {
|
||||
Remove-Item -Recurse -Force $BinDir
|
||||
Write-Host " Removed $BinDir"
|
||||
}
|
||||
|
||||
$shortcut = Join-Path $StartMenu 'Codex Launcher.lnk'
|
||||
if (Test-Path $shortcut) {
|
||||
Remove-Item -Force $shortcut
|
||||
Write-Host ' Removed Start Menu shortcut'
|
||||
}
|
||||
|
||||
$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($userPath -like "*$BinDir*") {
|
||||
$newPath = ($userPath -split ';' | Where-Object { $_ -ne $BinDir }) -join ';'
|
||||
[Environment]::SetEnvironmentVariable('PATH', $newPath, 'User')
|
||||
Write-Host ' Removed from PATH'
|
||||
}
|
||||
|
||||
Write-Host 'Uninstall complete.' -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' Codex Launcher - Windows Installer' -ForegroundColor Cyan
|
||||
Write-Host ' ====================================' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
|
||||
# Check Python
|
||||
$pythonExe = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $pythonExe) {
|
||||
$pythonExe = Get-Command python3 -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (-not $pythonExe) {
|
||||
Write-Host 'ERROR: Python not found. Install Python 3.8+ and add to PATH.' -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Python: $($pythonExe.Source)" -ForegroundColor Gray
|
||||
|
||||
# Create install directory
|
||||
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
|
||||
|
||||
# Copy files
|
||||
$srcDir = Join-Path $PSScriptRoot 'src'
|
||||
$files = @(
|
||||
'translate-proxy.py',
|
||||
'codex-launcher-gui.py',
|
||||
'codex_launcher_lib.py',
|
||||
'cleanup-codex-stale.py'
|
||||
)
|
||||
|
||||
foreach ($file in $files) {
|
||||
$src = Join-Path $srcDir $file
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Force $src $BinDir
|
||||
Write-Host " Installed: $file" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " WARNING: $file not found in src/" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# Create Start Menu shortcut
|
||||
$WshShell = New-Object -ComObject WScript.Shell
|
||||
$shortcutPath = Join-Path $StartMenu 'Codex Launcher.lnk'
|
||||
$Shortcut = $WshShell.CreateShortcut($shortcutPath)
|
||||
|
||||
# Find pythonw.exe for no-console launch
|
||||
$pythonw = Get-Command pythonw -ErrorAction SilentlyContinue
|
||||
if (-not $pythonw) {
|
||||
$pythonDir = Split-Path $pythonExe.Source
|
||||
$pythonwCandidate = Join-Path $pythonDir 'pythonw.exe'
|
||||
if (Test-Path $pythonwCandidate) {
|
||||
$pythonw = $pythonwCandidate
|
||||
}
|
||||
}
|
||||
|
||||
if ($pythonw) {
|
||||
$targetPath = if ($pythonw.Source) { $pythonw.Source } else { $pythonw }
|
||||
} else {
|
||||
$targetPath = $pythonExe.Source
|
||||
}
|
||||
$Shortcut.TargetPath = $targetPath
|
||||
$guiPath = Join-Path $BinDir 'codex-launcher-gui.py'
|
||||
$Shortcut.Arguments = $guiPath
|
||||
$Shortcut.WorkingDirectory = $BinDir
|
||||
$Shortcut.Description = 'Launch Codex Desktop with any AI provider'
|
||||
$Shortcut.Save()
|
||||
Write-Host ' Created Start Menu shortcut' -ForegroundColor Green
|
||||
|
||||
# Add to PATH
|
||||
$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($userPath -notlike "*$BinDir*") {
|
||||
$newUserPath = $userPath + ';' + $BinDir
|
||||
[Environment]::SetEnvironmentVariable('PATH', $newUserPath, 'User')
|
||||
$env:PATH = $env:PATH + ';' + $BinDir
|
||||
Write-Host ' Added to user PATH' -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Verify
|
||||
Write-Host ''
|
||||
Write-Host ' Installation complete!' -ForegroundColor Cyan
|
||||
Write-Host " Install dir: $BinDir" -ForegroundColor Gray
|
||||
Write-Host ''
|
||||
Write-Host ' Launch options:' -ForegroundColor White
|
||||
Write-Host ' Start Menu: Codex Launcher' -ForegroundColor Gray
|
||||
Write-Host ' Command: codex-launcher-gui.py' -ForegroundColor Gray
|
||||
Write-Host ' Uninstall: powershell -File install.ps1 -Uninstall' -ForegroundColor Gray
|
||||
Write-Host ''
|
||||
12
install.sh
12
install.sh
@@ -3,11 +3,13 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/codex-launcher_3.9.7_all.deb" ]; then
|
||||
echo "Installing codex-launcher_3.9.7_all.deb ..."
|
||||
sudo dpkg -i "$SCRIPT_DIR/codex-launcher_3.9.7_all.deb"
|
||||
echo ""
|
||||
echo "Installed v3.9.7 via .deb package."
|
||||
if [ -f "$SCRIPT_DIR/codex-launcher_3.11.6_all.deb" ]; then
|
||||
echo "Installing codex-launcher_3.11.6_all.deb ..."
|
||||
sudo dpkg -i "$SCRIPT_DIR/codex-launcher_3.11.6_all.deb"
|
||||
else
|
||||
echo "WARNING: codex-launcher_3.11.6_all.deb not found; copying files manually."
|
||||
fi
|
||||
echo "Installed v3.11.6 via .deb package."
|
||||
echo " translate-proxy.py -> /usr/bin/translate-proxy.py"
|
||||
echo " codex-launcher-gui -> /usr/bin/codex-launcher-gui"
|
||||
echo " cleanup-codex-stale -> /usr/bin/cleanup-codex-stale.sh"
|
||||
|
||||
24
src/antigravity_grpc/__init__.py
Normal file
24
src/antigravity_grpc/__init__.py
Normal 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",
|
||||
]
|
||||
609
src/antigravity_grpc/client.py
Normal file
609
src/antigravity_grpc/client.py
Normal 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
|
||||
88
src/antigravity_grpc/cloudcode_pb2.py
Normal file
88
src/antigravity_grpc/cloudcode_pb2.py
Normal file
File diff suppressed because one or more lines are too long
275
src/antigravity_grpc/cloudcode_pb2_grpc.py
Normal file
275
src/antigravity_grpc/cloudcode_pb2_grpc.py
Normal 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)
|
||||
183
src/antigravity_grpc/proto/cloudcode.proto
Normal file
183
src/antigravity_grpc/proto/cloudcode.proto
Normal 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);
|
||||
}
|
||||
14
src/antigravity_grpc/proto/google/api/annotations.proto
Normal file
14
src/antigravity_grpc/proto/google/api/annotations.proto
Normal 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;
|
||||
}
|
||||
18
src/antigravity_grpc/proto/google/api/http.proto
Normal file
18
src/antigravity_grpc/proto/google/api/http.proto
Normal 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;
|
||||
}
|
||||
101
src/cleanup-codex-stale.py
Normal file
101
src/cleanup-codex-stale.py
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cleanup stale Codex Launcher processes and artifacts — cross-platform.
|
||||
|
||||
Kills registered process groups and removes stale PID/socket files left
|
||||
by previous Codex Launcher sessions.
|
||||
|
||||
Windows: uses taskkill /F /T /PID
|
||||
Linux: uses kill -TERM -- -PGID
|
||||
"""
|
||||
|
||||
import json, os, sys, subprocess, time
|
||||
from pathlib import Path
|
||||
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
if IS_WINDOWS:
|
||||
_local = os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))
|
||||
PID_REGISTRY = Path(_local) / "codex-proxy" / "pids.json"
|
||||
CODEX_DIR = Path.home() / ".codex"
|
||||
_local_share = Path(_local)
|
||||
_cache = Path(_local)
|
||||
else:
|
||||
PID_REGISTRY = Path.home() / ".cache" / "codex-proxy" / "pids.json"
|
||||
CODEX_DIR = Path.home() / ".codex"
|
||||
_local_share = Path.home() / ".local" / "share"
|
||||
_cache = Path.home() / ".cache"
|
||||
|
||||
|
||||
def kill_group(pid):
|
||||
if IS_WINDOWS:
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
capture_output=True, timeout=10)
|
||||
else:
|
||||
import signal
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
print("[cleanup] Cleaning up stale Codex Launcher processes...", file=sys.stderr)
|
||||
|
||||
if PID_REGISTRY.exists():
|
||||
try:
|
||||
with open(PID_REGISTRY) as f:
|
||||
registry = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[cleanup] Failed to read PID registry: {e}", file=sys.stderr)
|
||||
registry = {}
|
||||
|
||||
for kind, info in registry.items():
|
||||
pid = info.get("pid") if isinstance(info, dict) else info
|
||||
if pid and isinstance(pid, int):
|
||||
print(f"[cleanup] Killing {kind} (PID {pid})", file=sys.stderr)
|
||||
kill_group(pid)
|
||||
|
||||
try:
|
||||
PID_REGISTRY.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
print("[cleanup] No PID registry found — nothing to stop", file=sys.stderr)
|
||||
|
||||
stale_files = []
|
||||
if IS_WINDOWS:
|
||||
stale_files = [
|
||||
_cache / "codex-desktop" / ".codex-desktop-pid",
|
||||
_cache / "codex-desktop" / ".webview-pid",
|
||||
]
|
||||
else:
|
||||
stale_files = [
|
||||
CODEX_DIR / ".launch-action-socket",
|
||||
CODEX_DIR / ".codex-desktop-launch-action",
|
||||
CODEX_DIR / ".codex-desktop-pid",
|
||||
CODEX_DIR / ".webview-pid",
|
||||
_local_share / "codex-desktop" / ".codex-desktop-pid",
|
||||
_local_share / "codex-desktop" / ".webview-pid",
|
||||
_cache / "codex-desktop" / ".codex-desktop-pid",
|
||||
_cache / "codex-desktop" / ".webview-pid",
|
||||
]
|
||||
|
||||
for fp in stale_files:
|
||||
try:
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
print(f"[cleanup] Removed {fp}", file=sys.stderr)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print("[cleanup] Done", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
3479
src/codex-launcher-gui.py
Normal file
3479
src/codex-launcher-gui.py
Normal file
File diff suppressed because it is too large
Load Diff
2329
src/codex_launcher_lib.py
Normal file
2329
src/codex_launcher_lib.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
482
test-antigravity.sh
Normal file
482
test-antigravity.sh
Normal 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
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
396
tests/test_antigravity_grpc.py
Normal file
396
tests/test_antigravity_grpc.py
Normal 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)
|
||||
1166
tests/test_translate_proxy.py
Normal file
1166
tests/test_translate_proxy.py
Normal file
File diff suppressed because it is too large
Load Diff
4072
translate-proxy.py
4072
translate-proxy.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user