- Swift 99.7%
- Shell 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| CodexModelSwitcher | ||
| CodexModelSwitcher.xcodeproj | ||
| CodexModelSwitcherTests | ||
| scripts | ||
| .gitignore | ||
| icon.png | ||
| LICENSE | ||
| README.md | ||
| screenshot.png | ||
| TODO.md | ||
Codex Model Switcher
A small macOS menu bar app for managing Codex model provider configurations.
The app helps you add, edit, and switch between Codex providers such as OpenAI, OpenRouter, DeepSeek, or other OpenAI-compatible services. It writes the selected model and provider settings to ~/.codex/config.toml, stores app metadata in ~/.codex/model-switcher.json, and can manage provider API keys and saved OpenAI account credentials.
Features
- Add, edit, and delete custom model providers.
- Show inline provider-setting errors and warnings before save.
- Manage multiple models per provider.
- Switch the active Codex model from the menu bar.
- Manage multiple OpenAI accounts.
- Write Codex config updates automatically.
- Export managed credentials to an owner-only JSON file after an explicit warning.
- Purge app-managed data, saved credentials, and managed Codex runtime files.
- Keyboard- and VoiceOver-friendly menu controls with labeled icon actions, model/account selection state, delete confirmations, and editor default/cancel keyboard actions.
- Built-in compatibility proxy with Responses SSE streaming support and chunked JSON response serialization for Chat Completions providers.
- Bounded, redacted in-memory proxy diagnostics for recent proxy failures.
- Keep the app menu-bar only, without a Dock icon, and terminate duplicate launches so only one menu bar item remains active.
Notes
Codex may need to be restarted after changing provider, model, or OpenAI account settings.
API keys and OpenAI credentials are sensitive. Long-lived provider API keys, saved OpenAI account JSON, and local compatibility proxy bearer tokens are stored in the macOS Keychain and are omitted from ~/.codex/model-switcher.json. Legacy app data that still contains those fields is decoded once, migrated to Keychain, and then persisted without plaintext secret fields. If a saved OpenAI account loses its Keychain secret, the app preserves any existing ~/.codex/auth.json file and asks for a fresh login instead of writing an empty auth file. Deleting the last saved OpenAI account removes the app-managed ~/.codex/auth.json copy.
The active Codex runtime can still require credentials in files that Codex itself reads, such as ~/.codex/auth.json, provider config, or the managed environment file. For normal non-proxy providers, the managed Codex provider config writes env_key and the provider API key is written to ~/.codex/model-switcher.env, not embedded in config.toml as experimental_bearer_token. Env file exports single-quote provider keys so shell metacharacters, command substitutions, quotes, and newlines source back as the original value instead of executing as shell syntax. The app appends an active .zprofile source line for the env file and does not treat a commented-out copy as active integration. The footer action with the broken-link icon removes the app-managed .zprofile marker and active source lines for the managed env file without deleting other user shell profile content or the managed env file; selecting or saving a provider can add the source line again. When the app loads an already-selected app-managed provider, it rewrites the managed config block so older embedded non-proxy bearer tokens are removed. The selected non-proxy key is also exported with launchctl setenv for GUI-launched Codex processes; when selection moves away from that provider, its API key is cleared, or the provider is deleted, the app calls launchctl unsetenv for stale managed env names that are not still actively exported for the selected non-proxy provider. For compatibility-proxy providers, the managed Codex provider config receives a local bearer token for 127.0.0.1:48117; the proxy listener is constrained to the loopback interface and configured for local-only connections. If that address is already in use, the menu bar status reports the port conflict directly and tells the user to quit the other process before reselecting the proxied provider. The proxy rejects headers over 32 KiB, request bodies over 16 MiB, malformed Content-Length values, unsupported transfer encodings, and partial requests that do not complete within 15 seconds. Each proxy request routes against a queue-safe snapshot of the enabled provider service, so provider updates do not race with in-flight request handling. For stream=true, the proxy forwards a streaming Chat Completions request upstream, converts text deltas, function-call argument deltas, incomplete length or content_filter finish reasons, and usage-only terminal chunks into Responses-compatible SSE events as chunks are consumed, and returns the downstream stream without a buffered Content-Length. Successful non-streaming Chat-to-Responses conversions also serialize the downstream application/json body in bounded chunks without a buffered Content-Length, reducing the extra full-response Data copy on the live proxy connection. Upstream provider error bodies, invalid upstream response payloads, malformed or oversized upstream stream chunks, truncated upstream streams, and mid-stream upstream transport errors are redacted before returning local proxy errors or sanitized failure events. The upstream provider API key stays in the app runtime and is used only by the proxy. Treat files under ~/.codex/ like secrets.
The footer export action writes a user-selected owner-only JSON file containing app-managed provider records plus hydrated provider API keys, local compatibility proxy tokens, saved OpenAI auth JSON, and the current selections. The file is plaintext by design and should be stored only in a secure location. The destructive purge action removes ~/.codex/model-switcher.json, Keychain secrets for app-managed providers/accounts, matching app-managed provider selection keys and managed provider blocks from ~/.codex/config.toml, ~/.codex/model-switcher.env, app-managed ~/.codex/auth.json only when the current file still matches the hydrated selected OpenAI account, the app-managed .zprofile source line, and the selected app-managed non-proxy provider's launchctl environment export. Deleting the final saved OpenAI account uses the same auth-file match guard before removing ~/.codex/auth.json or cli_auth_credentials_store. The app preserves unrelated Codex config, user-managed provider tables, top-level model selections changed after the app write, auth files changed after the app write, and unrelated shell profile content.
The app keeps only the most recent proxy diagnostic events in memory and exposes the latest safe summary through the proxy status help text. Diagnostic events contain a redacted category, provider ID, method, sanitized path without query string, optional HTTP status, and a canned message. They do not include headers, bearer tokens, upstream URLs, query parameters, request bodies, upstream response bodies, or raw error descriptions.
The app sets owner-only permissions on credential-bearing files after writes: ~/.codex/model-switcher.json, ~/.codex/config.toml, ~/.codex/auth.json, and ~/.codex/model-switcher.env are set to 0600; ~/.codex/ and temporary OpenAI login directories under ~/.codex/model-switcher/login/ are set to 0700. The app does not tighten ~/.zprofile because it only receives a source line for the managed env file, not the secret values themselves.
Provider env keys must be shell-compatible environment variable names. They must start with a letter or underscore and can contain only letters, numbers, and underscores.
Custom provider base URLs must be valid https:// URLs with a host. Normal save, config-write, and compatibility-proxy paths reject plaintext http:// provider URLs before bearer tokens are written or sent. The app-generated local compatibility proxy URL is the only normal plaintext HTTP endpoint and is limited to loopback.
The provider editor shows inline errors and warnings before save for invalid provider URLs, unsafe env keys, missing model IDs, saved API keys without env export names, env names without saved API keys, and compatibility-proxy providers that would forward upstream requests without an API key.
Menu bar controls are exposed with explicit accessibility labels, values, and hints for icon-only actions, provider model rows, proxy status, and the OpenAI account picker. Provider edit/delete actions remain visible and reachable without hover, destructive provider/account row actions ask for confirmation before changing data, and the provider editor focuses the Name field on open while supporting default Save and cancel Back keyboard actions. The OpenAI account add action, credential export action, and destructive purge action share one alert presenter so the profile login warning is not shadowed by other footer warnings.
The app claims an exclusive per-user launch lock before loading app state, then checks for older running sh.wmc.CodexModelSwitcher instances. Duplicate launches terminate without loading app state, which keeps repeated local builds or accidental double launches from stacking identical status items or running duplicate config/credential startup work.
Managed provider tables in ~/.codex/config.toml are written inside a Codex Model Switcher managed providers block. On later writes, the app replaces only that block and legacy app-marked provider tables, preserving user comments, unrelated settings, unrelated tables, nested tables, and user-managed providers outside the block. If a user-managed provider outside the block uses the same provider ID as an app-managed provider, the app rejects the save instead of writing duplicate provider tables. If the managed block is unfinished, the app also rejects the save until the block is closed or removed.
Provider saves are preflighted against the current Codex config before app data or Keychain changes are persisted. A provider ID conflict, unfinished managed block, invalid provider URL, invalid env key, or missing proxy token aborts the save without leaving a half-saved provider in ~/.codex/model-switcher.json or Keychain.
When updating Codex-managed files, the app snapshots config.toml, auth.json, .zprofile, and model-switcher.env in memory before the first write. If a later write fails, the app attempts to restore existing files or remove files created during the failed attempt. It does not create persistent backup copies of credential-bearing files.
Requirements
Codex Model Switcher targets macOS 15.3 or newer. The Xcode project sets MACOSX_DEPLOYMENT_TARGET = 15.3 for the app and test targets, so older macOS versions are outside the supported runtime range until the project target is lowered and reverified.
The supported local development and verification toolchain is the Xcode beta at /Applications/Xcode-beta.app/Contents/Developer. On this machine the verified toolchain is Xcode 27.0 beta, build 27A5194q, with the macOS 27.0 SDK. scripts/verify.sh defaults to that developer directory when it exists and runs strict swift-format lint, Debug tests, a Release build, and Debug static analysis.
Other Xcode versions are not part of the supported verification matrix yet. To try a different toolchain, set DEVELOPER_DIR explicitly and run scripts/verify.sh, git diff --check, and jj status --no-pager before opening a PR or preparing a release.
Security Model
Codex Model Switcher is a local-user configuration tool. Its trust boundary is the current macOS account, the user's Keychain, and the local Codex home under ~/.codex; a process or user that can read those locations or the user's session environment should be treated as able to use managed Codex credentials.
Persistent provider API keys, saved OpenAI account JSON, and generated compatibility-proxy bearer tokens are stored in the macOS Keychain. ~/.codex/model-switcher.json stores app metadata and sanitized provider/account records; legacy plaintext fields are read only to migrate them into Keychain and then persist sanitized data.
Codex itself can still require credential material in files it reads at runtime. Non-proxy providers write env_key entries into ~/.codex/config.toml, and stored API keys for non-proxy providers are exported through ~/.codex/model-switcher.env so those env keys can resolve. The app adds a source line to ~/.zprofile and exports the selected non-proxy key with launchctl setenv for GUI-launched Codex processes. OpenAI account selection can write ~/.codex/auth.json. The app sets credential-bearing Codex files to 0600, sets managed Codex directories to 0700, and avoids persistent backup files, but users should still treat the entire ~/.codex tree as sensitive.
Credential export is an explicit, warning-gated action. It writes a user-selected plaintext JSON file with owner-only permissions and includes provider API keys, local proxy tokens, and saved OpenAI auth JSON so users can back up or inspect exactly what the app manages. Credential purge is also warning-gated and removes app data, app-managed Keychain secrets, managed Codex runtime files, managed config blocks, matching managed config selection keys, app-managed ~/.codex/auth.json when the current file matches the selected saved OpenAI account's hydrated auth JSON, the managed shell profile source line, and the selected app-managed non-proxy provider's launch environment export while leaving unrelated user config intact. If the managed provider block in config.toml is malformed, purge stops before deleting app data or secrets so the user can close or remove the block explicitly.
Saved OpenAI account JWT payloads are decoded locally without signature, issuer, or audience validation. The decoded ID-token email is only an account display hint. The decoded access-token exp is only a local refresh/status hint used to avoid refreshing credentials that appear fresh; OpenAI and Codex still accept or reject the actual credential when it is used.
The compatibility proxy is opt-in per provider with the "Use compatibility proxy" setting. Turn it off by editing the provider, disabling that setting, and saving; if that provider is selected, the app rewrites the managed Codex config for direct provider use. When enabled for a provider, that provider's managed Codex config receives a generated local proxy token for http://127.0.0.1:48117/... instead of its upstream provider API key. The upstream provider API key remains in app runtime state and is sent only to the configured upstream provider.
The proxy listener is constrained to loopback. Proxied provider requests require the generated bearer token; unauthenticated GET /health only reports local proxy health and does not forward upstream. The proxy rejects unsafe request framing and oversized input, requires https:// upstream provider URLs in normal flows, and redacts upstream provider response bodies or stream failures before returning local errors. The generated local proxy URL is the only normal plaintext HTTP endpoint.
Proxy diagnostics are local in-memory state, not a persisted log. They are intentionally limited to redacted request parsing, routing, and upstream failure metadata so troubleshooting can identify the failing provider/path/status without storing credentials or prompt/response content.
Current limitations: .zprofile integration is automatic but reversible, OpenAI account freshness is not a live server-side validation while the local access-token expiration hint appears fresh, exported credential archives are plaintext by design, and local same-user processes may still observe environment variables or readable Codex runtime files according to normal macOS account permissions.
Provider Status
Codex now uses OpenAI's Responses API for custom providers and deprecated wire_api = "chat". For providers that only support Chat Completions, enable the built-in compatibility proxy to translate Codex /responses calls to /chat/completions. Request conversion preserves text messages, function calls, string or text-array function-call outputs, function tool_choice, and strict function tool settings. Unsupported non-text Responses input or tool-output parts such as images and files are rejected before forwarding instead of being silently dropped. Non-streaming response conversion preserves visible content arrays, sanitized compatible output_text annotations, tool calls, token counts, cached/reasoning usage details, Chat refusal text, and incomplete responses for Chat length or content_filter finish reasons while ignoring unmapped provider-specific root fields; provider reasoning fields such as reasoning_content are not converted into visible output_text when no visible Chat content or refusal exists. Successful JSON responses are written downstream in bounded chunks instead of materializing a second full response body. Streaming Responses requests are forwarded as upstream Chat Completions streams and returned as local Responses SSE events for text/refusal deltas, sanitized compatible annotations present on streamed Chat text/refusal content, function-call argument deltas/done events, response.incomplete terminal events for Chat length and content_filter, and usage details from empty-choice usage chunks. Proxy requests must include the generated local bearer token that the app writes into the managed Codex provider config.
Development
Use the Forgejo remote as the integration target:
jj git remote list
The Forgejo remote is [email protected]:wmc/CodexModelSwitcher.git; origin tracks the upstream GitHub repository.
Start each slice from the tracked Forgejo main bookmark and use a short codex/<slice-name> topic bookmark:
jj git fetch --remote forgejo
jj new main@forgejo
jj bookmark create codex/<slice-name>
Keep TODO.md current while working. Move completed tasks to "Done", update "In Progress" with the next pending item, and add newly discovered follow-ups before opening a PR. Update this README when behavior, setup, security posture, or workflow changes.
Run the local verification script before opening or merging code PRs:
scripts/verify.sh
git diff --check
jj status --no-pager
The script defaults to Xcode beta at /Applications/Xcode-beta.app/Contents/Developer when DEVELOPER_DIR is not already set. It runs strict swift-format lint, the Debug test suite, a Release build, and Debug static analysis with CODE_SIGNING_ALLOWED=NO. Override PROJECT, SCHEME, DESTINATION, DERIVED_DATA_ROOT, or DEVELOPER_DIR in the environment when needed.
The repository keeps the shared Xcode scheme under CodexModelSwitcher.xcodeproj/xcshareddata/ but ignores user-local Xcode state such as xcuserdata, user breakpoints, .xcuserstate files, and generated build outputs.
The Xcode project uses filesystem-synchronized source groups for CodexModelSwitcher/ and CodexModelSwitcherTests/. Add new Swift source files directly under those directories and verify with Xcode beta instead of hand-editing PBXSourcesBuildPhase entries for normal source splits.
Socket-level proxy tests should not bind the production compatibility-proxy port. Instantiate CompatibilityProxyServer(listenerPort: .any), wait for .active, and connect test clients to server.listeningPort so tests can run beside a running app or other test process.
Use the Xcode-bundled swift-format for Swift formatting. Do not add SwiftLint or third-party SwiftFormat as a required dependency unless the repo also adds checked-in configuration and a dedicated normalization PR.
For routine edits, keep the surrounding style and format touched Swift files only:
DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer \
xcrun swift-format format -i CodexModelSwitcher/Example.swift
The required whole-repo lint command is:
DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer \
xcrun swift-format lint --strict --recursive --parallel CodexModelSwitcher CodexModelSwitcherTests
The codebase is normalized to Xcode swift-format defaults, and scripts/verify.sh runs strict whole-repo lint before the Xcode build and test steps. Avoid whole-repo in-place formatting inside behavior PRs; keep formatting churn in dedicated formatting-only PRs so functional diffs stay reviewable.
Commit with the project author identity, push the topic bookmark, and open a Forgejo pull request against main:
jj metaedit --author 'Sebastian B <[email protected]>' -m '<message>'
jj git push --remote forgejo --bookmark codex/<slice-name>
tea api --login forgejo -X POST repos/wmc/CodexModelSwitcher/pulls \
-f title='<title>' \
-f head='codex/<slice-name>' \
-f base='main' \
-f body='<summary and verification>'
Use subagent review before merging code changes and workflow-defining documentation changes. After approval, merge through Forgejo, refresh the local bookmark, and leave the checkout on a clean empty child of main@forgejo:
tea api --login forgejo -X POST repos/wmc/CodexModelSwitcher/pulls/<number>/merge -f Do=merge
jj git fetch --remote forgejo
jj new main@forgejo
jj bookmark delete codex/<slice-name>
jj git push --remote forgejo --deleted
jj status --no-pager
Release Checklist
The fork uses the sh.wmc.CodexModelSwitcher app bundle identifier and sh.wmc.CodexModelSwitcherTests test bundle identifier. The Xcode project intentionally does not pin the inherited upstream Apple development team. Before distributing a signed build, set the release Apple Team ID in Xcode or a release-specific build setting, then run the full verification and archive/notarization flow with that signing identity.
Before shipping a tagged build, verify the bundled default model IDs in AppStore.defaultData() against the current official Codex models and OpenAI API models pages. As of 2026-06-23, the bundled OpenAI defaults gpt-5.5 and gpt-5.4-mini, with gpt-5.5 selected by default, match the current OpenAI guidance. Re-check these values for every release because model availability, deprecations, and recommended defaults change over time.
Also verify third-party bundled example IDs, such as the OpenRouter defaults, against that provider's current model catalog before tagging. If any bundled default is unavailable, deprecated, account-gated in a way that would surprise users, or no longer suitable for Codex usage through the Responses API, replace it before the release PR.
License
MIT License. See LICENSE.
