From 406a3f31f10cf88d2117959ca506e56e9824362a Mon Sep 17 00:00:00 2001 From: onlyterp <121772140+onlyterp@users.noreply.github.com> Date: Mon, 25 May 2026 04:31:15 -0400 Subject: [PATCH 1/9] Harden Codex shim docs and passthrough UX --- README.md | 616 ++++++++++++++++++++++++++++----- codex_shim/cli.py | 62 +++- codex_shim/server.py | 5 +- codex_shim/settings.py | 4 + pyproject.toml | 27 +- tests/test_server.py | 18 + tests/test_settings_catalog.py | 36 ++ 7 files changed, 667 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 60ce7e4b..2fb4fe0c 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,93 @@ # 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 models from `~/.factory/settings.json` (or any +compatible custom JSON file), 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 itself +> is plain Python and works anywhere Python/aiohttp can run. The macOS-specific +> part is only 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 / Factory BYOK 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 benchmark result:** on repeated local coding-agent runs, + ChatGPT passthrough plus prompt catching/proxying measured about **7x fewer + billed input tokens** and **5-10x faster wall time** than the baseline path + for the same task shape. Treat that as a workload-specific result; the + benchmark section below shows how to measure your own setup. --- -## Why +## Requirements -Codex Desktop only shows the models its server-side Statsig config whitelists. -If you have OpenAI / Anthropic / Z.ai / DeepSeek / Gemini / OpenRouter / 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: + - `~/.factory/settings.json` with Factory custom models; + - a compatible JSON file passed with `--settings`; + - `~/.codex/auth.json` containing `tokens.access_token` for ChatGPT/Codex + passthrough-only use. +- macOS only: `npx` and `codesign` if you need the optional Desktop picker + patch. --- ## Install +Runtime install: + +```bash +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 +``` + +For running the test suite: + ```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 +python3 -m pip install --user pytest pytest-asyncio ``` -Requires Python 3.11+. +If your shell cannot find the commands, make sure `~/.local/bin` is on `PATH`: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +Windows users should run the shim from WSL or Git Bash. The Python package is +portable, but the convenience wrappers in `bin/` are POSIX shell scripts. --- @@ -45,40 +96,74 @@ Requires Python 3.11+. ### 1. Generate the catalog and start the shim ```bash -codex-shim generate # reads ~/.factory/settings.json, writes catalog +codex-shim generate # reads ~/.factory/settings.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 +codex-shim status # health probe + model count ``` -### 2. Point Codex Desktop at it (no global config changes) +Generated runtime files live under the repo-local `.codex-shim/` directory: + +```text +.codex-shim/custom_model_catalog.json # model picker catalog for Codex +.codex-shim/config.toml # opt-in Codex provider config +.codex-shim/config.toml.before-codex-shim # backup of your previous Codex config +.codex-shim/shim.pid # daemon pid +.codex-shim/shim.log # stdout/stderr + request summaries +``` + +The server binds `127.0.0.1` by default. It is meant to be a local loopback +adapter, not an Internet-facing proxy. + +### 2. Point Codex Desktop at it + +```bash +codex-shim app . # launch Codex Desktop with the shim wired in +``` + +`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 -codex-shim app . # launch Codex with the shim wired in +codex-shim disable ``` -That command applies opt-in `-c` overrides only for this launch. Your -`~/.codex/config.toml` is left untouched. After this Codex Desktop sees every -entry from `~/.factory/settings.json` plus an optional `OpenAI GPT-5.5 -(ChatGPT)` slug as picker entries. +After this, Codex Desktop sees every entry from `~/.factory/settings.json` plus +the `GPT-5.5` ChatGPT passthrough slug when `~/.codex/auth.json` is available. -If your Codex Desktop's model picker only shows "default" and refuses to render -the catalog entries, you also need the **picker patch** below. +If your Codex Desktop's model picker only shows `default` and refuses to render +the catalog entries, apply the macOS picker patch below. -### 3. (Optional) Switch the active Desktop model +### 3. Switch the active Desktop model ```bash codex-model list -codex-model openai-gpt-5-5 # or any other slug from `list` -codex-app # relaunch Codex with new default +codex-model gpt-5.5 # or any other slug from `list` +codex-app # relaunch Codex with new default +``` + +`codex-model ` is a shortcut for `codex-shim model use `. It writes +only the shim-managed block in `~/.codex/config.toml`. + +### 4. Use the Codex CLI without writing config + +For one-off CLI runs, use inline `-c` overrides instead of changing +`~/.codex/config.toml`: + +```bash +codex-shim codex -- "inspect this repo and summarize the architecture" ``` --- ## 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: +The shim defaults to `~/.factory/settings.json` (the file Factory writes when +you save BYOK custom models). If that file is missing, the shim still generates +a catalog containing the ChatGPT passthrough entry. You can point it at any +compatible file: ```bash codex-shim --settings /path/to/my-models.json generate @@ -124,22 +209,32 @@ Supported `provider` values: | provider | upstream API | |---|---| -| `openai` | OpenAI/`/v1/chat/completions` | +| `openai` | OpenAI `/v1/chat/completions` | | `generic-chat-completion-api` | OpenAI-shaped chat completions | | `anthropic` | Anthropic `/v1/messages` | +Useful model fields: + +| field | behavior | +|---|---| +| `displayName` | Human-readable picker label. | +| `maxContextLimit` | Catalog context window and compaction limits. | +| `maxOutputTokens` | Default max output when translating to Anthropic. | +| `noImageSupport` | When true, catalog advertises text-only input. | +| `extraHeaders` | Optional upstream headers merged into requests. | + --- ## 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.** +> Back up `app.asar` and `Info.plist` before patching. ```bash APP=/Applications/Codex.app @@ -149,10 +244,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 @@ -160,9 +255,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 @@ -181,7 +276,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 @@ -190,30 +285,50 @@ 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 `~/.codex/auth.json` exists and contains `tokens.access_token`, the shim +exposes a synthetic `gpt-5.5` catalog entry that proxies straight to: + +```text +https://chatgpt.com/backend-api/codex/responses +``` + +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 Factory entirely and uses your ChatGPT subscription quota. -If you have a ChatGPT plan with Codex access (`~/.codex/auth.json` exists with -`auth_mode: chatgpt`), the shim exposes one synthetic slug -`openai-gpt-5-5` (display name `OpenAI GPT-5.5 (ChatGPT)`) 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. +It is already in `.codex-shim/custom_model_catalog.json` after `codex-shim +generate`. Select `GPT-5.5` in the picker, or run: -It's already in `.codex-shim/custom_model_catalog.json` after `codex-shim -generate`. Just select it in the picker. +```bash +codex-model gpt-5.5 +``` -If you don't want it, delete the entry with slug `openai-gpt-5-5` from the -generated catalog, or run with `CODEX_SHIM_DISABLE_CHATGPT=1` (TODO). +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 the routing works +## How routing works -``` +```text Codex Desktop ── /v1/responses ──▶ codex-shim (127.0.0.1:8765) │ - ├── slug "openai-gpt-5-5" + ├── slug "gpt-5.5" │ └─▶ chatgpt.com/backend-api/codex/responses │ (Authorization: Bearer ) │ @@ -227,13 +342,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: @@ -242,37 +412,280 @@ 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 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 inline shim overrides +codex-shim app [path] launch Codex Desktop through managed shim config +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 …` +``` + +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` and do not +use `--settings`. + +--- + +## 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. +- Windows native shells are not the primary target for the `bin/` wrappers. Use + WSL/Git Bash or install the package entry point with your Python environment. + +--- + +## Troubleshooting + +### Shim will not start + +```bash +codex-shim status +tail -n 80 .codex-shim/shim.log +``` + +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 . +``` + +### `~/.factory/settings.json` is missing + +That is fine for ChatGPT passthrough-only use. `codex-shim generate` will still +write a catalog containing `gpt-5.5`. For BYOK models, either save custom models +in Factory or pass a compatible file: + +```bash +codex-shim --settings /path/to/my-models.json generate +``` + +### 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. + +### Model appears but requests 404 + +The selected slug is not in the current generated catalog. Regenerate after +editing `~/.factory/settings.json`: + +```bash +codex-shim generate +codex-model list +codex-model +``` + +### Upstream returns 401/403 + +The API key in `~/.factory/settings.json` 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 ``` -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 -codex-shim codex -- exec `codex` CLI through the shim -codex-shim app [path] launch Codex Desktop through the shim -codex-app [path] shortcut for `codex-shim app` -codex-model [list|] shortcut for `codex-shim model …` +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 ``` -All commands accept `--settings ` and `--port `. +### 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` @@ -281,8 +694,43 @@ bin/codex-model shortcut wrapping `codex-shim model …` tests/ pytest suite ``` -The shim never edits `~/.codex/config.toml`. All Codex overrides are passed -inline as `-c key=value` arguments per launch. +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 + a managed block to `~/.codex/config.toml` and keep a backup under + `.codex-shim/`. +- `codex-shim disable` removes the managed block 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/codex_shim/cli.py b/codex_shim/cli.py index 78d1bc19..ff7d258e 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -8,6 +8,7 @@ import sys import time import hashlib +import json from urllib.request import urlopen from .catalog import codex_config_overrides, write_catalog, write_config @@ -104,8 +105,21 @@ def main(argv: list[str] | None = None) -> int: return 2 +def _load_models(settings_path: Path): + expanded = Path(settings_path).expanduser() + try: + return FactorySettings(expanded).load() + except FileNotFoundError as exc: + raise SystemExit( + f"Settings file not found: {expanded}\n" + "Create it by saving custom models in Factory, or pass --settings /path/to/settings.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() + models = _load_models(settings_path) write_catalog(models, CATALOG_PATH) write_config(models, CONFIG_PATH, CATALOG_PATH, port) print(f"Generated {len(models)} model entries:") @@ -115,7 +129,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) @@ -132,10 +146,15 @@ def install_codex_config(settings_path: Path, port: int, model_slug: str | None 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 = [("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) + width = max([len(row[0]) for row in rows] + [4]) + try: + for slug, display_name, model, provider in rows: + print(f"{slug:<{width}} {display_name} -> {model} ({provider})", flush=True) + except BrokenPipeError: + _silence_broken_pipe() return 0 @@ -206,9 +225,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 @@ -443,7 +465,7 @@ def _remove_section(text: str, section: str) -> str: def _override_args(settings_path: Path, port: int) -> list[str]: - models = FactorySettings(settings_path).load() + models = _load_models(settings_path) default_slug = default_model_slug(models) pairs = codex_config_overrides(CATALOG_PATH, default_slug, port) args: list[str] = [] @@ -455,6 +477,8 @@ def _override_args(settings_path: Path, port: int) -> list[str]: def _resolve_model_slug(models, requested: str | None) -> str: if requested is None: return _current_managed_model() or default_model_slug(models) + if requested in {"gpt-5.5", "openai-gpt-5-5"}: + return "gpt-5.5" by_slug = {model.slug: model.slug for model in models} by_model = {} for model in models: @@ -482,11 +506,25 @@ def _current_managed_model() -> str | None: 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 _silence_broken_pipe() -> None: + try: + sys.stdout.close() + except Exception: + pass + sys.stdout = open(os.devnull, "w") def _read_pid() -> int | None: diff --git a/codex_shim/server.py b/codex_shim/server.py index 01345688..7c53a112 100644 --- a/codex_shim/server.py +++ b/codex_shim/server.py @@ -35,11 +35,12 @@ 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)}) + return web.json_response({"ok": True, "models": len(models) + 1}) 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 = [{"id": "gpt-5.5", "object": "model", "created": now, "owned_by": "chatgpt"}] + data.extend({"id": model.slug, "object": "model", "created": now, "owned_by": "factory"} for model in self.settings.load()) return web.json_response({"object": "list", "data": data}) async def chat_completions(self, request: web.Request) -> web.StreamResponse: diff --git a/codex_shim/settings.py b/codex_shim/settings.py index a0ecd75f..fbafd7a4 100644 --- a/codex_shim/settings.py +++ b/codex_shim/settings.py @@ -47,6 +47,10 @@ def __init__(self, path: Path = DEFAULT_FACTORY_SETTINGS): self.path = Path(path).expanduser() def load(self) -> list[FactoryModel]: + if not self.path.exists(): + if self.path == DEFAULT_FACTORY_SETTINGS: + return [] + raise FileNotFoundError(self.path) data = json.loads(self.path.read_text()) rows = data.get("customModels", []) model_counts: dict[str, int] = {} diff --git a/pyproject.toml b/pyproject.toml index cf864988..0d942b32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,37 @@ +[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 Codex Desktop BYOK shim generated from Factory custom models." +readme = "README.md" requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [{ name = "0xSero" }] +keywords = ["codex", "factory", "byok", "openai", "anthropic", "responses-api"] +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.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" [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" - diff --git a/tests/test_server.py b/tests/test_server.py index d4ff7d60..d227611a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -57,6 +57,24 @@ async def chat(request): await upstream_client.close() +async def test_health_and_models_include_chatgpt_passthrough(tmp_path): + 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 + assert (await health.json())["models"] == 1 + + 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_chat_routes_to_anthropic(tmp_path): captured = {} diff --git a/tests/test_settings_catalog.py b/tests/test_settings_catalog.py index 3048aec5..06d1c7f4 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -2,6 +2,9 @@ import json +import pytest + +from codex_shim import cli from codex_shim.catalog import catalog_entry from codex_shim.settings import FactorySettings @@ -31,6 +34,39 @@ def test_catalog_preserves_context_and_visibility(): assert "free" in entry["available_in_plans"] +def test_default_missing_factory_settings_allows_chatgpt_only(monkeypatch, tmp_path): + missing = tmp_path / "missing-default.json" + monkeypatch.setattr("codex_shim.settings.DEFAULT_FACTORY_SETTINGS", missing) + assert FactorySettings(missing).load() == [] + + +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/settings.json" in str(exc.value) + + +def test_cli_resolves_chatgpt_passthrough_slug_without_factory_models(): + 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_list_models_includes_chatgpt_passthrough(monkeypatch, capsys): + monkeypatch.setattr(cli, "_load_models", lambda _settings_path: []) + assert cli.list_models("unused") == 0 + assert "gpt-5.5" in capsys.readouterr().out + + +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) + + class FactorySettingsFixture: @staticmethod def one(): From 8a42a3eaa0ce7f9ef854cc97869045d16fbbc526 Mon Sep 17 00:00:00 2001 From: onlyterp <121772140+onlyterp@users.noreply.github.com> Date: Mon, 25 May 2026 04:53:43 -0400 Subject: [PATCH 2/9] Gate gpt-5.5 passthrough on Codex auth and tighten install docs - Only advertise the synthetic gpt-5.5 entry in /health, /v1/models, list output, and the generated catalog when ~/.codex/auth.json holds a usable tokens.access_token, so the picker never shows an option that would 401 on first use. - Resolve the gpt-5.5/openai-gpt-5-5 slug only when auth is present; otherwise raise SystemExit telling the user to run codex login. - Drop the inline BrokenPipeError handler in list_models in favor of a single _entrypoint() boundary that handles SIGPIPE cleanly for any CLI path that pipes to head/grep/etc. - README: promote 'pip install -e .' as the recommended install path, soften the maintainer benchmark claim into honest anecdata, and document the new auth-gated passthrough behavior + troubleshooting for the empty-catalog case. - Tests: add coverage for chatgpt_passthrough_available(), auth-gated list/resolve/catalog behavior in both directions. --- README.md | 70 +++++++++++++++++++++++++++------- codex_shim/catalog.py | 6 ++- codex_shim/cli.py | 59 +++++++++++++++++++--------- codex_shim/server.py | 22 +++++++++-- codex_shim/settings.py | 34 ++++++++++++++++- pyproject.toml | 2 +- tests/test_server.py | 38 +++++++++++++++++- tests/test_settings_catalog.py | 65 +++++++++++++++++++++++++++++-- 8 files changed, 251 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 2fb4fe0c..7ddabb2e 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,13 @@ local: - **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 benchmark result:** on repeated local coding-agent runs, - ChatGPT passthrough plus prompt catching/proxying measured about **7x fewer - billed input tokens** and **5-10x faster wall time** than the baseline path - for the same task shape. Treat that as a workload-specific result; the - benchmark section below shows how to measure your own setup. +- **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. --- @@ -62,7 +64,25 @@ local: ## Install -Runtime install: +Recommended (installs the `codex-shim` entry point from `pyproject.toml`): + +```bash +git clone https://github.com/0xSero/codex-shim ~/codex-shim +cd ~/codex-shim +python3 -m pip install --user -e . +``` + +That pulls in `aiohttp` and puts `codex-shim` on your `PATH`. The +`codex-app` and `codex-model` shortcuts live in `bin/`; symlink them if you +want them on `PATH` too: + +```bash +mkdir -p ~/.local/bin +ln -sf "$PWD/bin/codex-app" ~/.local/bin/codex-app +ln -sf "$PWD/bin/codex-model" ~/.local/bin/codex-model +``` + +Alternative (no install, run straight from the checkout): ```bash git clone https://github.com/0xSero/codex-shim ~/codex-shim @@ -130,8 +150,9 @@ block can be removed with: codex-shim disable ``` -After this, Codex Desktop sees every entry from `~/.factory/settings.json` plus -the `GPT-5.5` ChatGPT passthrough slug when `~/.codex/auth.json` is available. +After this, Codex Desktop sees every entry from `~/.factory/settings.json`, +plus the `GPT-5.5` ChatGPT passthrough slug if (and only if) `~/.codex/auth.json` +holds a valid `tokens.access_token`. If your Codex Desktop's model picker only shows `default` and refuses to render the catalog entries, apply the macOS picker patch below. @@ -161,9 +182,10 @@ codex-shim codex -- "inspect this repo and summarize the architecture" ## Custom config file The shim defaults to `~/.factory/settings.json` (the file Factory writes when -you save BYOK custom models). If that file is missing, the shim still generates -a catalog containing the ChatGPT passthrough entry. You can point it at any -compatible file: +you save BYOK custom models). 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 --settings /path/to/my-models.json generate @@ -306,6 +328,13 @@ exposes a synthetic `gpt-5.5` catalog entry that proxies straight to: https://chatgpt.com/backend-api/codex/responses ``` +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 @@ -600,14 +629,27 @@ codex-shim --port 8766 app . ### `~/.factory/settings.json` is missing -That is fine for ChatGPT passthrough-only use. `codex-shim generate` will still -write a catalog containing `gpt-5.5`. For BYOK models, either save custom models -in Factory or pass a compatible file: +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 Factory custom models in `~/.factory/settings.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: diff --git a/codex_shim/catalog.py b/codex_shim/catalog.py index 287641b3..7334be86 100644 --- a/codex_shim/catalog.py +++ b/codex_shim/catalog.py @@ -3,7 +3,7 @@ import json from pathlib import Path -from .settings import FactoryModel, PROVIDER_NAME, default_model_slug +from .settings import FactoryModel, PROVIDER_NAME, chatgpt_passthrough_available, default_model_slug PLAN_TIERS = ["free", "plus", "pro", "team", "business", "enterprise"] @@ -111,7 +111,9 @@ def chatgpt_passthrough_entry() -> dict: def write_catalog(models: list[FactoryModel], path: Path) -> Path: path.parent.mkdir(parents=True, exist_ok=True) - entries = [chatgpt_passthrough_entry()] + 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") diff --git a/codex_shim/cli.py b/codex_shim/cli.py index ff7d258e..9f0f099b 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -12,7 +12,14 @@ from urllib.request import urlopen from .catalog import codex_config_overrides, write_catalog, write_config -from .settings import DEFAULT_FACTORY_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, FactorySettings, default_model_slug +from .settings import ( + DEFAULT_FACTORY_SETTINGS, + DEFAULT_HOST, + DEFAULT_PORT, + FactorySettings, + chatgpt_passthrough_available, + default_model_slug, +) PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -147,14 +154,20 @@ def install_codex_config(settings_path: Path, port: int, model_slug: str | None def list_models(settings_path: Path) -> int: models = _load_models(settings_path) - rows = [("gpt-5.5", "GPT-5.5", "gpt-5.5", "chatgpt")] + 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) - width = max([len(row[0]) for row in rows] + [4]) - try: - for slug, display_name, model, provider in rows: - print(f"{slug:<{width}} {display_name} -> {model} ({provider})", flush=True) - except BrokenPipeError: - _silence_broken_pipe() + if not rows: + print( + "No models available. Either save Factory custom models or sign in to Codex " + "(`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 @@ -478,6 +491,11 @@ def _resolve_model_slug(models, requested: str | None) -> str: if requested is None: return _current_managed_model() or default_model_slug(models) if requested in {"gpt-5.5", "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 "gpt-5.5" by_slug = {model.slug: model.slug for model in models} by_model = {} @@ -519,14 +537,6 @@ def _health(port: int) -> dict | None: return None -def _silence_broken_pipe() -> None: - try: - sys.stdout.close() - except Exception: - pass - sys.stdout = open(os.devnull, "w") - - def _read_pid() -> int | None: try: return int(PID_PATH.read_text().strip()) @@ -544,5 +554,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 7c53a112..d98b0b84 100644 --- a/codex_shim/server.py +++ b/codex_shim/server.py @@ -9,7 +9,14 @@ from aiohttp import ClientSession, ClientTimeout, web -from .settings import DEFAULT_FACTORY_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, FactoryModel, FactorySettings +from .settings import ( + DEFAULT_FACTORY_SETTINGS, + DEFAULT_HOST, + DEFAULT_PORT, + FactoryModel, + FactorySettings, + chatgpt_passthrough_available, +) from .translate import ( anthropic_to_chat_response, anthropic_to_response, @@ -35,11 +42,20 @@ 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) + 1}) + 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": "gpt-5.5", "object": "model", "created": now, "owned_by": "chatgpt"}] + data: list[dict[str, Any]] = [] + if chatgpt_passthrough_available(): + data.append({"id": "gpt-5.5", "object": "model", "created": now, "owned_by": "chatgpt"}) data.extend({"id": model.slug, "object": "model", "created": now, "owned_by": "factory"} for model in self.settings.load()) return web.json_response({"object": "list", "data": data}) diff --git a/codex_shim/settings.py b/codex_shim/settings.py index fbafd7a4..f4ba19ad 100644 --- a/codex_shim/settings.py +++ b/codex_shim/settings.py @@ -8,11 +8,38 @@ DEFAULT_FACTORY_SETTINGS = Path.home() / ".factory" / "settings.json" +DEFAULT_CODEX_AUTH = Path.home() / ".codex" / "auth.json" DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8765 PROVIDER_NAME = "factory_byok_shim" +def chatgpt_passthrough_available(auth_path: Path | None = None) -> bool: + """Return True if ~/.codex/auth.json holds a usable Codex access token. + + Used to gate the synthetic gpt-5.5 picker entry so the shim only advertises + the ChatGPT passthrough when a request would actually succeed. + + The default is looked up at call time so tests can monkeypatch + ``DEFAULT_CODEX_AUTH`` on the module. + """ + 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" @@ -124,7 +151,10 @@ def _int_or_none(value: Any) -> int | None: def default_model_slug(models: list[FactoryModel]) -> str: - if not models: + # Prefer the native ChatGPT passthrough when auth.json is usable; otherwise + # fall back to the first BYOK model so the picker has something selectable. + if chatgpt_passthrough_available(): return "gpt-5.5" - # Prefer the native ChatGPT passthrough slug first + if models: + return models[0].slug return "gpt-5.5" diff --git a/pyproject.toml b/pyproject.toml index 0d942b32..92483e49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ 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"] diff --git a/tests/test_server.py b/tests/test_server.py index d227611a..09222baa 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,12 +2,26 @@ import json +import pytest from aiohttp import web from aiohttp.test_utils import TestClient, TestServer from codex_shim.server import ShimServer +@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) + return auth + + +@pytest.fixture +def auth_missing(monkeypatch, tmp_path): + monkeypatch.setattr("codex_shim.settings.DEFAULT_CODEX_AUTH", tmp_path / "missing-auth.json") + + async def test_responses_routes_to_openai_chat(tmp_path): captured = {} @@ -57,7 +71,7 @@ async def chat(request): await upstream_client.close() -async def test_health_and_models_include_chatgpt_passthrough(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())) @@ -65,7 +79,9 @@ async def test_health_and_models_include_chatgpt_passthrough(tmp_path): health = await shim_client.get("/health") assert health.status == 200 - assert (await health.json())["models"] == 1 + 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 @@ -75,6 +91,24 @@ async def test_health_and_models_include_chatgpt_passthrough(tmp_path): 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_anthropic(tmp_path): captured = {} diff --git a/tests/test_settings_catalog.py b/tests/test_settings_catalog.py index 06d1c7f4..794b6163 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -5,8 +5,23 @@ import pytest from codex_shim import cli -from codex_shim.catalog import catalog_entry -from codex_shim.settings import FactorySettings +from codex_shim.catalog import catalog_entry, write_catalog +from codex_shim.settings import FactorySettings, 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): @@ -48,17 +63,31 @@ def test_cli_load_models_missing_custom_settings_has_actionable_error(tmp_path): assert "--settings /path/to/settings.json" in str(exc.value) -def test_cli_resolves_chatgpt_passthrough_slug_without_factory_models(): +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_list_models_includes_chatgpt_passthrough(monkeypatch, capsys): +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) + + +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 +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_cli_load_models_invalid_json_has_actionable_error(tmp_path): settings = tmp_path / "settings.json" settings.write_text("{") @@ -67,6 +96,34 @@ def test_cli_load_models_invalid_json_has_actionable_error(tmp_path): 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"] + + class FactorySettingsFixture: @staticmethod def one(): From 51396d0dbca400c0eb2ad20c882bb3181f06b037 Mon Sep 17 00:00:00 2001 From: onlyterp <121772140+onlyterp@users.noreply.github.com> Date: Mon, 25 May 2026 05:02:17 -0400 Subject: [PATCH 3/9] Add CI, dev extras, CONTRIBUTING, issue templates, CHANGELOG - .github/workflows/ci.yml: pytest + compileall on Python 3.11 and 3.12 on push/PR to main. - pyproject.toml: [project.optional-dependencies] dev so the dev loop is a single pip install -e .[dev]. - CONTRIBUTING.md: dev loop, what kinds of PRs are useful, what to include in bug reports, security disclosure policy. - .github/ISSUE_TEMPLATE/{bug_report,feature_request}.md: structured templates that ask for the diagnostic info we actually need. - CHANGELOG.md: capture the auth-gating, docs, and tooling work. --- .github/ISSUE_TEMPLATE/bug_report.md | 44 +++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 33 +++++++++++ .github/workflows/ci.yml | 31 +++++++++++ CHANGELOG.md | 68 +++++++++++++++++++++++ CONTRIBUTING.md | 60 ++++++++++++++++++++ pyproject.toml | 6 ++ 6 files changed, 242 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..252fd0aa --- /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 + +- [ ] Factory BYOK 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..c2be6afe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# 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). + +## 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 `FactorySettings.load()` 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. +- Six regression tests covering auth-gating and CLI error UX (total: 20 + passing). + +### 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 BYOK model when passthrough is not + usable, instead of unconditionally returning `gpt-5.5`. +- `settings.FactorySettings.load()` returns `[]` for a missing + `~/.factory/settings.json` (the default), while a missing custom + `--settings` path still raises `FileNotFoundError`. +- 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. Renamed package `codex-factory-shim` → + `codex-shim` to match the repo name. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..66c0b9f3 --- /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 Factory BYOK 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/pyproject.toml b/pyproject.toml index 92483e49..6c587eb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,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" From a25081cce7a1bcdab29685637ba489d1619e8c33 Mon Sep 17 00:00:00 2001 From: onlyterp <121772140+onlyterp@users.noreply.github.com> Date: Mon, 25 May 2026 05:41:03 -0400 Subject: [PATCH 4/9] Reframe codex-shim as an all-model Codex router - Make ~/.codex-shim/models.json the canonical default settings path instead of tying the repo identity to any single upstream model store. - Rename the generated Codex provider to codex_shim / Codex Shim. - Rename settings types to ModelSettings/ShimModel and keep support for both generic models+snake_case and legacy customModels+camelCase config shapes. - Rewrite README, metadata, issue templates, contributing docs, and changelog to describe the repo as an all-model BYOK Codex shim. - Add tests for the generic settings schema while preserving alias compatibility. --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- CHANGELOG.md | 26 ++++++---- CONTRIBUTING.md | 4 +- README.md | 76 +++++++++++++++------------- codex_shim/__init__.py | 2 +- codex_shim/catalog.py | 20 ++++---- codex_shim/cli.py | 25 ++++----- codex_shim/server.py | 34 ++++++------- codex_shim/settings.py | 73 ++++++++++++++------------ pyproject.toml | 4 +- tests/test_settings_catalog.py | 50 +++++++++++------- 11 files changed, 175 insertions(+), 141 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 252fd0aa..b52338d1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -33,7 +33,7 @@ labels: bug ## Route -- [ ] Factory BYOK model (slug: `____`) +- [ ] Configured BYOK/upstream model (slug: `____`) - [ ] `gpt-5.5` ChatGPT passthrough - [ ] Codex Desktop picker / ASAR patch - [ ] Other (please describe) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2be6afe..a9f14dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ and this project does not yet follow semantic versioning (pre-1.0). - `.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 @@ -26,13 +36,13 @@ and this project does not yet follow semantic versioning (pre-1.0). 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 `FactorySettings.load()` with actionable +- `_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. -- Six regression tests covering auth-gating and CLI error UX (total: 20 - passing). +- Regression tests covering auth-gating, CLI error UX, settings aliases, and + catalog generation. ### Changed @@ -41,11 +51,8 @@ and this project does not yet follow semantic versioning (pre-1.0). - `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 BYOK model when passthrough is not - usable, instead of unconditionally returning `gpt-5.5`. -- `settings.FactorySettings.load()` returns `[]` for a missing - `~/.factory/settings.json` (the default), while a missing custom - `--settings` path still raises `FileNotFoundError`. +- `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 @@ -64,5 +71,4 @@ and this project does not yet follow semantic versioning (pre-1.0). 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. Renamed package `codex-factory-shim` → - `codex-shim` to match the repo name. + classifiers, and project URLs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66c0b9f3..a8893769 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,8 +49,8 @@ Please include: - 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 Factory BYOK entry or the `gpt-5.5` ChatGPT - passthrough. +- 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. diff --git a/README.md b/README.md index 7ddabb2e..6989e929 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # codex-shim -Run **Codex Desktop** against models from `~/.factory/settings.json` (or any -compatible custom JSON file), plus an optional passthrough to your **ChatGPT +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 @@ -21,7 +21,7 @@ expects. ## 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 / Factory BYOK models +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 @@ -53,7 +53,7 @@ local: - Python 3.11+. - Codex CLI/Desktop installed and authenticated. - One of: - - `~/.factory/settings.json` with Factory custom models; + - `~/.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. @@ -116,7 +116,7 @@ portable, but the convenience wrappers in `bin/` are POSIX shell scripts. ### 1. Generate the catalog and start the shim ```bash -codex-shim generate # reads ~/.factory/settings.json if present +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 @@ -150,7 +150,7 @@ block can be removed with: codex-shim disable ``` -After this, Codex Desktop sees every entry from `~/.factory/settings.json`, +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`. @@ -181,49 +181,53 @@ codex-shim codex -- "inspect this repo and summarize the architecture" ## Custom config file -The shim defaults to `~/.factory/settings.json` (the file Factory writes when -you save BYOK custom models). 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: +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 --settings /path/to/my-models.json generate codex-shim --settings /path/to/my-models.json start ``` -Schema expected (Factory's own format): +Recommended schema: ```json { - "customModels": [ + "models": [ { "model": "gpt-5.5", "provider": "openai", - "baseUrl": "https://api.openai.com/v1", - "apiKey": "sk-…", - "displayName": "OpenAI GPT-5.5", - "maxContextLimit": 400000 + "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", - "baseUrl": "https://api.anthropic.com/v1", - "apiKey": "sk-ant-…", - "displayName": "Claude Opus 4.7" + "base_url": "https://api.anthropic.com/v1", + "api_key": "sk-ant-…", + "display_name": "Claude Opus 4.7" }, { "model": "deepseek-v4-pro", "provider": "anthropic", - "baseUrl": "https://api.deepseek.com/anthropic", - "apiKey": "…", - "displayName": "DeepSeek V4 Pro", - "noImageSupport": true + "base_url": "https://api.deepseek.com/anthropic", + "api_key": "…", + "display_name": "DeepSeek V4 Pro", + "no_image_support": true } ] } ``` +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. + The shim **never copies your API keys** into the generated catalog. Keys stay in your settings file and are read fresh on every request. @@ -239,11 +243,11 @@ Useful model fields: | field | behavior | |---|---| -| `displayName` | Human-readable picker label. | -| `maxContextLimit` | Catalog context window and compaction limits. | -| `maxOutputTokens` | Default max output when translating to Anthropic. | -| `noImageSupport` | When true, catalog advertises text-only input. | -| `extraHeaders` | Optional upstream headers merged into requests. | +| `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. | --- @@ -338,7 +342,7 @@ mint a new token and the entry comes back automatically on the next 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 Factory entirely and uses your ChatGPT subscription quota. +bypasses configured BYOK routes entirely and uses your ChatGPT subscription quota. It is already in `.codex-shim/custom_model_catalog.json` after `codex-shim generate`. Select `GPT-5.5` in the picker, or run: @@ -557,7 +561,7 @@ 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 Factory routes +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 @@ -627,7 +631,7 @@ codex-shim --port 8766 restart codex-shim --port 8766 app . ``` -### `~/.factory/settings.json` is missing +### `~/.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 @@ -641,8 +645,8 @@ codex-shim --settings /path/to/my-models.json generate ### `codex-shim list` exits 1 with "No models available" -You have neither Factory custom models in `~/.factory/settings.json` nor a -valid Codex login. Pick one: +You have neither configured models in `~/.codex-shim/models.json` nor a valid +Codex login. Pick one: ```bash codex login # populate ~/.codex/auth.json @@ -665,7 +669,7 @@ macOS picker patch. ### Model appears but requests 404 The selected slug is not in the current generated catalog. Regenerate after -editing `~/.factory/settings.json`: +editing `~/.codex-shim/models.json` or the file passed with `--settings`: ```bash codex-shim generate @@ -675,7 +679,7 @@ codex-model ### Upstream returns 401/403 -The API key in `~/.factory/settings.json` is wrong, expired, or missing a +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`. 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 7334be86..681d5110 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_available, default_model_slug +from .settings import 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}, @@ -109,7 +109,7 @@ def chatgpt_passthrough_entry() -> dict: } -def write_catalog(models: list[FactoryModel], path: Path) -> Path: +def write_catalog(models: list[ShimModel], path: Path) -> Path: path.parent.mkdir(parents=True, exist_ok=True) entries: list[dict] = [] if chatgpt_passthrough_available(): @@ -120,7 +120,7 @@ def write_catalog(models: list[FactoryModel], path: Path) -> Path: return path -def write_config(models: list[FactoryModel], path: Path, catalog_path: Path, port: int) -> 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) text = f'''# Generated by codex-shim. This file is opt-in and is not ~/.codex/config.toml. @@ -129,7 +129,7 @@ def write_config(models: list[FactoryModel], path: Path, catalog_path: Path, por 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" @@ -146,7 +146,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"', @@ -156,7 +156,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 @@ -167,7 +167,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" diff --git a/codex_shim/cli.py b/codex_shim/cli.py index 9f0f099b..9e199351 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -13,10 +13,11 @@ from .catalog import codex_config_overrides, write_catalog, write_config from .settings import ( - DEFAULT_FACTORY_SETTINGS, + DEFAULT_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, - FactorySettings, + PROVIDER_NAME, + ModelSettings, chatgpt_passthrough_available, default_model_slug, ) @@ -36,7 +37,7 @@ 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, default=DEFAULT_PORT) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("generate") @@ -115,11 +116,11 @@ def main(argv: list[str] | None = None) -> int: def _load_models(settings_path: Path): expanded = Path(settings_path).expanduser() try: - return FactorySettings(expanded).load() + return ModelSettings(expanded).load() except FileNotFoundError as exc: raise SystemExit( f"Settings file not found: {expanded}\n" - "Create it by saving custom models in Factory, or pass --settings /path/to/settings.json." + "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 @@ -145,7 +146,7 @@ def install_codex_config(settings_path: Path, port: int, model_slug: str | None CODEX_CONFIG_BACKUP_PATH.write_text(original) cleaned = _remove_managed_config(original) cleaned = _remove_top_level_keys(cleaned, {"model", "model_provider", "model_catalog_json"}) - 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) CODEX_CONFIG_PATH.write_text(top_block + "\n" + cleaned.lstrip() + "\n" + provider_block) print(f"Installed shim config into {CODEX_CONFIG_PATH}.") @@ -160,8 +161,8 @@ def list_models(settings_path: Path) -> int: rows.extend((model.slug, model.display_name, model.model, model.provider) for model in models) if not rows: print( - "No models available. Either save Factory custom models or sign in to Codex " - "(`codex login`) so ~/.codex/auth.json grants the gpt-5.5 passthrough.", + "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 @@ -231,7 +232,7 @@ def restore_codex_config() -> None: if CODEX_CONFIG_PATH.exists(): current = CODEX_CONFIG_PATH.read_text() restored = _remove_managed_config(current) - restored = _remove_section(restored, "model_providers.factory_byok_shim") + restored = _remove_section(restored, f"model_providers.{PROVIDER_NAME}") CODEX_CONFIG_PATH.write_text(restored.lstrip()) print(f"Removed shim config from {CODEX_CONFIG_PATH}.") @@ -417,14 +418,14 @@ def _foreground_codex_app() -> None: def _managed_config_blocks(default_slug: str, port: int) -> tuple[str, str]: top_block = f'''{MANAGED_BEGIN} model = "{default_slug}" -model_provider = "factory_byok_shim" +model_provider = "{PROVIDER_NAME}" model_catalog_json = "{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" diff --git a/codex_shim/server.py b/codex_shim/server.py index d98b0b84..31ae11bf 100644 --- a/codex_shim/server.py +++ b/codex_shim/server.py @@ -10,11 +10,11 @@ from aiohttp import ClientSession, ClientTimeout, web from .settings import ( - DEFAULT_FACTORY_SETTINGS, + DEFAULT_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, - FactoryModel, - FactorySettings, + ModelSettings, + ShimModel, chatgpt_passthrough_available, ) from .translate import ( @@ -28,8 +28,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: @@ -56,7 +56,7 @@ async def models(self, _request: web.Request) -> web.Response: data: list[dict[str, Any]] = [] if chatgpt_passthrough_available(): data.append({"id": "gpt-5.5", "object": "model", "created": now, "owned_by": "chatgpt"}) - data.extend({"id": model.slug, "object": "model", "created": now, "owned_by": "factory"} for model in self.settings.load()) + 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: @@ -69,7 +69,7 @@ async def chat_completions(self, request: web.Request) -> web.StreamResponse: 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() @@ -84,7 +84,7 @@ 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}") async def _chatgpt_passthrough( self, request: web.Request, body: dict[str, Any] @@ -92,7 +92,7 @@ async def _chatgpt_passthrough( """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() try: @@ -138,7 +138,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: @@ -146,7 +146,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) @@ -162,7 +162,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) @@ -178,7 +178,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) @@ -213,7 +213,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) @@ -736,14 +736,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", @@ -864,7 +864,7 @@ async def _error_response(upstream) -> web.Response: 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 f4ba19ad..0d09feb1 100644 --- a/codex_shim/settings.py +++ b/codex_shim/settings.py @@ -7,22 +7,15 @@ 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" def chatgpt_passthrough_available(auth_path: Path | None = None) -> bool: - """Return True if ~/.codex/auth.json holds a usable Codex access token. - - Used to gate the synthetic gpt-5.5 picker entry so the shim only advertises - the ChatGPT passthrough when a request would actually succeed. - - The default is looked up at call time so tests can monkeypatch - ``DEFAULT_CODEX_AUTH`` on the module. - """ + """Return True if ~/.codex/auth.json holds a usable Codex access token.""" if auth_path is None: import sys as _sys @@ -46,7 +39,7 @@ def slugify(value: str) -> str: @dataclass(frozen=True) -class FactoryModel: +class ShimModel: slug: str model: str display_name: str @@ -69,17 +62,17 @@ def is_openai_chat(self) -> bool: return self.provider in {"openai", "generic-chat-completion-api"} -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_FACTORY_SETTINGS: + 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() @@ -87,17 +80,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}" @@ -105,32 +98,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: @@ -141,6 +132,24 @@ def by_slug_or_model(self, requested: str) -> FactoryModel | None: return None +def _model_rows(data: Any) -> list[dict[str, Any]]: + if not isinstance(data, dict): + return [] + rows = data.get("models") + if rows is None: + rows = data.get("customModels", []) + if not isinstance(rows, list): + return [] + return [row for row in rows if isinstance(row, dict)] + + +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 @@ -150,9 +159,7 @@ def _int_or_none(value: Any) -> int | None: return None -def default_model_slug(models: list[FactoryModel]) -> str: - # Prefer the native ChatGPT passthrough when auth.json is usable; otherwise - # fall back to the first BYOK model so the picker has something selectable. +def default_model_slug(models: list[ShimModel]) -> str: if chatgpt_passthrough_available(): return "gpt-5.5" if models: diff --git a/pyproject.toml b/pyproject.toml index 6c587eb7..264a3f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta" [project] name = "codex-shim" version = "0.1.0" -description = "Local Codex Desktop 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", "factory", "byok", "openai", "anthropic", "responses-api"] +keywords = ["codex", "byok", "openai", "anthropic", "responses-api", "model-routing"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", diff --git a/tests/test_settings_catalog.py b/tests/test_settings_catalog.py index 794b6163..eb0db733 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -6,7 +6,7 @@ from codex_shim import cli from codex_shim.catalog import catalog_entry, write_catalog -from codex_shim.settings import FactorySettings, chatgpt_passthrough_available +from codex_shim.settings import ModelSettings, chatgpt_passthrough_available @pytest.fixture @@ -29,19 +29,36 @@ 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_catalog_preserves_context_and_visibility(): - model = FactorySettingsFixture.one() + model = ModelSettingsFixture.one() entry = catalog_entry(model) assert entry["slug"] == "claude-opus" assert entry["visibility"] == "list" @@ -49,10 +66,10 @@ def test_catalog_preserves_context_and_visibility(): assert "free" in entry["available_in_plans"] -def test_default_missing_factory_settings_allows_chatgpt_only(monkeypatch, tmp_path): +def test_default_missing_settings_allows_chatgpt_only(monkeypatch, tmp_path): missing = tmp_path / "missing-default.json" - monkeypatch.setattr("codex_shim.settings.DEFAULT_FACTORY_SETTINGS", missing) - assert FactorySettings(missing).load() == [] + monkeypatch.setattr("codex_shim.settings.DEFAULT_SETTINGS", missing) + assert ModelSettings().load() == [] def test_cli_load_models_missing_custom_settings_has_actionable_error(tmp_path): @@ -60,7 +77,7 @@ def test_cli_load_models_missing_custom_settings_has_actionable_error(tmp_path): with pytest.raises(SystemExit) as exc: cli._load_models(missing) assert "Settings file not found" in str(exc.value) - assert "--settings /path/to/settings.json" in str(exc.value) + assert "--settings /path/to/models.json" in str(exc.value) def test_cli_resolves_chatgpt_passthrough_slug_when_auth_present(auth_present): @@ -124,7 +141,7 @@ def test_write_catalog_includes_gpt55_when_auth_present(tmp_path, auth_present): assert [model["slug"] for model in data["models"]] == ["gpt-5.5"] -class FactorySettingsFixture: +class ModelSettingsFixture: @staticmethod def one(): import tempfile @@ -134,17 +151,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] From dd127814cd2b6e47ec7222add1082dab8b69af90 Mon Sep 17 00:00:00 2001 From: onlyterp Date: Mon, 25 May 2026 06:19:51 -0400 Subject: [PATCH 5/9] Document Windows support for codex-shim --- README.md | 102 ++++++++++++++++++++++++++++----- codex_shim/cli.py | 47 +++++++++++++-- tests/test_settings_catalog.py | 7 +++ 3 files changed, 137 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6989e929..6b795076 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ 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 itself -> is plain Python and works anywhere Python/aiohttp can run. The macOS-specific -> part is only the optional Desktop picker ASAR patch, needed when Codex hides -> custom catalog entries. +> 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. --- @@ -57,6 +57,8 @@ local: - 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. @@ -64,7 +66,8 @@ local: ## Install -Recommended (installs the `codex-shim` entry point from `pyproject.toml`): +Recommended on macOS/Linux/WSL/Git Bash (installs the `codex-shim` entry +point from `pyproject.toml`): ```bash git clone https://github.com/0xSero/codex-shim ~/codex-shim @@ -72,9 +75,17 @@ cd ~/codex-shim python3 -m pip install --user -e . ``` -That pulls in `aiohttp` and puts `codex-shim` on your `PATH`. The -`codex-app` and `codex-model` shortcuts live in `bin/`; symlink them if you -want them on `PATH` too: +Recommended on native Windows PowerShell/cmd: + +```powershell +git clone https://github.com/0xSero/codex-shim $HOME\codex-shim +cd $HOME\codex-shim +py -3.11 -m pip install --user -e . +``` + +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 mkdir -p ~/.local/bin @@ -82,7 +93,8 @@ ln -sf "$PWD/bin/codex-app" ~/.local/bin/codex-app ln -sf "$PWD/bin/codex-model" ~/.local/bin/codex-model ``` -Alternative (no install, run straight from the checkout): +Alternative on macOS/Linux/WSL/Git Bash (no install, run straight from the +checkout): ```bash git clone https://github.com/0xSero/codex-shim ~/codex-shim @@ -100,14 +112,76 @@ For running the test suite: python3 -m pip install --user pytest pytest-asyncio ``` -If your shell cannot find the commands, make sure `~/.local/bin` is on `PATH`: +If your POSIX shell cannot find the commands, make sure `~/.local/bin` is on +`PATH`: ```bash export PATH="$HOME/.local/bin:$PATH" ``` -Windows users should run the shim from WSL or Git Bash. The Python package is -portable, but the convenience wrappers in `bin/` are POSIX shell scripts. +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: + +```powershell +$env:APPDATA\Python\Python311\Scripts +``` + +You can also skip `PATH` entirely and run through Python: + +```powershell +py -3.11 -m codex_shim.cli status +``` + +--- + +## Windows support + +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: + +| 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. | + +Native Windows quick check: + +```powershell +py -3.11 -m pip install --user -e . +codex-shim generate +codex-shim start +codex-shim status +codex-shim list +``` + +If `codex-shim` is not on `Path`, use the module form: + +```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 +``` + +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. --- @@ -606,8 +680,8 @@ use `--settings`. 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. -- Windows native shells are not the primary target for the `bin/` wrappers. Use - WSL/Git Bash or install the package entry point with your Python environment. +- The `bin/codex-app` and `bin/codex-model` shortcuts are POSIX shell scripts. + In native Windows shells, use the installed `codex-shim` command instead. --- diff --git a/codex_shim/cli.py b/codex_shim/cli.py index 9e199351..d960b9d2 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -3,6 +3,7 @@ import argparse import os from pathlib import Path +import ctypes import signal import subprocess import sys @@ -11,7 +12,7 @@ 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 ( DEFAULT_SETTINGS, DEFAULT_HOST, @@ -33,6 +34,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 def main(argv: list[str] | None = None) -> int: @@ -191,7 +195,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): @@ -212,7 +216,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) @@ -265,6 +269,8 @@ 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] + if os.name == "nt": + raise SystemExit(subprocess.call(args)) os.execvp("codex", args) @@ -417,9 +423,9 @@ def _foreground_codex_app() -> None: def _managed_config_blocks(default_slug: str, port: int) -> tuple[str, str]: top_block = f'''{MANAGED_BEGIN} -model = "{default_slug}" +model = "{_toml_escape(default_slug)}" model_provider = "{PROVIDER_NAME}" -model_catalog_json = "{CATALOG_PATH}" +model_catalog_json = "{_toml_escape(str(CATALOG_PATH))}" {MANAGED_END} ''' @@ -478,6 +484,26 @@ 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 = _load_models(settings_path) default_slug = default_model_slug(models) @@ -548,6 +574,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 diff --git a/tests/test_settings_catalog.py b/tests/test_settings_catalog.py index eb0db733..45bbbe72 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -141,6 +141,13 @@ def test_write_catalog_includes_gpt55_when_auth_present(tmp_path, auth_present): 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 + + class ModelSettingsFixture: @staticmethod def one(): From 496f04aa8dd82db857240b50a3d7b51022e6da74 Mon Sep 17 00:00:00 2001 From: onlyterp Date: Mon, 25 May 2026 12:48:28 -0400 Subject: [PATCH 6/9] Fix Codex shim model switching edge cases --- README.md | 81 ++++++++++++++++++++++++++++++++-- codex_shim/cli.py | 25 +++++++++-- codex_shim/server.py | 38 +++++++++++++++- codex_shim/settings.py | 49 +++++++++++++++++--- codex_shim/translate.py | 3 +- tests/test_server.py | 56 ++++++++++++++++++++++- tests/test_settings_catalog.py | 71 +++++++++++++++++++++++++++++ 7 files changed, 309 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6b795076..6d6f1f0d 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,9 @@ ln -sf "$PWD/bin/codex-app" ~/.local/bin/codex-app ln -sf "$PWD/bin/codex-model" ~/.local/bin/codex-model ``` +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. + Alternative on macOS/Linux/WSL/Git Bash (no install, run straight from the checkout): @@ -183,6 +186,24 @@ 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" +``` + +`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. + --- ## Quick start @@ -323,6 +344,43 @@ Useful model fields: | `no_image_support` | When true, catalog advertises text-only input. | | `extra_headers` | Optional upstream headers merged into requests. | +### Ollama / local OpenAI-compatible chat endpoints + +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: + +```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 @@ -652,8 +710,8 @@ 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` and do not -use `--settings`. +`patch-app` and `restore-app` always target `/Applications/Codex.app`, do not +use `--settings`, and exit with a clear error on Windows/Linux. --- @@ -738,7 +796,24 @@ codex-shim model list ``` If the catalog contains your models but Desktop still hides them, apply the -macOS picker patch. +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 diff --git a/codex_shim/cli.py b/codex_shim/cli.py index d960b9d2..ba8e5017 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -269,18 +269,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] + env = _with_loopback_no_proxy(os.environ.copy()) if os.name == "nt": - raise SystemExit(subprocess.call(args)) - os.execvp("codex", args) + 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: @@ -291,6 +304,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" @@ -343,6 +359,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(): diff --git a/codex_shim/server.py b/codex_shim/server.py index 31ae11bf..4064b479 100644 --- a/codex_shim/server.py +++ b/codex_shim/server.py @@ -18,6 +18,7 @@ chatgpt_passthrough_available, ) from .translate import ( + SHIM_ENCRYPTED_CONTENT_PREFIX, anthropic_to_chat_response, anthropic_to_response, chat_completion_to_response, @@ -104,7 +105,7 @@ 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 = _sanitize_chatgpt_passthrough_body(body) forwarded["model"] = "gpt-5.5" headers = { "Authorization": f"Bearer {access_token}", @@ -248,6 +249,41 @@ 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) + + class ResponsesStreamState: """Translates upstream chat-completions / anthropic stream events into the Codex Desktop Responses-API event sequence. Keeps the message item and diff --git a/codex_shim/settings.py b/codex_shim/settings.py index 0d09feb1..8200f095 100644 --- a/codex_shim/settings.py +++ b/codex_shim/settings.py @@ -133,14 +133,53 @@ def by_slug_or_model(self, requested: str) -> ShimModel | None: def _model_rows(data: Any) -> list[dict[str, Any]]: - if not isinstance(data, dict): + 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 [] - rows = data.get("models") - if rows is None: - rows = data.get("customModels", []) if not isinstance(rows, list): return [] - return [row for row in rows if isinstance(row, dict)] + 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: diff --git a/codex_shim/translate.py b/codex_shim/translate.py index a68cb4fe..7e7eeffc 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: diff --git a/tests/test_server.py b/tests/test_server.py index 09222baa..ae1ace75 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,7 +6,8 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from codex_shim.server import ShimServer +from codex_shim.server import ShimServer, _sanitize_chatgpt_passthrough_body +from codex_shim.translate import SHIM_ENCRYPTED_CONTENT_PREFIX @pytest.fixture @@ -22,6 +23,59 @@ def auth_missing(monkeypatch, tmp_path): monkeypatch.setattr("codex_shim.settings.DEFAULT_CODEX_AUTH", tmp_path / "missing-auth.json") +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] + + async def test_responses_routes_to_openai_chat(tmp_path): captured = {} diff --git a/tests/test_settings_catalog.py b/tests/test_settings_catalog.py index 45bbbe72..e9944c79 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -57,6 +57,31 @@ def test_legacy_custom_models_schema_still_loads(tmp_path): 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_catalog_preserves_context_and_visibility(): model = ModelSettingsFixture.one() entry = catalog_entry(model) @@ -148,6 +173,52 @@ def test_managed_config_escapes_windows_catalog_path(monkeypatch): 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( + { + "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") + + cli.install_codex_config(settings, 8765, "llama3.2") + cli.install_codex_config(settings, 8765, "llama3.2") + + 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_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 ModelSettingsFixture: @staticmethod def one(): From 0da7b742f213946fb34cdf961be8806ce2fd957b Mon Sep 17 00:00:00 2001 From: TerpBot <121772140+OnlyTerp@users.noreply.github.com> Date: Mon, 25 May 2026 13:22:46 -0400 Subject: [PATCH 7/9] Port upstream shim hardening fixes. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 9 +- codex_shim/catalog.py | 9 +- codex_shim/cli.py | 128 ++++++++++++++++--- codex_shim/server.py | 224 +++++++++++++++++++++++++++++++-- codex_shim/settings.py | 17 ++- codex_shim/translate.py | 26 +++- tests/test_server.py | 138 +++++++++++++++++++- tests/test_settings_catalog.py | 56 +++++++++ tests/test_translate.py | 22 ++++ 9 files changed, 585 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 6d6f1f0d..0a96f415 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,6 @@ Generated runtime files live under the repo-local `.codex-shim/` directory: ```text .codex-shim/custom_model_catalog.json # model picker catalog for Codex .codex-shim/config.toml # opt-in Codex provider config -.codex-shim/config.toml.before-codex-shim # backup of your previous Codex config .codex-shim/shim.pid # daemon pid .codex-shim/shim.log # stdout/stderr + request summaries ``` @@ -894,9 +893,11 @@ 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 - a managed block to `~/.codex/config.toml` and keep a backup under - `.codex-shim/`. -- `codex-shim disable` removes the managed block and stops the daemon. + 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. --- diff --git a/codex_shim/catalog.py b/codex_shim/catalog.py index 681d5110..7f47e7ad 100644 --- a/codex_shim/catalog.py +++ b/codex_shim/catalog.py @@ -3,7 +3,7 @@ import json from pathlib import Path -from .settings import PROVIDER_NAME, ShimModel, chatgpt_passthrough_available, 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"] @@ -64,7 +64,7 @@ def catalog_entry(model: ShimModel) -> 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, @@ -122,7 +122,10 @@ def write_catalog(models: list[ShimModel], path: Path) -> 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) + 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}" diff --git a/codex_shim/cli.py b/codex_shim/cli.py index ba8e5017..074af47a 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -14,6 +14,7 @@ from .catalog import _toml_escape, codex_config_overrides, write_catalog, write_config from .settings import ( + CHATGPT_MODEL_SLUG, DEFAULT_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, @@ -37,6 +38,8 @@ 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"} def main(argv: list[str] | None = None) -> int: @@ -132,6 +135,10 @@ def _load_models(settings_path: Path): def generate(settings_path: Path, port: int) -> None: models = _load_models(settings_path) + try: + 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:") @@ -146,15 +153,19 @@ def install_codex_config(settings_path: Path, port: int, model_slug: str | None CODEX_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) RUNTIME_DIR.mkdir(parents=True, exist_ok=True) original = CODEX_CONFIG_PATH.read_text() if CODEX_CONFIG_PATH.exists() else "" - if MANAGED_BEGIN not in original and not CODEX_CONFIG_BACKUP_PATH.exists(): - CODEX_CONFIG_BACKUP_PATH.write_text(original) cleaned = _remove_managed_config(original) - cleaned = _remove_top_level_keys(cleaned, {"model", "model_provider", "model_catalog_json"}) + current_top_level = _extract_top_level_key_lines(cleaned, MANAGED_TOP_LEVEL_KEYS) + if current_top_level: + previous_top_level = current_top_level + else: + 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, f"model_providers.{PROVIDER_NAME}") - top_block, provider_block = _managed_config_blocks(default_slug, port) + 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}.") - print(f"Original backup: {CODEX_CONFIG_BACKUP_PATH}") def list_models(settings_path: Path) -> int: @@ -228,17 +239,19 @@ def stop() -> int: def restore_codex_config() -> None: - if CODEX_CONFIG_BACKUP_PATH.exists(): - CODEX_CONFIG_PATH.write_text(CODEX_CONFIG_BACKUP_PATH.read_text()) - CODEX_CONFIG_BACKUP_PATH.unlink() - print(f"Restored original {CODEX_CONFIG_PATH}.") - return 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, f"model_providers.{PROVIDER_NAME}") - CODEX_CONFIG_PATH.write_text(restored.lstrip()) + 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}.") + if CODEX_CONFIG_BACKUP_PATH.exists(): + CODEX_CONFIG_BACKUP_PATH.unlink() + print(f"Removed stale shim backup {CODEX_CONFIG_BACKUP_PATH}.") def status(port: int) -> int: @@ -440,9 +453,12 @@ def _foreground_codex_app() -> None: pass -def _managed_config_blocks(default_slug: str, port: int) -> tuple[str, str]: +def _managed_config_blocks(default_slug: str, port: int, previous_top_level: dict[str, str] | None = None) -> tuple[str, str]: + metadata = "" + if previous_top_level: + metadata = PREVIOUS_TOP_LEVEL_PREFIX + json.dumps(previous_top_level, sort_keys=True) + "\n" top_block = f'''{MANAGED_BEGIN} -model = "{_toml_escape(default_slug)}" +{metadata}model = "{_toml_escape(default_slug)}" model_provider = "{PROVIDER_NAME}" model_catalog_json = "{_toml_escape(str(CATALOG_PATH))}" {MANAGED_END} @@ -487,6 +503,59 @@ def _remove_top_level_keys(text: str, keys: set[str]) -> str: return "\n".join(output) + ("\n" if text.endswith("\n") else "") +def _extract_top_level_key_lines(text: str, keys: set[str]) -> dict[str, str]: + found: dict[str, str] = {} + in_top_level = True + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("["): + in_top_level = False + if not in_top_level or not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key = stripped.split("=", 1)[0].strip() + if key in keys: + found[key] = line + return found + + +def _managed_previous_top_level(text: str) -> dict[str, str]: + in_managed = False + for line in text.splitlines(): + stripped = line.strip() + if stripped == MANAGED_BEGIN: + in_managed = True + continue + if stripped == MANAGED_END: + in_managed = False + continue + if in_managed and stripped.startswith(PREVIOUS_TOP_LEVEL_PREFIX): + encoded = stripped[len(PREVIOUS_TOP_LEVEL_PREFIX) :] + try: + payload = json.loads(encoded) + except json.JSONDecodeError: + return {} + if isinstance(payload, dict): + return {str(k): str(v) for k, v in payload.items() if k in MANAGED_TOP_LEVEL_KEYS} + return {} + + +def _restore_missing_top_level_keys(text: str, previous_top_level: dict[str, str]) -> str: + if not previous_top_level: + return text + current = _extract_top_level_key_lines(text, MANAGED_TOP_LEVEL_KEYS) + lines = [ + previous_top_level[key] + for key in ("model", "model_provider", "model_catalog_json") + if key in previous_top_level and key not in current + ] + if not lines: + return text + prefix = "\n".join(lines) + "\n" + if text and not text.startswith("\n"): + return prefix + text + return prefix + text.lstrip() + + def _remove_section(text: str, section: str) -> str: lines = text.splitlines() output: list[str] = [] @@ -525,7 +594,10 @@ def _terminate_pid(pid: int) -> None: def _override_args(settings_path: Path, port: int) -> list[str]: models = _load_models(settings_path) - default_slug = default_model_slug(models) + 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: @@ -535,14 +607,20 @@ def _override_args(settings_path: Path, port: int) -> list[str]: def _resolve_model_slug(models, requested: str | None) -> str: if requested is None: - return _current_managed_model() or default_model_slug(models) - if requested in {"gpt-5.5", "openai-gpt-5-5"}: + current = _current_managed_model() + if current in _valid_model_slugs(models): + return current + 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 "gpt-5.5" + return CHATGPT_MODEL_SLUG by_slug = {model.slug: model.slug for model in models} by_model = {} for model in models: @@ -562,13 +640,27 @@ def _resolve_model_slug(models, requested: str | None) -> str: def _current_managed_model() -> str | None: if not CODEX_CONFIG_PATH.exists(): return None + in_managed = False for line in CODEX_CONFIG_PATH.read_text().splitlines(): stripped = line.strip() - if stripped.startswith("model = "): + if stripped == MANAGED_BEGIN: + in_managed = True + continue + if stripped == MANAGED_END: + in_managed = False + continue + if in_managed and stripped.startswith("model = "): return stripped.split("=", 1)[1].strip().strip('"') return None +def _valid_model_slugs(models) -> set[str]: + slugs = {model.slug for model in models} + if chatgpt_passthrough_available(): + slugs.add(CHATGPT_MODEL_SLUG) + return slugs + + def _healthy(port: int) -> bool: return _health(port) is not None diff --git a/codex_shim/server.py b/codex_shim/server.py index 4064b479..bb906ab2 100644 --- a/codex_shim/server.py +++ b/codex_shim/server.py @@ -10,6 +10,8 @@ from aiohttp import ClientSession, ClientTimeout, web from .settings import ( + CHATGPT_MODEL_SLUG, + DEFAULT_CODEX_AUTH, DEFAULT_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, @@ -56,7 +58,7 @@ async def models(self, _request: web.Request) -> web.Response: now = int(time.time()) data: list[dict[str, Any]] = [] if chatgpt_passthrough_available(): - data.append({"id": "gpt-5.5", "object": "model", "created": now, "owned_by": "chatgpt"}) + 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}) @@ -66,6 +68,8 @@ 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) @@ -76,8 +80,10 @@ 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 model == "gpt-5.5" 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) @@ -87,15 +93,172 @@ async def responses(self, request: web.Request) -> web.StreamResponse: return await self._post_anthropic(request, route, forwarded, as_responses=True) 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 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: @@ -106,7 +269,7 @@ async def _chatgpt_passthrough( if not access_token: raise web.HTTPUnauthorized(text="auth.json has no access_token") forwarded = _sanitize_chatgpt_passthrough_body(body) - forwarded["model"] = "gpt-5.5" + forwarded["model"] = CHATGPT_MODEL_SLUG headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", @@ -123,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: @@ -284,6 +461,19 @@ def _has_shim_encrypted_content(value: dict[str, Any]) -> bool: 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 @@ -314,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") @@ -336,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) @@ -898,6 +1091,17 @@ 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_SETTINGS) diff --git a/codex_shim/settings.py b/codex_shim/settings.py index 8200f095..e1f52713 100644 --- a/codex_shim/settings.py +++ b/codex_shim/settings.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field import json +import os from pathlib import Path import re from typing import Any @@ -12,10 +13,13 @@ DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8765 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 @@ -198,9 +202,14 @@ def _int_or_none(value: Any) -> int | None: return None -def default_model_slug(models: list[ShimModel]) -> str: - if chatgpt_passthrough_available(): - return "gpt-5.5" +def default_model_slug(models: list[ShimModel], include_chatgpt: bool | None = None) -> str: + if include_chatgpt is None: + include_chatgpt = chatgpt_passthrough_available() + if include_chatgpt: + return CHATGPT_MODEL_SLUG if models: return models[0].slug - return "gpt-5.5" + raise ValueError( + "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 7e7eeffc..82802a09 100644 --- a/codex_shim/translate.py +++ b/codex_shim/translate.py @@ -32,11 +32,17 @@ 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) chat: dict[str, Any] = { @@ -49,6 +55,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: @@ -213,6 +220,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( @@ -280,7 +297,10 @@ def flush_pending_assistant_tool_calls(): item_type = item.get("type") if item_type in {"message", None} and "role" in item: flush_pending_assistant_tool_calls() - messages.append({"role": item.get("role", "user"), "content": _content_to_text(item.get("content", ""))}) + role = item.get("role", "user") + if role == "developer": + role = "system" + messages.append({"role": role, "content": _content_to_text(item.get("content", ""))}) elif item_type in {"input_text", "text"}: flush_pending_assistant_tool_calls() messages.append({"role": "user", "content": _content_to_text(item)}) diff --git a/tests/test_server.py b/tests/test_server.py index ae1ace75..01b4c5a7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,7 +6,7 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from codex_shim.server import ShimServer, _sanitize_chatgpt_passthrough_body +from codex_shim.server import ShimServer, _rewrite_response_model, _sanitize_chatgpt_passthrough_body from codex_shim.translate import SHIM_ENCRYPTED_CONTENT_PREFIX @@ -15,12 +15,15 @@ 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): - monkeypatch.setattr("codex_shim.settings.DEFAULT_CODEX_AUTH", tmp_path / "missing-auth.json") + 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(): @@ -76,6 +79,96 @@ def test_sanitize_chatgpt_passthrough_body_removes_nested_shim_encrypted_content 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): captured = {} @@ -163,6 +256,47 @@ async def test_health_and_models_hide_chatgpt_passthrough_when_auth_missing(tmp_ await shim_client.close() +async def test_chat_routes_to_openai_normalizes_developer_role(tmp_path): + captured = {} + + async def chat(request): + captured["body"] = await request.json() + return web.json_response({"id": "chatcmpl_fake", "choices": [{"message": {"role": "assistant", "content": "ok"}}]}) + + upstream = web.Application() + upstream.router.add_post("/v1/chat/completions", chat) + upstream_client = TestClient(TestServer(upstream)) + await upstream_client.start_server() + + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps( + { + "customModels": [ + { + "model": "deepseek-reasoner", + "displayName": "DeepSeek Reasoner", + "provider": "openai", + "baseUrl": str(upstream_client.make_url("/v1")), + } + ] + } + ) + ) + shim_client = TestClient(TestServer(ShimServer(settings).app())) + await shim_client.start_server() + + 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 + assert [message["role"] for message in captured["body"]["messages"]] == ["system", "user"] + + await shim_client.close() + await upstream_client.close() + + async def test_chat_routes_to_anthropic(tmp_path): captured = {} diff --git a/tests/test_settings_catalog.py b/tests/test_settings_catalog.py index e9944c79..1b50aa93 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -198,6 +198,62 @@ def test_install_codex_config_is_idempotent(monkeypatch, tmp_path): 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"}) diff --git a/tests/test_translate.py b/tests/test_translate.py index 4493958d..1a7dc791 100644 --- a/tests/test_translate.py +++ b/tests/test_translate.py @@ -12,6 +12,28 @@ def test_responses_to_chat_text_input(): assert out["messages"] == [{"role": "system", "content": "System"}, {"role": "user", "content": "Hello"}] +def test_responses_to_chat_preserves_reasoning_and_effort_for_deepseek(): + body = { + "model": "slug", + "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, "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_function_tools_convert_to_chat_shape(): body = { "model": "slug", From 215d32e42742ee9a4bccf51390d0b11c0df974db Mon Sep 17 00:00:00 2001 From: TerpBot <121772140+OnlyTerp@users.noreply.github.com> Date: Mon, 25 May 2026 13:28:01 -0400 Subject: [PATCH 8/9] Harden chat translation for strict providers. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- codex_shim/translate.py | 87 +++++++++++++++++++++++++++++++++++++++++ tests/test_translate.py | 27 +++++++++++++ 2 files changed, 114 insertions(+) diff --git a/codex_shim/translate.py b/codex_shim/translate.py index 82802a09..1c25643e 100644 --- a/codex_shim/translate.py +++ b/codex_shim/translate.py @@ -44,6 +44,7 @@ def responses_to_chat(body: dict[str, Any], upstream_model: str) -> dict[str, An 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, @@ -428,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/tests/test_translate.py b/tests/test_translate.py index 1a7dc791..3f222330 100644 --- a/tests/test_translate.py +++ b/tests/test_translate.py @@ -34,6 +34,33 @@ def test_responses_to_chat_preserves_reasoning_and_effort_for_deepseek(): ] +def test_responses_to_chat_sanitizes_and_merges_strict_provider_messages(): + body = { + "model": "slug", + "instructions": "System\x00one", + "input": [ + {"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, "kimi-k2") + + assert out["messages"] == [ + {"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", From f9aece7f895c1964cc5a0144148171570cc7d590 Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Mon, 25 May 2026 22:21:35 +0200 Subject: [PATCH 9/9] Add provider setup shortcuts on upstream base --- README.md | 38 ++++ bin/codex-shim | 9 +- codex_shim/cli.py | 291 ++++++++++++++++++++++-- codex_shim/settings.py | 2 +- examples/minimax-models.example.json | 12 + examples/openrouter-models.example.json | 12 + tests/test_provider_workflows.py | 114 ++++++++++ tests/test_settings_catalog.py | 22 ++ 8 files changed, 479 insertions(+), 21 deletions(-) create mode 100644 examples/minimax-models.example.json create mode 100644 examples/openrouter-models.example.json create mode 100644 tests/test_provider_workflows.py diff --git a/README.md b/README.md index 0a96f415..965e9eb4 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,38 @@ For one-off CLI runs, use inline `-c` overrides instead of changing codex-shim codex -- "inspect this repo and summarize the architecture" ``` +### 5. One-command provider workflows + +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 setup openrouter +codex-shim openrouter . + +codex-shim setup minimax +codex-shim minimax . +``` + +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_MODEL=my-slug codex-shim openrouter . +codex-shim --port 8771 minimax . +codex-shim provider list +``` + --- ## Custom config file @@ -331,6 +363,7 @@ Supported `provider` values: |---|---| | `openai` | OpenAI `/v1/chat/completions` | | `generic-chat-completion-api` | OpenAI-shaped chat completions | +| `minimax` | MiniMax Token Plan `/v1/chat/completions` | | `anthropic` | Anthropic `/v1/messages` | Useful model fields: @@ -697,6 +730,11 @@ 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 diff --git a/bin/codex-shim b/bin/codex-shim index 8889a0d5..93657bd5 100755 --- a/bin/codex-shim +++ b/bin/codex-shim @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +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)" export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m codex_shim.cli "$@" - diff --git a/codex_shim/cli.py b/codex_shim/cli.py index 074af47a..9a4d6fb0 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -1,6 +1,8 @@ from __future__ import annotations import argparse +from dataclasses import dataclass +import getpass import os from pathlib import Path import ctypes @@ -42,10 +44,58 @@ MANAGED_TOP_LEVEL_KEYS = {"model", "model_provider", "model_catalog_json"} +@dataclass(frozen=True) +class ProviderSpec: + name: str + title: str + settings_path: Path + port: int + placeholder_key: str + default_model: str + default_display_name: str + default_provider: str + default_base_url: str + default_context: int + allowed_providers: frozenset[str] + template_path: Path + + +PROVIDER_SPECS = { + "openrouter": ProviderSpec( + name="openrouter", + title="OpenRouter", + settings_path=DEFAULT_SETTINGS.parent / "openrouter-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=PROJECT_ROOT / "examples" / "openrouter-models.example.json", + ), + "minimax": ProviderSpec( + name="minimax", + title="MiniMax Token Plan", + settings_path=DEFAULT_SETTINGS.parent / "minimax-models.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", "generic-chat-completion-api", "openai"}), + 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_SETTINGS) - parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--port", type=int) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("generate") sub.add_parser("list") @@ -71,17 +121,33 @@ def main(argv: list[str] | None = None) -> int: app_parser.add_argument("-m", "--model", dest="model_slug") app_parser.add_argument("path", nargs="?", default=".") + 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.") + run_parser.add_argument("provider", choices=sorted(PROVIDER_SPECS)) + run_parser.add_argument("args", nargs=argparse.REMAINDER) + + for provider_name, spec in PROVIDER_SPECS.items(): + provider_alias = sub.add_parser(provider_name, help=f"Run Codex CLI through {spec.title}.") + provider_alias.add_argument("args", nargs=argparse.REMAINDER) + + provider_parser = sub.add_parser("provider", help="List built-in provider workflows.") + provider_sub = provider_parser.add_subparsers(dest="provider_command", required=True) + provider_sub.add_parser("list") + args = parser.parse_args(argv) + port = args.port if args.port is not None else DEFAULT_PORT if args.command == "generate": - generate(args.settings, args.port) + generate(args.settings, port) return 0 if args.command == "list": return list_models(args.settings) if args.command in {"start", "enable"}: - generate(args.settings, args.port) - code = start(args.settings, args.port) + generate(args.settings, port) + code = start(args.settings, port) if code == 0 and args.command == "enable": - install_codex_config(args.settings, args.port) + install_codex_config(args.settings, port) return code if args.command in {"stop", "disable"}: if args.command == "disable": @@ -89,10 +155,10 @@ def main(argv: list[str] | None = None) -> int: return stop() if args.command == "restart": stop() - generate(args.settings, args.port) - return start(args.settings, args.port) + generate(args.settings, port) + return start(args.settings, port) if args.command == "status": - return status(args.port) + return status(port) if args.command == "patch-app": return patch_codex_app() if args.command == "restore-app": @@ -101,25 +167,214 @@ def main(argv: list[str] | None = None) -> int: if args.model_command == "list": return list_models(args.settings) if args.model_command == "use": - generate(args.settings, args.port) - ensure_started(args.settings, args.port) - install_codex_config(args.settings, args.port, args.model_slug) + generate(args.settings, port) + ensure_started(args.settings, port) + install_codex_config(args.settings, port, args.model_slug) print(f"Active Codex shim model: {args.model_slug}") return 0 if args.command == "codex": - generate(args.settings, args.port) - ensure_started(args.settings, args.port) - exec_codex(args.settings, args.port, args.args) + generate(args.settings, port) + ensure_started(args.settings, port) + exec_codex(args.settings, port, args.args) return 0 if args.command == "app": - generate(args.settings, args.port) - ensure_started(args.settings, args.port) - install_codex_config(args.settings, args.port, args.model_slug) - exec_codex_app(args.settings, args.port, args.path) + generate(args.settings, port) + ensure_started(args.settings, port) + install_codex_config(args.settings, port, args.model_slug) + exec_codex_app(args.settings, port, args.path) + return 0 + if args.command == "setup": + return setup_provider(args.provider) + if args.command == "run": + return run_provider(args.provider, args.args, args.port) + if args.command in PROVIDER_SPECS: + return run_provider(args.command, args.args, args.port) + if args.command == "provider": + if args.provider_command == "list": + return list_providers() return 0 return 2 +def list_providers() -> int: + width = max(len(name) for name in PROVIDER_SPECS) + for spec in PROVIDER_SPECS.values(): + print(f"{spec.name:<{width}} {spec.title} -> {spec.settings_path}") + return 0 + + +def setup_provider(provider: str) -> int: + spec = PROVIDER_SPECS[provider] + status = _provider_settings_status(spec) + _prompt_provider_setup(spec, status) + status = _provider_settings_status(spec) + 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 {spec.name} .") + return 0 + + +def run_provider(provider: str, codex_args: list[str], requested_port: int | None = None) -> int: + spec = PROVIDER_SPECS[provider] + status = _provider_settings_status(spec) + if status != "ok": + _prompt_provider_setup(spec, status) + status = _provider_settings_status(spec) + if status != "ok": + print(f"{spec.title} settings are still not usable: {status}", file=sys.stderr) + return 1 + + 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 + + +def _provider_settings_status(spec: ProviderSpec) -> str: + if not spec.settings_path.exists(): + return "missing" + try: + models = ModelSettings(spec.settings_path).load() + except (OSError, json.JSONDecodeError): + return "invalid_json" + if not models: + return "empty" + model = models[0] + if model.provider not in spec.allowed_providers: + return "unsupported_provider" + if not model.base_url: + return "missing_base_url" + if not model.api_key or model.api_key == spec.placeholder_key: + return "missing_key" + return "ok" + + +def _prompt_provider_setup(spec: ProviderSpec, status: str) -> None: + if not sys.stdin.isatty(): + print( + f"{spec.title} settings are not configured ({status}):\n" + f" {spec.settings_path}\n\n" + "Run this helper from an interactive terminal, or create the file manually from:\n" + f" {spec.template_path}", + file=sys.stderr, + ) + raise SystemExit(1) + + if status == "ok": + print( + f"{spec.title} setup:\n" + f" {spec.settings_path}\n\n" + "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 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("api_key", "") + if current_key and current_key != spec.placeholder_key: + key_prompt += " [keep existing]" + api_key = getpass.getpass(f"{key_prompt}: ") + if not api_key and current_key and current_key != spec.placeholder_key: + api_key = current_key + if not api_key: + print("API key is required.", file=sys.stderr) + raise SystemExit(1) + + model = _prompt_with_default(f"{spec.title} model", current["model"]) + 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: + context = spec.default_context + + _write_provider_settings(spec, api_key, model, display_name, base_url, context) + print(f"Wrote {spec.settings_path}", file=sys.stderr) + + +def _current_provider_settings(spec: ProviderSpec) -> dict[str, str]: + models = [] + try: + models = ModelSettings(spec.settings_path).load() + except (OSError, json.JSONDecodeError): + pass + model = models[0] if models else None + return { + "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), + } + + +def _prompt_with_default(label: str, default: str) -> str: + value = input(f"{label} [{default}]: ") + return value or default + + +def _write_provider_settings( + spec: ProviderSpec, + api_key: str, + model: str, + display_name: str, + base_url: str, + context: int, +) -> None: + spec.settings_path.parent.mkdir(parents=True, exist_ok=True) + try: + spec.settings_path.parent.chmod(0o700) + except OSError: + pass + old_umask = os.umask(0o077) + try: + payload = { + "models": [ + { + "model": model, + "provider": spec.default_provider, + "base_url": base_url.rstrip("/"), + "api_key": api_key, + "display_name": display_name, + "max_context_limit": context, + } + ] + } + spec.settings_path.write_text(json.dumps(payload, indent=2) + "\n") + finally: + os.umask(old_umask) + + +def _first_model_slug(settings_path: Path) -> str | None: + 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: diff --git a/codex_shim/settings.py b/codex_shim/settings.py index e1f52713..a1e42cf4 100644 --- a/codex_shim/settings.py +++ b/codex_shim/settings.py @@ -63,7 +63,7 @@ def is_anthropic(self) -> bool: @property def is_openai_chat(self) -> bool: - return self.provider in {"openai", "generic-chat-completion-api"} + return self.provider in {"openai", "generic-chat-completion-api", "minimax"} class ModelSettings: 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/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/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_settings_catalog.py b/tests/test_settings_catalog.py index 1b50aa93..79bf3706 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -82,6 +82,28 @@ def test_ollama_launch_models_schema_loads(tmp_path): ] +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 = ModelSettingsFixture.one() entry = catalog_entry(model)