Codex, the ChatGPT desktop app on the Mac, only talks to OpenAI models out of the box. Astra, Sol, Luna, Reserve. That is the picker.

I wanted Claude, MiniMax, Kimi, Qwen, OpenCode Go, DeepSeek, Copilot, and whatever else I already pay for, inside that same app. Same tasks, and the same computer-use cursor. No second chat window.

So I built Model Hub: a menu bar extra with a hexagon icon, a dashboard on 127.0.0.1:4545, and a small localhost gateway that translates Codex traffic into whatever the upstream provider actually speaks.

Model Hub menu bar icon, a terracotta hexagon with a cream node in the centre

What Codex forces you to do

A custom provider in Codex is not a Chat Completions proxy. OpenAI dropped wire_api = "chat". The only remaining value is wire_api = "responses".

That means the hub has to implement POST /v1/responses with SSE streaming. Function tools. Parallel tool calls. The freeform apply_patch tool Codex uses to edit files. Images. Reasoning. MCP tools that arrive wrapped as type: "namespace".

If any of that round-trips wrong, Codex looks fine in the picker and then falls over in the middle of a turn.

The other constraint is the one most proxies miss.

Codex reads model_provider once, at launch. After that, the running app keeps sending the picker slot it is already on as model on every request. Names like gpt-reserve, gpt-6-astra, gpt-5.6-sol. It does not send claude-opus or minimax-m3. It sends the slot.

So Model Hub publishes every hub model onto those official slot slugs, learns which slot the live app is using, and “Use in Codex” only re-points that slot. model in ~/.codex/config.toml stays untouched. The next message uses the new model. No relaunch.

A relaunch is required only when the provider itself changes: the first time you point Codex at the hub, or when you go Back to OpenAI.

How we built it

Work started on 14 September 2026 in the Models All For Codex workspace. The brief was blunt: a local Mac app, subscription keys and API keys, Claude, OpenCode, MiniMax, Qwen, Kimi, anything with an endpoint, and a dashboard that a person can operate without opening a terminal.

The order that actually shipped:

  1. A Node HTTP server on localhost that speaks Responses API.
  2. Translators for Chat Completions and Anthropic Messages, plus native Responses passthrough.
  3. Surgical writes to ~/.codex/config.toml and a generated model catalog.
  4. A Vite + React dashboard: Home, Providers, Models, Activity, Settings.
  5. A Swift AppKit menu bar app that spawns Node, starts and stops the hub, and reopens ChatGPT without an Automation prompt.
  6. The ugly production work: timeouts, body limits, picker-cache fights, OpenCode Go, computer use.

Stack, on purpose:

  • Node 20, ESM, vanilla http. One runtime dependency (smol-toml) so TOML comments survive.
  • Dashboard with handwritten CSS. No Tailwind, no component library.
  • Swift compiled with swiftc. No Xcode project, no Electron.
  • Bind 127.0.0.1 only. Secrets at ~/Library/Application Support/ModelHub/secrets.json with mode 0600.

Claude Pro/Max OAuth is not in the app. Anthropic blocks that in third-party tools. Claude is an API key.

How the app works

Codex app  -->  Responses API  -->  Model Hub (127.0.0.1:4545)  -->  the real provider

Codex thinks it is talking to an OpenAI-compatible Responses endpoint. The hub maps the incoming slot (gpt-reserve and friends) to {provider, model}, then:

  • anthropic goes to {base}/v1/messages
  • chat goes to {base}/chat/completions
  • responses is a passthrough to {base}/responses, with the model id rewritten and previous_response_id dropped

Whatever comes back is streamed to Codex as Responses SSE. Even if the upstream sent a single JSON blob.

Files the hub touches

Path Job
~/.codex/config.toml Provider block, catalog pointer. Comments kept. Backup before every rewrite.
~/.codex/modelhub-catalog.json Generated catalog: hub slugs plus aliases for native picker slots.
~/.codex/models_cache.json Overwritten while the hub is active, then watched. Codex loves to put Luna/Reserve back.
~/.codex/modelhub-backups/ Last 20 snapshots of config.toml.
~/Library/Application Support/ModelHub/ state.json, secrets.json, runtime copy, restart handshake.

The provider block looks like this:

[model_providers.modelhub]
name = "Model Hub"
base_url = "http://127.0.0.1:4545/v1"
wire_api = "responses"
requires_openai_auth = true
request_max_retries = 4
stream_max_retries = 5
stream_idle_timeout_ms = 600000

requires_openai_auth = true looks wrong until you hit computer use. Completions still go to the hub. The ChatGPT login is what unlocks the desktop cursor. Without that flag, the in-app browser opens like a normal browser and the cursor never attaches.

Slugs Codex will actually show

ChatGPT Desktop drops model ids that contain /. GitHub issue openai/codex #37700. It also hides ids that collide with native ChatGPT slugs.

So the hub never publishes provider/model. It publishes prefix--model-id. Example: go--deepseek-v4.1-flash. Prefixes are short tags like go, zen, ds.

Menu bar versus dashboard

Screenshot 2026 09 16 at 9.49.42 AM

The hexagon lives in the menu bar. Start now, Stop now, Open dashboard, Reopen ChatGPT, launch at login, auto-reopen.

The dashboard is the local page at http://127.0.0.1:4545. Pick a provider, paste a key, test, refresh models, hit Use in Codex.

Quit in the menu bar only hides the extra. The Node server keeps running until Stop now. That confused me the first time I looked for it in /Applications after a quit.

install.sh builds the dashboard, compiles the Swift app into /Applications/Model Hub.app, copies the runtime into Application Support, and opens it.

Things that broke, and what they taught us

The first gateway compiled. Codex still refused to use it. Most of the product is the week of failures after that.

The picker kept showing ChatGPT models. Codex rewrites models_cache.json on its own schedule. If the hub only writes config.toml, the desktop app keeps selecting native Luna or Reserve. The fix is to overwrite the cache with the hub catalog and watch the file. If Codex puts the native list back, the hub writes again.

OpenCode Go connected and still served the wrong model. Display names collapsed onto DeepSeek V4.1 Flash. The dashboard and the live Codex slot disagreed. Go also 403s some DeepSeek/Mimo routes with a China-region error. The hub now copies pretty names from OpenCode, keeps per-model protocol overrides, and falls back to your own DeepSeek key when Go blocks a region.

DeepSeek V4.1 Flash on Go is 4× usage until 20 September 2026. That label has to survive the refresh, or you will burn the plan without noticing.

Computer use opened Safari instead of the Codex browser. Two bugs stacked. First, Codex MCP tools arrive as namespace objects. If you pass them through as-is, a lot of models never see mcp__node_repl__js and they shell out to macOS open. Flatten on the way out, restore { namespace, name } on the way back. Second, requires_openai_auth has to stay true so the desktop cursor stays in the ChatGPT session.

Streams died with “disconnected before completion.” Node’s default request timeout is five minutes. Long reasoning turns last longer. Codex also re-sends the whole conversation, screenshots included. If the body is over the limit and you destroy the socket, Codex retries forever. Drain the rest of the request, answer 413 as JSON, set requestTimeout = 0, and abort only when the client actually hangs up.

A KeepAlive LaunchAgent on Node made Stop now impossible. The agent kept reviving the process. Launch at login now only opens the .app with --from-login. The menu bar extra is what spawns Node.

AppleScript quit asked for Automation. The polite reopen uses NSRunningApplication.terminate() instead. The hub drops restart-codex.json, the menu bar app waits until no turn is in flight (up to 90 seconds), then quits and reopens ChatGPT. Tasks stay where they were.

Using it

cd model-hub
chmod +x install.sh
./install.sh

Then:

  1. Click the hexagon. Start now if the status line says Stopped.
  2. Open the dashboard.
  3. Add a provider. Paste a key, import from OpenCode, or sign in to GitHub Copilot with the device code.
  4. Test the connection. Refresh models if the provider lists them.
  5. Home: pick a model, Use in Codex.
  6. First time, ChatGPT reopens itself. After that, a model switch lands on the next message. A turn that is already streaming keeps the model it started with.

Back to OpenAI does one reopen and leaves the provider block in config.toml, so switching back is one click. Uninstall from Codex removes the block, the catalog entries, and the hub slugs from the cache.

Providers the Add modal already knows: Kimi Code, Qwen Coding Plan, GLM Coding Plan, MiniMax, GitHub Copilot, OpenCode Go and Zen, Anthropic, OpenAI, OpenRouter, DeepSeek, Moonshot, Alibaba, xAI, Gemini, Groq, Mistral, Ollama, LM Studio, a CLIProxyAPI bridge, and custom OpenAI / Anthropic / Responses endpoints.

Qwen “subscription” here means an Alibaba Coding Plan key (sk-sp-…). Free Qwen OAuth is gone.

What I would not build again

A chat UI that replaces Codex. The whole point is to stay in the app I already work in.

Claude Pro login. It is blocked, and spoofing Claude Code headers is a good way to get the key revoked.

Listening on the LAN. This is a developer tool for one Mac.

Electron. The menu bar extra is Swift. The dashboard is a local page.

If you take one thing from this

The translation layer is the obvious work. The slot pinning is the product.

If you stand up a Responses proxy and then rewrite model in config.toml on every switch, you will relaunch ChatGPT all day and still lose the picker. Pin the live slot. Leave model alone. Watch models_cache.json. That is Model Hub.

Specification

The architecture is also written down as a 15-page PDF, dated 16 September 2026, under my name. Download the Model Hub technical specification (PDF).