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. Context window & usage display
- 10.
count_tokensbehavior - 11. Attribution header
- 12. Security
- 13. Troubleshooting
- 14. End-to-end example
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.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 §10)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 §10). - 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:
- 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.rs):
| Header | Value |
|---|---|
authorization |
Bearer <access_token> |
chatgpt-account-id |
<account_id> |
originator |
codex_cli_rs |
user-agent |
codex_cli_rs/0.144.1 (CODEX_USER_AGENT) |
version |
0.144.1 (CODEX_CLIENT_VERSION) |
OpenAI-Beta |
responses=experimental |
content-type |
application/json |
The user-agent / version are pinned to openai/codex rust-v0.144.1. If a future slug
demands a newer client, bump CODEX_USER_AGENT / CODEX_CLIENT_VERSION in
src/adapters/responses.rs.
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-5.6-sol,gpt-5.6-terra,gpt-5.6-luna(latest, frontier), andgpt-5.5/gpt-5.4/gpt-5.4-mini/gpt-5.2. 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-5.6-lunaneeds ≥ 0.144.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: 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 (§9) |
❌ 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 match a [[routes]]/[[route_prefixes]] rule. This is the recommended path — it's the only
one that also lets you set an accurate context window (§9).
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 have a [[routes]] entry (§6) and, being non-claude-, obeys
CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 (§8) and CLAUDE_CODE_MAX_CONTEXT_TOKENS (§9) — 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-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 have a route
[[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(§9) applies andCLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1is needed for effort (§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 — on shunt, stick withCLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1to enable the effort dial. - 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 family), else folded to xhigh |
max folds to xhigh unless the upstream slug contains gpt-5.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 family accepts max.
Required for custom gateway ids. For an id Claude Code doesn't recognize as effort-capable (like
gpt-5.6-sol), Claude Code omitsoutput_config.effortunless you set:export CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1Without it, shunt never sees a client effort and falls back to
medium.
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.
Claude Code computes the context indicator locally: usage tokens ÷ the model's window size.
- Numerator is accurate. shunt forwards the Responses
usage(input_tokens, peeling the cached part intocache_read_input_tokens), so the bar fills correctly as the conversation grows. - 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 (§10) |
✅ 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 404 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.
- 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 ApiKey mode 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 |
Set CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 (§8), or a route/provider effort override is winning. |
| Context bar over-reports / compacts early | Set CLAUDE_CODE_MAX_CONTEXT_TOKENS to the real window (§9). 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"
# 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_ALWAYS_ENABLE_EFFORT=1 # let the effort slider reach Codex
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.
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) ·
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" }