diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..b52338d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Report a problem with the shim, ChatGPT passthrough, or the Desktop patch +labels: bug +--- + +## What happened + + + +## Environment + +- Codex Desktop / CLI version: `codex --version` -> +- OS: macOS arm64 / x86_64 / Linux distro / WSL -> +- Python version: `python3 --version` -> +- codex-shim commit: `git -C rev-parse --short HEAD` -> + +## Repro + +```bash +# Exact commands that reproduce the issue. +``` + +## Output + +```text +# Output of: codex-shim status +``` + +```text +# Last ~80 lines of .codex-shim/shim.log (redact API keys / auth tokens). +``` + +## Route + +- [ ] Configured BYOK/upstream model (slug: `____`) +- [ ] `gpt-5.5` ChatGPT passthrough +- [ ] Codex Desktop picker / ASAR patch +- [ ] Other (please describe) + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..41ca3367 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,33 @@ +--- +name: Feature request +about: Suggest a new capability or behavior change +labels: enhancement +--- + +## What problem are you trying to solve + + + +## Proposed change + + + +## Alternatives considered + + + +## Scope + +- [ ] New provider translation +- [ ] New Codex Desktop / CLI compatibility +- [ ] CLI / UX +- [ ] Docs +- [ ] Other + +## Are you willing to send a PR + +- [ ] Yes +- [ ] Maybe, with guidance +- [ ] No diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3d59d5fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: pytest (${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install package + dev extras + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + - name: Compile check + run: python -m compileall codex_shim/ -q + - name: Run tests + run: python -m pytest tests/ -q diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a9f14dd8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,74 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is loosely based on [Keep a Changelog](https://keepachangelog.com/), +and this project does not yet follow semantic versioning (pre-1.0). + +## Unreleased + +### Added + +- GitHub Actions CI (`.github/workflows/ci.yml`) running pytest and + `compileall` on Python 3.11 and 3.12. +- `[project.optional-dependencies] dev` in `pyproject.toml` so + `pip install -e ".[dev]"` pulls `pytest` and `pytest-asyncio` in one step. +- `CONTRIBUTING.md` documenting the dev loop, what kinds of PRs are useful, + and what to include in bug reports. +- `.github/ISSUE_TEMPLATE/` with structured bug and feature request templates. +- `CHANGELOG.md` (this file). + +### Changed + +- Reframed the project around a generic all-model Codex shim instead of any + single upstream app or model store. +- Made `~/.codex-shim/models.json` the canonical default settings file. +- Renamed the generated Codex provider to `codex_shim` / "Codex Shim". +- Settings now prefer a generic top-level `models` array with snake_case keys, + while still accepting `customModels` and camelCase aliases for existing + exports. + +## 2026-05-25 — Auth-gated ChatGPT passthrough + docs hardening + +### Added + +- `settings.chatgpt_passthrough_available()` checks `~/.codex/auth.json` for a + usable `tokens.access_token`. The synthetic `gpt-5.5` slug is now only + advertised in `/health`, `/v1/models`, `codex-shim list`, and the generated + `custom_model_catalog.json` while that token is present. +- `_load_models()` in the CLI wraps model settings loading with actionable + errors for missing files and invalid JSON. +- `_entrypoint()` in the CLI catches `BrokenPipeError` at the boundary so + piping `codex-shim list` into `head`/`grep` exits cleanly instead of dumping + a traceback. +- Regression tests covering auth-gating, CLI error UX, settings aliases, and + catalog generation. + +### Changed + +- `/health` payload now includes `chatgpt_passthrough: bool` and reports the + real model count instead of always-plus-one. +- `cli._resolve_model_slug("gpt-5.5", ...)` raises `SystemExit` telling the + user to run `codex login` when auth.json is missing, instead of returning a + slug that would 401 on first request. +- `default_model_slug` picks the first configured BYOK model when passthrough + is not usable, instead of unconditionally returning `gpt-5.5`. +- README install section recommends `pip install -e .` as the primary path. +- README benchmarking section: replaced an unsupported "7x fewer input tokens + / 5–10x faster" claim with honest anecdata and a note that no reproducible + benchmark script ships with the repo yet. + +### Fixed + +- Codex Desktop picker / `/v1/models` no longer offers `gpt-5.5` when there's + no Codex login, removing the misleading "select it to get a 401" footgun. + +## 2026-05-25 — Initial public hardening + +### Added + +- Public-grade README rewrite covering install, ChatGPT passthrough, tool + calls, computer use, prompt catching/proxy patterns, benchmarking, security, + limitations, troubleshooting, and contributing. +- `pyproject.toml` build-system, `readme`, `license`, `authors`, `keywords`, + classifiers, and project URLs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a8893769 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing to codex-shim + +Thanks for hacking on the shim. Issues and PRs welcome. + +## Dev loop + +```bash +git clone https://github.com/0xSero/codex-shim +cd codex-shim +python3 -m pip install -e ".[dev]" + +python3 -m pytest tests/ -q +python3 -m compileall codex_shim/ -q +``` + +CI runs the same commands on Python 3.11 and 3.12 via +`.github/workflows/ci.yml`. Match it locally before opening a PR. + +## What kinds of changes are useful + +- Translation fixes for tricky tool-call / reasoning streams, with a + captured fixture under `tests/` proving the bug and the fix. +- New provider translations (e.g. a new chat-completions or + Anthropic-shaped upstream). Add a test that exercises the new shape end + to end through `ShimServer`, the way `test_server.py` does. +- Compatibility notes / safer detection for new Codex Desktop builds, + especially around the ASAR picker patch needle in + `codex_shim/cli.py::patch_codex_app`. +- Doc patches that name a specific build / version. "I tested on Codex + Desktop 0.x.y on macOS arm64 and it did Z" is more useful than a + generic warning. + +## Code style + +- Match the surrounding file. No new dependencies without a reason. +- Keep `codex_shim/server.py` translation behavior covered by tests in + `tests/test_server.py` or `tests/test_translate.py` — tool-call shape + bugs are easy to miss by eyeballing streams. +- Don't include API keys, ChatGPT access tokens, or `auth.json` contents + in fixtures, logs, or test data. Use synthetic tokens (`"stub"`, + `"secret"`) like the existing tests. + +## Reporting bugs + +Please include: + +- Codex Desktop / CLI version (`codex --version` and the Desktop About + panel). +- OS (macOS arm64 / x86_64 / Linux distro / WSL). +- Output of `codex-shim status` and the last ~80 lines of + `.codex-shim/shim.log` with API keys redacted. +- Whether the model is a configured BYOK/upstream entry or the `gpt-5.5` + ChatGPT passthrough. +- Minimal repro: the exact `codex-shim …` invocation and what you + expected vs. what happened. + +## Security + +Don't open public issues for security problems. Email or DM the +maintainer with details and a repro and we'll coordinate a fix. diff --git a/README.md b/README.md index b7c73433..965e9eb4 100644 --- a/README.md +++ b/README.md @@ -1,371 +1,430 @@ # codex-shim -Run **Codex Desktop** with any model declared in your `~/.factory/settings.json` -(or any custom JSON file), plus an optional passthrough to your **ChatGPT -subscription's GPT‑5.5** — without recompiling Codex. +Run **Codex Desktop** against any BYOK model you can describe in +`~/.codex-shim/models.json`, plus an optional passthrough to your **ChatGPT +subscription's Codex model** — without rebuilding Codex. + +The shim is a local Python/aiohttp server that exposes an OpenAI +Responses-compatible endpoint on loopback. Codex points at the shim; the shim +routes each request to the matching upstream (OpenAI chat completions, +Anthropic Messages, a generic OpenAI-shaped chat endpoint, or ChatGPT Codex +passthrough), then translates streaming responses back into the shape Codex +expects. + +> Tested on Codex Desktop **0.133.0-alpha.1** for macOS arm64. The shim server +> and routing layer are plain Python/aiohttp and work on Windows, macOS, Linux, +> WSL, and Git Bash. The only macOS-specific piece is the optional Desktop picker +> ASAR patch, needed when Codex hides custom catalog entries. -The shim is a small local Python server that pretends to be an OpenAI Responses -API endpoint. Codex points at it; the shim routes each request to whatever -upstream the matching Factory BYOK entry uses (OpenAI / Anthropic / -generic-chat-completion-api / ChatGPT subscription). +--- -> Status: tested on Codex Desktop **0.133.0-alpha.1** for macOS arm64. -> Linux/Windows users should be able to skip the ASAR patch section and use the -> shim itself unchanged. +## What this gives you + +Codex Desktop only shows models allowed by its server-side config. If you have +OpenAI / Anthropic / Z.ai / DeepSeek / Gemini / OpenRouter / local proxy models +you want as first-class picker entries, this wires them in locally. + +The practical win is that Codex keeps its native UX while model routing moves +local: + +- **BYOK models in the normal Codex picker.** No Codex rebuild, no request + replay workflow. +- **Native Codex agent loops stay intact.** Function calls, tool outputs, + reasoning blocks, image-capable models, shell-command metadata, and streaming + SSE are translated instead of flattened into plain text. +- **ChatGPT/Codex passthrough.** If `~/.codex/auth.json` has a valid Codex + access token, the shim can route Codex's native `/v1/responses` traffic to + ChatGPT's Codex backend under the `gpt-5.5` slug used by current Codex builds. +- **Prompt-catching/proxy-friendly architecture.** Put a local proxy in front + of the shim to dedupe boilerplate, inject stable instructions, repair + pseudo-tool text, or route prompts by policy before they hit an upstream. +- **Maintainer-side wins on real coding-agent runs.** In the maintainer's + internal Codex tasks, ChatGPT passthrough plus a prompt-catching proxy in + front of the shim has produced multi-x reductions in billed input tokens + and noticeably faster wall time vs. the baseline route. No reproducible + benchmark script ships with the repo yet, so treat that as anecdata — the + benchmark section below explains how to measure your own setup against + an explicit oracle before quoting numbers. --- -## Why +## Requirements -Codex Desktop only shows the models its server-side Statsig config whitelists. -If you have OpenAI / Anthropic / Z.ai / DeepSeek / Gemini / OpenRouter / -MiniMax / Factory -keys you'd like to use **as first-class models in the picker**, this gets you -there. It also lets you keep your ChatGPT subscription's GPT‑5.5 visible -alongside everything else. +- Python 3.11+. +- Codex CLI/Desktop installed and authenticated. +- One of: + - `~/.codex-shim/models.json` with configured BYOK/upstream models; + - a compatible JSON file passed with `--settings`; + - `~/.codex/auth.json` containing `tokens.access_token` for ChatGPT/Codex + passthrough-only use. +- Windows: PowerShell/cmd works when installed via the Python package entry + point; WSL or Git Bash is needed only for the optional `bin/` shell wrappers. +- macOS only: `npx` and `codesign` if you need the optional Desktop picker + patch. --- ## Install +Recommended on macOS/Linux/WSL/Git Bash (installs the `codex-shim` entry +point from `pyproject.toml`): + ```bash -git clone https://github.com//codex-shim ~/Documents/codex-shim -cd ~/Documents/codex-shim -python3 -m pip install --user aiohttp pytest # only runtime dep is aiohttp -ln -s "$PWD/bin/codex-shim" ~/.local/bin/codex-shim -ln -s "$PWD/bin/codex-app" ~/.local/bin/codex-app -ln -s "$PWD/bin/codex-model" ~/.local/bin/codex-model +git clone https://github.com/0xSero/codex-shim ~/codex-shim +cd ~/codex-shim +python3 -m pip install --user -e . ``` -Requires Python 3.11+. +Recommended on native Windows PowerShell/cmd: ---- - -## Quick start - -### 1. Generate the catalog and start the shim - -```bash -codex-shim generate # reads ~/.factory/settings.json, writes catalog -codex-shim start # background daemon on 127.0.0.1:8765 -codex-shim list # show generated slugs and upstream routes -codex-shim status # health probe +```powershell +git clone https://github.com/0xSero/codex-shim $HOME\codex-shim +cd $HOME\codex-shim +py -3.11 -m pip install --user -e . ``` -### 2. Point Codex CLI at it (no global config changes) +That pulls in `aiohttp` and installs the portable Python console command +`codex-shim`. On POSIX-like shells, the optional `codex-app` and `codex-model` +shortcuts live in `bin/`; symlink them if you want them on `PATH` too: ```bash -codex-shim codex -- . # run Codex CLI with temporary -c overrides +mkdir -p ~/.local/bin +ln -sf "$PWD/bin/codex-app" ~/.local/bin/codex-app +ln -sf "$PWD/bin/codex-model" ~/.local/bin/codex-model ``` -That command applies opt-in `-c` overrides only for this launch. Your -`~/.codex/config.toml` is left untouched. +If you move the checkout, recreate those symlinks; `codex-shim app` launches +`codex app` through the installed Python entry point and does not need them. -### 3. Point Codex Desktop at it (writes managed config) +Alternative on macOS/Linux/WSL/Git Bash (no install, run straight from the +checkout): ```bash -codex-shim app . # install managed config, then launch Desktop +git clone https://github.com/0xSero/codex-shim ~/codex-shim +cd ~/codex-shim +python3 -m pip install --user aiohttp +mkdir -p ~/.local/bin +ln -sf "$PWD/bin/codex-shim" ~/.local/bin/codex-shim +ln -sf "$PWD/bin/codex-app" ~/.local/bin/codex-app +ln -sf "$PWD/bin/codex-model" ~/.local/bin/codex-model ``` -Codex Desktop does not currently accept the same per-launch `-c` overrides as -the CLI wrapper. `codex-shim app`, `codex-shim enable`, and -`codex-shim model use ` therefore write a clearly marked managed block to -`~/.codex/config.toml`. `codex-shim disable` removes those managed shim blocks -without restoring stale whole-file backups. After this Codex Desktop sees every -entry from `~/.factory/settings.json` plus a `GPT-5.5` entry when ChatGPT -passthrough is enabled and usable. - -If your Codex Desktop's model picker only shows "default" and refuses to render -the catalog entries, you also need the **picker patch** below. - -### 4. (Optional) Switch the active Desktop model +For running the test suite: ```bash -codex-model list -codex-model openai-gpt-5-5 # or any other slug from `list` -codex-app # relaunch Codex with new default +python3 -m pip install --user pytest pytest-asyncio ``` ---- - -## Custom config file - -The shim defaults to `~/.factory/settings.json` (the file Factory.ai writes -when you save BYOK custom models). You can point it at any file: +If your POSIX shell cannot find the commands, make sure `~/.local/bin` is on +`PATH`: ```bash -codex-shim --settings /path/to/my-models.json generate -codex-shim --settings /path/to/my-models.json start +export PATH="$HOME/.local/bin:$PATH" ``` -Schema expected (Factory's own format): +If PowerShell cannot find `codex-shim`, add your Python user Scripts directory +to `Path`. For Python 3.11 installed from python.org, the usual path is: -```json -{ - "customModels": [ - { - "model": "gpt-5.5", - "provider": "openai", - "baseUrl": "https://api.openai.com/v1", - "apiKey": "sk-…", - "displayName": "OpenAI GPT-5.5", - "maxContextLimit": 400000 - }, - { - "model": "claude-opus-4-7-20251109", - "provider": "anthropic", - "baseUrl": "https://api.anthropic.com/v1", - "apiKey": "sk-ant-…", - "displayName": "Claude Opus 4.7" - }, - { - "model": "deepseek-v4-pro", - "provider": "anthropic", - "baseUrl": "https://api.deepseek.com/anthropic", - "apiKey": "…", - "displayName": "DeepSeek V4 Pro", - "noImageSupport": true - } - ] -} +```powershell +$env:APPDATA\Python\Python311\Scripts ``` -The shim **never copies your API keys** into the generated catalog. Keys stay -in your settings file and are read fresh on every request. +You can also skip `PATH` entirely and run through Python: -Supported `provider` values: - -| provider | upstream API | -|---|---| -| `openai` | OpenAI/`/v1/chat/completions` | -| `generic-chat-completion-api` | OpenAI-shaped chat completions | -| `minimax` | MiniMax Token Plan OpenAI-compatible `/v1/chat/completions` | -| `anthropic` | Anthropic `/v1/messages` | +```powershell +py -3.11 -m codex_shim.cli status +``` --- -## OpenRouter actual-test path +## Windows support -Factory is only the JSON shape this shim already understands. You do not need -Factory.ai to test the shim: any OpenAI-compatible chat-completions provider can -be listed in `customModels`. OpenRouter works with -`baseUrl = "https://openrouter.ai/api/v1"` and provider -`generic-chat-completion-api`. +Yes, the shim works on Windows. The core shim is Python/aiohttp, binds to +`127.0.0.1`, and writes the same Codex provider config that macOS/Linux use. +Use one of these setups: -Start from the committed-safe template, then keep the real API key in the -ignored `.codex-shim/` runtime directory: - -```bash -mkdir -p .codex-shim -cp examples/openrouter-settings.example.json .codex-shim/openrouter-settings.json -$EDITOR .codex-shim/openrouter-settings.json -``` +| Setup | Status | Notes | +|---|---|---| +| Native Windows PowerShell/cmd | Supported | Install with `py -3.11 -m pip install --user -e .` and run `codex-shim ...`. | +| WSL | Supported | Works like Linux. Best when Codex CLI/Desktop is also being driven from WSL. | +| Git Bash | Supported | Works with the POSIX `bin/` wrappers if Python/Codex are on `PATH`. | +| `bin/codex-app`, `bin/codex-model` in PowerShell/cmd | Not native | These are shell scripts. Use `codex-shim app ...` and `codex-shim model ...` instead. | +| `patch-app` / `restore-app` | macOS only | They target `/Applications/Codex.app` and Electron ASAR signing on macOS. | -In `.codex-shim/openrouter-settings.json`, replace -`REPLACE_WITH_OPENROUTER_API_KEY` with your OpenRouter key. You can also change -`model` to any OpenRouter model id, and `displayName` to the name you want -Codex to show. Do not commit this local file. +Native Windows quick check: -Generate, list, and start a shim instance on port `8766` with ChatGPT -passthrough disabled so the test uses only OpenRouter: +```powershell +py -3.11 -m pip install --user -e . +codex-shim generate +codex-shim start +codex-shim status +codex-shim list +``` -```bash -CODEX_SHIM_DISABLE_CHATGPT=1 codex-shim \ - --settings .codex-shim/openrouter-settings.json \ - --port 8766 \ - generate +If `codex-shim` is not on `Path`, use the module form: -CODEX_SHIM_DISABLE_CHATGPT=1 codex-shim \ - --settings .codex-shim/openrouter-settings.json \ - list +```powershell +py -3.11 -m codex_shim.cli generate +py -3.11 -m codex_shim.cli start +py -3.11 -m codex_shim.cli status +``` -CODEX_SHIM_DISABLE_CHATGPT=1 codex-shim \ - --settings .codex-shim/openrouter-settings.json \ - --port 8766 \ - start +Path behavior is intentionally ordinary: + +- In native Windows, `~/.codex-shim/models.json` means + `%USERPROFILE%\.codex-shim\models.json` and Codex config lives under + `%USERPROFILE%\.codex\config.toml`. +- In WSL, `~/.codex-shim/models.json` and `~/.codex/config.toml` are inside the + Linux home directory unless you explicitly point `--settings` at a Windows + path under `/mnt/c/...`. +- Do not mix a WSL-generated `~/.codex/config.toml` with native Windows Codex + and expect both to share files automatically. If Codex is native Windows, run + the native Windows install path or manually keep the Windows config in sync. +- The local provider URL is still `http://127.0.0.1:8765/v1`. + +The optional macOS picker patch is not required for the shim server to work. On +Windows, if Codex can read the generated catalog/provider config, requests route +through the same local endpoint as every other platform. + +Windows Store/MSIX Codex Desktop builds are stricter than the CLI. They may treat +custom local/BYOK slugs as unavailable, rewrite `model = ""` back to +`gpt-5.5`, and add `[tui.model_availability_nux]` entries on launch. That is a +Desktop allowlist behavior, not a shim routing behavior: `codex exec`, the TUI, +and the shim endpoint still use the configured model slug. The macOS `patch-app` +helper does not apply to MSIX packages under `C:\\Program Files\\WindowsApps`. + +If Windows has a system proxy such as Clash/V2Ray, make sure loopback bypasses it: + +```powershell +setx NO_PROXY "127.0.0.1,localhost,::1" +setx no_proxy "127.0.0.1,localhost,::1" ``` -`list` prints the shim slug in the first column. With the example unchanged it -is usually `openai-gpt-4o-mini`. Run Codex through the safe wrapper with that -slug, replacing it if your `list` output differs: +`codex-shim codex -- ...` and `codex-shim app ...` add those loopback entries to +the launched process environment automatically; set them globally too if you run +`codex.exe` directly. -```bash -CODEX_SHIM_DISABLE_CHATGPT=1 codex-shim \ - --settings .codex-shim/openrouter-settings.json \ - --port 8766 \ - codex -- -m openai-gpt-4o-mini . -``` +--- -For the usual local OpenRouter workflow, the `codex-openrouter` helper does -those steps for you: it uses `.codex-shim/openrouter-settings.json`, disables -ChatGPT passthrough, starts the shim on port `8766`, detects the first listed -slug, and runs Codex through the safe wrapper. On first run, if the settings -file is missing, empty, or still has the placeholder API key, it prompts for -your OpenRouter key and model and writes the ignored local settings file. +## Quick start + +### 1. Generate the catalog and start the shim ```bash -codex-shim setup openrouter -codex-shim openrouter . +codex-shim generate # reads ~/.codex-shim/models.json if present +codex-shim start # background daemon on 127.0.0.1:8765 +codex-shim list # show generated slugs and upstream routes +codex-shim status # health probe + model count ``` -To change the stored OpenRouter model or key later: +Generated runtime files live under the repo-local `.codex-shim/` directory: -```bash -codex-shim setup openrouter +```text +.codex-shim/custom_model_catalog.json # model picker catalog for Codex +.codex-shim/config.toml # opt-in Codex provider config +.codex-shim/shim.pid # daemon pid +.codex-shim/shim.log # stdout/stderr + request summaries ``` -The setup prompt shows current values and lets Enter keep them. It prompts for -model, display name, base URL, and max context. The API key is not echoed; -pressing Enter keeps the existing key when one is already present. +The server binds `127.0.0.1` by default. It is meant to be a local loopback +adapter, not an Internet-facing proxy. -Optional overrides: +### 2. Point Codex Desktop at it ```bash -CODEX_SHIM_MODEL=openrouter-owl-alpha codex-shim run openrouter . -codex-shim --port 8770 openrouter . +codex-shim app . # launch Codex Desktop with the shim wired in ``` -`bin/codex-openrouter` remains available as a shortcut for this workflow. - -That command uses temporary inline `-c` overrides only. To verify your normal -Codex config was not touched, compare the file before and after: +`app` generates the catalog, starts the local daemon if needed, and writes a +small managed block into `~/.codex/config.toml` so Codex Desktop uses the local +provider. The previous config is backed up under `.codex-shim/` and the managed +block can be removed with: ```bash -sha256sum ~/.codex/config.toml 2>/dev/null || true -# run the codex-shim codex -- ... command above -sha256sum ~/.codex/config.toml 2>/dev/null || true +codex-shim disable ``` -If `~/.codex/config.toml` does not exist, both commands may print nothing; the -safe wrapper should not create it. +After this, Codex Desktop sees every entry from `~/.codex-shim/models.json`, +plus the `GPT-5.5` ChatGPT passthrough slug if (and only if) `~/.codex/auth.json` +holds a valid `tokens.access_token`. -Stop the local shim when done: +If your Codex Desktop's model picker only shows `default` and refuses to render +the catalog entries, apply the macOS picker patch below. + +### 3. Switch the active Desktop model ```bash -codex-shim --port 8766 stop +codex-model list +codex-model gpt-5.5 # or any other slug from `list` +codex-app # relaunch Codex with new default ``` ---- - -## MiniMax Token Plan actual-test path +`codex-model ` is a shortcut for `codex-shim model use `. It writes +only the shim-managed block in `~/.codex/config.toml`. -MiniMax's global Token Plan API exposes an OpenAI-compatible chat-completions -endpoint at `https://api.minimax.io/v1` -([MiniMax docs](https://platform.minimax.io/docs/token-plan/other-tools)). -The shim supports this directly with `provider = "minimax"`; internally it uses -the same `/v1/chat/completions` translation path as other OpenAI-compatible -providers. +### 4. Use the Codex CLI without writing config -Start from the committed-safe template, then keep the real Token Plan key in -the ignored `.codex-shim/` runtime directory: +For one-off CLI runs, use inline `-c` overrides instead of changing +`~/.codex/config.toml`: ```bash -mkdir -p .codex-shim -cp examples/minimax-token-plan-settings.example.json .codex-shim/minimax-settings.json -$EDITOR .codex-shim/minimax-settings.json +codex-shim codex -- "inspect this repo and summarize the architecture" ``` -In `.codex-shim/minimax-settings.json`, replace -`REPLACE_WITH_MINIMAX_TOKEN_PLAN_KEY` with your MiniMax Token Plan key. You can -also change `model` to `MiniMax-M2.7-highspeed` or another MiniMax model id, -and `displayName` to the name you want Codex to show. Do not commit this local -file. +### 5. One-command provider workflows -Generate, list, and start a shim instance on port `8767` with ChatGPT -passthrough disabled so the test uses only MiniMax: +For common BYOK providers, `setup` writes a private settings file under +`~/.codex-shim/`, and the provider command starts the shim on its own port, +disables ChatGPT passthrough for that run, selects the first configured model, +and launches Codex with temporary inline `-c` overrides only: ```bash -CODEX_SHIM_DISABLE_CHATGPT=1 codex-shim \ - --settings .codex-shim/minimax-settings.json \ - --port 8767 \ - generate - -CODEX_SHIM_DISABLE_CHATGPT=1 codex-shim \ - --settings .codex-shim/minimax-settings.json \ - list +codex-shim setup openrouter +codex-shim openrouter . -CODEX_SHIM_DISABLE_CHATGPT=1 codex-shim \ - --settings .codex-shim/minimax-settings.json \ - --port 8767 \ - start +codex-shim setup minimax +codex-shim minimax . ``` -`list` prints the shim slug in the first column. With the example unchanged it -is usually `minimax-m2-7`. Run Codex through the safe wrapper with that slug, -replacing it if your `list` output differs: +OpenRouter uses `~/.codex-shim/openrouter-models.json` and port `8766`. +MiniMax Token Plan uses `~/.codex-shim/minimax-models.json` and port `8767`. +Real API keys belong only in those local files; committed-safe examples live in +`examples/openrouter-models.example.json` and `examples/minimax-models.example.json`. + +To change the stored model or key later, rerun `codex-shim setup `. +The prompt shows current values and lets Enter keep them; API keys are not +echoed, and Enter keeps the existing key when one is already present. + +Useful overrides: ```bash -CODEX_SHIM_DISABLE_CHATGPT=1 codex-shim \ - --settings .codex-shim/minimax-settings.json \ - --port 8767 \ - codex -- -m minimax-m2-7 . +CODEX_SHIM_MODEL=my-slug codex-shim openrouter . +codex-shim --port 8771 minimax . +codex-shim provider list ``` -For the usual local MiniMax workflow, the `codex-minimax` helper does those -steps for you: it uses `.codex-shim/minimax-settings.json`, disables ChatGPT -passthrough, starts the shim on port `8767`, detects the first listed slug, and -runs Codex through the safe wrapper. On first run, if the settings file is -missing, empty, or still has the placeholder API key, it prompts for your Token -Plan key and model and writes the ignored local settings file. +--- + +## Custom config file + +The shim defaults to `~/.codex-shim/models.json`. If that file is missing, the +shim still generates a catalog — and adds the `gpt-5.5` ChatGPT passthrough +entry only when `~/.codex/auth.json` contains a valid `tokens.access_token`. +You can point it at any compatible file: ```bash -codex-shim setup minimax -codex-shim minimax . +codex-shim --settings /path/to/my-models.json generate +codex-shim --settings /path/to/my-models.json start ``` -To change the stored MiniMax model, key, or base URL later: +Recommended schema: -```bash -codex-shim setup minimax +```json +{ + "models": [ + { + "model": "gpt-5.5", + "provider": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-…", + "display_name": "OpenAI GPT-5.5", + "max_context_limit": 400000 + }, + { + "model": "claude-opus-4-7-20251109", + "provider": "anthropic", + "base_url": "https://api.anthropic.com/v1", + "api_key": "sk-ant-…", + "display_name": "Claude Opus 4.7" + }, + { + "model": "deepseek-v4-pro", + "provider": "anthropic", + "base_url": "https://api.deepseek.com/anthropic", + "api_key": "…", + "display_name": "DeepSeek V4 Pro", + "no_image_support": true + } + ] +} ``` -The setup prompt shows current values and lets Enter keep them. The API key is -not echoed; pressing Enter keeps the existing key when one is already present. -For MiniMax's China endpoint, change the base URL to `https://api.minimaxi.com/v1`. +The loader also accepts camelCase aliases (`baseUrl`, `apiKey`, `displayName`, +`maxContextLimit`, `maxOutputTokens`, `noImageSupport`, `extraHeaders`) and a +legacy top-level `customModels` array, so existing model config exports can be +used directly. -Optional overrides: +The shim **never copies your API keys** into the generated catalog. Keys stay +in your settings file and are read fresh on every request. -```bash -CODEX_SHIM_MODEL=minimax-m2-7 codex-shim run minimax . -codex-shim --port 8771 minimax . -``` +Supported `provider` values: -`bin/codex-minimax` remains available as a shortcut for this workflow. +| provider | upstream API | +|---|---| +| `openai` | OpenAI `/v1/chat/completions` | +| `generic-chat-completion-api` | OpenAI-shaped chat completions | +| `minimax` | MiniMax Token Plan `/v1/chat/completions` | +| `anthropic` | Anthropic `/v1/messages` | -That command uses temporary inline `-c` overrides only. To verify your normal -Codex config was not touched, compare the file before and after: +Useful model fields: -```bash -sha256sum ~/.codex/config.toml 2>/dev/null || true -# run the codex-shim codex -- ... command above -sha256sum ~/.codex/config.toml 2>/dev/null || true -``` +| field | behavior | +|---|---| +| `display_name` | Human-readable picker label. | +| `max_context_limit` | Catalog context window and compaction limits. | +| `max_output_tokens` | Default max output when translating to Anthropic. | +| `no_image_support` | When true, catalog advertises text-only input. | +| `extra_headers` | Optional upstream headers merged into requests. | -If `~/.codex/config.toml` does not exist, both commands may print nothing; the -safe wrapper should not create it. +### Ollama / local OpenAI-compatible chat endpoints -Stop the local shim when done: +Codex sends the Responses API. Ollama and many local servers expose +OpenAI-shaped `/v1/chat/completions` instead. Keep Codex pointed at the shim with +`wire_api = "responses"`; configure Ollama as `generic-chat-completion-api` so +the shim translates Responses ⇄ chat completions: -```bash -codex-shim --port 8767 stop +```json +{ + "models": [ + { + "model": "llama3.2", + "display_name": "Ollama Llama 3.2", + "provider": "generic-chat-completion-api", + "base_url": "http://127.0.0.1:11434/v1", + "api_key": "ollama" + } + ] +} ``` +`codex-shim --settings /path/to/ollama-launch-models.json generate` also accepts +launch-model style files with a top-level `launchModels` / `launch_models` array, +including bare strings. `provider: "ollama"` is normalized to +`generic-chat-completion-api` with `http://127.0.0.1:11434/v1` when no base URL +is supplied. + +Repeated `codex-shim enable`, `codex-shim app`, and `codex-shim model use ...` +runs are idempotent: the shim-managed top-level keys and +`[model_providers.codex_shim]` block are removed before the new managed block is +written, so duplicate profile/provider keys should not accumulate. + +Codex may make small background calls to OpenAI model slugs such as +`gpt-5.4-mini` for its own product behavior. Those calls are not Ollama routing +failures; use the shim request log to confirm the actual selected model for the +agent turn. + --- ## Picker patch for Codex Desktop on macOS Codex Desktop has a Statsig server-side allowlist (`use_hidden_models: true`) -that hides any model whose slug isn't on a hardcoded list. Custom catalog +that hides any model whose slug is not on a hardcoded list. Custom catalog entries fall into the hidden bucket and never render in the picker. -A single‑boolean ASAR patch flips the allowlist branch off so the picker only -checks the local `hidden` flag (which our catalog never sets). +A single-boolean ASAR patch flips the allowlist branch off so the picker only +checks the local `hidden` flag (which this catalog never sets). -> **Always back up `app.asar` and `Info.plist` before patching.** -> The built-in `codex-shim patch-app` command is currently incomplete for modern -> Electron bundles because it does not update `ElectronAsarIntegrity` in -> `Info.plist`; prefer the manual procedure below until that command is fixed -> and tested on macOS. +> Back up `app.asar` and `Info.plist` before patching. ```bash APP=/Applications/Codex.app @@ -375,10 +434,10 @@ sudo cp -R "$APP" "$APP.unpatched-$(date +%Y%m%d-%H%M%S)" cd /tmp && rm -rf codex-asar-patch && mkdir codex-asar-patch && cd codex-asar-patch npx --yes @electron/asar extract "$APP/Contents/Resources/app.asar" extracted -# 2. Patch the picker filter (this match is single-occurrence, unique to that file) +# 2. Patch the picker filter (single occurrence in tested builds) PATCH_FILE=$(grep -RIl 'useHiddenModels' extracted/webview/assets/model-queries-*.js | head -n1) sed -i.bak -E 's/let u=c\.useHiddenModels&&o!==`amazonBedrock`,d;/let u=!1,d;/' "$PATCH_FILE" -diff "$PATCH_FILE.bak" "$PATCH_FILE" || true # confirm exactly one change +diff "$PATCH_FILE.bak" "$PATCH_FILE" || true rm "$PATCH_FILE.bak" # 3. Repack @@ -386,9 +445,9 @@ npx --yes @electron/asar pack extracted app.asar.new sudo cp app.asar.new "$APP/Contents/Resources/app.asar" ``` -That alone will crash Codex on next launch with `EXC_BREAKPOINT`. Electron's +That alone can crash Codex on next launch with `EXC_BREAKPOINT`. Electron's `ElectronAsarIntegrity` field in `Info.plist` is a SHA-256 of the **JSON -header** of the asar archive (not the whole file). Recompute it and re-sign: +header** of the ASAR archive (not the whole file). Recompute it and re-sign: ```bash # 4. Compute new header hash @@ -407,7 +466,7 @@ sudo /usr/libexec/PlistBuddy -c \ "Set :ElectronAsarIntegrity:Resources/app.asar:hash $HEADER_HASH" \ "$APP/Contents/Info.plist" -# 6. Ad-hoc re-sign (drops Apple signature; Gatekeeper will warn once) +# 6. Ad-hoc re-sign sudo codesign --force --deep --sign - "$APP" # 7. Launch @@ -416,26 +475,54 @@ open "$APP" To roll back: `sudo rm -rf "$APP" && sudo mv "$APP.unpatched-…" "$APP"`. +The CLI also has helper commands for patching/restoring `app.asar`: + +```bash +codex-shim patch-app +codex-shim restore-app +``` + +If Codex still crashes after `patch-app`, use the manual hash-update steps above +or restore with `codex-shim restore-app`. + --- -## ChatGPT GPT‑5.5 passthrough (optional) +## ChatGPT/Codex passthrough -If you have a ChatGPT plan with Codex access (`~/.codex/auth.json` exists with -a usable `tokens.access_token`), the shim exposes one synthetic slug -`gpt-5.5` (display name `GPT-5.5`) that proxies -straight to `https://chatgpt.com/backend-api/codex/responses` with your access -token. It bypasses Factory entirely and uses your ChatGPT subscription quota. +If `~/.codex/auth.json` exists and contains `tokens.access_token`, the shim +exposes a synthetic `gpt-5.5` catalog entry that proxies straight to: -It is included in `.codex-shim/custom_model_catalog.json` after -`codex-shim generate` only when usable ChatGPT auth is present. +```text +https://chatgpt.com/backend-api/codex/responses +``` -If you don't want it, run with `CODEX_SHIM_DISABLE_CHATGPT=1`. +The entry is **only** advertised in `/health`, `/v1/models`, `codex-shim list`, +and the generated `custom_model_catalog.json` while that token is present. Once +you `codex logout` or the file is missing, the slug stops appearing — so the +picker never shows an option that would 401 on first use. Run `codex login` to +mint a new token and the entry comes back automatically on the next +`codex-shim generate`. ---- +The passthrough keeps Codex's native `/v1/responses` payload intact, changes the +model to `gpt-5.5`, and sends your Codex access token as `Authorization: Bearer +` with the ChatGPT account id from `auth.json` when present. It +bypasses configured BYOK routes entirely and uses your ChatGPT subscription quota. -## How the routing works +It is already in `.codex-shim/custom_model_catalog.json` after `codex-shim +generate`. Select `GPT-5.5` in the picker, or run: +```bash +codex-model gpt-5.5 ``` + +Older local configs or notes may refer to `openai-gpt-5-5`; the server accepts +that prefix as an alias and routes it to the same passthrough. + +--- + +## How routing works + +```text Codex Desktop ── /v1/responses ──▶ codex-shim (127.0.0.1:8765) │ ├── slug "gpt-5.5" @@ -452,13 +539,68 @@ Codex Desktop ── /v1/responses ──▶ codex-shim (127.0.0.1:8765) ``` The shim translates Codex's Responses-API request into the upstream's shape -(chat completions or Anthropic Messages) and translates the streamed reply -back. Extended-thinking blocks from Anthropic-shaped upstreams (Claude, -DeepSeek, GLM) round-trip through `reasoning.encrypted_content` items. +(chat completions or Anthropic Messages) and translates the streamed reply back. +Extended-thinking blocks from Anthropic-shaped upstreams (Claude, DeepSeek, +GLM, etc.) round-trip through `reasoning.encrypted_content` items. --- -## MCP +## Tool calls and agent loops + +Codex expects Responses-API output items. Most BYOK upstreams speak either +OpenAI chat completions or Anthropic Messages. The shim bridges the gap: + +| Codex/Responses item | OpenAI-shaped upstream | Anthropic upstream | +|---|---|---| +| `tools: [{type: "function", ...}]` | `tools: [{type: "function", function: ...}]` | `tools: [{name, description, input_schema}]` | +| `function_call` output item | Chat `tool_calls[]` | `tool_use` content block | +| `function_call_output` input item | Chat `role: "tool"` message | `tool_result` user content block | +| streamed argument deltas | `response.function_call_arguments.delta` | `response.function_call_arguments.delta` | +| parallel calls | Preserved via `parallel_tool_calls` where supported | Multiple `tool_use` blocks | + +This is the piece that makes the shim useful for real Codex runs instead of only +text chat. A model can ask Codex to run tools, Codex sends the tool output back +through the shim, and the upstream model continues the same loop. + +Known edge cases: + +- Native Responses-only tool types such as freeform `apply_patch`, hosted + `web_search`, or `computer_use` are only fully native on the ChatGPT + passthrough path. Chat-completions and Anthropic upstreams receive function + tools after translation. +- Some OpenAI-compatible providers advertise tool calls but stream malformed + JSON arguments. The shim preserves deltas; the provider still has to emit + valid JSON by the end of the call. +- If a provider ignores `parallel_tool_calls`, Codex may still request one tool + at a time. That is an upstream behavior, not a catalog issue. + +--- + +## Computer use, shell commands, images, and MCP + +The generated catalog advertises the Codex-facing capabilities Codex needs to +run as an agent: + +| catalog field | value | +|---|---| +| `shell_type` | `shell_command` | +| `apply_patch_tool_type` | `freeform` | +| `web_search_tool_type` | `text_and_image` | +| `supports_parallel_tool_calls` | `true` | +| `input_modalities` | `text,image` unless `noImageSupport: true` | +| `supports_image_detail_original` | disabled when `noImageSupport: true` | + +What that means in practice: + +- **Shell/file operations** are still executed by Codex Desktop/CLI. The shim + only translates the model request and response stream. +- **Images/screenshots** can pass to providers that accept images. Set + `noImageSupport: true` for text-only upstreams so Codex does not send image + content they cannot parse. +- **Computer-use/native hosted tools** should use the ChatGPT passthrough path + for best fidelity. BYOK chat/Anthropic routes can still participate in Codex + tool loops, but hosted Responses-only tool item types are not equivalent to + normal function tools. Codex Desktop forwards three generic MCP tools to every model: @@ -467,57 +609,362 @@ Codex Desktop forwards three generic MCP tools to every model: - `read_mcp_resource` It does **not** flatten individual MCP server tools into the function list. -That's a Codex client behavior, not a shim limitation. Shim-routed models -receive the same MCP tools as built-in OpenAI models. The model is expected -to call `list_mcp_resources` to discover what's available. +That is a Codex client behavior, not a shim limitation. Shim-routed models +receive the same MCP tools as built-in OpenAI models. The model is expected to +call `list_mcp_resources` to discover what is available. + +--- + +## Prompt catching and request interception + +There are two useful interception layers: + +### 1. Built-in request summaries + +Every `/v1/responses` request is summarized into `.codex-shim/shim.log`. Use it +while debugging model routing, tool schemas, and prompt size: + +```bash +tail -f .codex-shim/shim.log +``` + +The log is intentionally summary-level so it does not dump API keys or full +prompt bodies by default. + +### 2. Local prompt-catching proxy in front of this shim + +For deeper control, put a small local proxy in front of `codex-shim` and point +Codex at that proxy. That layer can inspect the full Responses request before +it reaches this shim, then forward to `http://127.0.0.1:8765/v1/responses`. + +Common uses: + +- inject a stable system/developer preamble; +- strip repeated boilerplate before it burns tokens; +- repair pseudo-tool text such as XML-ish `` drafts into structured + tool calls before Codex sees them; +- route some prompts to ChatGPT passthrough and others to BYOK models; +- redact or hash large file blobs in logs. + +Minimal aiohttp forwarder shape: + +```python +from aiohttp import ClientSession, web + +UPSTREAM = "http://127.0.0.1:8765" + +async def responses(request): + body = await request.json() + body = catch_prompt(body) # mutate or record the Responses payload + async with ClientSession() as s: + async with s.post(f"{UPSTREAM}/v1/responses", json=body, headers=request.headers) as r: + return web.Response(body=await r.read(), status=r.status, headers=r.headers) + +def catch_prompt(body): + # Keep this deterministic. Codex retries are much easier to debug when the + # same input produces the same transformed payload. + return body + +app = web.Application() +app.router.add_post("/v1/responses", responses) +web.run_app(app, host="127.0.0.1", port=8766) +``` + +Then launch Codex with the shim provider URL set to `http://127.0.0.1:8766/v1` +instead of `8765`. Keep prompt catching outside `codex_shim/translate.py` unless +you want every BYOK route to share the same mutation policy. + +--- + +## Benchmarking cost and speed + +The right benchmark is an actual Codex task, not a synthetic hello-world +completion. Measure the same repository, prompt, model, and tool budget across +routes. + +Suggested quick protocol: + +1. Pick one real task that uses tools, e.g. "find the bug, edit the file, run + the focused test". +2. Run it once through your baseline Codex route and once through `gpt-5.5` + passthrough or your BYOK model. +3. Record wall time, request count, prompt tokens, output tokens, tool-call + count, and final test result. +4. Compare only successful end-to-end runs. + +Useful shell timing wrapper: + +```bash +/usr/bin/time -f 'wall=%E cpu=%P max_rss_kb=%M' codex-shim codex -- "your task here" +``` + +The `--` separator is accepted and stripped by the wrapper. It is optional, but +it keeps task prompts that start with `-` from being parsed as wrapper flags. + +A good report looks like: + +```text +Oracle: same repo commit, same prompt, same focused test command +Baseline: 12 requests, 210k input tokens, 19k output tokens, 18m42s, test passed +Shim: 8 requests, 31k input tokens, 11k output tokens, 2m35s, test passed +Result: 6.8x fewer billed input tokens, 7.2x faster wall time +``` + +The exact multiplier depends on model, prompt catcher policy, repo size, +network path, and how often the agent calls tools. --- ## Commands +```text +codex-shim generate regenerate catalog/config without starting daemon +codex-shim start regenerate catalog and start local shim daemon +codex-shim enable start daemon and write managed ~/.codex/config.toml block +codex-shim status health check + model count +codex-shim stop stop daemon +codex-shim disable remove managed config block and stop daemon +codex-shim restart stop, regenerate, and start daemon +codex-shim list list generated slugs and upstream routes +codex-shim model list list slugs currently usable in the picker +codex-shim model use set the Desktop default model in managed config +codex-shim codex -- exec `codex` CLI through inline shim overrides +codex-shim app [path] launch Codex Desktop through managed shim config +codex-shim setup openrouter create/update ~/.codex-shim/openrouter-models.json +codex-shim openrouter [path] run Codex CLI through OpenRouter on port 8766 +codex-shim setup minimax create/update ~/.codex-shim/minimax-models.json +codex-shim minimax [path] run Codex CLI through MiniMax on port 8767 +codex-shim provider list list built-in provider shortcuts +codex-shim patch-app patch macOS Codex Desktop picker allowlist +codex-shim restore-app restore macOS app.asar from patch backup + +codex-app [path] shortcut for `codex-shim app` +codex-model [list|] shortcut for `codex-shim model …` ``` -codex-shim generate regenerate catalog/config without starting daemon -codex-shim start start local shim daemon -codex-shim status health check + model count -codex-shim stop stop daemon -codex-shim restart restart daemon -codex-shim list list generated slugs and Factory routes -codex-shim model list list slugs currently usable in the picker -codex-shim model use set the Desktop default model in managed config -codex-shim codex -- exec `codex` CLI through temporary -c overrides -codex-shim app [path] install managed config and launch Codex Desktop -codex-shim provider list list built-in provider setup workflows -codex-shim setup configure an ignored local provider settings file -codex-shim run . start shim and run Codex through that provider -codex-shim . shortcut for `codex-shim run .` -codex-app [path] shortcut for `codex-shim app` -codex-model [list|] shortcut for `codex-shim model …` -codex-openrouter [args] shortcut for the safe OpenRouter CLI workflow -codex-minimax [args] shortcut for the safe MiniMax Token Plan workflow +Global flags: + +- `--settings `: used by catalog/model/start/app/codex flows. +- `--port `: used by daemon/provider flows. + +`patch-app` and `restore-app` always target `/Applications/Codex.app`, do not +use `--settings`, and exit with a clear error on Windows/Linux. + +--- + +## Security and privacy + +- The shim binds to `127.0.0.1` by default. +- API keys stay in your settings file; the generated catalog does not contain + them. +- Request logs are summary-level by default and avoid full prompt/API-key dumps. +- ChatGPT passthrough reads `~/.codex/auth.json` at request time and forwards + the access token only to ChatGPT's Codex endpoint. +- If you put a prompt-catching proxy in front of the shim, that proxy controls + what it logs. Redact or hash large/private prompt bodies there. + +--- + +## Limitations + +- Codex internals and model-picker bundles change. The ASAR patch is version + sensitive by nature. +- The ChatGPT passthrough endpoint is the endpoint current Codex builds use; it + may move or change shape in a future Codex release. +- BYOK providers vary wildly in tool-call quality. The shim translates shapes; + it cannot make an upstream model reliably emit valid tool-call JSON. +- Hosted Responses-only tools are highest fidelity on the ChatGPT passthrough + path. BYOK routes get normal function-tool translation. +- The `bin/codex-app` and `bin/codex-model` shortcuts are POSIX shell scripts. + In native Windows shells, use the installed `codex-shim` command instead. + +--- + +## Troubleshooting + +### Shim will not start + +```bash +codex-shim status +tail -n 80 .codex-shim/shim.log ``` -All commands accept `--settings ` and `--port `. +Common causes: + +- Python is older than 3.11. +- `aiohttp` is not installed in the Python used by the wrapper. +- Port `8765` is already in use. Start on another port: + +```bash +codex-shim --port 8766 restart +codex-shim --port 8766 app . +``` + +### `~/.codex-shim/models.json` is missing + +That is fine for ChatGPT passthrough-only use, **provided** `~/.codex/auth.json` +has a valid `tokens.access_token`. In that case `codex-shim generate` writes a +catalog containing just `gpt-5.5`. If neither file is present, the catalog will +be empty and `codex-shim list` will exit non-zero with a hint to run +`codex login` or pass a compatible settings file: + +```bash +codex-shim --settings /path/to/my-models.json generate +``` + +### `codex-shim list` exits 1 with "No models available" + +You have neither configured models in `~/.codex-shim/models.json` nor a valid +Codex login. Pick one: + +```bash +codex login # populate ~/.codex/auth.json +# or +codex-shim --settings /path/to/my-models.json list +``` + +### Codex shows only `default` + +Run: + +```bash +codex-shim generate +codex-shim model list +``` + +If the catalog contains your models but Desktop still hides them, apply the +macOS picker patch. On Windows Store/MSIX Desktop, the same allowlist can rewrite +the active model back to `gpt-5.5`; use `codex-shim codex -- ...` / Codex CLI for +BYOK routes, or a non-MSIX/Desktop build that can read the custom catalog without +rewriting the config. + +### Windows proxy sends loopback traffic away from the shim + +If `codex.exe` returns proxy/502 errors while the shim is healthy, a system proxy +may be intercepting `http://127.0.0.1:8765`. Set both uppercase and lowercase +bypass variables before launching Codex: + +```powershell +$env:NO_PROXY = "127.0.0.1,localhost,::1" +$env:no_proxy = "127.0.0.1,localhost,::1" +``` + +`codex-shim app ...` and `codex-shim codex -- ...` set those entries for the +child process automatically. + +### Model appears but requests 404 + +The selected slug is not in the current generated catalog. Regenerate after +editing `~/.codex-shim/models.json` or the file passed with `--settings`: + +```bash +codex-shim generate +codex-model list +codex-model +``` + +### Upstream returns 401/403 + +The API key in your model settings file is wrong, expired, or missing a +provider-specific header. For ChatGPT passthrough, refresh Codex login so +`~/.codex/auth.json` contains a valid `tokens.access_token`. + +### Tool calls turn into text + +Use the ChatGPT passthrough path first to confirm Codex itself is sending tools. +If passthrough works but a BYOK route does not, the upstream probably lacks +native tool-call support or emits malformed streamed arguments. Check +`.codex-shim/shim.log` for the requested model and tool count. + +### Images fail on a text-only model + +Set `"noImageSupport": true` for that model in the settings file and regenerate +the catalog. + +### Streaming hangs + +Check whether the upstream streams correctly outside Codex. Then restart the +local daemon: + +```bash +codex-shim restart +tail -f .codex-shim/shim.log +``` + +The server uses a long read timeout because real coding-agent turns can stream +for a while; a silent hang is usually upstream/network/provider behavior. + +### macOS app crashes after patching + +You repacked `app.asar` but did not update `ElectronAsarIntegrity` and re-sign, +or the patch hit the wrong JavaScript bundle. Restore and retry: + +```bash +codex-shim restore-app +codex-shim patch-app +``` + +### Reset generated shim state + +```bash +codex-shim stop +# Remove .codex-shim manually if you want a completely fresh generated state. +codex-shim generate +codex-shim start +``` --- ## File layout -``` +```text codex_shim/ python source (server + cli + translation) bin/codex-shim main entrypoint bin/codex-app shortcut wrapping `codex-shim app` bin/codex-model shortcut wrapping `codex-shim model …` -bin/codex-openrouter shortcut for the safe OpenRouter CLI workflow -bin/codex-minimax shortcut for the safe MiniMax Token Plan workflow .codex-shim/ generated catalog, config, logs, pid (gitignored) tests/ pytest suite ``` -The safe CLI path, `codex-shim codex -- ...`, never edits -`~/.codex/config.toml`; it passes overrides inline as `-c key=value` arguments -for that launch only. Desktop commands that need persistent Desktop integration -write and later remove marked managed blocks. +Config behavior: + +- `codex-shim generate`, `start`, `stop`, `restart`, `list`, `status`, and + `codex-shim codex -- ...` do not persistently modify `~/.codex/config.toml`. +- `codex-shim enable`, `codex-shim app`, and `codex-shim model use ` write + managed blocks to `~/.codex/config.toml`. If existing top-level Codex model + keys are displaced, the managed block records them so disable can restore + those keys without reverting unrelated config edits. +- `codex-shim disable` removes the managed blocks, restores displaced top-level + model keys when present, and stops the daemon. + +--- + +## Development checks + +```bash +python3 -m pytest tests/ +python3 -m compileall codex_shim/ -q +``` + +The tests cover settings/catalog generation, request translation, server +routing, and CLI settings-file UX. Add regression tests when changing +translation behavior; tool-call shape bugs are easy to miss by eyeballing +streams. + +--- + +## Contributing + +Good contributions include: + +- new provider translation tests; +- captured stream fixtures for tricky tool-call/reasoning cases; +- compatibility notes for new Codex Desktop builds; +- safer picker patch detection for changed ASAR bundles; +- docs for known-good provider configs. + +Before opening a PR, run the development checks above and include the Codex +Desktop/CLI version you tested. --- diff --git a/bin/codex-minimax b/bin/codex-minimax deleted file mode 100755 index 42c239c0..00000000 --- a/bin/codex-minimax +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SOURCE="${BASH_SOURCE[0]}" -while [ -h "$SOURCE" ]; do - DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" - SOURCE="$(readlink "$SOURCE")" - [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE" -done - -ROOT="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" -SETTINGS="${CODEX_SHIM_SETTINGS:-$ROOT/.codex-shim/minimax-settings.json}" -PORT="${CODEX_SHIM_PORT:-8767}" -PLACEHOLDER_KEY="REPLACE_WITH_MINIMAX_TOKEN_PLAN_KEY" -DEFAULT_MODEL="MiniMax-M2.7" -DEFAULT_DISPLAY_NAME="MiniMax M2.7" -DEFAULT_BASE_URL="https://api.minimax.io/v1" -DEFAULT_CONTEXT="1000000" -COMMAND="${1:-run}" -if [ "$COMMAND" = "setup" ] || [ "$COMMAND" = "--configure" ] || [ "$COMMAND" = "configure" ]; then - shift - CONFIGURE_ONLY=1 -else - CONFIGURE_ONLY=0 -fi - -settings_status() { - python3 - "$SETTINGS" "$PLACEHOLDER_KEY" <<'PY' -import json -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -placeholder = sys.argv[2] -if not path.exists(): - print("missing") - raise SystemExit(0) -try: - data = json.loads(path.read_text()) -except Exception: - print("invalid_json") - raise SystemExit(0) -models = data.get("customModels") -if not isinstance(models, list) or not models: - print("empty") - raise SystemExit(0) -row = models[0] if isinstance(models[0], dict) else {} -if not str(row.get("model") or "").strip(): - print("missing_model") - raise SystemExit(0) -if str(row.get("provider") or "").strip() not in {"minimax", "generic-chat-completion-api", "openai"}: - print("unsupported_provider") - raise SystemExit(0) -if not str(row.get("baseUrl") or "").strip(): - print("missing_base_url") - raise SystemExit(0) -api_key = str(row.get("apiKey") or "").strip() -if not api_key or api_key == placeholder: - print("missing_key") - raise SystemExit(0) -print("ok") -PY -} - -current_settings() { - python3 - "$SETTINGS" "$DEFAULT_MODEL" "$DEFAULT_DISPLAY_NAME" "$DEFAULT_BASE_URL" "$DEFAULT_CONTEXT" <<'PY' -import json -import shlex -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -defaults = { - "model": sys.argv[2], - "displayName": sys.argv[3], - "baseUrl": sys.argv[4], - "maxContextLimit": sys.argv[5], -} -row = {} -if path.exists(): - try: - data = json.loads(path.read_text()) - models = data.get("customModels") or [] - if models and isinstance(models[0], dict): - row = models[0] - except Exception: - row = {} -for key, fallback in [ - ("apiKey", ""), - ("model", defaults["model"]), - ("displayName", defaults["displayName"]), - ("baseUrl", defaults["baseUrl"]), - ("maxContextLimit", defaults["maxContextLimit"]), -]: - print(f"{key}={shlex.quote(str(row.get(key) or fallback))}") -PY -} - -prompt_setup() { - local status="$1" - if [ ! -t 0 ]; then - cat >&2 <&2 <&2 <&2 - if [ -z "$api_key" ] && [ -n "$current_key" ] && [ "$current_key" != "$PLACEHOLDER_KEY" ]; then - api_key="$current_key" - fi - if [ -z "$api_key" ]; then - echo "API key is required." >&2 - exit 1 - fi - read -r -p "MiniMax model [$current_model]: " model - model="${model:-$current_model}" - read -r -p "Display name [$current_displayName]: " display_name - display_name="${display_name:-$current_displayName}" - read -r -p "Base URL [$current_baseUrl]: " base_url - base_url="${base_url:-$current_baseUrl}" - read -r -p "Max context tokens [$current_maxContextLimit]: " context - context="${context:-$current_maxContextLimit}" - - mkdir -p "$(dirname "$SETTINGS")" - chmod 700 "$(dirname "$SETTINGS")" 2>/dev/null || true - umask 077 - python3 - "$SETTINGS" "$api_key" "$model" "$display_name" "$base_url" "$context" <<'PY' -import json -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -api_key = sys.argv[2] -model = sys.argv[3] -display_name = sys.argv[4] -base_url = sys.argv[5].rstrip("/") -try: - context = int(sys.argv[6]) -except ValueError: - context = 1000000 - -payload = { - "customModels": [ - { - "model": model, - "provider": "minimax", - "baseUrl": base_url, - "apiKey": api_key, - "displayName": display_name, - "maxContextLimit": context, - } - ] -} -path.write_text(json.dumps(payload, indent=2) + "\n") -PY - echo "Wrote $SETTINGS" >&2 -} - -STATUS="$(settings_status)" -if [ "$CONFIGURE_ONLY" = "1" ]; then - prompt_setup "$STATUS" - STATUS="$(settings_status)" - if [ "$STATUS" != "ok" ]; then - echo "MiniMax settings are still not usable: $STATUS" >&2 - exit 1 - fi - echo "MiniMax settings configured. Run: bin/codex-minimax ." >&2 - exit 0 -fi - -if [ "$STATUS" != "ok" ]; then - prompt_setup "$STATUS" - STATUS="$(settings_status)" -fi -if [ "$STATUS" != "ok" ]; then - echo "MiniMax settings are still not usable: $STATUS" >&2 - exit 1 -fi - -export CODEX_SHIM_DISABLE_CHATGPT="${CODEX_SHIM_DISABLE_CHATGPT:-1}" - -if [ -n "${CODEX_SHIM_MODEL:-}" ]; then - MODEL="$CODEX_SHIM_MODEL" -else - MODEL="$("$ROOT/bin/codex-shim" --settings "$SETTINGS" list | awk 'NR == 1 { print $1 }')" -fi - -if [ -z "$MODEL" ]; then - echo "No model slug found in $SETTINGS." >&2 - exit 1 -fi - -"$ROOT/bin/codex-shim" --settings "$SETTINGS" --port "$PORT" start >/dev/null - -exec "$ROOT/bin/codex-shim" \ - --settings "$SETTINGS" \ - --port "$PORT" \ - codex -- -m "$MODEL" "$@" diff --git a/bin/codex-openrouter b/bin/codex-openrouter deleted file mode 100755 index 62c5d1d9..00000000 --- a/bin/codex-openrouter +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SOURCE="${BASH_SOURCE[0]}" -while [ -h "$SOURCE" ]; do - DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" - SOURCE="$(readlink "$SOURCE")" - [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE" -done - -ROOT="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" -SETTINGS="${CODEX_SHIM_SETTINGS:-$ROOT/.codex-shim/openrouter-settings.json}" -PORT="${CODEX_SHIM_PORT:-8766}" -PLACEHOLDER_KEY="REPLACE_WITH_OPENROUTER_API_KEY" -DEFAULT_MODEL="openai/gpt-4o-mini" -DEFAULT_DISPLAY_NAME="OpenRouter GPT-4o Mini" -DEFAULT_CONTEXT="128000" -COMMAND="${1:-run}" -if [ "$COMMAND" = "setup" ] || [ "$COMMAND" = "--configure" ] || [ "$COMMAND" = "configure" ]; then - shift - CONFIGURE_ONLY=1 -else - CONFIGURE_ONLY=0 -fi - -settings_status() { - python3 - "$SETTINGS" "$PLACEHOLDER_KEY" <<'PY' -import json -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -placeholder = sys.argv[2] -if not path.exists(): - print("missing") - raise SystemExit(0) -try: - data = json.loads(path.read_text()) -except Exception: - print("invalid_json") - raise SystemExit(0) -models = data.get("customModels") -if not isinstance(models, list) or not models: - print("empty") - raise SystemExit(0) -row = models[0] if isinstance(models[0], dict) else {} -if not str(row.get("model") or "").strip(): - print("missing_model") - raise SystemExit(0) -if not str(row.get("provider") or "").strip(): - print("missing_provider") - raise SystemExit(0) -if not str(row.get("baseUrl") or "").strip(): - print("missing_base_url") - raise SystemExit(0) -api_key = str(row.get("apiKey") or "").strip() -if not api_key or api_key == placeholder: - print("missing_key") - raise SystemExit(0) -print("ok") -PY -} - -current_settings() { - python3 - "$SETTINGS" <<'PY' -import json -import shlex -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -row = {} -if path.exists(): - try: - data = json.loads(path.read_text()) - models = data.get("customModels") or [] - if models and isinstance(models[0], dict): - row = models[0] - except Exception: - row = {} -for key, fallback in [ - ("apiKey", ""), - ("model", "openai/gpt-4o-mini"), - ("displayName", "OpenRouter GPT-4o Mini"), - ("maxContextLimit", "128000"), -]: - print(f"{key}={shlex.quote(str(row.get(key) or fallback))}") -PY -} - -prompt_setup() { - local status="$1" - if [ ! -t 0 ]; then - cat >&2 <&2 <&2 <&2 - if [ -z "$api_key" ] && [ -n "$current_key" ] && [ "$current_key" != "$PLACEHOLDER_KEY" ]; then - api_key="$current_key" - fi - if [ -z "$api_key" ]; then - echo "API key is required." >&2 - exit 1 - fi - read -r -p "OpenRouter model [$current_model]: " model - model="${model:-$current_model}" - read -r -p "Display name [$current_displayName]: " display_name - display_name="${display_name:-$current_displayName}" - read -r -p "Max context tokens [$current_maxContextLimit]: " context - context="${context:-$current_maxContextLimit}" - - mkdir -p "$(dirname "$SETTINGS")" - chmod 700 "$(dirname "$SETTINGS")" 2>/dev/null || true - umask 077 - python3 - "$SETTINGS" "$api_key" "$model" "$display_name" "$context" <<'PY' -import json -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -api_key = sys.argv[2] -model = sys.argv[3] -display_name = sys.argv[4] -try: - context = int(sys.argv[5]) -except ValueError: - context = 128000 - -payload = { - "customModels": [ - { - "model": model, - "provider": "generic-chat-completion-api", - "baseUrl": "https://openrouter.ai/api/v1", - "apiKey": api_key, - "displayName": display_name, - "maxContextLimit": context, - } - ] -} -path.write_text(json.dumps(payload, indent=2) + "\n") -PY - echo "Wrote $SETTINGS" >&2 -} - -STATUS="$(settings_status)" -if [ "$CONFIGURE_ONLY" = "1" ]; then - prompt_setup "$STATUS" - STATUS="$(settings_status)" - if [ "$STATUS" != "ok" ]; then - echo "OpenRouter settings are still not usable: $STATUS" >&2 - exit 1 - fi - echo "OpenRouter settings configured. Run: bin/codex-openrouter ." >&2 - exit 0 -fi - -if [ "$STATUS" != "ok" ]; then - prompt_setup "$STATUS" - STATUS="$(settings_status)" -fi -if [ "$STATUS" != "ok" ]; then - echo "OpenRouter settings are still not usable: $STATUS" >&2 - exit 1 -fi - -export CODEX_SHIM_DISABLE_CHATGPT="${CODEX_SHIM_DISABLE_CHATGPT:-1}" - -if [ -n "${CODEX_SHIM_MODEL:-}" ]; then - MODEL="$CODEX_SHIM_MODEL" -else - MODEL="$("$ROOT/bin/codex-shim" --settings "$SETTINGS" list | awk 'NR == 1 { print $1 }')" -fi - -if [ -z "$MODEL" ]; then - echo "No model slug found in $SETTINGS." >&2 - exit 1 -fi - -"$ROOT/bin/codex-shim" --settings "$SETTINGS" --port "$PORT" start >/dev/null - -exec "$ROOT/bin/codex-shim" \ - --settings "$SETTINGS" \ - --port "$PORT" \ - codex -- -m "$MODEL" "$@" diff --git a/codex_shim/__init__.py b/codex_shim/__init__.py index 7474ef5d..938b7bac 100644 --- a/codex_shim/__init__.py +++ b/codex_shim/__init__.py @@ -1,4 +1,4 @@ -"""Factory-to-Codex BYOK shim.""" +"""All-model BYOK shim for Codex.""" __all__ = ["__version__"] __version__ = "0.1.0" diff --git a/codex_shim/catalog.py b/codex_shim/catalog.py index e27c44bb..7f47e7ad 100644 --- a/codex_shim/catalog.py +++ b/codex_shim/catalog.py @@ -3,13 +3,13 @@ import json from pathlib import Path -from .settings import FactoryModel, PROVIDER_NAME, chatgpt_passthrough_enabled, default_model_slug +from .settings import CHATGPT_MODEL_SLUG, PROVIDER_NAME, ShimModel, chatgpt_passthrough_available, default_model_slug PLAN_TIERS = ["free", "plus", "pro", "team", "business", "enterprise"] -def catalog_entry(model: FactoryModel) -> dict: +def catalog_entry(model: ShimModel) -> dict: context = model.max_context_limit or _default_context(model) compact = max(8_000, int(context * 0.8)) truncation = min(64_000, max(8_000, int(context * 0.32))) @@ -17,7 +17,7 @@ def catalog_entry(model: FactoryModel) -> dict: return { "slug": model.slug, "display_name": model.display_name, - "description": f"{model.display_name} via local Factory BYOK shim.", + "description": f"{model.display_name} via local Codex shim.", "context_window": context, "max_context_window": context, "auto_compact_token_limit": compact, @@ -53,7 +53,7 @@ def catalog_entry(model: FactoryModel) -> dict: "base_instructions": "You are a coding agent running in Codex through a local BYOK shim.", "model_messages": { "instructions_template": ( - "You are Codex running on {model_name} through a local Factory BYOK shim. " + "You are Codex running on {model_name} through a local all-model shim. " "Be a helpful, direct coding collaborator." ), "instructions_variables": {"model_name": model.display_name}, @@ -64,7 +64,7 @@ def catalog_entry(model: FactoryModel) -> dict: def chatgpt_passthrough_entry() -> dict: """Catalog entry for the original GPT-5.5 routed through ChatGPT passthrough.""" return { - "slug": "gpt-5.5", + "slug": CHATGPT_MODEL_SLUG, "display_name": "GPT-5.5", "description": "OpenAI GPT-5.5 — the default Codex model, routed through ChatGPT passthrough.", "context_window": 400000, @@ -109,33 +109,30 @@ def chatgpt_passthrough_entry() -> dict: } -def write_catalog(models: list[FactoryModel], path: Path, include_chatgpt: bool | None = None) -> Path: +def write_catalog(models: list[ShimModel], path: Path) -> Path: path.parent.mkdir(parents=True, exist_ok=True) - if include_chatgpt is None: - include_chatgpt = chatgpt_passthrough_enabled() - entries = [chatgpt_passthrough_entry()] if include_chatgpt else [] + entries: list[dict] = [] + if chatgpt_passthrough_available(): + entries.append(chatgpt_passthrough_entry()) entries.extend(catalog_entry(model) for model in models) payload = {"models": entries} path.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n") return path -def write_config( - models: list[FactoryModel], - path: Path, - catalog_path: Path, - port: int, - include_chatgpt: bool | None = None, -) -> Path: +def write_config(models: list[ShimModel], path: Path, catalog_path: Path, port: int) -> Path: path.parent.mkdir(parents=True, exist_ok=True) - default_slug = default_model_slug(models, include_chatgpt) + try: + default_slug = default_model_slug(models) + except ValueError as exc: + raise SystemExit(str(exc)) from exc text = f'''# Generated by codex-shim. This file is opt-in and is not ~/.codex/config.toml. model = "{_toml_escape(default_slug)}" model_provider = "{PROVIDER_NAME}" model_catalog_json = "{_toml_escape(str(catalog_path))}" [model_providers.{PROVIDER_NAME}] -name = "Factory BYOK Shim" +name = "Codex Shim" base_url = "http://127.0.0.1:{port}/v1" wire_api = "responses" experimental_bearer_token = "dummy" @@ -152,7 +149,7 @@ def codex_config_overrides(catalog_path: Path, default_slug: str, port: int) -> f'model="{_toml_escape(default_slug)}"', f'model_provider="{PROVIDER_NAME}"', f'model_catalog_json="{_toml_escape(str(catalog_path))}"', - f'model_providers.{PROVIDER_NAME}.name="Factory BYOK Shim"', + f'model_providers.{PROVIDER_NAME}.name="Codex Shim"', f'model_providers.{PROVIDER_NAME}.base_url="http://127.0.0.1:{port}/v1"', f'model_providers.{PROVIDER_NAME}.wire_api="responses"', f'model_providers.{PROVIDER_NAME}.experimental_bearer_token="dummy"', @@ -162,7 +159,7 @@ def codex_config_overrides(catalog_path: Path, default_slug: str, port: int) -> ] -def _default_context(model: FactoryModel) -> int: +def _default_context(model: ShimModel) -> int: lower = f"{model.model} {model.display_name}".lower() if "claude" in lower: return 200_000 @@ -173,7 +170,7 @@ def _default_context(model: FactoryModel) -> int: return 128_000 -def _reasoning_effort(model: FactoryModel) -> str: +def _reasoning_effort(model: ShimModel) -> str: lower = model.display_name.lower() if "xhigh" in lower or "x-high" in lower: return "xhigh" @@ -188,3 +185,4 @@ def _reasoning_effort(model: FactoryModel) -> str: def _toml_escape(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') + diff --git a/codex_shim/cli.py b/codex_shim/cli.py index ca6555f9..9a4d6fb0 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -3,24 +3,26 @@ import argparse from dataclasses import dataclass import getpass -import json import os from pathlib import Path +import ctypes import signal import subprocess import sys import time import hashlib +import json from urllib.request import urlopen -from .catalog import codex_config_overrides, write_catalog, write_config +from .catalog import _toml_escape, codex_config_overrides, write_catalog, write_config from .settings import ( CHATGPT_MODEL_SLUG, - DEFAULT_FACTORY_SETTINGS, + DEFAULT_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, - FactorySettings, - chatgpt_passthrough_enabled, + PROVIDER_NAME, + ModelSettings, + chatgpt_passthrough_available, default_model_slug, ) @@ -35,6 +37,9 @@ CODEX_CONFIG_BACKUP_PATH = RUNTIME_DIR / "config.toml.before-codex-shim" MANAGED_BEGIN = "# >>> codex-shim managed >>>" MANAGED_END = "# <<< codex-shim managed <<<" +WINDOWS_PROCESS_TERMINATE = 0x0001 +WINDOWS_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +WINDOWS_STILL_ACTIVE = 259 PREVIOUS_TOP_LEVEL_PREFIX = "# codex-shim previous-top-level = " MANAGED_TOP_LEVEL_KEYS = {"model", "model_provider", "model_catalog_json"} @@ -59,7 +64,7 @@ class ProviderSpec: "openrouter": ProviderSpec( name="openrouter", title="OpenRouter", - settings_path=RUNTIME_DIR / "openrouter-settings.json", + settings_path=DEFAULT_SETTINGS.parent / "openrouter-models.json", port=8766, placeholder_key="REPLACE_WITH_OPENROUTER_API_KEY", default_model="openai/gpt-4o-mini", @@ -68,12 +73,12 @@ class ProviderSpec: default_base_url="https://openrouter.ai/api/v1", default_context=128000, allowed_providers=frozenset({"generic-chat-completion-api", "openai"}), - template_path=PROJECT_ROOT / "examples" / "openrouter-settings.example.json", + template_path=PROJECT_ROOT / "examples" / "openrouter-models.example.json", ), "minimax": ProviderSpec( name="minimax", title="MiniMax Token Plan", - settings_path=RUNTIME_DIR / "minimax-settings.json", + settings_path=DEFAULT_SETTINGS.parent / "minimax-models.json", port=8767, placeholder_key="REPLACE_WITH_MINIMAX_TOKEN_PLAN_KEY", default_model="MiniMax-M2.7", @@ -82,25 +87,25 @@ class ProviderSpec: default_base_url="https://api.minimax.io/v1", default_context=1000000, allowed_providers=frozenset({"minimax", "generic-chat-completion-api", "openai"}), - template_path=PROJECT_ROOT / "examples" / "minimax-token-plan-settings.example.json", + template_path=PROJECT_ROOT / "examples" / "minimax-models.example.json", ), } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="codex-shim") - parser.add_argument("--settings", type=Path, default=DEFAULT_FACTORY_SETTINGS) + parser.add_argument("--settings", type=Path, default=DEFAULT_SETTINGS) parser.add_argument("--port", type=int) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("generate") sub.add_parser("list") sub.add_parser("start") - sub.add_parser("enable", help="Install managed shim blocks into ~/.codex/config.toml and start the shim.") + sub.add_parser("enable") sub.add_parser("stop") sub.add_parser("disable") sub.add_parser("restart") sub.add_parser("status") - sub.add_parser("patch-app", help="Experimental/incomplete: patch Codex Desktop ASAR model picker.") + sub.add_parser("patch-app", help="Patch Codex Desktop model dropdown to allow custom catalog models.") sub.add_parser("restore-app", help="Restore Codex Desktop app.asar from the pre-patch backup.") model_parser = sub.add_parser("model", help="List or set the active shim model in Codex config.") @@ -112,11 +117,11 @@ def main(argv: list[str] | None = None) -> int: codex_parser = sub.add_parser("codex", help="Run Codex CLI with opt-in shim config overrides.") codex_parser.add_argument("args", nargs=argparse.REMAINDER) - app_parser = sub.add_parser("app", help="Install managed shim config blocks, then launch Codex Desktop.") + app_parser = sub.add_parser("app", help="Launch Codex Desktop with opt-in shim config overrides.") app_parser.add_argument("-m", "--model", dest="model_slug") app_parser.add_argument("path", nargs="?", default=".") - setup_parser = sub.add_parser("setup", help="Configure an ignored local provider settings file.") + setup_parser = sub.add_parser("setup", help="Configure a provider settings file under ~/.codex-shim.") setup_parser.add_argument("provider", choices=sorted(PROVIDER_SPECS)) run_parser = sub.add_parser("run", help="Run Codex CLI through a configured provider.") @@ -206,7 +211,7 @@ def setup_provider(provider: str) -> int: if status != "ok": print(f"{spec.title} settings are still not usable: {status}", file=sys.stderr) return 1 - print(f"{spec.title} settings configured. Run: codex-shim run {spec.name} .") + print(f"{spec.title} settings configured. Run: codex-shim {spec.name} .") return 0 @@ -220,18 +225,25 @@ def run_provider(provider: str, codex_args: list[str], requested_port: int | Non print(f"{spec.title} settings are still not usable: {status}", file=sys.stderr) return 1 - os.environ.setdefault("CODEX_SHIM_DISABLE_CHATGPT", "1") - port = requested_port if requested_port is not None else spec.port - generate(spec.settings_path, port) - ensure_started(spec.settings_path, port) - model = os.environ.get("CODEX_SHIM_MODEL") or _first_model_slug(spec.settings_path) - if not model: - print(f"No model slug found in {spec.settings_path}.", file=sys.stderr) - return 1 - codex_args = list(codex_args or []) - if codex_args[:1] == ["--"]: - codex_args = codex_args[1:] - exec_codex(spec.settings_path, port, ["-m", model, *codex_args]) + previous_disable_chatgpt = os.environ.get("CODEX_SHIM_DISABLE_CHATGPT") + os.environ["CODEX_SHIM_DISABLE_CHATGPT"] = "1" + try: + port = requested_port if requested_port is not None else spec.port + generate(spec.settings_path, port) + ensure_started(spec.settings_path, port) + model = os.environ.get("CODEX_SHIM_MODEL") or _first_model_slug(spec.settings_path) + if not model: + print(f"No model slug found in {spec.settings_path}.", file=sys.stderr) + return 1 + codex_args = list(codex_args or []) + if codex_args[:1] == ["--"]: + codex_args = codex_args[1:] + exec_codex(spec.settings_path, port, ["-m", model, *codex_args]) + finally: + if previous_disable_chatgpt is None: + os.environ.pop("CODEX_SHIM_DISABLE_CHATGPT", None) + else: + os.environ["CODEX_SHIM_DISABLE_CHATGPT"] = previous_disable_chatgpt return 0 @@ -239,24 +251,17 @@ def _provider_settings_status(spec: ProviderSpec) -> str: if not spec.settings_path.exists(): return "missing" try: - data = json.loads(spec.settings_path.read_text()) - except Exception: + models = ModelSettings(spec.settings_path).load() + except (OSError, json.JSONDecodeError): return "invalid_json" - models = data.get("customModels") - if not isinstance(models, list) or not models: + if not models: return "empty" - row = models[0] if isinstance(models[0], dict) else {} - if not str(row.get("model") or "").strip(): - return "missing_model" - provider = str(row.get("provider") or "").strip() - if not provider: - return "missing_provider" - if provider not in spec.allowed_providers: + model = models[0] + if model.provider not in spec.allowed_providers: return "unsupported_provider" - if not str(row.get("baseUrl") or "").strip(): + if not model.base_url: return "missing_base_url" - api_key = str(row.get("apiKey") or "").strip() - if not api_key or api_key == spec.placeholder_key: + if not model.api_key or model.api_key == spec.placeholder_key: return "missing_key" return "ok" @@ -276,20 +281,20 @@ def _prompt_provider_setup(spec: ProviderSpec, status: str) -> None: print( f"{spec.title} setup:\n" f" {spec.settings_path}\n\n" - "This will update the local ignored settings file. The API key will not be echoed.", + "This will update the provider settings file. The API key will not be echoed.", file=sys.stderr, ) else: print( f"{spec.title} settings need configuration ({status}):\n" f" {spec.settings_path}\n\n" - "This will write a local ignored settings file. The API key will not be echoed.", + "This will write a provider settings file. The API key will not be echoed.", file=sys.stderr, ) current = _current_provider_settings(spec) key_prompt = f"{spec.title} API key" - current_key = current.get("apiKey", "") + current_key = current.get("api_key", "") if current_key and current_key != spec.placeholder_key: key_prompt += " [keep existing]" api_key = getpass.getpass(f"{key_prompt}: ") @@ -300,9 +305,9 @@ def _prompt_provider_setup(spec: ProviderSpec, status: str) -> None: raise SystemExit(1) model = _prompt_with_default(f"{spec.title} model", current["model"]) - display_name = _prompt_with_default("Display name", current["displayName"]) - base_url = _prompt_with_default("Base URL", current["baseUrl"]) - context_raw = _prompt_with_default("Max context tokens", str(current["maxContextLimit"])) + display_name = _prompt_with_default("Display name", current["display_name"]) + base_url = _prompt_with_default("Base URL", current["base_url"]) + context_raw = _prompt_with_default("Max context tokens", str(current["max_context_limit"])) try: context = int(context_raw) except ValueError: @@ -313,21 +318,18 @@ def _prompt_provider_setup(spec: ProviderSpec, status: str) -> None: def _current_provider_settings(spec: ProviderSpec) -> dict[str, str]: - row = {} - if spec.settings_path.exists(): - try: - data = json.loads(spec.settings_path.read_text()) - models = data.get("customModels") or [] - if models and isinstance(models[0], dict): - row = models[0] - except Exception: - row = {} + models = [] + try: + models = ModelSettings(spec.settings_path).load() + except (OSError, json.JSONDecodeError): + pass + model = models[0] if models else None return { - "apiKey": str(row.get("apiKey") or ""), - "model": str(row.get("model") or spec.default_model), - "displayName": str(row.get("displayName") or spec.default_display_name), - "baseUrl": str(row.get("baseUrl") or spec.default_base_url), - "maxContextLimit": str(row.get("maxContextLimit") or spec.default_context), + "api_key": model.api_key if model else "", + "model": model.model if model else spec.default_model, + "display_name": model.display_name if model else spec.default_display_name, + "base_url": model.base_url if model else spec.default_base_url, + "max_context_limit": str(model.max_context_limit if model and model.max_context_limit else spec.default_context), } @@ -352,14 +354,14 @@ def _write_provider_settings( old_umask = os.umask(0o077) try: payload = { - "customModels": [ + "models": [ { "model": model, "provider": spec.default_provider, - "baseUrl": base_url.rstrip("/"), - "apiKey": api_key, - "displayName": display_name, - "maxContextLimit": context, + "base_url": base_url.rstrip("/"), + "api_key": api_key, + "display_name": display_name, + "max_context_limit": context, } ] } @@ -369,19 +371,31 @@ def _write_provider_settings( def _first_model_slug(settings_path: Path) -> str | None: - models = FactorySettings(settings_path).load() + models = ModelSettings(settings_path).load() return models[0].slug if models else None +def _load_models(settings_path: Path): + expanded = Path(settings_path).expanduser() + try: + return ModelSettings(expanded).load() + except FileNotFoundError as exc: + raise SystemExit( + f"Settings file not found: {expanded}\n" + "Create ~/.codex-shim/models.json, or pass --settings /path/to/models.json." + ) from exc + except json.JSONDecodeError as exc: + raise SystemExit(f"Settings file is not valid JSON: {expanded}: {exc}") from exc + + def generate(settings_path: Path, port: int) -> None: - models = FactorySettings(settings_path).load() - include_chatgpt = chatgpt_passthrough_enabled() + models = _load_models(settings_path) try: - default_model_slug(models, include_chatgpt) - write_catalog(models, CATALOG_PATH, include_chatgpt) - write_config(models, CONFIG_PATH, CATALOG_PATH, port, include_chatgpt) + default_model_slug(models) except ValueError as exc: raise SystemExit(str(exc)) from exc + write_catalog(models, CATALOG_PATH) + write_config(models, CONFIG_PATH, CATALOG_PATH, port) print(f"Generated {len(models)} model entries:") print(f" catalog: {CATALOG_PATH}") print(f" config: {CONFIG_PATH}") @@ -389,7 +403,7 @@ def generate(settings_path: Path, port: int) -> None: def install_codex_config(settings_path: Path, port: int, model_slug: str | None = None) -> None: - models = FactorySettings(settings_path).load() + models = _load_models(settings_path) default_slug = _resolve_model_slug(models, model_slug) CODEX_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) RUNTIME_DIR.mkdir(parents=True, exist_ok=True) @@ -398,22 +412,33 @@ def install_codex_config(settings_path: Path, port: int, model_slug: str | None current_top_level = _extract_top_level_key_lines(cleaned, MANAGED_TOP_LEVEL_KEYS) if current_top_level: previous_top_level = current_top_level - elif MANAGED_BEGIN in original: - previous_top_level = _managed_previous_top_level(original) else: - previous_top_level = _extract_top_level_key_lines(original, MANAGED_TOP_LEVEL_KEYS) + previous_top_level = _managed_previous_top_level(original) + if not previous_top_level and CODEX_CONFIG_BACKUP_PATH.exists(): + previous_top_level = _extract_top_level_key_lines(CODEX_CONFIG_BACKUP_PATH.read_text(), MANAGED_TOP_LEVEL_KEYS) cleaned = _remove_top_level_keys(cleaned, MANAGED_TOP_LEVEL_KEYS) - cleaned = _remove_section(cleaned, "model_providers.factory_byok_shim") + cleaned = _remove_section(cleaned, f"model_providers.{PROVIDER_NAME}") top_block, provider_block = _managed_config_blocks(default_slug, port, previous_top_level) CODEX_CONFIG_PATH.write_text(top_block + "\n" + cleaned.lstrip() + "\n" + provider_block) print(f"Installed shim config into {CODEX_CONFIG_PATH}.") def list_models(settings_path: Path) -> int: - models = FactorySettings(settings_path).load() - width = max([len(m.slug) for m in models] + [4]) - for model in models: - print(f"{model.slug:<{width}} {model.display_name} -> {model.model} ({model.provider})") + models = _load_models(settings_path) + rows: list[tuple[str, str, str, str]] = [] + if chatgpt_passthrough_available(): + rows.append(("gpt-5.5", "GPT-5.5", "gpt-5.5", "chatgpt")) + rows.extend((model.slug, model.display_name, model.model, model.provider) for model in models) + if not rows: + print( + "No models available. Create ~/.codex-shim/models.json, pass --settings /path/to/models.json, " + "or run `codex login` so ~/.codex/auth.json grants the gpt-5.5 passthrough.", + file=sys.stderr, + ) + return 1 + width = max(len(row[0]) for row in rows) + for slug, display_name, model, provider in rows: + print(f"{slug:<{width}} {display_name} -> {model} ({provider})", flush=True) return 0 @@ -436,7 +461,7 @@ def start(settings_path: Path, port: int) -> int: ] env = os.environ.copy() env["PYTHONPATH"] = str(PROJECT_ROOT) + os.pathsep + env.get("PYTHONPATH", "") - process = subprocess.Popen(cmd, cwd=str(PROJECT_ROOT), env=env, stdout=log, stderr=log, start_new_session=True) + process = _popen_daemon(cmd, log, env) PID_PATH.write_text(str(process.pid)) for _ in range(50): if _healthy(port): @@ -457,7 +482,7 @@ def stop() -> int: print("Shim is not running.") PID_PATH.unlink(missing_ok=True) return 0 - os.kill(pid, signal.SIGTERM) + _terminate_pid(pid) for _ in range(50): if not _pid_running(pid): PID_PATH.unlink(missing_ok=True) @@ -472,8 +497,10 @@ def restore_codex_config() -> None: if CODEX_CONFIG_PATH.exists(): current = CODEX_CONFIG_PATH.read_text() previous_top_level = _managed_previous_top_level(current) + if not previous_top_level and CODEX_CONFIG_BACKUP_PATH.exists(): + previous_top_level = _extract_top_level_key_lines(CODEX_CONFIG_BACKUP_PATH.read_text(), MANAGED_TOP_LEVEL_KEYS) restored = _remove_managed_config(current) - restored = _remove_section(restored, "model_providers.factory_byok_shim") + restored = _remove_section(restored, f"model_providers.{PROVIDER_NAME}") restored = _restore_missing_top_level_keys(restored.lstrip(), previous_top_level) CODEX_CONFIG_PATH.write_text(restored) print(f"Removed shim config from {CODEX_CONFIG_PATH}.") @@ -484,9 +511,12 @@ def restore_codex_config() -> None: def status(port: int) -> int: pid = _read_pid() - if _pid_running(pid) and _healthy(port): - print(f"Shim is running on http://{DEFAULT_HOST}:{port} with pid {pid}.") - return 0 + if _pid_running(pid): + health = _health(port) + if health is not None: + model_count = health.get("models", "unknown") + print(f"Shim is running on http://{DEFAULT_HOST}:{port} with pid {pid} ({model_count} models).") + return 0 if _pid_running(pid): print(f"Shim process {pid} exists but health check failed.") return 1 @@ -507,16 +537,31 @@ def exec_codex(settings_path: Path, port: int, codex_args: list[str]) -> None: if codex_args[:1] == ["--"]: codex_args = codex_args[1:] args = ["codex", *overrides, *codex_args] - os.execvp("codex", args) + env = _with_loopback_no_proxy(os.environ.copy()) + if os.name == "nt": + raise SystemExit(subprocess.call(args, env=env)) + os.execvpe("codex", args, env) def exec_codex_app(settings_path: Path, port: int, path: str) -> None: _quit_codex_app() args = ["codex", "app", path] - subprocess.Popen(args) + subprocess.Popen(args, env=_with_loopback_no_proxy(os.environ.copy())) _foreground_codex_app() +def _with_loopback_no_proxy(env: dict[str, str]) -> dict[str, str]: + loopback = ["127.0.0.1", "localhost", "::1"] + for key in ("NO_PROXY", "no_proxy"): + values = [part.strip() for part in env.get(key, "").split(",") if part.strip()] + lower_values = {value.lower() for value in values} + for host in loopback: + if host.lower() not in lower_values: + values.append(host) + env[key] = ",".join(values) + return env + + def _quit_codex_app() -> None: script = 'tell application "Codex" to if it is running then quit' try: @@ -527,6 +572,9 @@ def _quit_codex_app() -> None: def patch_codex_app() -> int: + if sys.platform != "darwin": + print("patch-app is macOS-only; Windows MSIX Codex Desktop cannot be patched with this ASAR helper.", file=sys.stderr) + return 1 app_asar = Path("/Applications/Codex.app/Contents/Resources/app.asar") backup = RUNTIME_DIR / "app.asar.before-codex-shim-model-picker-patch" workdir = RUNTIME_DIR / "app-asar-work" @@ -579,6 +627,9 @@ def patch_codex_app() -> int: def restore_codex_app_bundle() -> int: + if sys.platform != "darwin": + print("restore-app is macOS-only; Windows MSIX Codex Desktop cannot be restored with this ASAR helper.", file=sys.stderr) + return 1 app_asar = Path("/Applications/Codex.app/Contents/Resources/app.asar") backup = RUNTIME_DIR / "app.asar.before-codex-shim-model-picker-patch" if not backup.exists(): @@ -662,15 +713,15 @@ def _managed_config_blocks(default_slug: str, port: int, previous_top_level: dic if previous_top_level: metadata = PREVIOUS_TOP_LEVEL_PREFIX + json.dumps(previous_top_level, sort_keys=True) + "\n" top_block = f'''{MANAGED_BEGIN} -{metadata}model = "{default_slug}" -model_provider = "factory_byok_shim" -model_catalog_json = "{CATALOG_PATH}" +{metadata}model = "{_toml_escape(default_slug)}" +model_provider = "{PROVIDER_NAME}" +model_catalog_json = "{_toml_escape(str(CATALOG_PATH))}" {MANAGED_END} ''' provider_block = f'''{MANAGED_BEGIN} -[model_providers.factory_byok_shim] -name = "Factory BYOK Shim" +[model_providers.{PROVIDER_NAME}] +name = "Codex Shim" base_url = "http://127.0.0.1:{port}/v1" wire_api = "responses" experimental_bearer_token = "dummy" @@ -776,9 +827,32 @@ def _remove_section(text: str, section: str) -> str: return "\n".join(output) + ("\n" if text.endswith("\n") else "") +def _popen_daemon(cmd: list[str], log, env: dict[str, str]) -> subprocess.Popen: + kwargs = {"cwd": str(PROJECT_ROOT), "env": env, "stdout": log, "stderr": log} + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) + return subprocess.Popen(cmd, creationflags=creationflags, **kwargs) + return subprocess.Popen(cmd, start_new_session=True, **kwargs) + + +def _terminate_pid(pid: int) -> None: + if os.name == "nt": + handle = ctypes.windll.kernel32.OpenProcess(WINDOWS_PROCESS_TERMINATE, False, pid) + if handle: + try: + ctypes.windll.kernel32.TerminateProcess(handle, 0) + finally: + ctypes.windll.kernel32.CloseHandle(handle) + return + os.kill(pid, signal.SIGTERM) + + def _override_args(settings_path: Path, port: int) -> list[str]: - models = FactorySettings(settings_path).load() - default_slug = default_model_slug(models) + models = _load_models(settings_path) + try: + default_slug = default_model_slug(models) + except ValueError as exc: + raise SystemExit(str(exc)) from exc pairs = codex_config_overrides(CATALOG_PATH, default_slug, port) args: list[str] = [] for pair in pairs: @@ -789,10 +863,19 @@ def _override_args(settings_path: Path, port: int) -> list[str]: def _resolve_model_slug(models, requested: str | None) -> str: if requested is None: current = _current_managed_model() - valid = _valid_model_slugs(models) - if current in valid: + if current in _valid_model_slugs(models): return current - return default_model_slug(models) + try: + return default_model_slug(models) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + if requested in {CHATGPT_MODEL_SLUG, "openai-gpt-5-5"}: + if not chatgpt_passthrough_available(): + raise SystemExit( + "gpt-5.5 passthrough requires a Codex login. " + "Run `codex login` so ~/.codex/auth.json contains tokens.access_token." + ) + return CHATGPT_MODEL_SLUG by_slug = {model.slug: model.slug for model in models} by_model = {} for model in models: @@ -828,17 +911,23 @@ def _current_managed_model() -> str | None: def _valid_model_slugs(models) -> set[str]: slugs = {model.slug for model in models} - if chatgpt_passthrough_enabled(): + if chatgpt_passthrough_available(): slugs.add(CHATGPT_MODEL_SLUG) return slugs def _healthy(port: int) -> bool: + return _health(port) is not None + + +def _health(port: int) -> dict | None: try: with urlopen(f"http://{DEFAULT_HOST}:{port}/health", timeout=0.5) as response: - return response.status == 200 + if response.status != 200: + return None + return json.loads(response.read().decode("utf-8")) except Exception: - return False + return None def _read_pid() -> int | None: @@ -851,6 +940,17 @@ def _read_pid() -> int | None: def _pid_running(pid: int | None) -> bool: if not pid: return False + if os.name == "nt": + handle = ctypes.windll.kernel32.OpenProcess(WINDOWS_PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + try: + exit_code = ctypes.c_ulong() + if not ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == WINDOWS_STILL_ACTIVE + finally: + ctypes.windll.kernel32.CloseHandle(handle) try: os.kill(pid, 0) return True @@ -858,5 +958,20 @@ def _pid_running(pid: int | None) -> bool: return False +def _entrypoint() -> int: + try: + return main() + except BrokenPipeError: + # Downstream pipe (e.g. `codex-shim list | head`) closed early. Mute the + # interpreter's atexit flush so we exit cleanly instead of dumping a + # traceback to stderr. + try: + sys.stdout.flush() + except BrokenPipeError: + pass + os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) + return 0 + + if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(_entrypoint()) diff --git a/codex_shim/server.py b/codex_shim/server.py index 0791688c..bb906ab2 100644 --- a/codex_shim/server.py +++ b/codex_shim/server.py @@ -11,14 +11,16 @@ from .settings import ( CHATGPT_MODEL_SLUG, - DEFAULT_FACTORY_SETTINGS, + DEFAULT_CODEX_AUTH, + DEFAULT_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, - FactoryModel, - FactorySettings, - chatgpt_passthrough_enabled, + ModelSettings, + ShimModel, + chatgpt_passthrough_available, ) from .translate import ( + SHIM_ENCRYPTED_CONTENT_PREFIX, anthropic_to_chat_response, anthropic_to_response, chat_completion_to_response, @@ -29,8 +31,8 @@ class ShimServer: - def __init__(self, settings_path: Path = DEFAULT_FACTORY_SETTINGS): - self.settings = FactorySettings(settings_path) + def __init__(self, settings_path: Path = DEFAULT_SETTINGS): + self.settings = ModelSettings(settings_path) self.timeout = ClientTimeout(total=None, sock_connect=120, sock_read=None) def app(self) -> web.Application: @@ -43,11 +45,21 @@ def app(self) -> web.Application: async def health(self, _request: web.Request) -> web.Response: models = self.settings.load() - return web.json_response({"ok": True, "models": len(models)}) + count = len(models) + (1 if chatgpt_passthrough_available() else 0) + return web.json_response( + { + "ok": True, + "models": count, + "chatgpt_passthrough": chatgpt_passthrough_available(), + } + ) async def models(self, _request: web.Request) -> web.Response: now = int(time.time()) - data = [{"id": model.slug, "object": "model", "created": now, "owned_by": "factory"} for model in self.settings.load()] + data: list[dict[str, Any]] = [] + if chatgpt_passthrough_available(): + data.append({"id": CHATGPT_MODEL_SLUG, "object": "model", "created": now, "owned_by": "chatgpt"}) + data.extend({"id": model.slug, "object": "model", "created": now, "owned_by": "codex-shim"} for model in self.settings.load()) return web.json_response({"object": "list", "data": data}) async def chat_completions(self, request: web.Request) -> web.StreamResponse: @@ -56,18 +68,22 @@ async def chat_completions(self, request: web.Request) -> web.StreamResponse: if route.is_openai_chat: forwarded = dict(body) forwarded["model"] = route.model + if "messages" in forwarded: + forwarded["messages"] = _normalize_roles(forwarded["messages"]) return await self._post_openai_chat(request, route, forwarded, as_responses=False) if route.is_anthropic: forwarded = chat_to_anthropic(body, route.model, route.max_output_tokens) return await self._post_anthropic(request, route, forwarded, as_responses=False) - raise web.HTTPBadGateway(text=f"Unsupported Factory provider: {route.provider}") + raise web.HTTPBadGateway(text=f"Unsupported model provider: {route.provider}") async def responses(self, request: web.Request) -> web.StreamResponse: body = await request.json() _log_incoming_request("/v1/responses", body) model = str(body.get("model") or "") - if chatgpt_passthrough_enabled() and (model == CHATGPT_MODEL_SLUG or model.startswith("openai-gpt-5-5")): + if model == CHATGPT_MODEL_SLUG or model.startswith("openai-gpt-5-5"): return await self._chatgpt_passthrough(request, body) + if self._needs_image_gen(body) or self._needs_image_followup(body): + return await self._chatgpt_passthrough(request, body, response_model_override=model) route = self._route(body) if route.is_openai_chat: forwarded = responses_to_chat(body, route.model) @@ -75,17 +91,174 @@ async def responses(self, request: web.Request) -> web.StreamResponse: if route.is_anthropic: forwarded = responses_to_anthropic(body, route.model, route.max_output_tokens) return await self._post_anthropic(request, route, forwarded, as_responses=True) - raise web.HTTPBadGateway(text=f"Unsupported Factory provider: {route.provider}") + raise web.HTTPBadGateway(text=f"Unsupported model provider: {route.provider}") + + def _needs_image_gen(self, body: dict[str, Any]) -> bool: + tools = body.get("tools") or [] + image_tool_names: set[str] = set() + non_image_tool_count = 0 + for tool in tools: + if not isinstance(tool, dict): + non_image_tool_count += 1 + continue + tool_type = str(tool.get("type") or "") + fn = tool.get("function") or tool.get("name") or {} + name = fn.get("name") if isinstance(fn, dict) else fn + normalized = f"{tool_type} {name or ''}".lower() + is_image_tool = tool_type in {"image_generation", "image_gen"} or ("image" in normalized and "gen" in normalized) + if is_image_tool: + image_tool_names.add(str(name or tool_type)) + else: + non_image_tool_count += 1 + if not image_tool_names: + return False + + tool_choice = body.get("tool_choice") + if isinstance(tool_choice, str): + if any(name.lower() in tool_choice.lower() for name in image_tool_names): + return True + elif isinstance(tool_choice, dict): + fn = tool_choice.get("function") or {} + choice_name = str(tool_choice.get("name") or (fn.get("name") if isinstance(fn, dict) else "") or tool_choice.get("type") or "").lower() + if any(name.lower() in choice_name for name in image_tool_names): + return True + + if non_image_tool_count == 0: + return True + + latest = self._latest_user_text(body).lower() + if not latest: + return False + image_intent_markers = ( + "@image", + "imagegen", + "image gen", + "image_gen", + "generate image", + "generate an image", + "generate a picture", + "generate a photo", + "generate an illustration", + "create image", + "create an image", + "create a picture", + "create a photo", + "draw image", + "draw an image", + "make image", + "make an image", + "render image", + ) + if any(marker in latest for marker in image_intent_markers): + return True + code_words = {"code", "component", "react", "tsx", "jsx", "html", "css", "svg", "file"} + latest_words = {"".join(ch for ch in word if ch.isalnum()) for word in latest.split()} + if latest_words & code_words: + return False + creative_objects = ("icon", "logo", "wallpaper", "poster", "banner", "avatar") + creative_verbs = ("generate", "create", "draw", "design", "make", "render") + return any(verb in latest for verb in creative_verbs) and any(obj in latest for obj in creative_objects) + + def _needs_image_followup(self, body: dict[str, Any]) -> bool: + if not self._has_image_generation_history(body): + return False + latest = self._latest_user_text(body).lower() + if not latest: + return False + direct_image_refs = ("image", "picture", "photo", "icon", "logo", "illustration") + followup_actions = ( + "inspect", + "look at", + "view", + "describe", + "what do you see", + "analyze", + "modify", + "edit", + "change", + "improve", + "enhance", + "upscale", + "variation", + "use", + "based on", + "same", + ) + if any(ref in latest for ref in direct_image_refs) and any(action in latest for action in followup_actions): + return True + pronoun_followups = ( + "inspect it", + "look at it", + "view it", + "describe it", + "analyze it", + "modify it", + "edit it", + "change it", + "improve it", + "enhance it", + "upscale it", + "make it brighter", + "make it darker", + "make it more", + "use it", + "based on it", + ) + return any(marker in latest for marker in pronoun_followups) + + def _has_image_generation_history(self, body: dict[str, Any]) -> bool: + inputs = body.get("input") or [] + if not isinstance(inputs, list): + return False + return any(isinstance(item, dict) and item.get("type") == "image_generation_call" for item in inputs) + + def _latest_user_text(self, body: dict[str, Any]) -> str: + inputs = body.get("input") or [] + if isinstance(inputs, str): + return inputs + if not isinstance(inputs, list): + return "" + for item in reversed(inputs): + if isinstance(item, str): + return item + if not isinstance(item, dict): + continue + if item.get("role") == "user": + text = self._content_to_debug_text(item.get("content")) + if text: + return text + elif item.get("type") in {"input_text", "text"}: + text = self._content_to_debug_text(item) + if text: + return text + return "" + + def _content_to_debug_text(self, content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict): + parts.append(str(part.get("text") or part.get("content") or "")) + else: + parts.append(str(part)) + return "\n".join(part for part in parts if part) + if isinstance(content, dict): + return str(content.get("text") or content.get("content") or "") + return str(content) async def _chatgpt_passthrough( - self, request: web.Request, body: dict[str, Any] + self, request: web.Request, body: dict[str, Any], response_model_override: str | None = None ) -> web.StreamResponse: """Forward a Responses request to chatgpt.com using the user's Codex auth. Lets the picker expose OpenAI's real GPT-5.5 (ChatGPT subscription) as a - first-class model alongside Factory BYOK entries. + first-class model alongside configured BYOK entries. """ - auth_path = Path("~/.codex/auth.json").expanduser() + auth_path = DEFAULT_CODEX_AUTH.expanduser() try: auth = json.loads(auth_path.read_text()) except FileNotFoundError: @@ -95,8 +268,8 @@ async def _chatgpt_passthrough( account_id = tokens.get("account_id") or "" if not access_token: raise web.HTTPUnauthorized(text="auth.json has no access_token") - forwarded = dict(body) - forwarded["model"] = "gpt-5.5" + forwarded = _sanitize_chatgpt_passthrough_body(body) + forwarded["model"] = CHATGPT_MODEL_SLUG headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", @@ -113,12 +286,26 @@ async def _chatgpt_passthrough( return await _error_response(upstream) if not forwarded.get("stream"): payload = await upstream.json(content_type=None) + _rewrite_response_model(payload, response_model_override) return web.json_response(payload) response = _sse_response() await response.prepare(request) try: - async for chunk in upstream.content.iter_chunked(4096): - await _safe_write(response, chunk) + if response_model_override: + async for line in _sse_lines(upstream): + if line == "[DONE]": + await _safe_write(response, b"data: [DONE]\n\n") + break + try: + payload = json.loads(line) + except json.JSONDecodeError: + await _safe_write(response, f"data: {line}\n\n".encode()) + continue + _rewrite_response_model(payload, response_model_override) + await _write_sse(response, payload) + else: + async for chunk in upstream.content.iter_chunked(4096): + await _safe_write(response, chunk) except ClientDisconnected: pass finally: @@ -129,7 +316,7 @@ async def _chatgpt_passthrough( pass return response - def _route(self, body: dict[str, Any]) -> FactoryModel: + def _route(self, body: dict[str, Any]) -> ShimModel: requested = str(body.get("model") or "") route = self.settings.by_slug_or_model(requested) if route is None: @@ -137,7 +324,7 @@ def _route(self, body: dict[str, Any]) -> FactoryModel: return route async def _post_openai_chat( - self, request: web.Request, route: FactoryModel, body: dict[str, Any], as_responses: bool + self, request: web.Request, route: ShimModel, body: dict[str, Any], as_responses: bool ) -> web.StreamResponse: url = _join_url(route.base_url, "/chat/completions") headers = _openai_headers(route) @@ -153,7 +340,7 @@ async def _post_openai_chat( return web.json_response(payload) async def _post_anthropic( - self, request: web.Request, route: FactoryModel, body: dict[str, Any], as_responses: bool + self, request: web.Request, route: ShimModel, body: dict[str, Any], as_responses: bool ) -> web.StreamResponse: url = _join_url(route.base_url, "/messages") headers = _anthropic_headers(route) @@ -169,7 +356,7 @@ async def _post_anthropic( return web.json_response(anthropic_to_chat_response(payload, route.slug)) async def _stream_openai_chat( - self, request: web.Request, upstream, route: FactoryModel, as_responses: bool + self, request: web.Request, upstream, route: ShimModel, as_responses: bool ) -> web.StreamResponse: response = _sse_response() await response.prepare(request) @@ -204,7 +391,7 @@ async def _stream_openai_chat( return response async def _stream_anthropic( - self, request: web.Request, upstream, route: FactoryModel, as_responses: bool + self, request: web.Request, upstream, route: ShimModel, as_responses: bool ) -> web.StreamResponse: response = _sse_response() await response.prepare(request) @@ -239,6 +426,54 @@ async def _stream_anthropic( return response +_DROP_ITEM = object() + + +def _sanitize_chatgpt_passthrough_body(body: dict[str, Any]) -> dict[str, Any]: + sanitized = _sanitize_chatgpt_passthrough_value(body) + return sanitized if isinstance(sanitized, dict) else {} + + +def _sanitize_chatgpt_passthrough_value(value: Any) -> Any: + if isinstance(value, list): + output = [] + for item in value: + sanitized = _sanitize_chatgpt_passthrough_value(item) + if sanitized is not _DROP_ITEM: + output.append(sanitized) + return output + if isinstance(value, dict): + if value.get("type") == "reasoning" and _has_shim_encrypted_content(value): + return _DROP_ITEM + output = {} + for key, item in value.items(): + if key == "encrypted_content" and isinstance(item, str) and item.startswith(SHIM_ENCRYPTED_CONTENT_PREFIX): + continue + sanitized = _sanitize_chatgpt_passthrough_value(item) + if sanitized is not _DROP_ITEM: + output[key] = sanitized + return output + return value + + +def _has_shim_encrypted_content(value: dict[str, Any]) -> bool: + encrypted_content = value.get("encrypted_content") + return isinstance(encrypted_content, str) and encrypted_content.startswith(SHIM_ENCRYPTED_CONTENT_PREFIX) + + +def _rewrite_response_model(payload: Any, model: str | None) -> None: + if not model: + return + if isinstance(payload, dict): + if payload.get("model") == CHATGPT_MODEL_SLUG: + payload["model"] = model + for value in payload.values(): + _rewrite_response_model(value, model) + elif isinstance(payload, list): + for item in payload: + _rewrite_response_model(item, model) + + class ResponsesStreamState: """Translates upstream chat-completions / anthropic stream events into the Codex Desktop Responses-API event sequence. Keeps the message item and @@ -269,14 +504,14 @@ async def start(self, response: web.StreamResponse) -> None: await _write_sse(response, {"type": "response.created", "response": self._response("in_progress")}) async def finish(self, response: web.StreamResponse) -> None: + for state in sorted(self.reasoning_blocks.values(), key=lambda s: s["output_index"]): + if not state.get("closed"): + await self._close_reasoning(response, state) if self.message_opened and not self.message_closed: await self._close_message(response) for state in sorted(self.tool_calls.values(), key=lambda s: s["output_index"]): if not state.get("closed"): await self._close_tool(response, state) - for state in sorted(self.reasoning_blocks.values(), key=lambda s: s["output_index"]): - if not state.get("closed"): - await self._close_reasoning(response, state) await _write_sse(response, {"type": "response.completed", "response": self._response("completed", final=True)}) await response.write(b"data: [DONE]\n\n") @@ -291,6 +526,9 @@ async def write_chat_delta(self, response: web.StreamResponse, chunk: dict[str, await self._chat_reasoning_delta(response, reasoning) content = delta.get("content") if content: + for state in list(self.reasoning_blocks.values()): + if not state.get("closed"): + await self._close_reasoning(response, state) await self._text_delta(response, content) for call in delta.get("tool_calls") or []: await self._chat_tool_delta(response, call) @@ -727,14 +965,14 @@ def _join_url(base_url: str, endpoint: str) -> str: return urljoin(base + "/", "v1" + endpoint) -def _openai_headers(route: FactoryModel) -> dict[str, str]: +def _openai_headers(route: ShimModel) -> dict[str, str]: headers = {"Content-Type": "application/json", **route.extra_headers} if route.api_key: headers.setdefault("Authorization", f"Bearer {route.api_key}") return headers -def _anthropic_headers(route: FactoryModel) -> dict[str, str]: +def _anthropic_headers(route: ShimModel) -> dict[str, str]: headers = { "Content-Type": "application/json", "anthropic-version": "2023-06-01", @@ -853,9 +1091,20 @@ async def _error_response(upstream) -> web.Response: return web.Response(status=upstream.status, text=text, content_type=upstream.content_type or "text/plain") +def _normalize_roles(messages: list[dict]) -> list[dict]: + result = [] + for message in messages: + if isinstance(message, dict): + message = dict(message) + if message.get("role") == "developer": + message["role"] = "system" + result.append(message) + return result + + def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser() - parser.add_argument("--settings", type=Path, default=DEFAULT_FACTORY_SETTINGS) + parser.add_argument("--settings", type=Path, default=DEFAULT_SETTINGS) parser.add_argument("--host", default=DEFAULT_HOST) parser.add_argument("--port", type=int, default=DEFAULT_PORT) args = parser.parse_args(argv) diff --git a/codex_shim/settings.py b/codex_shim/settings.py index 03333a3d..a1e42cf4 100644 --- a/codex_shim/settings.py +++ b/codex_shim/settings.py @@ -8,20 +8,42 @@ from typing import Any -DEFAULT_FACTORY_SETTINGS = Path.home() / ".factory" / "settings.json" +DEFAULT_SETTINGS = Path.home() / ".codex-shim" / "models.json" +DEFAULT_CODEX_AUTH = Path.home() / ".codex" / "auth.json" DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8765 -PROVIDER_NAME = "factory_byok_shim" +PROVIDER_NAME = "codex_shim" CHATGPT_MODEL_SLUG = "gpt-5.5" +def chatgpt_passthrough_available(auth_path: Path | None = None) -> bool: + """Return True if ~/.codex/auth.json holds a usable Codex access token.""" + if os.environ.get("CODEX_SHIM_DISABLE_CHATGPT", "").lower() in {"1", "true", "yes", "on"}: + return False + if auth_path is None: + import sys as _sys + + auth_path = getattr(_sys.modules[__name__], "DEFAULT_CODEX_AUTH") + expanded = Path(auth_path).expanduser() + if not expanded.exists(): + return False + try: + data = json.loads(expanded.read_text()) + except (OSError, json.JSONDecodeError): + return False + tokens = data.get("tokens") if isinstance(data, dict) else None + if not isinstance(tokens, dict): + return False + return bool(tokens.get("access_token")) + + def slugify(value: str) -> str: slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") return slug or "model" @dataclass(frozen=True) -class FactoryModel: +class ShimModel: slug: str model: str display_name: str @@ -44,13 +66,17 @@ def is_openai_chat(self) -> bool: return self.provider in {"openai", "generic-chat-completion-api", "minimax"} -class FactorySettings: - def __init__(self, path: Path = DEFAULT_FACTORY_SETTINGS): - self.path = Path(path).expanduser() +class ModelSettings: + def __init__(self, path: Path | None = None): + self.path = Path(path or DEFAULT_SETTINGS).expanduser() - def load(self) -> list[FactoryModel]: + def load(self) -> list[ShimModel]: + if not self.path.exists(): + if self.path == DEFAULT_SETTINGS: + return [] + raise FileNotFoundError(self.path) data = json.loads(self.path.read_text()) - rows = data.get("customModels", []) + rows = _model_rows(data) model_counts: dict[str, int] = {} for row in rows: model = str(row.get("model") or "").strip() @@ -58,17 +84,17 @@ def load(self) -> list[FactoryModel]: model_counts[model] = model_counts.get(model, 0) + 1 used: set[str] = set() - models: list[FactoryModel] = [] + models: list[ShimModel] = [] for fallback_index, row in enumerate(rows): model = str(row.get("model") or "").strip() provider = str(row.get("provider") or "").strip() - base_url = str(row.get("baseUrl") or "").strip().rstrip("/") + base_url = str(_field(row, "base_url", "baseUrl") or "").strip().rstrip("/") if not model or not provider or not base_url: continue index = int(row.get("index", fallback_index)) - display_name = str(row.get("displayName") or model).strip() - slug_base = display_name if model_counts.get(model, 0) > 1 else model + display_name = str(_field(row, "display_name", "displayName", default=model)).strip() + slug_base = str(row.get("slug") or (display_name if model_counts.get(model, 0) > 1 else model)) slug = slugify(slug_base) if slug in used: slug = f"{slug}-{index}" @@ -76,32 +102,30 @@ def load(self) -> list[FactoryModel]: slug = f"{slug}-{len(used)}" used.add(slug) - max_context = _int_or_none(row.get("maxContextLimit")) - max_output = _int_or_none(row.get("maxOutputTokens")) extra_headers = { str(k): str(v) - for k, v in (row.get("extraHeaders") or {}).items() + for k, v in (_field(row, "extra_headers", "extraHeaders", default={}) or {}).items() if v is not None } models.append( - FactoryModel( + ShimModel( slug=slug, model=model, display_name=display_name, provider=provider, base_url=base_url, - api_key=str(row.get("apiKey") or ""), + api_key=str(_field(row, "api_key", "apiKey", default="")), index=index, - max_context_limit=max_context, - max_output_tokens=max_output, - no_image_support=bool(row.get("noImageSupport", False)), + max_context_limit=_int_or_none(_field(row, "max_context_limit", "maxContextLimit")), + max_output_tokens=_int_or_none(_field(row, "max_output_tokens", "maxOutputTokens")), + no_image_support=bool(_field(row, "no_image_support", "noImageSupport", default=False)), extra_headers=extra_headers, raw=row, ) ) return models - def by_slug_or_model(self, requested: str) -> FactoryModel | None: + def by_slug_or_model(self, requested: str) -> ShimModel | None: models = self.load() by_slug = {m.slug: m for m in models} if requested in by_slug: @@ -112,6 +136,63 @@ def by_slug_or_model(self, requested: str) -> FactoryModel | None: return None +def _model_rows(data: Any) -> list[dict[str, Any]]: + if isinstance(data, list): + rows = data + elif isinstance(data, dict): + rows = data.get("models") + if rows is None: + rows = data.get("customModels") + if rows is None: + rows = data.get("launchModels", data.get("launch_models", [])) + else: + return [] + if not isinstance(rows, list): + return [] + return [row for row in (_coerce_model_row(row) for row in rows) if row is not None] + + +def _coerce_model_row(row: Any) -> dict[str, Any] | None: + if isinstance(row, str): + return { + "model": row, + "display_name": row, + "provider": "generic-chat-completion-api", + "base_url": "http://127.0.0.1:11434/v1", + } + if isinstance(row, dict): + return _normalize_model_row(row) + return None + + +def _normalize_model_row(row: dict[str, Any]) -> dict[str, Any]: + normalized = dict(row) + if "display_name" not in normalized and "name" in normalized: + normalized["display_name"] = normalized["name"] + if "base_url" not in normalized and "baseURL" in normalized: + normalized["base_url"] = normalized["baseURL"] + if "api_key" not in normalized and "apiKey" not in normalized and "bearerToken" in normalized: + normalized["api_key"] = normalized["bearerToken"] + if _looks_like_ollama_row(normalized): + normalized["provider"] = "generic-chat-completion-api" + if not _field(normalized, "base_url", "baseUrl", "baseURL"): + normalized["base_url"] = "http://127.0.0.1:11434/v1" + return normalized + + +def _looks_like_ollama_row(row: dict[str, Any]) -> bool: + provider = str(row.get("provider") or "").lower() + base_url = str(_field(row, "base_url", "baseUrl", "baseURL", default="")).lower() + return provider == "ollama" or "11434" in base_url or "ollama" in base_url + + +def _field(row: dict[str, Any], *keys: str, default: Any = None) -> Any: + for key in keys: + if key in row: + return row[key] + return default + + def _int_or_none(value: Any) -> int | None: if value in (None, ""): return None @@ -121,26 +202,14 @@ def _int_or_none(value: Any) -> int | None: return None -def chatgpt_passthrough_enabled(auth_path: Path | None = None) -> bool: - if os.environ.get("CODEX_SHIM_DISABLE_CHATGPT", "").lower() in {"1", "true", "yes", "on"}: - return False - auth_path = auth_path or Path.home() / ".codex" / "auth.json" - try: - auth = json.loads(auth_path.read_text()) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return False - tokens = auth.get("tokens") or {} - return bool(tokens.get("access_token")) - - -def default_model_slug(models: list[FactoryModel], include_chatgpt: bool | None = None) -> str: +def default_model_slug(models: list[ShimModel], include_chatgpt: bool | None = None) -> str: if include_chatgpt is None: - include_chatgpt = chatgpt_passthrough_enabled() + include_chatgpt = chatgpt_passthrough_available() if include_chatgpt: return CHATGPT_MODEL_SLUG if models: return models[0].slug raise ValueError( - "No usable codex-shim models: add Factory custom models or sign in to ChatGPT, " + "No usable codex-shim models: add models to ~/.codex-shim/models.json, run `codex login`, " "or unset CODEX_SHIM_DISABLE_CHATGPT if ChatGPT passthrough should be used." ) diff --git a/codex_shim/translate.py b/codex_shim/translate.py index 5f29d735..1c25643e 100644 --- a/codex_shim/translate.py +++ b/codex_shim/translate.py @@ -7,7 +7,8 @@ THINK_RE = re.compile(r".*?", re.IGNORECASE | re.DOTALL) -_THINKING_MAGIC = "anthropic-thinking-v1:" +SHIM_ENCRYPTED_CONTENT_PREFIX = "anthropic-thinking-v1:" +_THINKING_MAGIC = SHIM_ENCRYPTED_CONTENT_PREFIX def _decode_thinking_blob(encoded: Any) -> dict[str, Any] | None: @@ -31,12 +32,19 @@ def responses_to_chat(body: dict[str, Any], upstream_model: str) -> dict[str, An instructions = body.get("instructions") if instructions: messages.append({"role": "system", "content": _content_to_text(instructions)}) - # Chat-completions upstreams don't understand reasoning items; drop the - # marker messages we emit for the Anthropic path. + pending_reasoning: str | None = None for m in _responses_input_to_messages(body.get("input")): if m.get("_reasoning_only"): + summary = m.get("summary") or [] + text = " ".join(item.get("text", "") for item in summary if isinstance(item, dict)) + if text: + pending_reasoning = text continue + if pending_reasoning and m.get("role") == "assistant": + m["reasoning_content"] = pending_reasoning + pending_reasoning = None messages.append(m) + messages = _sanitize_chat_messages(_merge_consecutive_messages(_normalize_chat_roles(messages))) chat: dict[str, Any] = { "model": upstream_model, @@ -48,6 +56,7 @@ def responses_to_chat(body: dict[str, Any], upstream_model: str) -> dict[str, An _copy_if_present(body, chat, "max_output_tokens", "max_tokens") _copy_if_present(body, chat, "max_tokens") _copy_if_present(body, chat, "parallel_tool_calls") + _copy_if_present(body, chat, "reasoning_effort") tools = _responses_tools_to_chat_tools(body.get("tools")) if tools: @@ -212,6 +221,16 @@ def chat_completion_to_response(payload: dict[str, Any], requested_model: str) - choice = (payload.get("choices") or [{}])[0] message = choice.get("message") or {} output: list[dict[str, Any]] = [] + reasoning = message.get("reasoning_content") + if reasoning: + output.append( + { + "id": "reasoning_0", + "type": "reasoning", + "status": "completed", + "summary": [{"type": "summary_text", "text": reasoning}], + } + ) text = strip_think(message.get("content") or "") if text: output.append( @@ -410,3 +429,89 @@ def _jsonish(value: Any) -> str: if isinstance(value, str): return value return json.dumps(value, separators=(",", ":")) + + +def _sanitize_string(value: str) -> str: + value = value.replace("\x00", "") + return "".join(char for char in value if char in "\n\r\t" or ord(char) >= 0x20) + + +def _sanitize_chat_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + cleaned = [] + for message in messages: + current = dict(message) + current.pop("_reasoning_only", None) + current.pop("encrypted_content", None) + current.pop("summary", None) + role = current.get("role", "user") + content = current.get("content") + if role != "assistant": + if content is None: + current["content"] = "" + elif not isinstance(content, str): + current["content"] = _content_to_text(content) + current["content"] = _sanitize_string(current["content"]) + elif content is not None: + if not isinstance(content, str): + content = _content_to_text(content) + current["content"] = _sanitize_string(content) + + if isinstance(current.get("reasoning_content"), str): + current["reasoning_content"] = _sanitize_string(current["reasoning_content"]) + tool_calls = current.get("tool_calls") + if tool_calls: + copied_calls = [] + for call in tool_calls: + if not isinstance(call, dict): + continue + copied_call = dict(call) + if isinstance(copied_call.get("id"), str): + copied_call["id"] = _sanitize_string(copied_call["id"]) + function = copied_call.get("function") + if isinstance(function, dict): + function = dict(function) + arguments = function.get("arguments") + if isinstance(arguments, str): + function["arguments"] = _sanitize_string(arguments) + copied_call["function"] = function + copied_calls.append(copied_call) + current["tool_calls"] = copied_calls + tool_call_id = current.get("tool_call_id") + if isinstance(tool_call_id, str): + current["tool_call_id"] = _sanitize_string(tool_call_id) + cleaned.append(current) + return cleaned + + +def _normalize_chat_roles(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + normalized = [] + for message in messages: + current = dict(message) + if current.get("role") == "developer": + current["role"] = "system" + normalized.append(current) + return normalized + + +def _merge_consecutive_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + merged: list[dict[str, Any]] = [] + for message in messages: + current = dict(message) + role = current.get("role") + if merged and role == merged[-1].get("role") and role in {"system", "user", "assistant"}: + previous = merged[-1] + previous_content = previous.get("content") or "" + current_content = current.get("content") or "" + if previous_content and current_content: + previous["content"] = f"{previous_content}\n\n{current_content}" + elif current_content: + previous["content"] = current_content + if role == "assistant": + if current.get("reasoning_content") and not previous.get("reasoning_content"): + previous["reasoning_content"] = current["reasoning_content"] + tool_calls = list(previous.get("tool_calls") or []) + list(current.get("tool_calls") or []) + if tool_calls: + previous["tool_calls"] = tool_calls + continue + merged.append(current) + return merged diff --git a/examples/minimax-models.example.json b/examples/minimax-models.example.json new file mode 100644 index 00000000..10f1b7df --- /dev/null +++ b/examples/minimax-models.example.json @@ -0,0 +1,12 @@ +{ + "models": [ + { + "model": "MiniMax-M2.7", + "provider": "minimax", + "base_url": "https://api.minimax.io/v1", + "api_key": "REPLACE_WITH_MINIMAX_TOKEN_PLAN_KEY", + "display_name": "MiniMax M2.7", + "max_context_limit": 1000000 + } + ] +} diff --git a/examples/minimax-token-plan-settings.example.json b/examples/minimax-token-plan-settings.example.json deleted file mode 100644 index 512ad6be..00000000 --- a/examples/minimax-token-plan-settings.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "customModels": [ - { - "model": "MiniMax-M2.7", - "provider": "minimax", - "baseUrl": "https://api.minimax.io/v1", - "apiKey": "REPLACE_WITH_MINIMAX_TOKEN_PLAN_KEY", - "displayName": "MiniMax M2.7", - "maxContextLimit": 1000000 - } - ] -} diff --git a/examples/openrouter-models.example.json b/examples/openrouter-models.example.json new file mode 100644 index 00000000..85ed02c2 --- /dev/null +++ b/examples/openrouter-models.example.json @@ -0,0 +1,12 @@ +{ + "models": [ + { + "model": "openai/gpt-4o-mini", + "provider": "generic-chat-completion-api", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "REPLACE_WITH_OPENROUTER_API_KEY", + "display_name": "OpenRouter GPT-4o Mini", + "max_context_limit": 128000 + } + ] +} diff --git a/examples/openrouter-settings.example.json b/examples/openrouter-settings.example.json deleted file mode 100644 index 7341306e..00000000 --- a/examples/openrouter-settings.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "customModels": [ - { - "model": "openai/gpt-4o-mini", - "provider": "generic-chat-completion-api", - "baseUrl": "https://openrouter.ai/api/v1", - "apiKey": "REPLACE_WITH_OPENROUTER_API_KEY", - "displayName": "OpenRouter GPT-4o Mini", - "maxContextLimit": 128000 - } - ] -} diff --git a/pyproject.toml b/pyproject.toml index ae491b28..264a3f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,43 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + [project] -name = "codex-factory-shim" +name = "codex-shim" version = "0.1.0" -description = "Opt-in Codex BYOK shim generated from Factory custom models." +description = "Local all-model BYOK shim for Codex Desktop and CLI." +readme = "README.md" requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [{ name = "0xSero" }] +keywords = ["codex", "byok", "openai", "anthropic", "responses-api", "model-routing"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] dependencies = [ "aiohttp>=3.9", ] +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", +] + +[project.urls] +Homepage = "https://github.com/0xSero/codex-shim" +Repository = "https://github.com/0xSero/codex-shim" +Issues = "https://github.com/0xSero/codex-shim/issues" + [project.scripts] -codex-shim = "codex_shim.cli:main" +codex-shim = "codex_shim.cli:_entrypoint" [tool.pytest.ini_options] testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index bef82f9c..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -import asyncio -import inspect - - -def pytest_pyfunc_call(pyfuncitem): - testfunction = pyfuncitem.obj - if not inspect.iscoroutinefunction(testfunction): - return None - kwargs = {name: pyfuncitem.funcargs[name] for name in pyfuncitem._fixtureinfo.argnames} - asyncio.run(testfunction(**kwargs)) - return True diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py deleted file mode 100644 index 2c1826a9..00000000 --- a/tests/test_cli_config.py +++ /dev/null @@ -1,308 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from codex_shim import cli - - -def _settings(path, model: str = "claude-opus"): - path.write_text( - json.dumps( - { - "customModels": [ - { - "model": model, - "displayName": "Claude Opus", - "provider": "anthropic", - "baseUrl": "http://anthropic", - } - ] - } - ) - ) - return path - - -def _empty_settings(path): - path.write_text(json.dumps({"customModels": []})) - return path - - -def test_codex_wrapper_uses_inline_overrides_without_writing_codex_config(tmp_path, monkeypatch): - settings = _settings(tmp_path / "settings.json") - codex_config = tmp_path / "codex" / "config.toml" - captured = {} - - monkeypatch.setenv("CODEX_SHIM_DISABLE_CHATGPT", "1") - monkeypatch.setattr(cli, "RUNTIME_DIR", tmp_path / "runtime") - monkeypatch.setattr(cli, "CATALOG_PATH", tmp_path / "runtime" / "catalog.json") - monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "runtime" / "config.toml") - monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", codex_config) - monkeypatch.setattr(cli, "CODEX_CONFIG_BACKUP_PATH", tmp_path / "runtime" / "backup.toml") - monkeypatch.setattr(cli, "ensure_started", lambda *args: None) - monkeypatch.setattr(cli.os, "execvp", lambda file, args: captured.update(file=file, args=args)) - - rc = cli.main(["--settings", str(settings), "codex", "--", "--version"]) - - assert rc == 0 - assert captured["file"] == "codex" - assert captured["args"][0] == "codex" - assert "-c" in captured["args"] - assert "--version" in captured["args"] - assert not codex_config.exists() - - -def test_current_model_reuse_requires_managed_valid_shim_model(tmp_path, monkeypatch): - settings = _settings(tmp_path / "settings.json") - models = cli.FactorySettings(settings).load() - codex_config = tmp_path / "codex" / "config.toml" - codex_config.parent.mkdir() - codex_config.write_text('model = "unrelated-user-model"\n') - - monkeypatch.setenv("CODEX_SHIM_DISABLE_CHATGPT", "1") - monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", codex_config) - assert cli._resolve_model_slug(models, None) == "claude-opus" - - top_block, _ = cli._managed_config_blocks("missing-old-shim-model", 8765) - codex_config.write_text(top_block) - assert cli._resolve_model_slug(models, None) == "claude-opus" - - top_block, _ = cli._managed_config_blocks("claude-opus", 8765) - codex_config.write_text(top_block) - assert cli._resolve_model_slug(models, None) == "claude-opus" - - -def test_disable_removes_managed_blocks_without_restoring_stale_backup(tmp_path, monkeypatch): - codex_config = tmp_path / "codex" / "config.toml" - backup = tmp_path / "runtime" / "config.toml.before-codex-shim" - codex_config.parent.mkdir() - backup.parent.mkdir() - backup.write_text('model = "old-backup"\n') - top_block, provider_block = cli._managed_config_blocks("claude-opus", 8765) - codex_config.write_text( - 'model = "user-model"\n' - 'setting = true\n' - '# user newer edit\n' - + top_block - + '\n[model_providers.factory_byok_shim]\nname = "old unmanaged provider"\n' - + provider_block - ) - - monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", codex_config) - monkeypatch.setattr(cli, "CODEX_CONFIG_BACKUP_PATH", backup) - - cli.restore_codex_config() - - restored = codex_config.read_text() - assert 'model = "old-backup"' not in restored - assert 'model = "user-model"' in restored - assert "setting = true" in restored - assert "# user newer edit" in restored - assert "factory_byok_shim" not in restored - assert not backup.exists() - - -def test_install_then_disable_restores_original_top_level_keys_without_losing_newer_edits(tmp_path, monkeypatch): - settings = _settings(tmp_path / "settings.json") - codex_config = tmp_path / "codex" / "config.toml" - backup = tmp_path / "runtime" / "config.toml.before-codex-shim" - codex_config.parent.mkdir() - codex_config.write_text( - 'model = "normal-model"\n' - 'model_provider = "normal_provider"\n' - 'model_catalog_json = "/tmp/normal-catalog.json"\n' - 'user_setting = true\n' - '\n[model_providers.normal_provider]\n' - 'name = "Normal Provider"\n' - ) - - monkeypatch.setenv("CODEX_SHIM_DISABLE_CHATGPT", "1") - monkeypatch.setattr(cli, "RUNTIME_DIR", tmp_path / "runtime") - monkeypatch.setattr(cli, "CATALOG_PATH", tmp_path / "runtime" / "catalog.json") - monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", codex_config) - monkeypatch.setattr(cli, "CODEX_CONFIG_BACKUP_PATH", backup) - - cli.install_codex_config(settings, 8765) - installed = codex_config.read_text() - assert 'model = "normal-model"' not in installed - assert "codex-shim previous-top-level" in installed - assert "[model_providers.factory_byok_shim]" in installed - - with codex_config.open("a") as f: - f.write("\n# user newer edit while shim installed\n") - - cli.restore_codex_config() - - restored = codex_config.read_text() - assert 'model = "normal-model"' in restored - assert 'model_provider = "normal_provider"' in restored - assert 'model_catalog_json = "/tmp/normal-catalog.json"' in restored - assert "user_setting = true" in restored - assert "[model_providers.normal_provider]" in restored - assert "# user newer edit while shim installed" in restored - assert "factory_byok_shim" not in restored - assert "codex-shim previous-top-level" not in restored - assert not backup.exists() - - -def test_generate_fails_when_no_factory_models_and_no_chatgpt_passthrough(tmp_path, monkeypatch): - settings = _empty_settings(tmp_path / "settings.json") - monkeypatch.setenv("CODEX_SHIM_DISABLE_CHATGPT", "1") - monkeypatch.setattr(cli, "RUNTIME_DIR", tmp_path / "runtime") - monkeypatch.setattr(cli, "CATALOG_PATH", tmp_path / "runtime" / "catalog.json") - monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "runtime" / "config.toml") - - try: - cli.generate(settings, 8765) - except SystemExit as exc: - message = str(exc) - else: - raise AssertionError("generate should fail when no usable models exist") - - assert "No usable codex-shim models" in message - assert not (tmp_path / "runtime" / "catalog.json").exists() - assert not (tmp_path / "runtime" / "config.toml").exists() - - -def test_provider_setup_writes_ignored_local_settings(tmp_path, monkeypatch): - settings = tmp_path / "runtime" / "openrouter-settings.json" - spec = cli.ProviderSpec( - name="openrouter", - title="OpenRouter", - settings_path=settings, - port=8766, - placeholder_key="REPLACE_WITH_OPENROUTER_API_KEY", - default_model="openai/gpt-4o-mini", - default_display_name="OpenRouter GPT-4o Mini", - default_provider="generic-chat-completion-api", - default_base_url="https://openrouter.ai/api/v1", - default_context=128000, - allowed_providers=frozenset({"generic-chat-completion-api", "openai"}), - template_path=Path("examples/openrouter-settings.example.json"), - ) - - monkeypatch.setitem(cli.PROVIDER_SPECS, "test-openrouter", spec) - monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(cli.getpass, "getpass", lambda prompt: "sk-test") - answers = iter(["anthropic/claude-sonnet-4", "Claude Sonnet", "https://openrouter.ai/api/v1", "131000"]) - monkeypatch.setattr("builtins.input", lambda prompt: next(answers)) - - assert cli.setup_provider("test-openrouter") == 0 - - payload = json.loads(settings.read_text()) - row = payload["customModels"][0] - assert row["apiKey"] == "sk-test" - assert row["model"] == "anthropic/claude-sonnet-4" - assert row["provider"] == "generic-chat-completion-api" - assert row["baseUrl"] == "https://openrouter.ai/api/v1" - assert row["displayName"] == "Claude Sonnet" - assert row["maxContextLimit"] == 131000 - - -def test_provider_setup_non_interactive_missing_settings_fails(tmp_path, monkeypatch): - spec = cli.ProviderSpec( - name="minimax", - title="MiniMax Token Plan", - settings_path=tmp_path / "runtime" / "minimax-settings.json", - port=8767, - placeholder_key="REPLACE_WITH_MINIMAX_TOKEN_PLAN_KEY", - default_model="MiniMax-M2.7", - default_display_name="MiniMax M2.7", - default_provider="minimax", - default_base_url="https://api.minimax.io/v1", - default_context=1000000, - allowed_providers=frozenset({"minimax"}), - template_path=Path("examples/minimax-token-plan-settings.example.json"), - ) - - monkeypatch.setitem(cli.PROVIDER_SPECS, "test-minimax", spec) - monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False) - - try: - cli.setup_provider("test-minimax") - except SystemExit as exc: - assert exc.code == 1 - else: - raise AssertionError("setup should fail outside an interactive terminal") - - -def test_provider_run_uses_provider_settings_port_and_inline_model(tmp_path, monkeypatch): - settings = tmp_path / "runtime" / "minimax-settings.json" - settings.parent.mkdir() - settings.write_text( - json.dumps( - { - "customModels": [ - { - "model": "MiniMax-M2.7", - "provider": "minimax", - "baseUrl": "https://api.minimax.io/v1", - "apiKey": "secret", - "displayName": "MiniMax M2.7", - } - ] - } - ) - ) - spec = cli.ProviderSpec( - name="minimax", - title="MiniMax Token Plan", - settings_path=settings, - port=8767, - placeholder_key="REPLACE_WITH_MINIMAX_TOKEN_PLAN_KEY", - default_model="MiniMax-M2.7", - default_display_name="MiniMax M2.7", - default_provider="minimax", - default_base_url="https://api.minimax.io/v1", - default_context=1000000, - allowed_providers=frozenset({"minimax"}), - template_path=Path("examples/minimax-token-plan-settings.example.json"), - ) - captured = {} - - monkeypatch.setenv("CODEX_SHIM_DISABLE_CHATGPT", "1") - monkeypatch.setitem(cli.PROVIDER_SPECS, "test-minimax", spec) - monkeypatch.setattr(cli, "RUNTIME_DIR", tmp_path / "runtime") - monkeypatch.setattr(cli, "CATALOG_PATH", tmp_path / "runtime" / "catalog.json") - monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "runtime" / "config.toml") - monkeypatch.setattr(cli, "ensure_started", lambda path, port: captured.update(started=(path, port))) - monkeypatch.setattr(cli.os, "execvp", lambda file, args: captured.update(file=file, args=args)) - - rc = cli.run_provider("test-minimax", ["hello"]) - - assert rc == 0 - assert captured["started"] == (settings, 8767) - assert captured["file"] == "codex" - assert "-m" in captured["args"] - assert "minimax-m2-7" in captured["args"] - assert "hello" in captured["args"] - - -def test_provider_top_level_alias_runs_provider(tmp_path, monkeypatch): - captured = {} - - monkeypatch.setitem( - cli.PROVIDER_SPECS, - "test-provider", - cli.ProviderSpec( - name="test-provider", - title="Test Provider", - settings_path=tmp_path / "settings.json", - port=9999, - placeholder_key="PLACEHOLDER", - default_model="model", - default_display_name="Model", - default_provider="openai", - default_base_url="https://example.test/v1", - default_context=1000, - allowed_providers=frozenset({"openai"}), - template_path=Path("examples/test.json"), - ), - ) - monkeypatch.setattr(cli, "run_provider", lambda provider, args, port: captured.update(provider=provider, args=args, port=port) or 0) - - rc = cli.main(["--port", "9998", "test-provider", "."]) - - assert rc == 0 - assert captured == {"provider": "test-provider", "args": ["."], "port": 9998} diff --git a/tests/test_provider_workflows.py b/tests/test_provider_workflows.py new file mode 100644 index 00000000..ab49ab93 --- /dev/null +++ b/tests/test_provider_workflows.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from codex_shim import cli + + +def _spec(tmp_path, name="openrouter"): + return cli.ProviderSpec( + name=name, + title="OpenRouter", + settings_path=tmp_path / f"{name}-models.json", + port=8766, + placeholder_key="REPLACE_WITH_OPENROUTER_API_KEY", + default_model="openai/gpt-4o-mini", + default_display_name="OpenRouter GPT-4o Mini", + default_provider="generic-chat-completion-api", + default_base_url="https://openrouter.ai/api/v1", + default_context=128000, + allowed_providers=frozenset({"generic-chat-completion-api", "openai"}), + template_path=Path("examples/openrouter-models.example.json"), + ) + + +def test_provider_setup_writes_private_settings_file(tmp_path, monkeypatch): + spec = _spec(tmp_path) + + monkeypatch.setitem(cli.PROVIDER_SPECS, "test-openrouter", spec) + monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(cli.getpass, "getpass", lambda prompt: "sk-test") + answers = iter(["anthropic/claude-sonnet-4", "Claude Sonnet", "https://openrouter.ai/api/v1", "131000"]) + monkeypatch.setattr("builtins.input", lambda prompt: next(answers)) + + assert cli.setup_provider("test-openrouter") == 0 + + payload = json.loads(spec.settings_path.read_text()) + row = payload["models"][0] + assert row["api_key"] == "sk-test" + assert row["model"] == "anthropic/claude-sonnet-4" + assert row["provider"] == "generic-chat-completion-api" + assert row["base_url"] == "https://openrouter.ai/api/v1" + assert row["display_name"] == "Claude Sonnet" + assert row["max_context_limit"] == 131000 + + +def test_provider_setup_non_interactive_missing_settings_fails(tmp_path, monkeypatch): + spec = _spec(tmp_path) + + monkeypatch.setitem(cli.PROVIDER_SPECS, "test-openrouter", spec) + monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False) + + try: + cli.setup_provider("test-openrouter") + except SystemExit as exc: + assert exc.code == 1 + else: + raise AssertionError("setup should fail outside an interactive terminal") + + +def test_provider_run_uses_provider_port_inline_model_and_no_codex_config(tmp_path, monkeypatch): + spec = _spec(tmp_path, name="minimax") + spec.settings_path.write_text( + json.dumps( + { + "models": [ + { + "model": "MiniMax-M2.7", + "provider": "generic-chat-completion-api", + "base_url": "https://api.minimax.io/v1", + "api_key": "secret", + "display_name": "MiniMax M2.7", + } + ] + } + ) + ) + captured = {} + codex_config = tmp_path / ".codex" / "config.toml" + + monkeypatch.delenv("CODEX_SHIM_DISABLE_CHATGPT", raising=False) + monkeypatch.setitem(cli.PROVIDER_SPECS, "test-minimax", spec) + monkeypatch.setattr(cli, "RUNTIME_DIR", tmp_path / "runtime") + monkeypatch.setattr(cli, "CATALOG_PATH", tmp_path / "runtime" / "catalog.json") + monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "runtime" / "config.toml") + monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", codex_config) + monkeypatch.setattr(cli, "CODEX_CONFIG_BACKUP_PATH", tmp_path / "runtime" / "backup.toml") + monkeypatch.setattr(cli, "ensure_started", lambda path, port: captured.update(started=(path, port))) + monkeypatch.setattr(cli.os, "execvpe", lambda file, args, env: captured.update(file=file, args=args, env=env)) + + assert cli.run_provider("test-minimax", ["hello"]) == 0 + + assert captured["started"] == (spec.settings_path, 8766) + assert captured["file"] == "codex" + assert "-m" in captured["args"] + assert "minimax-m2-7" in captured["args"] + assert "hello" in captured["args"] + assert captured["env"]["CODEX_SHIM_DISABLE_CHATGPT"] == "1" + assert not codex_config.exists() + + +def test_provider_top_level_alias_runs_provider(tmp_path, monkeypatch): + captured = {} + + monkeypatch.setitem(cli.PROVIDER_SPECS, "test-provider", _spec(tmp_path, name="test-provider")) + monkeypatch.setattr( + cli, + "run_provider", + lambda provider, args, port: captured.update(provider=provider, args=args, port=port) or 0, + ) + + assert cli.main(["--port", "9998", "test-provider", "."]) == 0 + + assert captured == {"provider": "test-provider", "args": ["."], "port": 9998} diff --git a/tests/test_server.py b/tests/test_server.py index c349dd22..01b4c5a7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,10 +2,171 @@ import json +import pytest from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from codex_shim.server import ShimServer +from codex_shim.server import ShimServer, _rewrite_response_model, _sanitize_chatgpt_passthrough_body +from codex_shim.translate import SHIM_ENCRYPTED_CONTENT_PREFIX + + +@pytest.fixture +def auth_present(monkeypatch, tmp_path): + auth = tmp_path / "auth.json" + auth.write_text(json.dumps({"tokens": {"access_token": "stub", "account_id": "acct"}})) + monkeypatch.setattr("codex_shim.settings.DEFAULT_CODEX_AUTH", auth) + monkeypatch.setattr("codex_shim.server.DEFAULT_CODEX_AUTH", auth) + return auth + + +@pytest.fixture +def auth_missing(monkeypatch, tmp_path): + missing = tmp_path / "missing-auth.json" + monkeypatch.setattr("codex_shim.settings.DEFAULT_CODEX_AUTH", missing) + monkeypatch.setattr("codex_shim.server.DEFAULT_CODEX_AUTH", missing) + + +def test_sanitize_chatgpt_passthrough_body_drops_shim_reasoning(): + body = { + "model": "claude-local", + "input": [ + {"type": "message", "role": "user", "content": "hi"}, + { + "id": "rs_shim", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "local thought"}], + "encrypted_content": f"{SHIM_ENCRYPTED_CONTENT_PREFIX}deadbeef", + }, + { + "id": "rs_openai", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "openai thought"}], + "encrypted_content": "openai-verifiable-content", + }, + ], + } + + sanitized = _sanitize_chatgpt_passthrough_body(body) + + assert sanitized is not body + assert sanitized["input"] is not body["input"] + assert [item["id"] for item in sanitized["input"] if item.get("type") == "reasoning"] == ["rs_openai"] + assert sanitized["input"][1]["encrypted_content"] == "openai-verifiable-content" + assert len(body["input"]) == 3 + + +def test_sanitize_chatgpt_passthrough_body_removes_nested_shim_encrypted_content(): + body = { + "model": "claude-local", + "input": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "done", + "encrypted_content": f"{SHIM_ENCRYPTED_CONTENT_PREFIX}deadbeef", + } + ], + } + ], + } + + sanitized = _sanitize_chatgpt_passthrough_body(body) + + assert "encrypted_content" not in sanitized["input"][0]["content"][0] + assert "encrypted_content" in body["input"][0]["content"][0] + + +def test_rewrite_response_model_only_rewrites_chatgpt_metadata(): + payload = { + "model": "gpt-5.5", + "nested": [{"model": "gpt-5.5"}, {"model": "other"}], + } + + _rewrite_response_model(payload, "custom-model") + + assert payload == { + "model": "custom-model", + "nested": [{"model": "custom-model"}, {"model": "other"}], + } + + +def test_image_generation_detection_is_conservative(): + shim = ShimServer() + tools = [ + {"type": "function", "function": {"name": "shell"}}, + {"type": "image_generation", "name": "image_generation"}, + ] + + assert shim._needs_image_gen({"tools": tools, "input": [{"role": "user", "content": "write code for an icon component"}]}) is False + assert shim._needs_image_gen({"tools": tools, "input": [{"role": "user", "content": "@image generate a neon fox"}]}) is True + assert shim._needs_image_gen({"tools": tools, "tool_choice": {"type": "image_generation"}, "input": "hi"}) is True + assert shim._needs_image_followup( + { + "input": [ + {"type": "image_generation_call", "id": "ig_1"}, + {"role": "user", "content": "make it brighter"}, + ] + } + ) is True + + +async def test_image_generation_routes_to_chatgpt_passthrough_and_rewrites_model(monkeypatch, tmp_path, auth_present): + captured = {} + + class FakeUpstream: + status = 200 + content_type = "application/json" + + async def json(self, content_type=None): + return {"id": "resp_img", "model": "gpt-5.5", "output": [{"type": "image_generation_call", "model": "gpt-5.5"}]} + + def release(self): + pass + + async def fake_post(self, url, json=None, headers=None): + captured["url"] = url + captured["body"] = json + captured["headers"] = headers + return FakeUpstream() + + monkeypatch.setattr("codex_shim.server.ClientSession.post", fake_post) + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps( + { + "customModels": [ + { + "model": "real-openai", + "displayName": "Real OpenAI", + "provider": "openai", + "baseUrl": "http://example.invalid/v1", + } + ] + } + ) + ) + shim_client = TestClient(TestServer(ShimServer(settings).app())) + await shim_client.start_server() + + resp = await shim_client.post( + "/v1/responses", + json={ + "model": "real-openai", + "input": [{"role": "user", "content": "@image generate a neon fox"}], + "tools": [{"type": "image_generation", "name": "image_generation"}], + }, + ) + assert resp.status == 200 + payload = await resp.json() + assert payload["model"] == "real-openai" + assert payload["output"][0]["model"] == "real-openai" + assert captured["body"]["model"] == "gpt-5.5" + assert captured["headers"]["Authorization"] == "Bearer stub" + + await shim_client.close() async def test_responses_routes_to_openai_chat(tmp_path): @@ -57,19 +218,50 @@ async def chat(request): await upstream_client.close() -async def test_minimax_provider_routes_to_openai_chat(tmp_path): +async def test_health_and_models_include_chatgpt_passthrough_when_auth_present(tmp_path, auth_present): + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"customModels": []})) + shim_client = TestClient(TestServer(ShimServer(settings).app())) + await shim_client.start_server() + + health = await shim_client.get("/health") + assert health.status == 200 + body = await health.json() + assert body["models"] == 1 + assert body["chatgpt_passthrough"] is True + + models = await shim_client.get("/v1/models") + assert models.status == 200 + payload = await models.json() + assert [model["id"] for model in payload["data"]] == ["gpt-5.5"] + + await shim_client.close() + + +async def test_health_and_models_hide_chatgpt_passthrough_when_auth_missing(tmp_path, auth_missing): + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"customModels": []})) + shim_client = TestClient(TestServer(ShimServer(settings).app())) + await shim_client.start_server() + + health = await shim_client.get("/health") + body = await health.json() + assert body["models"] == 0 + assert body["chatgpt_passthrough"] is False + + models = await shim_client.get("/v1/models") + payload = await models.json() + assert payload["data"] == [] + + await shim_client.close() + + +async def test_chat_routes_to_openai_normalizes_developer_role(tmp_path): captured = {} async def chat(request): - captured["headers"] = dict(request.headers) captured["body"] = await request.json() - return web.json_response( - { - "id": "chatcmpl_minimax", - "choices": [{"message": {"role": "assistant", "content": "minimax hello"}}], - "usage": {"total_tokens": 4}, - } - ) + return web.json_response({"id": "chatcmpl_fake", "choices": [{"message": {"role": "assistant", "content": "ok"}}]}) upstream = web.Application() upstream.router.add_post("/v1/chat/completions", chat) @@ -82,11 +274,10 @@ async def chat(request): { "customModels": [ { - "model": "MiniMax-M2.7", - "displayName": "MiniMax M2.7", - "provider": "minimax", + "model": "deepseek-reasoner", + "displayName": "DeepSeek Reasoner", + "provider": "openai", "baseUrl": str(upstream_client.make_url("/v1")), - "apiKey": "secret", } ] } @@ -95,12 +286,12 @@ async def chat(request): shim_client = TestClient(TestServer(ShimServer(settings).app())) await shim_client.start_server() - resp = await shim_client.post("/v1/responses", json={"model": "minimax-m2-7", "input": "hi"}) + resp = await shim_client.post( + "/v1/chat/completions", + json={"model": "deepseek-reasoner", "messages": [{"role": "developer", "content": "rules"}, {"role": "user", "content": "hi"}]}, + ) assert resp.status == 200 - payload = await resp.json() - assert payload["output"][0]["content"][0]["text"] == "minimax hello" - assert captured["body"]["model"] == "MiniMax-M2.7" - assert captured["headers"]["Authorization"] == "Bearer secret" + assert [message["role"] for message in captured["body"]["messages"]] == ["system", "user"] await shim_client.close() await upstream_client.close() @@ -147,3 +338,4 @@ async def messages(request): await shim_client.close() await upstream_client.close() + diff --git a/tests/test_settings_catalog.py b/tests/test_settings_catalog.py index 9d25d806..79bf3706 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -2,8 +2,26 @@ import json -from codex_shim.catalog import catalog_entry, write_catalog, write_config -from codex_shim.settings import FactorySettings, chatgpt_passthrough_enabled, default_model_slug +import pytest + +from codex_shim import cli +from codex_shim.catalog import catalog_entry, write_catalog +from codex_shim.settings import ModelSettings, chatgpt_passthrough_available + + +@pytest.fixture +def auth_present(monkeypatch, tmp_path): + """Point chatgpt_passthrough_available() at a valid stub auth.json.""" + auth = tmp_path / "auth.json" + auth.write_text(json.dumps({"tokens": {"access_token": "stub", "account_id": "acct"}})) + monkeypatch.setattr("codex_shim.settings.DEFAULT_CODEX_AUTH", auth) + return auth + + +@pytest.fixture +def auth_missing(monkeypatch, tmp_path): + """Point chatgpt_passthrough_available() at a path that does not exist.""" + monkeypatch.setattr("codex_shim.settings.DEFAULT_CODEX_AUTH", tmp_path / "missing-auth.json") def test_duplicate_models_get_unique_display_slugs(tmp_path): @@ -11,19 +29,83 @@ def test_duplicate_models_get_unique_display_slugs(tmp_path): settings.write_text( json.dumps( { - "customModels": [ - {"model": "gpt-5.5", "displayName": "Fast High", "provider": "openai", "baseUrl": "http://x/v1", "index": 1}, - {"model": "gpt-5.5", "displayName": "Fast Low", "provider": "openai", "baseUrl": "http://x/v1", "index": 2}, + "models": [ + {"model": "gpt-5.5", "display_name": "Fast High", "provider": "openai", "base_url": "http://x/v1", "index": 1}, + {"model": "gpt-5.5", "display_name": "Fast Low", "provider": "openai", "base_url": "http://x/v1", "index": 2}, ] } ) ) - models = FactorySettings(settings).load() + models = ModelSettings(settings).load() assert [m.slug for m in models] == ["fast-high", "fast-low"] +def test_legacy_custom_models_schema_still_loads(tmp_path): + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps( + { + "customModels": [ + {"model": "legacy-model", "displayName": "Legacy Model", "provider": "openai", "baseUrl": "http://x/v1"} + ] + } + ) + ) + [model] = ModelSettings(settings).load() + assert model.slug == "legacy-model" + assert model.display_name == "Legacy Model" + assert model.base_url == "http://x/v1" + + +def test_ollama_launch_models_schema_loads(tmp_path): + settings = tmp_path / "ollama-launch-models.json" + settings.write_text( + json.dumps( + { + "launchModels": [ + "llama3.2", + {"model": "qwen2.5-coder:14b", "name": "Qwen Coder", "provider": "ollama"}, + {"model": "deepseek-r1", "baseURL": "http://localhost:11434/v1"}, + ] + } + ) + ) + + models = ModelSettings(settings).load() + + assert [model.slug for model in models] == ["llama3-2", "qwen2-5-coder-14b", "deepseek-r1"] + assert [model.provider for model in models] == ["generic-chat-completion-api"] * 3 + assert [model.base_url for model in models] == [ + "http://127.0.0.1:11434/v1", + "http://127.0.0.1:11434/v1", + "http://localhost:11434/v1", + ] + + +def test_minimax_provider_uses_openai_chat_translation(tmp_path): + settings = tmp_path / "minimax-models.json" + settings.write_text( + json.dumps( + { + "models": [ + { + "model": "MiniMax-M2.7", + "display_name": "MiniMax M2.7", + "provider": "minimax", + "base_url": "https://api.minimax.io/v1", + } + ] + } + ) + ) + + [model] = ModelSettings(settings).load() + + assert model.is_openai_chat is True + + def test_catalog_preserves_context_and_visibility(): - model = FactorySettingsFixture.one() + model = ModelSettingsFixture.one() entry = catalog_entry(model) assert entry["slug"] == "claude-opus" assert entry["visibility"] == "list" @@ -31,65 +113,191 @@ def test_catalog_preserves_context_and_visibility(): assert "free" in entry["available_in_plans"] -def test_chatgpt_passthrough_disabled_by_env(tmp_path, monkeypatch): - auth = tmp_path / "auth.json" - auth.write_text(json.dumps({"tokens": {"access_token": "token"}})) - monkeypatch.setenv("CODEX_SHIM_DISABLE_CHATGPT", "1") +def test_default_missing_settings_allows_chatgpt_only(monkeypatch, tmp_path): + missing = tmp_path / "missing-default.json" + monkeypatch.setattr("codex_shim.settings.DEFAULT_SETTINGS", missing) + assert ModelSettings().load() == [] - assert chatgpt_passthrough_enabled(auth) is False - models = [FactorySettingsFixture.one()] - catalog = tmp_path / "catalog.json" - write_catalog(models, catalog) - slugs = [row["slug"] for row in json.loads(catalog.read_text())["models"]] - assert slugs == ["claude-opus"] +def test_cli_load_models_missing_custom_settings_has_actionable_error(tmp_path): + missing = tmp_path / "missing.json" + with pytest.raises(SystemExit) as exc: + cli._load_models(missing) + assert "Settings file not found" in str(exc.value) + assert "--settings /path/to/models.json" in str(exc.value) -def test_default_model_uses_chatgpt_only_with_usable_auth(tmp_path, monkeypatch): - model = FactorySettingsFixture.one() - auth = tmp_path / "auth.json" +def test_cli_resolves_chatgpt_passthrough_slug_when_auth_present(auth_present): + assert cli._resolve_model_slug([], "gpt-5.5") == "gpt-5.5" + assert cli._resolve_model_slug([], "openai-gpt-5-5") == "gpt-5.5" + + +def test_cli_rejects_chatgpt_passthrough_slug_when_auth_missing(auth_missing): + with pytest.raises(SystemExit) as exc: + cli._resolve_model_slug([], "gpt-5.5") + assert "codex login" in str(exc.value) - assert chatgpt_passthrough_enabled(auth) is False - assert default_model_slug([model], include_chatgpt=False) == "claude-opus" - auth.write_text(json.dumps({"tokens": {"access_token": "token"}})) - assert chatgpt_passthrough_enabled(auth) is True - assert default_model_slug([model], include_chatgpt=True) == "gpt-5.5" +def test_list_models_includes_chatgpt_passthrough_when_auth_present(monkeypatch, capsys, auth_present): + monkeypatch.setattr(cli, "_load_models", lambda _settings_path: []) + assert cli.list_models("unused") == 0 + assert "gpt-5.5" in capsys.readouterr().out - catalog = tmp_path / "catalog.json" - config = tmp_path / "config.toml" - write_catalog([model], catalog, include_chatgpt=False) - write_config([model], config, catalog, 8765, include_chatgpt=False) - assert [row["slug"] for row in json.loads(catalog.read_text())["models"]] == ["claude-opus"] - assert 'model = "claude-opus"' in config.read_text() +def test_list_models_hides_chatgpt_passthrough_when_auth_missing(monkeypatch, capsys, auth_missing): + monkeypatch.setattr(cli, "_load_models", lambda _settings_path: []) + assert cli.list_models("unused") == 1 + out = capsys.readouterr() + assert "gpt-5.5" not in out.out + assert "codex login" in out.err -def test_minimax_provider_uses_openai_chat_shape(tmp_path): + +def test_cli_load_models_invalid_json_has_actionable_error(tmp_path): settings = tmp_path / "settings.json" + settings.write_text("{") + with pytest.raises(SystemExit) as exc: + cli._load_models(settings) + assert "Settings file is not valid JSON" in str(exc.value) + + +def test_chatgpt_passthrough_available_requires_access_token(tmp_path): + missing = tmp_path / "missing.json" + assert chatgpt_passthrough_available(missing) is False + no_tokens = tmp_path / "no-tokens.json" + no_tokens.write_text(json.dumps({})) + assert chatgpt_passthrough_available(no_tokens) is False + empty_token = tmp_path / "empty.json" + empty_token.write_text(json.dumps({"tokens": {"access_token": ""}})) + assert chatgpt_passthrough_available(empty_token) is False + valid = tmp_path / "valid.json" + valid.write_text(json.dumps({"tokens": {"access_token": "x"}})) + assert chatgpt_passthrough_available(valid) is True + + +def test_write_catalog_omits_gpt55_when_auth_missing(tmp_path, auth_missing): + catalog_path = tmp_path / "catalog.json" + write_catalog([], catalog_path) + data = json.loads(catalog_path.read_text()) + assert data == {"models": []} + + +def test_write_catalog_includes_gpt55_when_auth_present(tmp_path, auth_present): + catalog_path = tmp_path / "catalog.json" + write_catalog([], catalog_path) + data = json.loads(catalog_path.read_text()) + assert [model["slug"] for model in data["models"]] == ["gpt-5.5"] + + +def test_managed_config_escapes_windows_catalog_path(monkeypatch): + monkeypatch.setattr(cli, "CATALOG_PATH", r"C:\Users\User\codex-shim\.codex-shim\custom_model_catalog.json") + top_block, _ = cli._managed_config_blocks("vendor\\model", 8765) + assert 'model = "vendor\\\\model"' in top_block + assert 'model_catalog_json = "C:\\\\Users\\\\User\\\\codex-shim\\\\.codex-shim\\\\custom_model_catalog.json"' in top_block + + +def test_install_codex_config_is_idempotent(monkeypatch, tmp_path): + settings = tmp_path / "models.json" settings.write_text( json.dumps( { - "customModels": [ - { - "model": "MiniMax-M2.7", - "displayName": "MiniMax M2.7", - "provider": "minimax", - "baseUrl": "https://api.minimax.io/v1", - "apiKey": "secret", - } + "models": [ + {"model": "llama3.2", "display_name": "Llama", "provider": "generic-chat-completion-api", "base_url": "http://127.0.0.1:11434/v1"} ] } ) ) + config_path = tmp_path / ".codex" / "config.toml" + monkeypatch.setattr(cli, "RUNTIME_DIR", tmp_path / ".codex-shim") + monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", config_path) + monkeypatch.setattr(cli, "CODEX_CONFIG_BACKUP_PATH", tmp_path / ".codex-shim" / "config.toml.before-codex-shim") - model = FactorySettings(settings).load()[0] + cli.install_codex_config(settings, 8765, "llama3.2") + cli.install_codex_config(settings, 8765, "llama3.2") - assert model.slug == "minimax-m2-7" - assert model.is_openai_chat is True - assert model.is_anthropic is False + text = config_path.read_text() + assert text.count("[model_providers.codex_shim]") == 1 + assert text.count("model_provider = \"codex_shim\"") == 1 + assert text.count("model_catalog_json") == 1 + + +def test_install_and_restore_preserve_displaced_top_level_config(monkeypatch, tmp_path): + settings = tmp_path / "models.json" + settings.write_text( + json.dumps( + { + "models": [ + {"model": "llama3.2", "display_name": "Llama", "provider": "generic-chat-completion-api", "base_url": "http://127.0.0.1:11434/v1"} + ] + } + ) + ) + config_path = tmp_path / ".codex" / "config.toml" + config_path.parent.mkdir() + config_path.write_text( + 'model = "gpt-5.5"\n' + 'model_provider = "openai"\n' + 'model_catalog_json = "/tmp/catalog.json"\n' + '\n[profiles.dev]\nmodel = "profile-model"\n' + ) + monkeypatch.setattr(cli, "RUNTIME_DIR", tmp_path / ".codex-shim") + monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", config_path) + monkeypatch.setattr(cli, "CODEX_CONFIG_BACKUP_PATH", tmp_path / ".codex-shim" / "config.toml.before-codex-shim") + + cli.install_codex_config(settings, 8765, "llama3.2") + installed = config_path.read_text() + assert cli.PREVIOUS_TOP_LEVEL_PREFIX in installed + assert '\nmodel = "llama3-2"\n' in installed + assert '\nmodel_provider = "openai"\n' not in installed + assert '[profiles.dev]\nmodel = "profile-model"' in installed + + cli.restore_codex_config() + restored = config_path.read_text().rstrip() + "\n" + assert restored == ( + 'model = "gpt-5.5"\n' + 'model_provider = "openai"\n' + 'model_catalog_json = "/tmp/catalog.json"\n' + '[profiles.dev]\nmodel = "profile-model"\n' + ) + + +def test_current_managed_model_ignores_user_top_level_and_stale_managed(monkeypatch, tmp_path, auth_missing): + config_path = tmp_path / ".codex" / "config.toml" + config_path.parent.mkdir() + config_path.write_text( + 'model = "user-top"\n' + f'{cli.MANAGED_BEGIN}\n' + 'model = "stale-managed"\n' + f'{cli.MANAGED_END}\n' + ) + monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", config_path) + + model = ModelSettingsFixture.one() + assert cli._current_managed_model() == "stale-managed" + assert cli._resolve_model_slug([model], None) == "claude-opus" + + +def test_loopback_no_proxy_adds_upper_and_lowercase_entries(): + env = cli._with_loopback_no_proxy({"NO_PROXY": "example.com,localhost"}) + + assert env["NO_PROXY"] == "example.com,localhost,127.0.0.1,::1" + assert env["no_proxy"] == "127.0.0.1,localhost,::1" + + +def test_patch_app_fails_off_macos(monkeypatch, capsys): + monkeypatch.setattr(cli.sys, "platform", "win32") + + assert cli.patch_codex_app() == 1 + assert "macOS-only" in capsys.readouterr().err + + +def test_restore_app_fails_off_macos(monkeypatch, capsys): + monkeypatch.setattr(cli.sys, "platform", "linux") + + assert cli.restore_codex_app_bundle() == 1 + assert "macOS-only" in capsys.readouterr().err -class FactorySettingsFixture: +class ModelSettingsFixture: @staticmethod def one(): import tempfile @@ -99,16 +307,16 @@ def one(): path.write_text( json.dumps( { - "customModels": [ + "models": [ { "model": "claude-opus", - "displayName": "Claude Opus", + "display_name": "Claude Opus", "provider": "anthropic", - "baseUrl": "http://anthropic", - "maxContextLimit": 200000, + "base_url": "http://anthropic", + "max_context_limit": 200000, } ] } ) ) - return FactorySettings(path).load()[0] + return ModelSettings(path).load()[0] diff --git a/tests/test_translate.py b/tests/test_translate.py index 92ac6072..3f222330 100644 --- a/tests/test_translate.py +++ b/tests/test_translate.py @@ -12,34 +12,67 @@ def test_responses_to_chat_text_input(): assert out["messages"] == [{"role": "system", "content": "System"}, {"role": "user", "content": "Hello"}] -def test_responses_function_tools_convert_to_chat_shape(): +def test_responses_to_chat_preserves_reasoning_and_effort_for_deepseek(): body = { "model": "slug", - "input": "Hi", - "tools": [{"type": "function", "name": "do_work", "description": "Do work", "parameters": {"type": "object"}}], + "reasoning_effort": "high", + "input": [ + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "prior thought"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "prior answer"}]}, + {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "rules"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "next"}]}, + ], } - out = responses_to_chat(body, "real-model") - assert out["tools"] == [ - { - "type": "function", - "function": {"name": "do_work", "description": "Do work", "parameters": {"type": "object"}}, - } + + out = responses_to_chat(body, "deepseek-reasoner") + + assert out["reasoning_effort"] == "high" + assert out["messages"] == [ + {"role": "assistant", "content": "prior answer", "reasoning_content": "prior thought"}, + {"role": "system", "content": "rules"}, + {"role": "user", "content": "next"}, ] -def test_responses_to_chat_maps_developer_role_to_system(): +def test_responses_to_chat_sanitizes_and_merges_strict_provider_messages(): body = { "model": "slug", + "instructions": "System\x00one", "input": [ - {"type": "message", "role": "developer", "content": "Follow repo rules."}, - {"type": "message", "role": "user", "content": "Hi"}, + {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "rules\x00two"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi\x00"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "again\x01"}]}, + {"type": "function_call", "call_id": "call\x000", "name": "tool", "arguments": "{\"x\":\"y\x00\"}"}, ], } - out = responses_to_chat(body, "real-model") + + out = responses_to_chat(body, "kimi-k2") assert out["messages"] == [ - {"role": "system", "content": "Follow repo rules."}, - {"role": "user", "content": "Hi"}, + {"role": "system", "content": "Systemone\n\nrulestwo"}, + {"role": "user", "content": "hi\n\nagain"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call0", "type": "function", "function": {"name": "tool", "arguments": "{\"x\":\"y\"}"}} + ], + }, + ] + + +def test_responses_function_tools_convert_to_chat_shape(): + body = { + "model": "slug", + "input": "Hi", + "tools": [{"type": "function", "name": "do_work", "description": "Do work", "parameters": {"type": "object"}}], + } + out = responses_to_chat(body, "real-model") + assert out["tools"] == [ + { + "type": "function", + "function": {"name": "do_work", "description": "Do work", "parameters": {"type": "object"}}, + } ]