How to route Claude Code inference to your ChatGPT / Codex subscription through shunt,
reusing the credential the Codex CLI already wrote to ~/.codex/auth.json. No API key, no
per-token billing — the request is authenticated as your ChatGPT account and answered by the
same backend the codex CLI talks to.
This page consolidates every Codex-specific knob. For the broader gateway workflow see
running.md; for the credential-acquisition spec see
m2-chatgpt-oauth.md.
- 1. How it fits together
- 2. Prerequisites
- 3. The
[providers.codex]block - 4. Authentication (
~/.codex/auth.json) - 5. Model slugs
- 6. Routing a model to Codex
- 7. Selecting the model in Claude Code
- 8. Reasoning effort
- 9. Fast mode (
service_tier) - 10. Context window & usage display
- 11.
count_tokensbehavior - 12. Attribution header
- 13. Multi-account pooling
- 14. Security
- 15. Troubleshooting
- 16. End-to-end example
- 17. Inbound Codex endpoint (point the Codex CLI at shunt)
The codex provider is a built-in kind = "responses" provider: shunt translates Claude
Code's Anthropic Messages request into the OpenAI Responses API shape, then sends it to the
ChatGPT-account Codex backend and translates the streamed response back. Three things make it
"Codex" rather than plain OpenAI:
| Aspect | Value | Source |
|---|---|---|
| Upstream endpoint | <base_url>/codex/responses |
src/adapters/responses/request.rs |
| Auth | ChatGPT OAuth from ~/.codex/auth.json (auto-refreshed) |
src/auth/codex_auth.rs |
| Responses dialect | ResponsesFlavor::Chatgpt — drops params codex never sends |
src/config.rs, src/model/responses_request.rs |
The Chatgpt flavor is detected from auth = "chatgpt_oauth" (not the provider name), so the
per-backend quirks apply to any provider that uses that auth mode. Notably the ChatGPT backend
rejects max_output_tokens ("Unsupported parameter: 'max_output_tokens'"), so shunt drops
it for this flavor only; it also sends store: false and round-trips the encrypted reasoning
blob so chain-of-thought survives across turns.
Log in once with the Codex CLI. shunt reads and refreshes the file it writes — it does not initiate its own login for Codex:
codex loginThis creates ~/.codex/auth.json. If that file is missing, has no tokens, or the refresh token
is gone, shunt returns an authentication_error whose message tells you to run codex login
again.
The codex provider is built in — you do not need to declare it at all. This is the full
default; every key shown is the value shunt uses when you omit it:
[providers.codex]
kind = "responses" # translate Anthropic Messages <-> OpenAI Responses
base_url = "https://chatgpt.com/backend-api" # shunt appends /codex/responses
auth = "chatgpt_oauth" # read + auto-refresh ~/.codex/auth.json
# api_key_header = "bearer" # unused for chatgpt_oauth (bearer is implicit)
# effort = "high" # optional default reasoning effort (see §8)
# count_tokens = "tiktoken" # default; "estimate" opts out (see §11)
# request_compression = true # default; false sends uncompressed bodies (see §4.5)A partial [providers.codex] table overrides only the keys it sets — the built-in defaults
fill the rest. Practical uses:
- Pin a default effort for everything routed to Codex:
effort = "high". - Opt out of local token counting:
count_tokens = "estimate"(see §11). - Point at a different backend host (rare): change
base_url. shunt still appends/codex/responsesand still sends the ChatGPT OAuth headers, so the host must be the ChatGPT Codex backend.
Keys that do not apply to chatgpt_oauth: api_key_env and api_key_header are for
auth = "api_key" providers only. The credential comes from the auth file, not the environment.
Two accounts, one CLI: if
~/.codex/auth.jsonis inApiKeymode (you logged in with an OpenAI API key rather than a ChatGPT account), thecodexprovider's OAuth path finds no tokens and errors. That API key is instead picked up by theopenaiprovider as a fallback whenOPENAI_API_KEYis unset (src/auth/mod.rs). Thecodexprovider is specifically the ChatGPT-subscription path.
shunt resolves the auth-file path in this order (default_codex_auth_path, src/auth/mod.rs):
$CODEX_AUTH_FILEif set — point shunt at a non-standard location (CI, a sandbox, a second account).$HOME/.codex/auth.json..codex/auth.jsonrelative to the working directory (last-resort fallback).
# Example: run shunt against an auth file outside the home directory
export CODEX_AUTH_FILE=/etc/shunt/codex-auth.jsonshunt reads (and, on refresh, rewrites) this JSON, written by codex login:
auth_modespelling — the Codex CLI serializes itsAuthModeenum withrename_all = "lowercase", so a currentcodex loginwrites"chatgpt"and an API-key login (printenv OPENAI_API_KEY | codex login --with-api-key) writes"apikey". Older Codex versions wrote"ChatGPT"/"ApiKey". shunt compares this field case-insensitively everywhere it reads it (import_auth,read_openai_api_key, credential observation), so either spelling is accepted.- Account id — shunt prefers
tokens.account_id; if absent it decodes theaccess_tokenJWT payload and reads["https://api.openai.com/auth"].chatgpt_account_id. If neither exists the request fails withChatGPT account id missing; run codex login. - Expiry — there is no
expires_atfield. shunt reads theexpclaim from theaccess_tokenJWT and treats the token as expired 5 minutes early (EXPIRY_BUFFER), so a request in flight never races the expiry.
On every routed request (get_valid_chatgpt, src/auth/codex_auth.rs):
- Read fresh from the auth file — the Codex CLI may have refreshed it under shunt, so re-reading avoids a redundant refresh and a clobber.
- If the access token is valid (
now < exp − 5min), use it as-is. - Otherwise re-read once more (a concurrent
codexprocess may have just refreshed it), and only if still expired, refresh:POST https://auth.openai.com/oauth/token, form-encoded:grant_type=refresh_token,refresh_token=<current>,client_id=app_EMoamEEZ73f0CkXaXp7hrann.- The response yields a new
access_token, and possibly a rotatedrefresh_tokenandid_token.
- Write back atomically: re-read the file, update
tokens.{access_token, refresh_token, id_token}, recomputeaccount_idfrom the new access token, setlast_refresh, and preserve every other field (auth_mode,OPENAI_API_KEY, and any unknown keys the Codex CLI owns). The write goes to a private temp file (0600, created exclusively) and is renamed into place, so tokens are never briefly world-readable.
You don't configure any of this — it is automatic. The only knob is the file path (§4.1).
For a Codex request shunt sends the Codex-CLI identity so client-version gating (§5) passes
(src/adapters/responses/request.rs):
| Header | Value |
|---|---|
authorization |
Bearer <access_token> |
chatgpt-account-id |
<account_id> |
originator |
codex_cli_rs |
user-agent |
codex_cli_rs/0.159.3 (CODEX_USER_AGENT) |
version |
0.159.3 (CODEX_CLIENT_VERSION) |
x-codex-routing-hint |
model=<upstream_model>, or model=<upstream_model>;tier=<service_tier> when a tier is set — omitted when the model can't be safely put in a header (see below) |
OpenAI-Beta |
responses=experimental |
content-type |
application/json |
content-encoding |
zstd — only when the request body was compressed (see §4.5) |
shunt pins user-agent and version to openai/codex rust-v0.159.3. If a future slug
demands a newer client, bump CODEX_USER_AGENT / CODEX_CLIENT_VERSION in
src/adapters/responses/request.rs.
On an entitled account, gpt-6.1-sol was rejected with the 0.156.0 identity and completed with 0.159.2, even though its catalog minimal_client_version is 0.153.0. 0.159.3 is the next upstream patch release.
A stale identity can produce a model-not-supported error even when the account has access.
This pin does not specify the minimum required version.
The identity headers — chatgpt-account-id, originator, user-agent, version, and
x-codex-routing-hint — are sent only on the ChatGPT OAuth arm; an API-key (or any other)
credential on a responses provider gets the bearer alone. One exception: an API-key request whose
provider host is exactly api.openai.com also carries regenerated session-id, thread-id,
x-client-request-id, and x-codex-window-id when a conversation id resolves; on a routed request
the caller's own identity headers are stripped first (see §17.5). OpenAI-Beta is gated on the
provider flavor instead (withheld for xAI/Grok, sent otherwise), and content-type is always sent.
x-codex-routing-hint mirrors openai/codex's X_CODEX_ROUTING_HINT_HEADER
(build_routing_hint_header, codex-rs/core/src/client.rs), which upstream likewise suppresses for
api-key/bearer/aws providers. The ;tier= segment appears only when the same predicate that
governs the request body's service_tier holds — a tier is configured and it isn't the
client-only "default" sentinel (§9). The websocket transport (m7-codex-websocket.md) sends it
too, as a handshake header; because shunt pools and reuses those connections, a reused socket
carries the hint of the turn that opened it. That matches upstream, which also builds the header
only at connection time — and it is a routing hint, not a routing decision.
The hint is omitted, not an error, when it cannot be built safely. Unlike upstream codex —
which builds the hint from its own local config — shunt's <upstream_model> is client-controlled
on a prefix route or via default_provider, where the request's raw model string is passed
through unchanged. So shunt sends the header only when the model is a plausible slug: non-empty,
at most 128 bytes, and made up solely of ASCII letters/digits and - _ . : / +. Anything
else — a ;, a ,, whitespace, a control character — drops the header and the request still goes
out. This is an allowlist rather than a list of banned separators on purpose: shunt does not own
the backend's parser for this header, so a parser splitting on , (the standard HTTP list
separator) would read model=gpt-5,tier=priority as two fields, and enumerating separators against
a grammar this repo cannot observe is the guard that erodes. Failing by omission also keeps a
malformed client model from becoming an upstream send failure, which the Codex account pool would
otherwise charge to the account as a transport cooldown. Each omission logs a debug-level line
carrying the reason and the model's length — never the model string itself.
That honesty property covers the headers shunt builds. On the inbound Codex passthrough
(§17) the caller's own x-codex-routing-hint is relayed verbatim like the rest of its Codex-CLI
headers, and its body is the caller's too — so there is no hint/body divergence for shunt to police
there.
On the ChatGPT/Codex flavor shunt zstd-compresses the Responses request body at level 3 and
announces it with content-encoding: zstd — the same wire shape the Codex CLI itself sends
(codex-rs/http-client/src/request.rs; issue #285). A long agentic turn re-uploads its whole
history on every request, so this is where the bytes are: the win grows with the conversation.
Gating mirrors codex's own two conditions (responses_request_compression: the ChatGPT backend
plus ChatGPT OAuth rather than an API key) as a single flavor check, and adds a per-provider
opt-out:
| Condition | Effect |
|---|---|
Chatgpt flavor (auth = "chatgpt_oauth") |
compressed |
| any other flavor (stock OpenAI, xAI, Grok) | uncompressed — none is verified to accept a compressed request body, and one that rejects it would fail the whole turn |
request_compression = false on the provider |
uncompressed |
| body under 1 KiB | uncompressed — the frame overhead can exceed the saving, and the backend takes either form |
The body is prepared once per turn (prepare_body in src/adapters/responses/body.rs; same
discipline as the serialize-once fix in issue #251), so a retry, an account rotation, or a 401
refresh-retry reuses the same bytes instead of re-serializing and re-compressing them. The
single-credential path prepares it once, up front, before its bounded-retry loop; the account-pool
path has no fixed point before its rotation loop, so it prepares the body lazily on first dispatch
and memoizes it for every later attempt. Compression always runs on Tokio's blocking pool under
bounded admission rather than on the async executor: a fresh zstd level-3 encoder costs ~90 µs to
build regardless of body size, so even a 1 KiB body would consume Tokio's whole ~100 µs
blocking-work budget inline, while offloading adds only ~40 µs of latency once per turn (measured —
see compress_request_body). A compression failure is not fatal: it is logged and the
uncompressed body is sent, which the backend accepts.
The websocket transport (§ m7-codex-websocket.md) is unaffected: it sends uncompressed frames, so
request compression does not apply there.
The ChatGPT-account Codex backend only accepts the slugs your account is currently entitled
to, and rejects the gpt-*-codex slugs (e.g. gpt-5.2-codex) with a 400
"The 'X' model is not supported when using Codex with a ChatGPT account." Do not invent a
-codex model id.
- The authoritative catalog of Codex slugs (and the reasoning levels each accepts) is openai/codex's
codex-rs/models-manager/models.json. - Current listed slugs:
gpt-6-astra,gpt-6-sol,gpt-6-luna,gpt-6.1-sol,gpt-6.1-terra(latest),gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna(frontier), andgpt-5.5/gpt-5.4. Older accounts may only be entitled to the earlier ones; a free account has resolved togpt-5.5in testing. - To see what your account can use, look at what the
codexCLI itself sends, or the live/modelsfetch it performs at startup.
Client-version gating. Some slugs carry a
minimal_client_version(e.g.gpt-6-astraneeds ≥ 0.153.0;gpt-6-solandgpt-6-lunaneed ≥ 0.155.0). When the request's client identity is missing or too old the backend answersModel not found <slug>— not an entitlement error. shunt avoids this by sending the pinned Codex CLI headers (§4.4). See openai/codex#31967.
shunt surfaces the backend's own detail message on error, so a wrong or unentitled slug returns
the real reason rather than a generic failure.
A request's model id selects the provider. Precedence: matching [models.upstream_model] entry
→ exact [[routes]] → [[route_prefixes]] → server.default_provider.
[[routes]]
model = "gpt-5.6-sol" # the id Claude Code sends (see §7)
provider = "codex"
# upstream_model = "gpt-5.6-sol" # optional: rewrite to a different slug upstream
# effort = "high" # optional: pin effort for this route (see §8)upstream_model lets the id Claude Code sends differ from the slug the backend receives — the
mechanism behind discovery aliases (§7) and a way to swap the real slug without touching your
Claude Code env.
Send every gpt-* id to Codex with one rule (note: the built-in example config points gpt- at
the openai provider — change it to codex if you want the subscription path):
[[route_prefixes]]
prefix = "gpt-5.6-"
provider = "codex"Claude Code's /model picker only honors discovery ids that begin with claude/anthropic, so
a raw gpt-* id needs one of two paths (or remap the tier aliases entirely — §7.4). They don't
overlap — the split is on the claude-/anthropic- prefix:
| What | claude-… discovery alias |
non-claude- id (e.g. gpt-5.6-sol) |
|---|---|---|
/v1/models discovery → /model picker |
✅ auto-listed ("From gateway"), many models | ❌ dropped by Claude Code |
ANTHROPIC_CUSTOM_MODEL_OPTION |
❌ not honored | ✅ adds to picker (one id only) |
CLAUDE_CODE_MAX_CONTEXT_TOKENS window (§10) |
❌ ignored → 200k default | ✅ applies → real window |
export ANTHROPIC_CUSTOM_MODEL_OPTION="gpt-5.6-sol"Adds a picker entry whose id skips validation; that id is exactly what shunt routes on, so it
must resolve through a matching [models.upstream_model] entry, [[routes]], or
[[route_prefixes]] rule. This is the recommended path — it's the only one that also lets you set
an accurate context window (§10).
Convenient when you want several Codex models auto-listed in the picker. Expose a claude--named
alias and rewrite it upstream:
[[models]]
id = "claude-gpt-5.6-sol-via-codex" # MUST begin with claude/anthropic
display_name = "GPT-5.6-Sol (via Codex)"
[[routes]]
model = "claude-gpt-5.6-sol-via-codex" # the alias Claude Code sends
provider = "codex"
upstream_model = "gpt-5.6-sol" # real slug forwarded to the ChatGPT backendexport CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 # Claude Code v2.1.129+The alias shows in /model as From gateway. Trade-off: because the alias begins with
claude-, CLAUDE_CODE_MAX_CONTEXT_TOKENS can't reach it, so its context bar stays pinned at the
200k default (safe, but under-sized). Use ANTHROPIC_CUSTOM_MODEL_OPTION when you need the real
window. Discovery also needs a gateway credential set (an API key / ANTHROPIC_AUTH_TOKEN /
apiKeyHelper) — a plain Max/Pro login alone won't trigger the /v1/models request. See
running.md §5.5 and m3-discovery.md.
You can run a subagent on a Codex slug while the main session stays on Claude. The model:
frontmatter field is the key: it accepts any string (unlike the Agent/Task tool's model
parameter, which only takes the built-in aliases opus/sonnet/haiku/fable).
Point an existing subagent at a Codex slug — edit its .claude/agents/<name>.md frontmatter
and set (or add) model:. For example, to move an existing researcher agent onto gpt-5.6-sol:
---
name: researcher
description: Deep research agent.
model: gpt-5.6-sol # was: sonnet (or absent → inherited)
---
<the agent's system prompt — unchanged>Then spawn it by its type without a model override — the tool parameter outranks frontmatter,
so passing one would shadow the slug. Resolution order:
CLAUDE_CODE_SUBAGENT_MODEL > Agent/Task tool model > frontmatter model: > inherit.
Force every subagent onto one Codex slug — set the env var (highest precedence, global):
export CLAUDE_CODE_SUBAGENT_MODEL="gpt-5.6-sol"Either way the id must resolve through a matching [models.upstream_model] entry, [[routes]], or
[[route_prefixes]] rule (§6) and, being non-claude-, obeys
CLAUDE_CODE_MAX_CONTEXT_TOKENS (§10) — the context
window follows the id automatically, so one global value sizes the mapped subagent while the Claude
main keeps its own.
The shunt-codex plugin ships ready-made subagents for
gpt-6-sol / -luna and gpt-5.6-sol / -terra / -luna (each pins its model: frontmatter
to the slug), so you can
@-mention a Codex model without authoring the agent files yourself.
Instead of adding one custom id, you can repoint Claude Code's built-in tier aliases at Codex slugs, so the whole session's tier system resolves to your ChatGPT subscription (model-config env vars):
| Env var | Controls |
|---|---|
ANTHROPIC_DEFAULT_HAIKU_MODEL |
what the haiku alias and the background/"small-fast" model resolve to |
ANTHROPIC_DEFAULT_SONNET_MODEL |
what the sonnet alias resolves to |
ANTHROPIC_DEFAULT_OPUS_MODEL |
what the opus alias resolves to |
ANTHROPIC_DEFAULT_FABLE_MODEL |
what the fable alias resolves to |
Example — the two-tier setup (haiku → gpt-5.6-luna, sonnet → gpt-5.6-sol):
export ANTHROPIC_DEFAULT_HAIKU_MODEL="gpt-5.6-luna"
export ANTHROPIC_DEFAULT_SONNET_MODEL="gpt-5.6-sol"# shunt.toml — both resolved ids must resolve through routing config
[[routes]]
model = "gpt-5.6-luna"
provider = "codex"
[[routes]]
model = "gpt-5.6-sol"
provider = "codex"Now selecting Sonnet in /model runs gpt-5.6-sol via Codex, and every background/haiku task
runs gpt-5.6-luna. The resolved id (gpt-5.6-sol / gpt-5.6-luna) is exactly what shunt routes
on — no ANTHROPIC_CUSTOM_MODEL_OPTION needed.
Nicer picker labels — the _NAME / _DESCRIPTION companions take effect on a gateway
(ANTHROPIC_BASE_URL → shunt), so the raw slug isn't shown as-is:
export ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="GPT-5.6-Sol"
export ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="ChatGPT/Codex Sol via shunt"
export ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="GPT-5.6-Luna"
export ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="ChatGPT/Codex Luna via shunt (background tier)"Things to get right:
- These ids don't start with
claude-, soCLAUDE_CODE_MAX_CONTEXT_TOKENS(§10) applies. They already send effort without a flag (§8). Handily,gpt-5.6-solandgpt-5.6-lunaare both 372k, so one globalCLAUDE_CODE_MAX_CONTEXT_TOKENS=372000fits both tiers. - The
_SUPPORTED_CAPABILITIEScompanion is documented for third-party providers (Bedrock, etc.), not confirmed for gateways — it isn't needed for the effort dial on shunt, since these ids already send effort (§8). - The haiku tier is Claude Code's background "small-fast" model (
ANTHROPIC_SMALL_FAST_MODEL, now deprecated in favor of this alias) — used for cheap, frequent work like conversation summaries, titles, and quick classification. Routing it to a full reasoning model like Luna is fine, but it spends your ChatGPT quota on that background traffic and can be slower; pick your cheapest entitled slug for the haiku tier if that matters. - This is global and session-wide. If an allowlist is in force (
availableModels/enforceAvailableModels), an alias can't be redirected to a model outside the list (Claude Code enforces this on the tier-alias env vars as of v2.1.176).
Claude Code's effort level (/effort, the /model slider, --effort, or
CLAUDE_CODE_EFFORT_LEVEL) is sent as output_config.effort, which shunt maps to the Responses
reasoning.effort:
| Claude Code effort | → reasoning.effort |
|---|---|
low / medium / high / xhigh |
passthrough |
max |
passthrough on slugs that accept it (the gpt-5.6 and gpt-6 families), else folded to xhigh |
max folds to xhigh unless the upstream slug contains gpt-5.6 or gpt-6 (supports_max_effort,
src/model/responses_request.rs), because models.json supported_reasoning_levels caps
gpt-5.5/5.4/5.2 at xhigh while the gpt-5.6 and gpt-6 families accept max.
Custom gateway ids carry effort on their own. Verified on the wire against Claude Code v2.1.224: an id like
gpt-5.6-solalready sendsoutput_config.effortwithCLAUDE_CODE_ALWAYS_ENABLE_EFFORTunset, and setting it to1produces a byte-identical request. The flag applies to non-first-party providers (Bedrock, Vertex, Foundry, gateway login).What does lose effort: ids on the client's legacy deny list (
claude-sonnet-4-5,claude-haiku-4-5) send nooutput_config.effortat all, no environment variable overrides that, and shunt falls back tomedium.
Precedence in shunt (effort(), src/model/responses_request.rs):
- A config
route.effort— or[providers.codex].effort— override wins. - Else the request's
output_config.effort. - Else
thinking.enabled→high. - Else a model-name suffix:
-xhigh/-high/-medium/-low(with-sparktreated as-low). - Else
medium.
So effort = "high" under [providers.codex] pins high effort for all Codex traffic regardless
of the Claude Code slider; drop it to let the client control effort per-turn.
Codex CLI's /fast toggle sends the Responses API top-level field service_tier: "priority" — a
sibling of reasoning, not a model or effort change. It is branded "Fast" (faster responses at
increased usage). shunt exposes the same knob as a config key, at both provider and route level,
exactly parallel to effort (§8):
[providers.codex]
# service_tier = "fast" # optional: opt in to Codex's "Fast" mode (see below)[[routes]]
model = "gpt-5.6-sol"
provider = "codex"
# service_tier = "priority" # optional: pin service_tier for this routeAccepted values: fast (legacy alias, normalized to priority), priority, flex, or
default (a client-only sentinel — it is never sent on the wire). Any other value fails config
validation at load time. A route-level value wins over the provider-level default, mirroring
effort's precedence: setting a route to service_tier = "default" explicitly opts that route
out of a provider-level priority/flex tier, since config validation preserves default as
its own sentinel rather than collapsing it to "unset" (which would make it indistinguishable from a
route that never configured service_tier at all, and let the provider tier leak through). The
sentinel is stripped only at the point the request is built, so GET /routes
(docs/running.md) can still show an explicit service_tier = "default" override
verbatim, distinguishing it from a route that never configured service_tier.
Off by default — shunt never derives service_tier from the request, the model id, or any
other signal; it is sent only when explicitly configured. shunt does not gate service_tier on
model capability: it forwards whatever value is configured, and the upstream rejects any value the
target model doesn't support.
Codex CLI's per-model default differs: the Codex CLI defaults gpt-6-sol and gpt-6-luna to
Fast (priority), because their models.json entry sets default_service_tier. That default is
applied client-side by the Codex CLI, not by the backend, so on a translated route (Claude Code →
shunt → Codex), these models run at the standard tier unless you set service_tier = "fast". The
inbound Codex endpoint (m11-inbound-codex-endpoint.md) is a
byte-faithful passthrough, so a Codex CLI pointed at shunt keeps the tier it sends itself.
xAI/Grok exception: the field is withheld for the xai and grok provider flavors even when
configured — xAI's Responses API 400s on service_tier, and the Grok CLI flavor inherits xAI's
request-shaping rules (§6 of m6-xai-provider.md).
Claude Code computes the context indicator locally: usage tokens ÷ the model's window size.
- Numerator is accurate. shunt forwards the Responses
usage, peelinginput_tokens_details.cached_tokensintocache_read_input_tokensandinput_tokens_details.cache_write_tokensintocache_creation_input_tokensand leaving the remainder ininput_tokens— the Responsesinput_tokensis the whole prompt, and Claude Code sums all three, so the split preserves the total. The bar therefore fills correctly as the conversation grows. An upstream that doesn't reportcache_write_tokensleavescache_creation_input_tokensat0. - Denominator defaults to 200k for mapped ids. Claude Code's
getContextWindowForModelreturns200_000for any id it doesn't recognize (its accurate per-model lookup only runs when the base URL isapi.anthropic.com). A larger real window (e.g.gpt-5.6-solat 372k) shows a conservative, over-reported percentage — harmless except that auto-compact triggers early.
Override the denominator for non-claude- ids (verified in Claude Code 2.1.205):
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=372000 # gpt-5.6-sol's real windowCaveats:
- Global — one value for every non-
claude-model in the session. Match it to the smallest real window among your mapped models. - Don't set it larger than the real window. On the streaming path Claude Code uses, an
overflow surfaces mid-stream as
prompt is too long; shunt normalizes it (context_overflow_message,src/model/responses.rs) and Claude Code auto-compacts and retries — the session recovers, but every overflow round-trip is wasted latency. (Live-verified forgpt-5.6-sol: 365k answers normally, 372k+ overflows — the boundary is itsmodels.jsoncontext_windowof 372000;gpt-5.5is 272000.) - A
claude-…-via-codexdiscovery alias ignores this override (theclaude-gate) — its window stays at 200k. Use a non-claude-id for the accurate window.
The [1m] id suffix forces a 1M window client-side; shunt strips a trailing [1m] before route
matching and before forwarding upstream, so gpt-5.6-sol[1m] still routes correctly. Only use it
if the upstream genuinely has a 1M window, or it under-reports.
| Field | Codex (responses) model |
Claude passthrough |
|---|---|---|
| Context tokens used | ✅ accurate (forwarded by shunt) | ✅ accurate |
| Context window (denominator) | CLAUDE_CODE_MAX_CONTEXT_TOKENS |
✅ exact |
count_tokens (pre-flight) |
char/4 (§11) |
✅ exact (upstream) |
rate_limits (5h / weekly) |
❌ needs Anthropic headers | ✅ shown |
The Responses API has no server-side token-count endpoint, so shunt answers Claude Code's
pre-flight POST /v1/messages/count_tokens itself. Controlled by count_tokens under
[providers.codex]:
| Value | Behavior |
|---|---|
"tiktoken" (default) |
Count locally with the o200k_base encoder (the GPT-family tokenizer) and return {"input_tokens": N}. Near-exact for text; it can't see the backend's image/tool-schema encoding, reasoning tokens, or cache accounting. |
"estimate" |
Return 501 not_supported so Claude Code falls back to its own estimate. Its /context then re-counts every category against Haiku over the network — slow, and silently zero without an Anthropic credential — so this is opt-in. |
[providers.codex]
count_tokens = "estimate" # opt out of local tiktoken countingThe default (tiktoken) is the better choice for most Codex users — it's far closer than Claude
Code's char/4 fallback and needs no network round-trip. See src/count_tokens.rs.
Claude Code prepends an attribution line to the system prompt
(x-anthropic-billing-header: cc_version=…). Anthropic strips it; a Codex backend receives it as
the first line of instructions. It's harmless but meaningless. To drop it:
export CLAUDE_CODE_ATTRIBUTION_HEADER=0Global — it also removes attribution from any Anthropic-passthrough traffic (used for cost tracking), which is fine when routing to Codex.
Everything above describes a single chatgpt_oauth credential (~/.codex/auth.json). The
codex provider (or any chatgpt_oauth provider) can instead pool several ChatGPT accounts with
session-to-account bindings, quota-aware selection and reactive failover:
# Log in with the Codex CLI, then import that login into shunt's account store.
codex login
shunt login codex --name main[[providers.codex.accounts]]
name = "main" # resolves ~/.shunt/accounts/codex/main.json (override dir: SHUNT_CODEX_ACCOUNTS_DIR)A provider with no accounts configured behaves exactly as described above — this is purely
opt-in. See m10-codex-multi-account.md for account fields,
cooldown/failover rules, and how this differs from the Anthropic (claude_oauth) pool in
m8-anthropic-multi-account.md.
- Tokens are never logged. shunt logs only non-secret facts (auth mode, account-id presence, expiry, refresh success/failure).
- File permissions. On Unix/Linux/macOS any auth file shunt writes is created
0600via an exclusive temp file + atomic rename — tokens are never momentarily world-readable. On non-Unix platforms the temp file is written withfs::write(no0600or exclusive-create guarantee). - Treat
~/.codex/auth.jsonas sensitive. It is not in.worktreeinclude, so it isn't copied into Orca worktrees; don't copy it into logs, telemetry, or shared checkouts. - The refresh endpoint is OpenAI's own (
auth.openai.com/oauth/token) with the public Codex CLIclient_id; shunt sends the refresh token there and nowhere else.
| Symptom | Likely cause / fix |
|---|---|
authentication_error: ChatGPT auth not found; run codex login |
No ~/.codex/auth.json (or wrong $CODEX_AUTH_FILE). Run codex login. |
ChatGPT auth tokens missing / refresh token missing |
Auth file is in API-key mode ("auth_mode": "apikey") or truncated — that path is the openai provider, not codex. Re-codex login with a ChatGPT account. |
400 … not supported when using Codex with a ChatGPT account |
You used a gpt-*-codex slug. Use an entitled non--codex slug (§5). |
Model not found <slug> |
Client-version gating or an unentitled slug — not a code error. Confirm the slug via models.json; shunt already sends the pinned CLI headers (§4.4). |
Effort slider seems ignored on a gpt-* id |
A route/provider effort override is winning, or no effort level is set. CLAUDE_CODE_ALWAYS_ENABLE_EFFORT is not the fix — these ids already send effort (§8). |
| Context bar over-reports / compacts early | Set CLAUDE_CODE_MAX_CONTEXT_TOKENS to the real window (§10). A discovery alias can't take it — use a non-claude- id. |
prompt is too long churn mid-session |
CLAUDE_CODE_MAX_CONTEXT_TOKENS is set larger than the real window. Lower it to the smallest mapped window. |
gpt-* model never appears in /model |
Discovery drops non-claude- ids. Use ANTHROPIC_CUSTOM_MODEL_OPTION (§7.1) or a claude--named discovery alias (§7.2). |
Validate config before running: cargo run -- check (or ./target/release/shunt check).
shunt.toml:
[server]
bind = "127.0.0.1:3001"
default_provider = "anthropic"
# codex is built in; this table only pins a default effort and keeps local counting.
[providers.codex]
effort = "high"
# service_tier = "fast" # optional: opt in to Codex's "Fast" mode (see §9)
# count_tokens = "tiktoken" # default
[[routes]]
model = "gpt-5.6-sol"
provider = "codex"Shell (the environment shunt and Claude Code run in):
# One-time: log in so ~/.codex/auth.json exists
codex login
# Run the gateway
./target/release/shunt run
# In the Claude Code environment
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_CUSTOM_MODEL_OPTION="gpt-5.6-sol" # add gpt-5.6-sol to the picker
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=372000 # gpt-5.6-sol's real windowThen pick gpt-5.6-sol from /model. Everything else in the session still flows to Anthropic
unchanged; only the mapped model's inference is answered by your ChatGPT/Codex subscription.
Everything above routes Claude Code to a Codex/ChatGPT backend. shunt can also run the
opposite direction: an opt-in inbound OpenAI Responses endpoint that lets the Codex CLI
itself point its base_url at shunt and be load-balanced across a ChatGPT/Codex OAuth account
pool. Unlike every path above, the request is not translated to or from Anthropic Messages —
it is a raw Responses-to-Responses passthrough. Full behavior spec:
m11-inbound-codex-endpoint.md.
[server.codex_endpoint]
provider = "codex" # default; must be a chatgpt_oauth provider (e.g. the built-in codex)Absent ⇒ none of the Codex-specific routes exist. Present ⇒ shunt registers three inference
routes at boot — POST /backend-api/codex/responses, POST /responses, POST /v1/responses — all
served by the named provider's account pool, plus GET /models and GET /backend-api/codex/models model-catalog aliases. Codex catalog requests receive the valid empty
fallback {"models":[]} rather than fabricated partial model rows. The shared GET /v1/models
also returns this Codex shape when its query contains client_version; otherwise its Anthropic
discovery bytes are unchanged. These catalog variants use the existing model-discovery auth gate.
Config validation rejects an unknown provider or one not using auth = "chatgpt_oauth" at
startup.
Two ~/.codex/config.toml shapes work, depending on which base URL the CLI appends /responses
to:
# Option A: mirror the ChatGPT backend's base URL
chatgpt_base_url = "http://127.0.0.1:3001/backend-api/codex"# Option B: a custom model provider (selected via the top-level model_provider)
model_provider = "shunt"
[model_providers.shunt]
base_url = "http://127.0.0.1:3001/v1"
wire_api = "responses"If shunt has [server.auth] configured (recommended for anything beyond loopback), present the
client token either way — shunt accepts both (InboundAuth::authenticate_bearer):
Option A: OpenAI-style Bearer key (the LiteLLM/llmgateway idiom), on the built-in openai provider. Set the base URL in ~/.codex/config.toml — NOT via the OPENAI_BASE_URL env var, which does not redirect the CLI's Responses WebSocket transport (the CLI keeps hitting wss://api.openai.com and bypasses shunt):
# ~/.codex/config.toml
openai_base_url = "http://127.0.0.1:3001/v1"# then present only the token via env — Codex sends it as Authorization: Bearer
export OPENAI_API_KEY="<shunt-token>"Option B: the x-shunt-token header — only a custom provider can attach one:
[model_providers.shunt]
base_url = "http://127.0.0.1:3001/v1"
wire_api = "responses"
env_key = "SHUNT_TOKEN" # option A on a custom provider: Bearer from $SHUNT_TOKEN
# http_headers = { "x-shunt-token" = "<token>" } # option B: the header formThe Codex CLI's own local ~/.codex/auth.json login is irrelevant once pointed at shunt this
way — the account comes from shunt's pool, not the CLI.
Reuses the same pool as §13 — the target provider's [[accounts]] (or the auto-discovered account
store). Import a Codex CLI login the same way:
codex login
shunt login codex --name mainWith no [[providers.codex.accounts]] configured and an empty account store, the endpoint falls
back to the single default ~/.codex/auth.json credential (no pooling, no failover) — it works out
of the box for a single account. (The handler first scans the account store and pools any
auto-discovered accounts, so imported store logins still get pooling.)
- No model-based routing by default — every inbound request goes to the one configured
provider, regardless of the
modelfield in the body. §17.5 opts specific models out of that. - Verbatim header passthrough. The outbound path synthesizes the Codex identity headers of
§4.4 (pinned
originator/user-agent=codex_cli_rs/0.159.3/version=0.159.3,OpenAI-Beta, session headers). The inbound endpoint does not — the client already is a Codex CLI, so its own request headers (version,originator,OpenAI-Beta,x-codex-*, …) are forwarded unchanged and shunt swaps in only the pool account'sAuthorization+chatgpt-account-id(and strips thex-shunt-tokenheader, plusx-api-keyunconditionally — the target provider is validatedchatgpt_oauth-only, so no inboundx-api-keyvalue can ever be a valid upstream credential). So the client's realversion— not shunt's pinned one — drives the backend'sminimal_client_versiongating (§5). - On pool exhaustion, the last upstream response is relayed verbatim rather than re-shaped
into an Anthropic-style error — the opposite of §13's outbound Codex pool, which re-shapes the
last response into an Anthropic error envelope (
build_upstream_error). - Two inbound transports on the same three paths: HTTP
POST(byte-faithful SSE passthrough) and a WebSocketGETupgrade that relays each upstream SSE event as a text frame through the first terminal event. Neither depends on the target provider's outboundwebsocket = true.
See m11-inbound-codex-endpoint.md for the full spec, including
the exact failover/cooldown table and reload semantics.
By default every inbound request goes to the one chatgpt_oauth provider of §17.1. An optional
[[server.codex_endpoint.routes]] table lets a Codex CLI select a different
Responses-compatible upstream by model id, while every model with no route keeps today's behavior.
Several vendors document a native Responses endpoint for the Codex CLI — these are the shape this feature targets (base URLs as the vendor documents them for Codex):
| Upstream | base_url |
Example slugs |
|---|---|---|
| Z.ai GLM Coding Plan | https://api.z.ai/api/v1 |
glm-5.3 |
| DeepSeek | https://api.deepseek.com |
deepseek-v4-flash, deepseek-v4-pro |
| Kimi Code | https://api.kimi.com/coding/v1 |
k3, k3-256k |
| MiniMax Token Plan | https://api.minimax.io/v1 |
MiniMax-M3 |
| Mimo Token Plan | https://api.xiaomimimo.com/v1 |
mimo-v2.5-pro, mimo-v2.5 |
| OpenRouter | https://openrouter.ai/api/v1 |
~openai/gpt-latest |
| Vercel AI Gateway | https://ai-gateway.vercel.sh/codex/v1 |
openai/gpt-5.6-sol |
| OpenAI | https://api.openai.com/v1 |
gpt-5.6-sol |
shunt appends /responses to a provider's base_url, so give it the same base URL the vendor
documents for Codex. The provider must expose a native Responses API — 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 = "deepseek-v4-flash"upstream_model is optional and defaults to model; set it when the id you want the CLI to type
differs from the id the vendor serves. Config validation rejects a route to an unknown provider, to
one that is not kind = "responses", or to one using a credential-free auth mode (passthrough
or none), and rejects duplicate
or blank entries. Note that shunt's built-in kimi preset is kind = "anthropic" (the
Anthropic-shaped coding endpoint), so a Codex route needs a separate kind = "responses"
provider:
[providers.kimi-responses]
kind = "responses"
auth = "api_key"
api_key_env = "KIMI_API_KEY"
base_url = "https://api.kimi.com/coding/v1"Routing a Codex model at the Anthropic-kind kimi preset instead is rejected at boot by the kind
check.
On the CLI side, point Codex at shunt (not at the vendor) 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" # when [server.auth] is configuredWhile shunt responds to Codex CLI discovery requests with the valid empty fallback {"models":[]},
it does not advertise Codex routes in its model list. The CLI learns metadata for non-OpenAI slugs the way these
vendors document — a ~/.codex/models.json catalog referenced by model_catalog_json (Mimo ships
its own at ~/.codex/model-catalogs/model-catalogs.json). Only the model value selects the shunt
route.
Route matching is exact and case-sensitive, with no charset restriction, so mixed-case and
slash- or ~-qualified vendor slugs (MiniMax-M3, openai/gpt-5.6-sol, ~openai/gpt-latest)
route as written.
A routed request to a non-ChatGPT upstream starts from a fresh header allowlist: only content-type (defaulted to application/json) and accept come from the client, while caller-supplied authorization, x-api-key, Codex identity/session headers, x-codex-*, and x-shunt-* are stripped. shunt adds the resolved credential and the identity the routed upstream itself requires (OpenAI-Beta, or the Grok-CLI header set for an xai_oauth route). An api_key route whose host is exactly api.openai.com also gets regenerated session-id, thread-id, x-client-request-id, and x-codex-window-id headers when the resolved conversation id is nonempty; those values never come from the caller. xAI and every other third-party OpenAI-compatible host keep the four affinity headers absent, and the API-key path does not synthesize accept: text/event-stream. The request uses an identity-encoded body with model rewritten to upstream_model and one credential — no pool and no failover, so a 429 relays verbatim with its retry-after. Routes hot-reload; only toggling [server.codex_endpoint] itself needs a restart.
See also: running.md (full gateway workflow) ·
m2-chatgpt-oauth.md (credential spec) ·
m1-responses-translation.md (Anthropic ↔ Responses translation) ·
m3-discovery.md (model discovery) ·
m10-codex-multi-account.md (multi-account pooling) ·
m11-inbound-codex-endpoint.md (inbound Codex endpoint) ·
plugins/shunt-codex/ (ready-made GPT-5.6 subagents).
{ "auth_mode": "chatgpt", // "apikey" routes to the openai provider instead "OPENAI_API_KEY": null, // a string only in apikey mode "tokens": { "id_token": "<JWT>", "access_token": "<JWT>", // bearer sent upstream; carries exp + account claim "refresh_token": "<JWT>", // used to mint a new access token "account_id": "<uuid>" // preferred account-id source }, "last_refresh": "2026-07-11T09:00:00Z" }