diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1780818 --- /dev/null +++ b/AGENTS.md @@ -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` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..953efc9 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/translate-proxy.py b/translate-proxy.py index 8b81dd9..0641346 100755 --- a/translate-proxy.py +++ b/translate-proxy.py @@ -5677,23 +5677,40 @@ class Handler(http.server.BaseHTTPRequestHandler): break self._forward_oa_compat(upstream, stream, model, chat_body, body, input_data, fwd, target, tracker) + @staticmethod + def _is_mimo_provider(): + return "xiaomimimo.com" in TARGET_URL + def _build_chat_body(self, model, messages, body, stream): chat_body = {"model": model, "messages": messages} + is_mimo = self._is_mimo_provider() for k in ("temperature", "top_p"): if k in body: chat_body[k] = body[k] - chat_body["max_tokens"] = max(body.get("max_output_tokens", 0), 64000) + max_tok = max(body.get("max_output_tokens", 0), 64000) + if is_mimo: + chat_body["max_completion_tokens"] = max_tok + else: + chat_body["max_tokens"] = max_tok 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 - if not REASONING_ENABLED or REASONING_EFFORT == "none": - chat_body["enable_thinking"] = False - chat_body["reasoning_effort"] = "none" + if is_mimo: + if not REASONING_ENABLED or REASONING_EFFORT == "none": + chat_body["thinking"] = {"type": "disabled"} + else: + mimo_effort = {"minimal": "low", "max": "high"}.get(REASONING_EFFORT, REASONING_EFFORT) + chat_body["thinking"] = {"type": "enabled"} + chat_body["reasoning_effort"] = mimo_effort else: - chat_body["reasoning_effort"] = REASONING_EFFORT + if not REASONING_ENABLED or REASONING_EFFORT == "none": + chat_body["enable_thinking"] = False + chat_body["reasoning_effort"] = "none" + else: + chat_body["reasoning_effort"] = REASONING_EFFORT return chat_body def _handle_antigravity_v2(self, body, model, stream, tracker=None): @@ -8572,42 +8589,67 @@ def _handle_shutdown_signal(sig, frame): SERVER.shutdown() def _anti_stall_cleanup(): + import subprocess as _sp my_pid = os.getpid() - my_ppid = os.getppid() - my_pgid = os.getpgid(0) killed = [] try: - import subprocess as _sp - out = _sp.run(["pgrep", "-f", "translate-proxy"], capture_output=True, text=True, timeout=5).stdout.strip() - for pid_str in out.splitlines(): - pid_str = pid_str.strip() - if not pid_str or not pid_str.isdigit(): - continue - pid = int(pid_str) - if pid == my_pid or pid == my_ppid: - continue - try: - pgid = os.getpgid(pid) - if pgid == my_pgid: + if sys.platform == "win32": + out = _sp.run( + ["tasklist", "/FI", "IMAGENAME eq python.exe", "/FO", "CSV", "/NH"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + for line in out.splitlines(): + parts = line.split(",") + if len(parts) >= 2: + pid_str = parts[1].strip('"') + if not pid_str.isdigit(): + continue + pid = int(pid_str) + if pid == my_pid: + continue + cmd = _sp.run( + ["wmic", "process", "where", f"ProcessId={pid}", "/FORMAT:CommandLine", "/VALUE"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + if "translate-proxy" not in cmd: + continue + try: + _sp.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, timeout=5) + killed.append(pid) + except Exception: + pass + else: + my_ppid = os.getppid() + my_pgid = os.getpgid(0) + out = _sp.run(["pgrep", "-f", "translate-proxy"], capture_output=True, text=True, timeout=5).stdout.strip() + for pid_str in out.splitlines(): + pid_str = pid_str.strip() + if not pid_str or not pid_str.isdigit(): continue - except OSError: - pass - try: - stat = open(f"/proc/{pid}/stat").read().split() - start_ticks = int(stat[21]) - import time as _t - ticks_per_sec = os.sysconf('SC_CLK_TCK') - start_time = start_ticks / ticks_per_sec - age = _t.time() - start_time - if age < 60: + pid = int(pid_str) + if pid == my_pid or pid == my_ppid: continue - except Exception: - continue - try: - os.kill(pid, signal.SIGTERM) - killed.append(pid) - except (ProcessLookupError, PermissionError): - pass + try: + pgid = os.getpgid(pid) + if pgid == my_pgid: + continue + except OSError: + pass + try: + stat = open(f"/proc/{pid}/stat").read().split() + start_ticks = int(stat[21]) + ticks_per_sec = os.sysconf('SC_CLK_TCK') + start_time = start_ticks / ticks_per_sec + age = time.time() - start_time + if age < 60: + continue + except Exception: + continue + try: + os.kill(pid, signal.SIGTERM) + killed.append(pid) + except (ProcessLookupError, PermissionError): + pass except Exception: pass try: @@ -8621,6 +8663,7 @@ def _anti_stall_cleanup(): print(f"[anti-stall] killed {len(killed)} stale proxy process(es): {killed}", flush=True) time.sleep(1) + def main(): global SERVER, _START_TIME _START_TIME = time.time()