Every other guide on this site routes Claude Code to another backend. shunt can also run the opposite direction: an opt-in raw OpenAI Responses passthrough that lets the Codex CLI point its own base_url at shunt and be load-balanced across a ChatGPT/Codex OAuth account pool. It is opt-in: when [server.codex_endpoint] is absent, none of those routes are registered and shunt’s default HTTP surface is unchanged.
This builds on the same account pool as Codex Multi-Account — selection, cooldowns, and refresh are shared unchanged. See the M11 behavior specification for the full spec, including the exact failover table and reload semantics.
For the end-to-end setup walkthrough — enabling the endpoint, pointing the Codex CLI at shunt, client auth, account provisioning, and picking an entitled model — follow Connect the Codex CLI. This page focuses on what the endpoint does; that guide is the how to connect checklist.
Enable the endpoint
[server.codex_endpoint] # all keys optional; default shown
provider = "codex" # must be a chatgpt_oauth providershunt check
shunt runStartup validation rejects an unknown provider or one that doesn’t use auth = "chatgpt_oauth" — the endpoint injects the operator’s Codex bearer, so only a chatgpt_oauth provider qualifies. See the configuration reference for every key and default, and HTTP Endpoints for the registered routes.
The opt-in also makes Codex CLI model discovery parseable. GET /models and GET /backend-api/codex/models return the valid fallback {"models":[]}. On the shared GET /v1/models path, a client_version query field selects that Codex shape even when Anthropic-looking headers are present; without it, the existing Anthropic discovery response is unchanged. These requests pass the normal model-discovery auth gate, and shunt deliberately avoids fabricating incomplete Codex model rows.
Web search
The Codex CLI’s built-in web search tool (web.run) posts to {base_url}/alpha/search — the same base URL it appends /responses to. shunt registers that path for each base-URL form:
POST /backend-api/codex/alpha/searchPOST /alpha/searchPOST /v1/alpha/search
A search uses the same [server.auth] policy and the same ChatGPT/Codex account pool (session-sticky selection, failover, refresh) as a Responses turn, and relays the request body and the reply verbatim to the backend’s /codex/alpha/search. It always goes to [server.codex_endpoint].provider: a search is not a model turn, so [[server.codex_endpoint.routes]] does not apply. The routes accept POST only — the tool has no WebSocket form.
Client analytics sink
The Codex CLI also posts product analytics to the base URL. shunt accepts both paths the CLI can produce:
POST /backend-api/codex/analytics-events/eventsPOST /codex/analytics-events/events
These routes use the same [server.auth] policy as the Responses routes but never forward telemetry upstream, because choosing one pooled account would misattribute the client event to that account. They always return 200 {} after authentication, including for malformed, unreadable, or oversized bodies.
The payload and event properties are neither logged nor exported. shunt records only the sanitized event_type as the event attribute on the opt-in shunt.codex_client_events counter: names may contain lowercase ASCII letters, digits, ., _, and -, up to 64 bytes; invalid names become other, and unrecognized batches become unparsed. Without Sentry or OpenTelemetry metrics enabled, this is a pure discard sink.
Point the Codex CLI at shunt
The Codex CLI always appends /responses to whatever base URL it uses, so either ~/.codex/config.toml shape works:
Mirror the ChatGPT backend’s base URL:
chatgpt_base_url = "http://127.0.0.1:3001/backend-api/codex"Or a custom model provider (the top-level model_provider must select it, or the CLI keeps its built-in provider):
model_provider = "shunt"
[model_providers.shunt]
base_url = "http://127.0.0.1:3001/v1"
wire_api = "responses"With the custom provider (add requires_openai_auth = false so the CLI needs no local login), the Codex CLI’s own ~/.codex/auth.json becomes irrelevant once pointed at shunt — the account comes from shunt’s pool on every request. The chatgpt_base_url shape instead keeps the CLI in ChatGPT-login mode, so it still needs its local login and works only against an ungated endpoint: its ChatGPT bearer is not the configured shunt token, so [server.auth] rejects it.
Client authentication
If shunt has [server.auth] configured — recommended for anything beyond loopback — present the client token either as an OpenAI-style Bearer key (OPENAI_API_KEY / a custom provider’s env_key, the LiteLLM/llmgateway idiom) or as the x-shunt-token header:
# A. Bearer — built-in openai provider. Set the base URL in ~/.codex/config.toml,
# NOT via the OPENAI_BASE_URL env var: the env var leaves the CLI's Responses
# WebSocket pointed at wss://api.openai.com, so it bypasses shunt. See
# "Point the Codex CLI at shunt" in the connect guide.
openai_base_url = "http://127.0.0.1:3001/v1"export OPENAI_API_KEY="<shunt-token>" # sent as Authorization: Bearer# B. Header — a custom provider carries it (use env_http_headers to keep it out of the file):
[model_providers.shunt]
base_url = "http://127.0.0.1:3001/v1"
wire_api = "responses"
http_headers = { "x-shunt-token" = "<token>" }Without [server.auth], the endpoint is open to anyone who can reach it — acceptable for loopback or personal use, not for a shared gateway. The client’s presented credential is used only to authenticate to shunt: it (and any Authorization the CLI happens to send) is stripped and never forwarded upstream. The [server.admin] credential header — x-shunt-admin-token by default, or whatever [server.admin] header names — is stripped too, since the admin surface authenticates on that slot and an admin credential can provision upstream accounts. So is the whole cookie header, because the admin surface also accepts a session cookie there; shunt keeps no cookie jar, so nothing upstream depends on it. x-api-key is stripped unconditionally too — even when [server.auth] is not configured — since the target provider is validated chatgpt_oauth-only at boot, so no inbound x-api-key value can ever be a valid upstream credential; a client whose apiKeyHelper sets both Authorization and x-api-key to the same key (as Claude Code’s does) does not leak that key through the second slot. Because the inbound client is a real Codex CLI, the passthrough forwards its request headers verbatim (version, originator, OpenAI-Beta, x-codex-*, …) and swaps in only the selected pool account’s Authorization bearer + chatgpt-account-id. See Connect the Codex CLI for the full auth walkthrough.
WebSocket transport
The three Responses paths accept both HTTP POST and authenticated WebSocket GET upgrades. Authentication completes before 101 Switching Protocols. On a socket, generate: false warmups complete locally; live response.create frames reuse the HTTP account pool, force upstream streaming, and forward each SSE data: payload as a WebSocket text frame through the first terminal event. Replacing a turn or closing the socket cancels the active upstream body. Client frames and SSE events are limited to 4 MiB, sends apply bounded backpressure, and protocol/upstream failures use standalone type: "error" frames with only safe response metadata.
Without [server.auth], browser upgrades are checked by comparing Origin with Host, and that check cannot distinguish http from https on the same host, so gate the endpoint with [server.auth] for anything beyond loopback.
Account provisioning
Reuses the same pool as Codex Multi-Account:
codex login
shunt login codex --name main[[providers.codex.accounts]]
name = "main"With no [[providers.codex.accounts]] configured and an empty shunt account store, the endpoint falls back to the single default ~/.codex/auth.json credential — no pooling, no failover — so a single Codex login works the moment [server.codex_endpoint] is set. (The handler first scans the account store and pools any accounts it discovers, so imported store accounts still enable pooling.)
Route models to other upstreams
By default every request goes to the one provider named in [server.codex_endpoint]. An optional [[server.codex_endpoint.routes]] table lets the Codex CLI pick a different Responses-compatible upstream by model id; every model with no route keeps the fixed-provider behavior.
Several vendors document a native Responses endpoint for the Codex CLI: Z.ai GLM (https://api.z.ai/api/v1), DeepSeek (https://api.deepseek.com), Kimi Code (https://api.kimi.com/coding/v1), MiniMax (https://api.minimax.io/v1), Mimo (https://api.xiaomimimo.com/v1), OpenRouter (https://openrouter.ai/api/v1), Vercel AI Gateway (https://ai-gateway.vercel.sh/codex/v1), and stock OpenAI. shunt appends /responses to a provider’s base_url, so give it the same base URL the vendor documents for Codex. The upstream must implement the Responses API natively — there is no Responses → Chat Completions adapter.
[providers.glm]
kind = "responses"
auth = "api_key"
api_key_env = "GLM_API_KEY"
base_url = "https://api.z.ai/api/v1"
[providers.deepseek]
kind = "responses"
auth = "api_key"
api_key_env = "DEEPSEEK_API_KEY"
base_url = "https://api.deepseek.com"
[server.codex_endpoint]
provider = "codex"
[[server.codex_endpoint.routes]]
model = "glm-5.3"
provider = "glm"
[[server.codex_endpoint.routes]]
model = "deepseek-v4-flash"
provider = "deepseek"upstream_model is optional and defaults to model; set it when the id the CLI types differs from the id the vendor serves. The routed provider must carry a real credential — a credential-free auth mode (passthrough or none) is rejected at boot, since the client’s own Authorization is always stripped. shunt’s built-in kimi preset is kind = "anthropic", so a Codex route to Kimi Code needs a separate kind = "responses" provider — routing a Codex model at the Anthropic-kind preset is rejected at boot.
On the CLI side, point Codex at shunt and select the route by model:
# ~/.codex/config.toml
model = "glm-5.3"
model_provider = "shunt"
model_catalog_json = "~/.codex/models.json"
[model_providers.shunt]
base_url = "http://127.0.0.1:3001/v1"
wire_api = "responses"
env_key = "SHUNT_TOKEN"While shunt responds to Codex CLI discovery requests with the valid fallback {"models":[]}, it does not advertise Codex routes in its model list. The CLI gets slug metadata from a ~/.codex/models.json catalog referenced by model_catalog_json, exactly as these vendors document; only the model value selects the shunt route.
What changes for a routed request to a non-ChatGPT upstream:
- Header allowlist. The fresh allowlist initially takes only
content-type(defaulted toapplication/json) andacceptfrom the client, then adds the resolved credential and the headers the routed upstream itself requires:OpenAI-Beta: responses=experimental(skipped for xAI/Grok), the Grok-CLI identity header set for anxai_oauthroute, and, when anapi_keyroute targets exactlyapi.openai.comand has a nonempty resolved conversation id, four regenerated session-affinity headers (session-id,thread-id,x-client-request-id, andx-codex-window-id). Client-suppliedauthorization,x-api-key,chatgpt-account-id,originator,version,user-agent,session-id,thread-id,x-client-request-id,x-codex-*, andx-shunt-*remain stripped; the stock-OpenAI values are generated, never forwarded. xAI and every other third-party OpenAI-compatible host receive no session-affinity headers. The API-key path does not synthesizeaccept: text/event-stream. - Body
modelrewrite. Whenupstream_modeldiffers from the requested model, shunt rewrites the top-levelmodeland leaves every other field intact. A body that is not a JSON object is rejected with a400rather than sent on. - Identity encoding. A zstd request body is decoded first — a stock Responses API does not accept that encoding — and
content-encodingis not forwarded. - One credential, no failover. A routed third party has no failover pool, so a 429 or 5xx relays verbatim with its
retry-afterinstead of triggering rotation. The one pool-aware exception is anxai_oauthroute with named accounts: it still sends the request once (an upstream failure is relayed, not rotated), but the credential is the first account in pool selection order whose login loads (the provider’saccount/accountsscope honored), and an account whose login fails to load is cooled down and skipped; with no named accounts it is~/.shunt/xai-auth.json.
Matching is exact and case-sensitive with no charset restriction, so vendor slugs like MiniMax-M3, openai/gpt-5.6-sol, and ~openai/gpt-latest route as written. A route to another chatgpt_oauth provider instead keeps the full pool passthrough. Routes are read from the live config, so they take effect on reload.
Spend limits
With [server.spend] configured, the endpoint checks and meters the same per-principal caps as /v1/messages; without it nothing here applies. See Gateway spend limits for the full behavior.
- Checked first. Every turn is checked before any upstream call, on the HTTP
POSTof all three Responses paths and on the Responses WebSocket. The principal is the static[server.auth]token name, the verified JWT email, or the anonymous principal when[server.auth]is off. This endpoint does not accept gateway-login sessions. - Refusal. An over-cap turn gets
429with{"error":{"message":…,"type":"usage_limit_reached","code":"usage_limit_reached","resets_at":<epoch seconds>}}, plusretry-after,x-should-retry: falseandx-codex-promo-message: <blocked_message>. The promo header is sent only whenblocked_messageis plain ASCII; the text always rides in the message. The named cap is the reached one that resets last. Withfail_closed_on_error, a capped principal whose spend state is unavailable is refused with the same type, noresets_atand noretry-after. - What the Codex CLI shows. At the pinned version
rust-v0.159.3, the Codex CLI does not retry this error. It renders its own “You’ve hit your usage limit … Try again at” text fromresets_at, with the promo message when the header is present, and ignores the server message. That is Codex CLI behavior, so another version can differ. - WebSocket. A refused
response.creategets one{"type":"error","status":429,"error":{…},"headers":{…}}frame carrying the same error plusretry-afterandx-codex-promo-message. The socket stays open, each later turn is checked afresh, andgenerate: falsewarmups are not checked. - Web search.
/alpha/searchand its aliases are refused when over cap, and are never metered. - Metering. A completed turn is billed from the Responses
usageinto the counters/v1/messageswrites, so one cap bounds both surfaces. Cached tokens bill at the cache-read rate and the rest of the input at the input rate; reasoning is inside the output and there is no cache write. A turn that ends without reported usage (a client close, a replaced turn, a broken stream) is billed a floor: the input estimated from the request body plus the output already delivered. A non-streamed 2xx JSON body is billed from itsusagethe same way; one that reports no input bills the estimated input instead. - GPT pricing. The built-in catalog has no GPT or Codex prices, so those models bill at the unknown-model rate (
$5 / $25 / $0.50 / $6.25per million tokens, with one warning per model) until you add a row:
[[server.spend.pricing.overrides]]
upstream = "codex" # the endpoint's provider
model = "gpt-6.1-sol"
input = 1.00 # placeholder rates, USD per million tokens
output = 8.00
cache_read = 0.10
cache_write = 1.00What’s different from /v1/messages
- No translation. The inbound Responses body is forwarded upstream byte-for-byte, and the upstream response — SSE or JSON, success or error — is relayed back verbatim (status and
content-typepreserved). There is no Anthropic Messages ⇄ Responses translation step at all. - Compressed request bodies pass through. Current Codex releases zstd-compress the request body when they talk to the ChatGPT backend, which includes the
chatgpt_base_urlshape pointed at this endpoint. The bytes and theircontent-encoding: zstdheader are forwarded unchanged; shunt additionally decodes a copy in-memory only to read the request’smodelfor its metrics, logs, and spans. A body shunt cannot decode still relays fine — only themodellabel degrades tounknown, with a warning naming the reason. - Model-based routing is opt-in. By default every request goes to the one provider named in
[server.codex_endpoint]and the body’smodelforwards through as-is. With[[server.codex_endpoint.routes]], an exactly matchingmodelselects that entry’s provider instead — see Route models to other upstreams. - Exhaustion relays verbatim. If every pooled account is tried and at least one upstream response came back, shunt relays that last response unchanged rather than re-shaping it into an Anthropic-style error, since a Responses client expects the raw shape it would have gotten from the real ChatGPT backend.
- Gateway-owned errors are OpenAI-shaped. When the failure is shunt’s own — a bad or missing client token (
401), an unresolvable pool with no upstream response (502), an oversized request body, or an unconfigured endpoint — shunt returns it in the OpenAI Responses error shape ({"error":{"message":…,"type":…,"code":null}}) with the same status code, so the Codex CLI parses it through its own error path instead of the Anthropic{"type":"error",…}envelope. Relayed upstream errors (429/4xx/5xx from the backend) still pass through verbatim. - Two inbound transports. HTTP
POSTremains byte-faithful; WebSocketGETadds bounded event delivery and does not depend on the provider’s outboundwebsocket = truesetting.
Security
- Gate this endpoint with
[server.auth]on anything beyond loopback — the provider injects a real Codex bearer on every request. - Nothing about the client’s own credential reaches the Codex backend; the passthrough forwards the Codex CLI’s own request headers verbatim and swaps in only the selected pool account’s bearer +
chatgpt-account-id(the shunt client-token header, the[server.admin]credential header, the wholecookieheader, the internalx-shunt-inbound-clientlabel, the client’sAuthorization/chatgpt-account-id, andx-api-keyare all stripped, never forwarded). - Only the endpoint’s HTTP route registration is decided once at boot: toggling
[server.codex_endpoint]on or off at runtime logs a warning that a restart is required to add or drop those paths. Everything the table contains hot-reloads — the targetproviderand the whole[[server.codex_endpoint.routes]]model table are read from the live config on every request, so adding, editing, or removing a route takes effect on reload.