Initial commit: Codex Launcher — Any AI Provider
Multi-provider integration for OpenAI Codex CLI/Desktop. - Translation proxy: Responses API ↔ Chat Completions / Anthropic Messages - GTK launcher with endpoint management, provider presets, Desktop/CLI launch - Codex Default mode (built-in OAuth, zero config) - Browser UA injection for Cloudflare-protected providers - Streaming SSE, tool calls, reasoning content support - Profile backup/import, model auto-fetch, bulk import - Zero pip dependencies (pure Python stdlib + GTK)
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
*.bak
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
.endpoints.json
|
||||||
|
config.toml
|
||||||
|
*.log
|
||||||
|
.cache/
|
||||||
|
.codex/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
447
README.md
Normal file
447
README.md
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://z.ai/subscribe?ic=ROK78RJKNW">
|
||||||
|
<img src="https://img.shields.io/badge/Z.AI-10%25_OFF_Coding_Plans-6366f1?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiPgogIDxwYXRoIGQ9Ik0xMiAyTDIgN2wxMCA1IDEwLTV6Ii8+CiAgPHBhdGggZD0iTTIgMTdsMTAgNSAxMC01Ii8+CiAgPHBhdGggZD0iTTIgMTJsMTAgNSAxMC01Ii8+Cjwvc3ZnPg==&labelColor=4f46e5" alt="Z.AI 10% OFF" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Get 10% OFF Z.AI coding plans</strong><br/>
|
||||||
|
<a href="https://z.ai/subscribe?ic=ROK78RJKNW">z.ai/subscribe</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/>
|
||||||
|
OpenCode • Z.AI • Anthropic • OpenRouter • Crof.ai • NVIDIA NIM • Kilo.ai • and more
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Python-3.8+-blue?logo=python&logoColor=white" alt="Python 3.8+" />
|
||||||
|
<img src="https://img.shields.io/badge/GTK-3.0-green?logo=gtk&logoColor=white" alt="GTK 3.0" />
|
||||||
|
<img src="https://img.shields.io/badge/License-MIT-yellow" alt="MIT License" />
|
||||||
|
<img src="https://img.shields.io/badge/Zero_Pip_Dependencies-✓-brightgreen" alt="Zero pip dependencies" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Responses_API-✓-success" />
|
||||||
|
<img src="https://img.shields.io/badge/Chat_Completions-✓-success" />
|
||||||
|
<img src="https://img.shields.io/badge/Anthropic_Messages-✓-success" />
|
||||||
|
<img src="https://img.shields.io/badge/Streaming_SSE-✓-success" />
|
||||||
|
<img src="https://img.shields.io/badge/Tool_Calls-✓-success" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
OpenAI's Codex CLI v2.0+ exclusively uses the **Responses API** — a protocol that is incompatible with virtually every other AI provider:
|
||||||
|
|
||||||
|
| Provider | API | Works with Codex? |
|
||||||
|
|----------|-----|:-:|
|
||||||
|
| OpenAI | Responses API | ✅ |
|
||||||
|
| Z.AI | Chat Completions | ❌ |
|
||||||
|
| OpenCode | Chat Completions | ❌ |
|
||||||
|
| Anthropic | Messages API | ❌ |
|
||||||
|
| Ollama | Chat Completions | ❌ |
|
||||||
|
| OpenRouter | Chat Completions | ❌ |
|
||||||
|
| NVIDIA NIM | Chat Completions | ❌ |
|
||||||
|
| Crof.ai | Chat Completions | ❌ |
|
||||||
|
|
||||||
|
The protocols differ in **endpoint paths**, **message formats**, **tool-call structures**, **streaming events**, and **completion semantics**. You can't just swap a base URL.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
A three-component system:
|
||||||
|
|
||||||
|
1. **Translation Proxy** — translates Responses API ↔ Chat Completions / Anthropic Messages in real-time
|
||||||
|
2. **Config Engine** — generates Codex config files on-the-fly per provider, with backup/restore
|
||||||
|
3. **GTK Launcher** — manages endpoints, launches Desktop or CLI, handles the full lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Codex Launcher GUI │
|
||||||
|
│ (endpoint management + lifecycle) │
|
||||||
|
└──────────┬─────────────────┬──────────────────┬────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
┌──────▼──────┐ ┌──────▼──────┐ ┌────────▼─────────┐
|
||||||
|
│ Codex │ │ Native │ │ Translation │
|
||||||
|
│ Default │ │ OpenAI │ │ Proxy │
|
||||||
|
│ (remove │ │ (direct │ │ (port 8080) │
|
||||||
|
│ config) │ │ URL) │ │ │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └────────┬─────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ┌────────┴────────┐
|
||||||
|
┌──────────────┐ ┌───────────┐ │ │
|
||||||
|
│ Built-in │ │ config. │ ▼ ▼
|
||||||
|
│ Codex OAuth │ │ toml │ ┌────────────┐ ┌───────────┐
|
||||||
|
└──────────────┘ └───────────┘ │ OpenAI │ │ Anthropic │
|
||||||
|
│ Chat Comp. │ │ Messages │
|
||||||
|
└────────────┘ └───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Multi-Provider Support
|
||||||
|
- **Native OpenAI** — direct connection, no proxy needed
|
||||||
|
- **OpenAI-compatible** — Z.AI, OpenCode Zen/Go, Crof.ai, NVIDIA NIM, Kilo.ai, OpenRouter, Ollama, Together, Groq, and any provider with a Chat Completions endpoint
|
||||||
|
- **Anthropic** — Claude models via the Messages API
|
||||||
|
- **Codex Default** — built-in Codex OAuth with official models, zero config
|
||||||
|
|
||||||
|
### Translation Proxy (`translate-proxy.py`)
|
||||||
|
- Full Responses API ↔ Chat Completions / Anthropic Messages bidirectional translation
|
||||||
|
- **Streaming SSE** support with proper event sequencing (`response.created` → `response.output_text.delta` → `response.completed`)
|
||||||
|
- **Tool calls** — full function calling support including parallel tool calls
|
||||||
|
- **Reasoning content** — forwards `reasoning_content` fields from providers that support it
|
||||||
|
- **Browser UA injection** — bypasses Cloudflare bot detection for providers like OpenCode
|
||||||
|
- **Smart URL construction** — prevents double-path bugs (`/v1/chat/completions/chat/completions`)
|
||||||
|
- **Header forwarding** — preserves client identity headers while filtering hop-by-hop headers
|
||||||
|
- Zero dependencies — pure Python stdlib
|
||||||
|
|
||||||
|
### GTK Launcher (`codex-launcher-gui`)
|
||||||
|
- **Endpoint manager** — add, edit, delete, set default providers
|
||||||
|
- **Provider presets** — one-click setup for 10+ providers with pre-filled URLs and model lists
|
||||||
|
- **Model auto-fetch** — pulls available models directly from provider APIs
|
||||||
|
- **Bulk model import** — paste a comma/newline-separated list of model IDs
|
||||||
|
- **Launch Desktop** — starts Codex Desktop with the selected provider and model
|
||||||
|
- **Launch CLI** — opens Codex CLI in a terminal with the selected provider
|
||||||
|
- **Codex Default** — launch with built-in OAuth, no proxy or custom config
|
||||||
|
- **Profile backup/import** — export and import endpoint configurations as portable JSON bundles
|
||||||
|
- **Threaded operations** — model refresh runs in background, UI stays responsive
|
||||||
|
- **Process lifecycle** — stall detection, kill/cleanup, config backup/restore around sessions
|
||||||
|
- **Config normalization** — automatically strips stale API path suffixes from URLs
|
||||||
|
|
||||||
|
### Process Management
|
||||||
|
- Kills stale electron/webview/app-server processes from previous sessions
|
||||||
|
- Removes stale PID files and sockets
|
||||||
|
- Manages proxy lifecycle (start, health-check, stop)
|
||||||
|
- Config backup before launch, automatic restore after exit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Component | Technology | Why |
|
||||||
|
|-----------|-----------|-----|
|
||||||
|
| Translation Proxy | Python 3 stdlib (`http.server`, `urllib`, `json`) | Zero dependencies, runs anywhere |
|
||||||
|
| GUI Launcher | Python 3 + GTK 3.0 (`PyGObject/gi`) | Native Linux desktop integration |
|
||||||
|
| Config Engine | Python 3 (`toml` generation, `json` catalogs) | Dynamic, no hardcoded configs |
|
||||||
|
| Process Mgmt | bash + `os.setsid`/`os.killpg` | Unix process group lifecycle |
|
||||||
|
| Streaming | Server-Sent Events (SSE) | Required by Codex Responses API |
|
||||||
|
| API Translation | Responses API ↔ Chat Completions / Anthropic Messages | Protocol bridging |
|
||||||
|
|
||||||
|
**Zero pip dependencies.** Everything uses Python stdlib + system GTK bindings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Codex CLI** ≥ 2.0 (`npm install -g @openai/codex` or bundled with Codex Desktop)
|
||||||
|
- **Codex Desktop** installed at `/opt/codex-desktop/` (optional, for Desktop mode)
|
||||||
|
- **Python 3.8+** (stdlib only)
|
||||||
|
- **python3-gi** for GTK (`sudo apt install python3-gi`)
|
||||||
|
- bash, curl, lsof
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.rommark.dev/admin/Codex-Launcher---Any-AI-Porovider.git
|
||||||
|
cd Codex-Launcher---Any-AI-Porovider
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
Open **Codex Launcher** from your app grid, or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codex-launcher-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### First Launch
|
||||||
|
|
||||||
|
1. Click **Manage Endpoints** → **Add**
|
||||||
|
2. Select a provider preset (e.g., "OpenCode Zen (OpenAI-compatible)")
|
||||||
|
3. Enter your API key
|
||||||
|
4. Click **Fetch from API** to auto-discover models, or add them manually
|
||||||
|
5. Click **Save**
|
||||||
|
6. Select the endpoint and model from the dropdowns
|
||||||
|
7. Click **Launch Desktop** or **Launch CLI**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Journey
|
||||||
|
|
||||||
|
### Phase 1: The Z.AI Proxy — Protocol Reverse Engineering
|
||||||
|
|
||||||
|
**Problem:** Codex CLI v2.0 switched exclusively to the Responses API. Z.AI (and every other provider) uses Chat Completions. They are fundamentally incompatible.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
1. Captured Codex's HTTP traffic to understand the exact Responses API request/response shape
|
||||||
|
2. Mapped the protocol differences:
|
||||||
|
- `/v1/responses` → `/chat/completions` (endpoint)
|
||||||
|
- `input` array with typed items → `messages` array with role/content (message format)
|
||||||
|
- `function_call` items → `tool_calls` array on assistant messages (tool format)
|
||||||
|
- SSE `response.output_text.delta` events → `delta.content` chunks (streaming)
|
||||||
|
3. Built the initial `zai-proxy.py` — a 200-line HTTP server that translates in both directions
|
||||||
|
4. Hit a critical bug: Codex hung in "thinking" state. Discovered that merely emitting `response.done` is insufficient — the `response.completed` event **must** contain the full output item array
|
||||||
|
5. Added streaming SSE with proper event sequencing — this was the breakthrough that made it work
|
||||||
|
|
||||||
|
**Testing:** Manual end-to-end testing with `curl`, Codex CLI `--profile zai`, and Codex Desktop. Verified streaming, tool calls, and reasoning content.
|
||||||
|
|
||||||
|
### Phase 2: Multi-Provider Architecture — The Unified Proxy
|
||||||
|
|
||||||
|
**Problem:** Maintaining separate proxies for each provider (Z.AI, Anthropic, OpenRouter, etc.) was unmaintainable.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
1. Abstracted the translation into a backend plugin architecture:
|
||||||
|
- `openai-compat` backend: translates Responses → Chat Completions (works for any OpenAI-compatible API)
|
||||||
|
- `anthropic` backend: translates Responses → Anthropic Messages API
|
||||||
|
2. Unified config loading: JSON config file, CLI arguments, and environment variables
|
||||||
|
3. Shared HTTP server, model serving, and SSE framework
|
||||||
|
4. Per-backend: message conversion, tool conversion, response conversion, stream conversion
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
- Pure Python stdlib — no Flask, no aiohttp, no pip dependencies. The proxy must work on any system with Python 3.
|
||||||
|
- `http.server.BaseHTTPRequestHandler` — simple, synchronous, but sufficient for single-user desktop use
|
||||||
|
- Config via JSON file — the launcher writes a proxy config to `~/.cache/codex-proxy/` for each endpoint
|
||||||
|
|
||||||
|
### Phase 3: The GTK Launcher — Desktop Integration
|
||||||
|
|
||||||
|
**Problem:** Users had to manage config files, start proxies manually, and remember which wrapper script to use.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
1. Built a GTK 3.0 GUI with three layers: main window, endpoint manager dialog, edit endpoint dialog
|
||||||
|
2. Implemented dynamic config generation: for each launch, generate `config.toml` with the right provider definition, profile, and model catalog
|
||||||
|
3. Model catalog generation with dual field naming (`slug` + `model`, `supported_reasoning_levels` + `supportedReasoningEfforts`) — required because Codex CLI and Codex Desktop use different field names
|
||||||
|
4. Process lifecycle: backup config → cleanup stale processes → start proxy → write config → launch Codex → wait → restore config → stop proxy
|
||||||
|
|
||||||
|
**Threading challenges:**
|
||||||
|
- GTK requires all UI updates on the main loop. Used `GLib.idle_add()` for all cross-thread communication
|
||||||
|
- Model refresh was blocking the UI — moved to a background thread with idle_add completion callbacks
|
||||||
|
- Proxy startup waits up to 15 seconds with health checks before proceeding
|
||||||
|
|
||||||
|
### Phase 4: Endpoint Management & Provider Presets
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
1. JSON-based endpoint storage (`~/.codex/endpoints.json`)
|
||||||
|
2. Provider presets with pre-filled URLs and model lists for common providers
|
||||||
|
3. Auto-fetch models from provider `/v1/models` endpoints
|
||||||
|
4. Bulk model import (paste comma/newline-separated model IDs)
|
||||||
|
5. Profile backup/import — portable JSON bundles with endpoints + config
|
||||||
|
|
||||||
|
**URL normalization:** Discovered that saved URLs sometimes had `/chat/completions` appended from manual entry. Added `normalize_base_url()` that strips trailing API path suffixes to prevent double-path bugs.
|
||||||
|
|
||||||
|
### Phase 5: Cloudflare Bot Detection — The OpenCode 403 Saga
|
||||||
|
|
||||||
|
**Problem:** OpenCode Zen/Go returned 403 (Cloudflare error 1010) for all requests.
|
||||||
|
|
||||||
|
**Investigation:**
|
||||||
|
1. Tested direct `curl` requests — all returned 403 regardless of auth header type (Bearer, x-api-key, both, none)
|
||||||
|
2. Examined Codex logs — found `turn.has_metadata_header=true` and `error code: 1010`
|
||||||
|
3. Error 1010 is Cloudflare's "Access denied" — bot detection based on User-Agent and browser headers
|
||||||
|
4. Python's `urllib` sends `User-Agent: Python-urllib/3.x` which triggers the block
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Added `_BROWSER_HEADERS` — a set of Chrome-like headers (User-Agent, Sec-Ch-Ua, Sec-Fetch-*)
|
||||||
|
2. `forwarded_headers()` with `browser_ua=True` — injects browser headers while preserving incoming client headers and filtering hop-by-hop headers
|
||||||
|
3. Applied only to `openai-compat` backend where Cloudflare is common
|
||||||
|
4. This resolved the 403 — upstream now returns 401 (auth) instead of 403 (bot block), confirming the headers work
|
||||||
|
|
||||||
|
### Phase 6: Codex Default Mode — OAuth Without Config
|
||||||
|
|
||||||
|
**Problem:** Users wanted to quickly switch back to built-in Codex OAuth without maintaining a separate endpoint.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
1. Added "Codex Default (Desktop)" and "Codex Default (CLI)" buttons
|
||||||
|
2. On launch: backup config → **delete** `config.toml` entirely → start Codex → restore config after exit
|
||||||
|
3. Key insight: writing empty strings (`model = ""`, `model_provider = ""`) causes Codex to error with "Model provider `` not found". The config must not exist at all for Codex to fall back to built-in defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Deep Dive
|
||||||
|
|
||||||
|
### Request Flow (OpenAI-compatible provider)
|
||||||
|
|
||||||
|
```
|
||||||
|
Codex CLI/Desktop
|
||||||
|
│
|
||||||
|
│ POST /responses (Responses API)
|
||||||
|
│ Body: { model: "glm-5.1", input: [...], stream: true }
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ translate-proxy.py (port 8080) │
|
||||||
|
│ │
|
||||||
|
│ 1. Parse Responses API body │
|
||||||
|
│ 2. Convert input → messages │
|
||||||
|
│ 3. Convert tools format │
|
||||||
|
│ 4. Inject browser UA headers │
|
||||||
|
│ │
|
||||||
|
│ POST /v1/chat/completions │──→ Upstream Provider
|
||||||
|
│ Body: { model, messages, ... }│ (opencode.ai, z.ai, etc.)
|
||||||
|
│ │
|
||||||
|
│ 5. Receive Chat Comp. response │ ←── SSE stream or JSON
|
||||||
|
│ 6. Convert response format │
|
||||||
|
│ 7. Emit Responses API SSE │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ SSE: response.created
|
||||||
|
│ SSE: response.output_text.delta (streamed tokens)
|
||||||
|
│ SSE: response.output_text.done
|
||||||
|
│ SSE: response.completed
|
||||||
|
▼
|
||||||
|
Codex CLI/Desktop (receives Responses API events)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐
|
||||||
|
│ Backup │───→│ Cleanup │───→│ Generate │───→│ Launch │───→│ Restore │
|
||||||
|
│ config │ │ stale │ │ config │ │ Codex │ │ config │
|
||||||
|
│ .toml │ │ processes│ │ + models │ │ process │ │ from │
|
||||||
|
│ │ │ │ │ │ │ │ │ backup │
|
||||||
|
└─────────┘ └──────────┘ └──────────┘ └──────────┘ └─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Catalog Format
|
||||||
|
|
||||||
|
The launcher generates model catalog JSON with dual field naming to satisfy both Codex CLI and Codex Desktop:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"models": [{
|
||||||
|
"slug": "glm-5.1", // CLI reads this
|
||||||
|
"model": "glm-5.1", // Desktop reads this
|
||||||
|
"supported_reasoning_levels": [...], // CLI
|
||||||
|
"supportedReasoningEfforts": [...], // Desktop
|
||||||
|
...
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider Presets
|
||||||
|
|
||||||
|
| Preset | Backend | Base URL |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| OpenAI | Native | `https://api.openai.com/v1` |
|
||||||
|
| Anthropic | Anthropic | `https://api.anthropic.com/v1` |
|
||||||
|
| OpenCode Zen | OpenAI-compat | `https://opencode.ai/zen/v1` |
|
||||||
|
| OpenCode Go | OpenAI-compat | `https://opencode.ai/zen/go/v1` |
|
||||||
|
| Crof.ai | OpenAI-compat | `https://crof.ai/v1` |
|
||||||
|
| NVIDIA NIM | OpenAI-compat | `https://integrate.api.nvidia.com/v1` |
|
||||||
|
| Kilo.ai | OpenAI-compat | `https://api.kilo.ai/api/gateway` |
|
||||||
|
| OpenRouter | OpenAI-compat | `https://openrouter.ai/api/v1` |
|
||||||
|
| Z.AI | OpenAI-compat | `https://api.z.ai/api/coding/paas/v4` |
|
||||||
|
| Custom | Any | User-defined |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── translate-proxy.py # Translation proxy (openai-compat + anthropic)
|
||||||
|
├── codex-launcher-gui # GTK launcher GUI
|
||||||
|
├── cleanup-codex-stale.sh # Stale process cleanup
|
||||||
|
└── codex-launcher.desktop.template # Desktop entry template
|
||||||
|
|
||||||
|
install.sh # One-command installer
|
||||||
|
README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installed Locations
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.local/bin/translate-proxy.py # Proxy
|
||||||
|
~/.local/bin/codex-launcher-gui # Launcher
|
||||||
|
~/.local/bin/cleanup-codex-stale.sh # Cleanup
|
||||||
|
~/.local/share/applications/codex-launcher.desktop # App grid entry
|
||||||
|
~/.codex/endpoints.json # Endpoint storage
|
||||||
|
~/.codex/config.toml # Codex config (auto-generated)
|
||||||
|
~/.cache/codex-proxy/ # Proxy configs + model catalogs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Cause | Fix |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| Window opens then disappears | Stale processes from previous session | Click **Kill && Cleanup** |
|
||||||
|
| Window never opens | Startup freeze | Kill && Cleanup, then retry |
|
||||||
|
| "Model provider `` not found" | Empty strings in config | V2 deletes config entirely for Default mode |
|
||||||
|
| 403 Forbidden from OpenCode | Cloudflare bot detection | Proxy injects browser UA headers |
|
||||||
|
| 401 Unauthorized from OpenCode | Invalid key or no credits | Check API key and billing |
|
||||||
|
| Double path in URL | Stale `/chat/completions` in base URL | `normalize_base_url()` strips suffixes |
|
||||||
|
| Proxy stops when terminal closes | SIGHUP to subprocess | Launcher uses `os.setsid` process groups |
|
||||||
|
| Models not showing in picker | Wrong model catalog format | Must have both `slug` + `model` fields |
|
||||||
|
| Codex hangs in "thinking" | Missing `response.completed` | Proxy emits full SSE event sequence |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Provider
|
||||||
|
|
||||||
|
1. Click **Manage Endpoints** → **Add**
|
||||||
|
2. Choose a preset or set **Custom**
|
||||||
|
3. Set backend type: `OpenAI-compatible`, `Anthropic`, or `Native`
|
||||||
|
4. Enter base URL and API key
|
||||||
|
5. Click **Fetch from API** or add models manually
|
||||||
|
6. Save and launch
|
||||||
|
|
||||||
|
For providers behind Cloudflare, the proxy automatically injects browser headers. For providers with non-standard APIs, add a new backend module to `translate-proxy.py` following the `oa_*` / `an_*` pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Proxy Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start proxy for any OpenAI-compatible provider
|
||||||
|
python3 src/translate-proxy.py \
|
||||||
|
--backend openai-compat \
|
||||||
|
--target-url https://api.your-provider.com/v1 \
|
||||||
|
--api-key YOUR_KEY \
|
||||||
|
--port 8080
|
||||||
|
|
||||||
|
# Or use a JSON config
|
||||||
|
python3 src/translate-proxy.py --config my-proxy-config.json
|
||||||
|
|
||||||
|
# Then run Codex
|
||||||
|
codex --profile my-profile -c model=my-model
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python ≥ 3.8
|
||||||
|
- python3-gi (`sudo apt install python3-gi`)
|
||||||
|
- Codex CLI ≥ 2.0
|
||||||
|
- Codex Desktop (optional, for Desktop mode)
|
||||||
|
- bash, curl, lsof
|
||||||
|
|
||||||
|
**No pip dependencies.** Zero. Pure stdlib + system GTK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Get 10% OFF Z.AI coding plans</strong><br/>
|
||||||
|
<a href="https://z.ai/subscribe?ic=ROK78RJKNW">
|
||||||
|
<img src="https://img.shields.io/badge/Claim_Your_Discount-10%25_OFF-6366f1?style=for-the-badge&labelColor=4f46e5" alt="Z.AI 10% OFF" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
29
install.sh
Executable file
29
install.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
BIN_DIR="$HOME/.local/bin"
|
||||||
|
APP_DIR="$HOME/.local/share/applications"
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR" "$APP_DIR"
|
||||||
|
|
||||||
|
cp "$SCRIPT_DIR/src/translate-proxy.py" "$BIN_DIR/"
|
||||||
|
cp "$SCRIPT_DIR/src/codex-launcher-gui" "$BIN_DIR/"
|
||||||
|
cp "$SCRIPT_DIR/src/cleanup-codex-stale.sh" "$BIN_DIR/"
|
||||||
|
|
||||||
|
chmod +x "$BIN_DIR/translate-proxy.py"
|
||||||
|
chmod +x "$BIN_DIR/codex-launcher-gui"
|
||||||
|
chmod +x "$BIN_DIR/cleanup-codex-stale.sh"
|
||||||
|
|
||||||
|
USERNAME=$(whoami)
|
||||||
|
sed "s/YOUR_USERNAME/$USERNAME/g" "$SCRIPT_DIR/src/codex-launcher.desktop.template" > "$APP_DIR/codex-launcher.desktop"
|
||||||
|
|
||||||
|
update-desktop-database "$APP_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Installed."
|
||||||
|
echo " translate-proxy.py -> $BIN_DIR/translate-proxy.py"
|
||||||
|
echo " codex-launcher-gui -> $BIN_DIR/codex-launcher-gui"
|
||||||
|
echo " cleanup-codex-stale -> $BIN_DIR/cleanup-codex-stale.sh"
|
||||||
|
echo " desktop entry -> $APP_DIR/codex-launcher.desktop"
|
||||||
|
echo ""
|
||||||
|
echo "Open 'Codex Launcher' from your app grid, or run: codex-launcher-gui"
|
||||||
57
src/cleanup-codex-stale.sh
Executable file
57
src/cleanup-codex-stale.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Cleanup script for Codex Desktop - kills stale processes before launch
|
||||||
|
|
||||||
|
echo "Cleaning up stale Codex processes..." >&2
|
||||||
|
|
||||||
|
# Kill codex app-server processes
|
||||||
|
for pid in $(ps aux 2>/dev/null | grep -E "codex .*app-server" | grep -v grep | awk '{print $2}'); do
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
echo " Killed app-server pid=$pid"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Kill webview server
|
||||||
|
for pid in $(ps aux 2>/dev/null | grep webview-server.py | grep -v grep | awk '{print $2}'); do
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
echo " Killed webview-server pid=$pid"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Kill main electron process for codex-desktop
|
||||||
|
for pid in $(ps aux 2>/dev/null | grep "/opt/codex-desktop/electron" | grep "class=codex-desktop" | grep -v grep | awk '{print $2}'); do
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
echo " Killed electron pid=$pid"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Kill all remaining child processes of codex-desktop
|
||||||
|
for pid in $(ps aux 2>/dev/null | grep "/opt/codex-desktop/" | grep -v grep | awk '{print $2}'); do
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Kill zai proxy (if any)
|
||||||
|
for pid in $(ps aux 2>/dev/null | grep zai-proxy.py | grep -v grep | awk '{print $2}'); do
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Kill unified translation proxy (if any)
|
||||||
|
for pid in $(ps aux 2>/dev/null | grep translate-proxy.py | grep -v grep | awk '{print $2}'); do
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Remove stale socket and PID files
|
||||||
|
rm -f "$HOME/.codex/.launch-action-socket" 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.codex/.codex-desktop-launch-action" 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.local/share/codex-desktop/.launch-action-socket" 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.cache/codex-desktop/.launch-action-socket" 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.local/share/codex-desktop/.codex-desktop-pid" 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.cache/codex-desktop/.codex-desktop-pid" 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.local/share/codex-desktop/.webview-pid" 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.cache/codex-desktop/.webview-pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Verify no remaining process on port 5175 (webview)
|
||||||
|
if lsof -ti :5175 2>/dev/null | grep -q .; then
|
||||||
|
echo " Warning: Port 5175 still in use"
|
||||||
|
lsof -ti :5175 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleanup complete"
|
||||||
1362
src/codex-launcher-gui
Executable file
1362
src/codex-launcher-gui
Executable file
File diff suppressed because it is too large
Load Diff
10
src/codex-launcher.desktop.template
Normal file
10
src/codex-launcher.desktop.template
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Codex Launcher
|
||||||
|
Comment=Launch Codex Desktop with any AI provider
|
||||||
|
Exec=/home/YOUR_USERNAME/.local/bin/codex-launcher-gui
|
||||||
|
Icon=/opt/codex-desktop/resources/app.asar.unpacked/images/icon.png
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=true
|
||||||
|
Categories=Development;IDE;
|
||||||
|
Keywords=ai;coding;launcher;
|
||||||
632
src/translate-proxy.py
Executable file
632
src/translate-proxy.py
Executable file
@@ -0,0 +1,632 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
translate-proxy.py — Responses API → backend API translation proxy.
|
||||||
|
|
||||||
|
Backends:
|
||||||
|
openai-compat — any OpenAI-compatible Chat Completions API
|
||||||
|
anthropic — Anthropic Messages API
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 translate-proxy.py --config proxy-config.json
|
||||||
|
python3 translate-proxy.py --backend openai-compat --target-url https://... --api-key sk-...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json, http.server, urllib.request, time, uuid, os, sys, argparse
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Config
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DEFAULT_MODELS = {
|
||||||
|
"openai-compat": [
|
||||||
|
{"id": "gpt-4o-mini", "object": "model", "created": 1700000000, "owned_by": "custom"},
|
||||||
|
],
|
||||||
|
"anthropic": [
|
||||||
|
{"id": "claude-sonnet-4-20250514", "object": "model", "created": 1700000000, "owned_by": "anthropic"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
p = argparse.ArgumentParser(description="Responses API translation proxy")
|
||||||
|
p.add_argument("--config", help="JSON config file path")
|
||||||
|
p.add_argument("--port", type=int, default=None)
|
||||||
|
p.add_argument("--backend", default=None, choices=["openai-compat", "anthropic"])
|
||||||
|
p.add_argument("--target-url", default=None)
|
||||||
|
p.add_argument("--api-key", default=None)
|
||||||
|
p.add_argument("--models-file", default=None, help="JSON file with model list array")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
cfg = {}
|
||||||
|
if args.config:
|
||||||
|
with open(args.config) as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
|
||||||
|
for ck, ak in [("port", "port"), ("backend_type", "backend"),
|
||||||
|
("target_url", "target_url"), ("api_key", "api_key")]:
|
||||||
|
v = getattr(args, ak, None)
|
||||||
|
if v is not None:
|
||||||
|
cfg[ck] = v
|
||||||
|
|
||||||
|
env_map = {
|
||||||
|
"port": ("PROXY_PORT", "ZAI_PROXY_PORT", int),
|
||||||
|
"backend_type": ("PROXY_BACKEND", None, str),
|
||||||
|
"target_url": ("PROXY_TARGET_URL", "ZAI_BASE_URL", str),
|
||||||
|
"api_key": ("PROXY_API_KEY", "ZAI_API_KEY", str),
|
||||||
|
}
|
||||||
|
for ck, (ev1, ev2, conv) in env_map.items():
|
||||||
|
if ck not in cfg:
|
||||||
|
v = os.environ.get(ev1) or (os.environ.get(ev2) if ev2 else None)
|
||||||
|
if v:
|
||||||
|
cfg[ck] = conv(v) if conv == int else v
|
||||||
|
|
||||||
|
cfg.setdefault("port", 8080)
|
||||||
|
cfg.setdefault("backend_type", "openai-compat")
|
||||||
|
cfg.setdefault("target_url", "http://localhost:11434/v1")
|
||||||
|
cfg.setdefault("api_key", "")
|
||||||
|
|
||||||
|
models = cfg.get("models", [])
|
||||||
|
if not models and args.models_file:
|
||||||
|
with open(args.models_file) as f:
|
||||||
|
models = json.load(f)
|
||||||
|
if not models:
|
||||||
|
models = DEFAULT_MODELS.get(cfg["backend_type"], [])
|
||||||
|
cfg["models"] = models
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
CONFIG = load_config()
|
||||||
|
PORT = CONFIG["port"]
|
||||||
|
BACKEND = CONFIG["backend_type"]
|
||||||
|
TARGET_URL = CONFIG["target_url"].rstrip("/")
|
||||||
|
API_KEY = CONFIG["api_key"]
|
||||||
|
MODELS = CONFIG["models"]
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Shared helpers
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_pool = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
_HOP_BY_HOP_HEADERS = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailers",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"host",
|
||||||
|
"content-length",
|
||||||
|
}
|
||||||
|
|
||||||
|
def uid(prefix="id"):
|
||||||
|
return f"{prefix}-{_pool}-{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
def emit(event, data):
|
||||||
|
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
||||||
|
|
||||||
|
def upstream_target(base_url, suffix):
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith(suffix):
|
||||||
|
return base
|
||||||
|
return f"{base}{suffix}"
|
||||||
|
|
||||||
|
_BROWSER_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||||
|
"Accept": "application/json, text/event-stream, */*",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Sec-Ch-Ua": '"Chromium";v="137", "Not/A)Brand";v="99"',
|
||||||
|
"Sec-Ch-Ua-Mobile": "?0",
|
||||||
|
"Sec-Ch-Ua-Platform": '"Linux"',
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
}
|
||||||
|
|
||||||
|
def forwarded_headers(request_headers, extra=None, browser_ua=False):
|
||||||
|
headers = {}
|
||||||
|
if browser_ua:
|
||||||
|
headers.update(_BROWSER_HEADERS)
|
||||||
|
for key, value in request_headers.items():
|
||||||
|
if key.lower() in _HOP_BY_HOP_HEADERS:
|
||||||
|
continue
|
||||||
|
if browser_ua and key.lower() == "user-agent":
|
||||||
|
continue
|
||||||
|
headers[key] = value
|
||||||
|
if extra:
|
||||||
|
headers.update(extra)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# OpenAI-compat backend
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def oa_input_to_messages(input_data):
|
||||||
|
msgs = []
|
||||||
|
if isinstance(input_data, str):
|
||||||
|
msgs.append({"role": "user", "content": input_data})
|
||||||
|
elif isinstance(input_data, list):
|
||||||
|
for item in input_data:
|
||||||
|
t = item.get("type")
|
||||||
|
if t == "message":
|
||||||
|
role = item.get("role", "user")
|
||||||
|
text = ""
|
||||||
|
for part in item.get("content", []):
|
||||||
|
pt = part.get("type", "")
|
||||||
|
if pt in ("input_text", "output_text"):
|
||||||
|
text += part.get("text", "")
|
||||||
|
elif pt == "input_image":
|
||||||
|
img = part.get("image_url", part)
|
||||||
|
msgs.append({"role": role, "content": [{"type": "text", "text": text},
|
||||||
|
{"type": "image_url", "image_url": img}]})
|
||||||
|
text = None
|
||||||
|
break
|
||||||
|
if text is not None:
|
||||||
|
msgs.append({"role": role, "content": text})
|
||||||
|
elif t == "function_call":
|
||||||
|
msgs.append({"role": "assistant", "content": None, "tool_calls": [
|
||||||
|
{"id": item.get("call_id", item.get("id", uid("tc"))),
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": item.get("name", ""),
|
||||||
|
"arguments": item.get("arguments", "{}")}}
|
||||||
|
]})
|
||||||
|
elif t == "function_call_output":
|
||||||
|
msgs.append({"role": "tool", "tool_call_id": item.get("id", ""),
|
||||||
|
"content": item.get("output", "")})
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
def oa_convert_tools(tools):
|
||||||
|
if not tools:
|
||||||
|
return None
|
||||||
|
out = []
|
||||||
|
for t in tools:
|
||||||
|
if t.get("type") != "function":
|
||||||
|
continue
|
||||||
|
fn = t.get("function", {})
|
||||||
|
if fn:
|
||||||
|
out.append(t)
|
||||||
|
else:
|
||||||
|
out.append({
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": t.get("name", ""), "description": t.get("description", ""),
|
||||||
|
"parameters": t.get("parameters", {})}
|
||||||
|
})
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
def oa_resp_to_responses(chat_resp, model, resp_id=None):
|
||||||
|
choice = chat_resp["choices"][0]
|
||||||
|
msg = choice["message"]
|
||||||
|
content = msg.get("content") or ""
|
||||||
|
finish = choice.get("finish_reason", "stop")
|
||||||
|
fm = {"stop": "completed", "length": "incomplete", "tool_calls": "completed", "content_filter": "incomplete"}
|
||||||
|
status = fm.get(finish, "incomplete")
|
||||||
|
outputs = []
|
||||||
|
rc = msg.get("reasoning_content")
|
||||||
|
if rc:
|
||||||
|
outputs.append({"type": "reasoning", "id": uid("rsn"), "status": "completed",
|
||||||
|
"content": [{"type": "text", "text": rc}]})
|
||||||
|
if content:
|
||||||
|
outputs.append({"type": "message", "id": uid("msg"), "role": "assistant", "status": "completed",
|
||||||
|
"content": [{"type": "output_text", "text": content, "annotations": []}]})
|
||||||
|
for tc in msg.get("tool_calls") or []:
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
outputs.append({"type": "function_call", "id": uid("fc"), "call_id": tc.get("id"),
|
||||||
|
"name": fn.get("name"), "arguments": fn.get("arguments", "{}"), "status": "completed"})
|
||||||
|
usage = chat_resp.get("usage", {})
|
||||||
|
return {"id": resp_id or uid("resp"), "object": "response", "created": int(time.time()),
|
||||||
|
"model": model, "status": status, "output": outputs,
|
||||||
|
"usage": {"input_tokens": usage.get("prompt_tokens", 0),
|
||||||
|
"output_tokens": usage.get("completion_tokens", 0),
|
||||||
|
"total_tokens": usage.get("total_tokens", 0),
|
||||||
|
"input_tokens_details": {"cached_tokens": usage.get("prompt_tokens_details", {}).get("cached_tokens", 0)}}}
|
||||||
|
|
||||||
|
def oa_stream_to_sse(chat_stream, model, req_id):
|
||||||
|
resp_id = req_id or uid("resp")
|
||||||
|
msg_id = uid("msg")
|
||||||
|
text_buf = ""
|
||||||
|
tc_buf = {}
|
||||||
|
fr = None
|
||||||
|
|
||||||
|
yield emit("response.created", {"type": "response.created",
|
||||||
|
"response": {"id": resp_id, "object": "response", "model": model,
|
||||||
|
"status": "in_progress", "created": int(time.time()), "output": []}})
|
||||||
|
yield emit("response.in_progress", {"type": "response.in_progress", "response": {"id": resp_id}})
|
||||||
|
yield emit("response.output_item.added", {"type": "response.output_item.added",
|
||||||
|
"item": {"type": "message", "id": msg_id, "role": "assistant", "status": "in_progress", "content": []}})
|
||||||
|
yield emit("response.content_part.added", {"type": "response.content_part.added",
|
||||||
|
"part": {"type": "output_text", "text": "", "annotations": []}, "item_id": msg_id})
|
||||||
|
|
||||||
|
for line in chat_stream:
|
||||||
|
line = line.decode("utf-8", errors="replace").strip()
|
||||||
|
if not line or line.startswith(":") or line == "data: [DONE]":
|
||||||
|
continue
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chunk = json.loads(line[6:])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
choices = chunk.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
continue
|
||||||
|
delta = choices[0].get("delta", {})
|
||||||
|
fr = choices[0].get("finish_reason")
|
||||||
|
|
||||||
|
content = delta.get("content")
|
||||||
|
if content:
|
||||||
|
text_buf += content
|
||||||
|
yield emit("response.output_text.delta", {"type": "response.output_text.delta",
|
||||||
|
"delta": content, "item_id": msg_id, "content_index": 0})
|
||||||
|
|
||||||
|
for tc in delta.get("tool_calls") or []:
|
||||||
|
idx = tc.get("index", 0)
|
||||||
|
if idx not in tc_buf:
|
||||||
|
fid = uid("fc")
|
||||||
|
tc_buf[idx] = {"id": fid, "call_id": tc.get("id", fid), "name": "", "args": ""}
|
||||||
|
yield emit("response.output_item.added", {"type": "response.output_item.added",
|
||||||
|
"item": {"type": "function_call", "id": fid, "call_id": tc_buf[idx]["call_id"],
|
||||||
|
"name": "", "arguments": "", "status": "in_progress"}})
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
if "name" in fn and fn["name"]:
|
||||||
|
tc_buf[idx]["name"] = fn["name"]
|
||||||
|
if "arguments" in fn and fn["arguments"]:
|
||||||
|
tc_buf[idx]["args"] += fn["arguments"]
|
||||||
|
yield emit("response.output_text.delta", {"type": "response.function_call_arguments.delta",
|
||||||
|
"delta": fn["arguments"], "item_id": tc_buf[idx]["id"]})
|
||||||
|
|
||||||
|
rc = delta.get("reasoning_content")
|
||||||
|
if rc:
|
||||||
|
yield emit("response.reasoning.delta", {"type": "response.reasoning.delta", "delta": rc})
|
||||||
|
|
||||||
|
if text_buf:
|
||||||
|
yield emit("response.output_text.done", {"type": "response.output_text.done",
|
||||||
|
"text": text_buf, "item_id": msg_id, "content_index": 0})
|
||||||
|
yield emit("response.content_part.done", {"type": "response.content_part.done",
|
||||||
|
"part": {"type": "output_text", "text": text_buf, "annotations": []}, "item_id": msg_id})
|
||||||
|
yield emit("response.output_item.done", {"type": "response.output_item.done",
|
||||||
|
"item": {"type": "message", "id": msg_id, "role": "assistant", "status": "completed",
|
||||||
|
"content": [{"type": "output_text", "text": text_buf, "annotations": []}]}})
|
||||||
|
|
||||||
|
for idx in sorted(tc_buf):
|
||||||
|
t = tc_buf[idx]
|
||||||
|
yield emit("response.function_call_arguments.done", {"type": "response.function_call_arguments.done",
|
||||||
|
"item_id": t["id"], "name": t["name"], "arguments": t["args"]})
|
||||||
|
yield emit("response.output_item.done", {"type": "response.output_item.done",
|
||||||
|
"item": {"type": "function_call", "id": t["id"], "call_id": t["call_id"],
|
||||||
|
"name": t["name"], "arguments": t["args"], "status": "completed"}})
|
||||||
|
|
||||||
|
fm = {"stop": "completed", "length": "incomplete", "tool_calls": "completed", "content_filter": "incomplete"}
|
||||||
|
status = fm.get(fr, "incomplete")
|
||||||
|
final_out = []
|
||||||
|
if text_buf:
|
||||||
|
final_out.append({"type": "message", "id": msg_id, "role": "assistant", "status": "completed",
|
||||||
|
"content": [{"type": "output_text", "text": text_buf, "annotations": []}]})
|
||||||
|
for idx in sorted(tc_buf):
|
||||||
|
t = tc_buf[idx]
|
||||||
|
final_out.append({"type": "function_call", "id": t["id"], "call_id": t["call_id"],
|
||||||
|
"name": t["name"], "arguments": t["args"], "status": "completed"})
|
||||||
|
yield emit("response.completed", {"type": "response.completed",
|
||||||
|
"response": {"id": resp_id, "object": "response", "model": model,
|
||||||
|
"status": status, "created": int(time.time()), "output": final_out}})
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Anthropic backend
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def an_input_to_messages(input_data):
|
||||||
|
msgs = []
|
||||||
|
if isinstance(input_data, str):
|
||||||
|
msgs.append({"role": "user", "content": input_data})
|
||||||
|
elif isinstance(input_data, list):
|
||||||
|
for item in input_data:
|
||||||
|
t = item.get("type")
|
||||||
|
if t == "message":
|
||||||
|
role = item.get("role", "user")
|
||||||
|
text = ""
|
||||||
|
for part in item.get("content", []):
|
||||||
|
pt = part.get("type", "")
|
||||||
|
if pt in ("input_text", "output_text"):
|
||||||
|
text += part.get("text", "")
|
||||||
|
if role == "assistant":
|
||||||
|
msgs.append({"role": "assistant", "content": text})
|
||||||
|
else:
|
||||||
|
msgs.append({"role": "user", "content": text})
|
||||||
|
elif t == "function_call":
|
||||||
|
msgs.append({"role": "assistant", "content": [
|
||||||
|
{"type": "tool_use", "id": item.get("call_id", item.get("id", uid("tu"))),
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"input": json.loads(item.get("arguments", "{}"))}
|
||||||
|
]})
|
||||||
|
elif t == "function_call_output":
|
||||||
|
msgs.append({"role": "user", "content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": item.get("id", ""),
|
||||||
|
"content": item.get("output", "")}
|
||||||
|
]})
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
def an_convert_tools(tools):
|
||||||
|
if not tools:
|
||||||
|
return None
|
||||||
|
out = []
|
||||||
|
for t in tools:
|
||||||
|
if t.get("type") != "function":
|
||||||
|
continue
|
||||||
|
fn = t.get("function", {})
|
||||||
|
if fn:
|
||||||
|
out.append({"name": fn.get("name"), "description": fn.get("description", ""),
|
||||||
|
"input_schema": fn.get("parameters", {"type": "object", "properties": {}})})
|
||||||
|
else:
|
||||||
|
out.append({"name": t.get("name"), "description": t.get("description", ""),
|
||||||
|
"input_schema": t.get("parameters", {"type": "object", "properties": {}})})
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
def an_resp_to_responses(anthro_resp, model, resp_id=None):
|
||||||
|
blocks = anthro_resp.get("content", [])
|
||||||
|
sr = anthro_resp.get("stop_reason", "end_turn")
|
||||||
|
sm = {"end_turn": "completed", "max_tokens": "incomplete", "stop_sequence": "completed", "tool_use": "completed"}
|
||||||
|
status = sm.get(sr, "incomplete")
|
||||||
|
outputs = []
|
||||||
|
for b in blocks:
|
||||||
|
bt = b.get("type", "")
|
||||||
|
if bt == "text":
|
||||||
|
outputs.append({"type": "message", "id": uid("msg"), "role": "assistant", "status": "completed",
|
||||||
|
"content": [{"type": "output_text", "text": b.get("text", ""), "annotations": []}]})
|
||||||
|
elif bt == "tool_use":
|
||||||
|
outputs.append({"type": "function_call", "id": uid("fc"), "call_id": b.get("id", ""),
|
||||||
|
"name": b.get("name", ""), "arguments": json.dumps(b.get("input", {})),
|
||||||
|
"status": "completed"})
|
||||||
|
elif bt == "thinking":
|
||||||
|
outputs.append({"type": "reasoning", "id": uid("rsn"), "status": "completed",
|
||||||
|
"content": [{"type": "text", "text": b.get("thinking", "")}]})
|
||||||
|
usage = anthro_resp.get("usage", {})
|
||||||
|
return {"id": resp_id or uid("resp"), "object": "response", "created": int(time.time()),
|
||||||
|
"model": model, "status": status, "output": outputs,
|
||||||
|
"usage": {"input_tokens": usage.get("input_tokens", 0),
|
||||||
|
"output_tokens": usage.get("output_tokens", 0),
|
||||||
|
"total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
|
||||||
|
"input_tokens_details": {"cached_tokens": 0}}}
|
||||||
|
|
||||||
|
def an_stream_to_sse(stream, model, req_id):
|
||||||
|
resp_id = req_id or uid("resp")
|
||||||
|
completed = []
|
||||||
|
msg_id = uid("msg")
|
||||||
|
text_buf = ""
|
||||||
|
tc_id = None
|
||||||
|
tc_call_id = None
|
||||||
|
tc_name = ""
|
||||||
|
tc_args = ""
|
||||||
|
block_type = None
|
||||||
|
stop_reason = "end_turn"
|
||||||
|
|
||||||
|
yield emit("response.created", {"type": "response.created",
|
||||||
|
"response": {"id": resp_id, "object": "response", "model": model,
|
||||||
|
"status": "in_progress", "created": int(time.time()), "output": []}})
|
||||||
|
yield emit("response.in_progress", {"type": "response.in_progress", "response": {"id": resp_id}})
|
||||||
|
|
||||||
|
for raw in stream:
|
||||||
|
line = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith("event: "):
|
||||||
|
evt_type = line[7:]
|
||||||
|
continue
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line[6:])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
et = data.get("type", "")
|
||||||
|
|
||||||
|
if et == "message_start":
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif et == "content_block_start":
|
||||||
|
cb_type = data.get("content_block", {}).get("type", "")
|
||||||
|
block_type = cb_type
|
||||||
|
if cb_type == "text":
|
||||||
|
msg_id = uid("msg")
|
||||||
|
yield emit("response.output_item.added", {"type": "response.output_item.added",
|
||||||
|
"item": {"type": "message", "id": msg_id, "role": "assistant",
|
||||||
|
"status": "in_progress", "content": []}})
|
||||||
|
yield emit("response.content_part.added", {"type": "response.content_part.added",
|
||||||
|
"part": {"type": "output_text", "text": "", "annotations": []}, "item_id": msg_id})
|
||||||
|
elif cb_type == "tool_use":
|
||||||
|
cb = data.get("content_block", {})
|
||||||
|
tc_id = uid("fc")
|
||||||
|
tc_call_id = cb.get("id", tc_id)
|
||||||
|
tc_name = cb.get("name", "")
|
||||||
|
yield emit("response.output_item.added", {"type": "response.output_item.added",
|
||||||
|
"item": {"type": "function_call", "id": tc_id, "call_id": tc_call_id,
|
||||||
|
"name": tc_name, "arguments": "", "status": "in_progress"}})
|
||||||
|
elif cb_type == "thinking":
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif et == "content_block_delta":
|
||||||
|
dd = data.get("delta", {})
|
||||||
|
dt = dd.get("type", "")
|
||||||
|
if dt == "text_delta":
|
||||||
|
txt = dd.get("text", "")
|
||||||
|
text_buf += txt
|
||||||
|
yield emit("response.output_text.delta", {"type": "response.output_text.delta",
|
||||||
|
"delta": txt, "item_id": msg_id, "content_index": 0})
|
||||||
|
elif dt == "input_json_delta":
|
||||||
|
pj = dd.get("partial_json", "")
|
||||||
|
tc_args += pj
|
||||||
|
yield emit("response.output_text.delta", {"type": "response.function_call_arguments.delta",
|
||||||
|
"delta": pj, "item_id": tc_id})
|
||||||
|
elif dt == "thinking_delta":
|
||||||
|
tk = dd.get("thinking", "")
|
||||||
|
yield emit("response.reasoning.delta", {"type": "response.reasoning.delta", "delta": tk})
|
||||||
|
|
||||||
|
elif et == "content_block_stop":
|
||||||
|
if block_type == "text":
|
||||||
|
yield emit("response.output_text.done", {"type": "response.output_text.done",
|
||||||
|
"text": text_buf, "item_id": msg_id, "content_index": 0})
|
||||||
|
yield emit("response.content_part.done", {"type": "response.content_part.done",
|
||||||
|
"part": {"type": "output_text", "text": text_buf, "annotations": []}, "item_id": msg_id})
|
||||||
|
yield emit("response.output_item.done", {"type": "response.output_item.done",
|
||||||
|
"item": {"type": "message", "id": msg_id, "role": "assistant", "status": "completed",
|
||||||
|
"content": [{"type": "output_text", "text": text_buf, "annotations": []}]}})
|
||||||
|
completed.append({"type": "message", "id": msg_id, "role": "assistant", "status": "completed",
|
||||||
|
"content": [{"type": "output_text", "text": text_buf, "annotations": []}]})
|
||||||
|
text_buf = ""
|
||||||
|
elif block_type == "tool_use":
|
||||||
|
yield emit("response.function_call_arguments.done", {"type": "response.function_call_arguments.done",
|
||||||
|
"item_id": tc_id, "name": tc_name, "arguments": tc_args})
|
||||||
|
yield emit("response.output_item.done", {"type": "response.output_item.done",
|
||||||
|
"item": {"type": "function_call", "id": tc_id, "call_id": tc_call_id,
|
||||||
|
"name": tc_name, "arguments": tc_args, "status": "completed"}})
|
||||||
|
completed.append({"type": "function_call", "id": tc_id, "call_id": tc_call_id,
|
||||||
|
"name": tc_name, "arguments": tc_args, "status": "completed"})
|
||||||
|
tc_id = None
|
||||||
|
tc_args = ""
|
||||||
|
block_type = None
|
||||||
|
|
||||||
|
elif et == "message_delta":
|
||||||
|
stop_reason = data.get("delta", {}).get("stop_reason", "end_turn")
|
||||||
|
|
||||||
|
elif et == "message_stop":
|
||||||
|
sm = {"end_turn": "completed", "max_tokens": "incomplete",
|
||||||
|
"stop_sequence": "completed", "tool_use": "completed"}
|
||||||
|
status = sm.get(stop_reason, "incomplete")
|
||||||
|
yield emit("response.completed", {"type": "response.completed",
|
||||||
|
"response": {"id": resp_id, "object": "response", "model": model,
|
||||||
|
"status": status, "created": int(time.time()), "output": completed}})
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# HTTP Server
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path in ("/v1/models", "/models"):
|
||||||
|
self.send_json(200, {"object": "list", "data": MODELS})
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path in ("/v1/responses", "/responses"):
|
||||||
|
self._handle()
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def _handle(self):
|
||||||
|
try:
|
||||||
|
clen = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = json.loads(self.rfile.read(clen))
|
||||||
|
except Exception as e:
|
||||||
|
return self.send_json(400, {"error": {"message": f"Bad request: {e}"}})
|
||||||
|
|
||||||
|
model = body.get("model", MODELS[0]["id"] if MODELS else "unknown")
|
||||||
|
stream = body.get("stream", False)
|
||||||
|
|
||||||
|
if BACKEND == "anthropic":
|
||||||
|
self._handle_anthropic(body, model, stream)
|
||||||
|
else:
|
||||||
|
self._handle_openai_compat(body, model, stream)
|
||||||
|
|
||||||
|
def _handle_openai_compat(self, body, model, stream):
|
||||||
|
input_data = body.get("input", "")
|
||||||
|
chat_body = {"model": model, "messages": oa_input_to_messages(input_data)}
|
||||||
|
for k in ("temperature", "top_p", "max_output_tokens"):
|
||||||
|
if k in body:
|
||||||
|
chat_body["max_tokens" if k == "max_output_tokens" else k] = body[k]
|
||||||
|
tools = oa_convert_tools(body.get("tools"))
|
||||||
|
if tools:
|
||||||
|
chat_body["tools"] = tools
|
||||||
|
if body.get("tool_choice"):
|
||||||
|
chat_body["tool_choice"] = body["tool_choice"]
|
||||||
|
chat_body["stream"] = stream
|
||||||
|
|
||||||
|
target = upstream_target(TARGET_URL, "/chat/completions")
|
||||||
|
fwd = forwarded_headers(self.headers, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {API_KEY}",
|
||||||
|
}, browser_ua=True)
|
||||||
|
print(f"[translate-proxy] POST {target} model={model} stream={stream} ua={fwd.get('User-Agent','')[:50]}", file=sys.stderr)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
target,
|
||||||
|
data=json.dumps(chat_body).encode(),
|
||||||
|
headers=fwd,
|
||||||
|
)
|
||||||
|
self._forward(req, stream, model,
|
||||||
|
lambda r: oa_resp_to_responses(json.loads(r.read()), model),
|
||||||
|
lambda s: oa_stream_to_sse(s, model, body.get("request_id") or body.get("id")))
|
||||||
|
|
||||||
|
def _handle_anthropic(self, body, model, stream):
|
||||||
|
input_data = body.get("input", "")
|
||||||
|
an_body = {"model": model, "messages": an_input_to_messages(input_data),
|
||||||
|
"max_tokens": body.get("max_output_tokens", 8192)}
|
||||||
|
for k in ("temperature", "top_p"):
|
||||||
|
if k in body:
|
||||||
|
an_body[k] = body[k]
|
||||||
|
tools = an_convert_tools(body.get("tools"))
|
||||||
|
if tools:
|
||||||
|
an_body["tools"] = tools
|
||||||
|
if body.get("tool_choice"):
|
||||||
|
tc = body["tool_choice"]
|
||||||
|
if isinstance(tc, str):
|
||||||
|
an_body["tool_choice"] = {"type": tc}
|
||||||
|
elif isinstance(tc, dict):
|
||||||
|
an_body["tool_choice"] = tc
|
||||||
|
an_body["stream"] = stream
|
||||||
|
|
||||||
|
target = upstream_target(TARGET_URL, "/messages")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
target,
|
||||||
|
data=json.dumps(an_body).encode(),
|
||||||
|
headers=forwarded_headers(self.headers, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
self._forward(req, stream, model,
|
||||||
|
lambda r: an_resp_to_responses(json.loads(r.read()), model),
|
||||||
|
lambda s: an_stream_to_sse(s, model, body.get("request_id") or body.get("id")))
|
||||||
|
|
||||||
|
def _forward(self, req, stream, model, nonstream_fn, stream_fn):
|
||||||
|
try:
|
||||||
|
upstream = urllib.request.urlopen(req)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err = e.read().decode()
|
||||||
|
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}})
|
||||||
|
except Exception as e:
|
||||||
|
return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}})
|
||||||
|
|
||||||
|
if stream:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Connection", "keep-alive")
|
||||||
|
self.end_headers()
|
||||||
|
for event in stream_fn(upstream):
|
||||||
|
self.wfile.write(event.encode("utf-8"))
|
||||||
|
self.wfile.flush()
|
||||||
|
else:
|
||||||
|
result = nonstream_fn(upstream)
|
||||||
|
self.send_json(200, result)
|
||||||
|
|
||||||
|
def send_json(self, status, data):
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
msg = fmt % args if args else fmt
|
||||||
|
print(f"[translate-proxy] {BACKEND} {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
|
||||||
|
print(f"translate-proxy ({BACKEND}) listening on http://127.0.0.1:{PORT}", flush=True)
|
||||||
|
print(f"Target: {TARGET_URL}", flush=True)
|
||||||
|
print(f"Models: {[m['id'] for m in MODELS]}", flush=True)
|
||||||
|
server.serve_forever()
|
||||||
Reference in New Issue
Block a user