Skip to content

Latest commit

 

History

History
962 lines (753 loc) · 47.7 KB

codex-configuration.md

File metadata and controls

962 lines (753 loc) · 47.7 KB

Codex / ChatGPT provider — configuration reference

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

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.


2. Prerequisites

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 login

This 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.


3. The [providers.codex] block

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/responses and 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.json is in ApiKey mode (you logged in with an OpenAI API key rather than a ChatGPT account), the codex provider's OAuth path finds no tokens and errors. That API key is instead picked up by the openai provider as a fallback when OPENAI_API_KEY is unset (src/auth/mod.rs). The codex provider is specifically the ChatGPT-subscription path.


4. Authentication (~/.codex/auth.json)

4.1 File location

shunt resolves the auth-file path in this order (default_codex_auth_path, src/auth/mod.rs):

  1. $CODEX_AUTH_FILE if set — point shunt at a non-standard location (CI, a sandbox, a second account).
  2. $HOME/.codex/auth.json.
  3. .codex/auth.json relative 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.json

4.2 File schema

shunt reads (and, on refresh, rewrites) this JSON, written by codex login:

{
  "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"
}
  • auth_mode spelling — the Codex CLI serializes its AuthMode enum with rename_all = "lowercase", so a current codex login writes "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 the access_token JWT payload and reads ["https://api.openai.com/auth"].chatgpt_account_id. If neither exists the request fails with ChatGPT account id missing; run codex login.
  • Expiry — there is no expires_at field. shunt reads the exp claim from the access_token JWT and treats the token as expired 5 minutes early (EXPIRY_BUFFER), so a request in flight never races the expiry.

4.3 Read / refresh / write-back cycle

On every routed request (get_valid_chatgpt, src/auth/codex_auth.rs):

  1. 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.
  2. If the access token is valid (now < exp − 5min), use it as-is.
  3. Otherwise re-read once more (a concurrent codex process 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 rotated refresh_token and id_token.
  4. Write back atomically: re-read the file, update tokens.{access_token, refresh_token, id_token}, recompute account_id from the new access token, set last_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).

4.4 Headers shunt sends upstream

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.

4.5 Request-body compression

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.


5. Model slugs

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), and gpt-5.5 / gpt-5.4. Older accounts may only be entitled to the earlier ones; a free account has resolved to gpt-5.5 in testing.
  • To see what your account can use, look at what the codex CLI itself sends, or the live /models fetch it performs at startup.

Client-version gating. Some slugs carry a minimal_client_version (e.g. gpt-6-astra needs ≥ 0.153.0; gpt-6-sol and gpt-6-luna need ≥ 0.155.0). When the request's client identity is missing or too old the backend answers Model 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.


6. Routing a model to Codex

A request's model id selects the provider. Precedence: matching [models.upstream_model] entry → exact [[routes]] → [[route_prefixes]] → server.default_provider.

6.1 Exact route

[[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.

6.2 Prefix route

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"

7. Selecting the model in Claude Code

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

7.1 Primary path — ANTHROPIC_CUSTOM_MODEL_OPTION

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).

7.2 Discovery alias — a claude--named alias rewritten to a Codex slug

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 backend
export 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.

7.3 Subagents

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.

7.4 Remap the tier aliases (haiku/sonnet/opus → Codex)

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-, so CLAUDE_CODE_MAX_CONTEXT_TOKENS (§10) applies. They already send effort without a flag (§8). Handily, gpt-5.6-sol and gpt-5.6-luna are both 372k, so one global CLAUDE_CODE_MAX_CONTEXT_TOKENS=372000 fits both tiers.
  • The _SUPPORTED_CAPABILITIES companion 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).

8. Reasoning effort

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-sol already sends output_config.effort with CLAUDE_CODE_ALWAYS_ENABLE_EFFORT unset, and setting it to 1 produces 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 no output_config.effort at all, no environment variable overrides that, and shunt falls back to medium.

Precedence in shunt (effort(), src/model/responses_request.rs):

  1. A config route.effort — or [providers.codex].effort — override wins.
  2. Else the request's output_config.effort.
  3. Else thinking.enabled → high.
  4. Else a model-name suffix: -xhigh / -high / -medium / -low (with -spark treated as -low).
  5. 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.


9. Fast mode (service_tier)

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 route

Accepted 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).


10. Context window & usage display

Claude Code computes the context indicator locally: usage tokens ÷ the model's window size.

  • Numerator is accurate. shunt forwards the Responses usage, peeling input_tokens_details.cached_tokens into cache_read_input_tokens and input_tokens_details.cache_write_tokens into cache_creation_input_tokens and leaving the remainder in input_tokens — the Responses input_tokens is 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 report cache_write_tokens leaves cache_creation_input_tokens at 0.
  • Denominator defaults to 200k for mapped ids. Claude Code's getContextWindowForModel returns 200_000 for any id it doesn't recognize (its accurate per-model lookup only runs when the base URL is api.anthropic.com). A larger real window (e.g. gpt-5.6-sol at 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 window

Caveats:

  • 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 for gpt-5.6-sol: 365k answers normally, 372k+ overflows — the boundary is its models.json context_window of 372000; gpt-5.5 is 272000.)
  • A claude-…-via-codex discovery alias ignores this override (the claude- 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) ⚠️ 200k default; set CLAUDE_CODE_MAX_CONTEXT_TOKENS ✅ exact
count_tokens (pre-flight) ⚠️ local tiktoken or client char/4 (§11) ✅ exact (upstream)
rate_limits (5h / weekly) ❌ needs Anthropic headers ✅ shown

11. count_tokens behavior

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 counting

The 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.


12. Attribution header

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=0

Global — it also removes attribution from any Anthropic-passthrough traffic (used for cost tracking), which is fine when routing to Codex.


13. Multi-account pooling

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.


14. Security

  • 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 0600 via an exclusive temp file + atomic rename — tokens are never momentarily world-readable. On non-Unix platforms the temp file is written with fs::write (no 0600 or exclusive-create guarantee).
  • Treat ~/.codex/auth.json as 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 CLI client_id; shunt sends the refresh token there and nowhere else.

15. Troubleshooting

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).


16. End-to-end example

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 window

Then 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.


17. Inbound Codex endpoint (point the Codex CLI at shunt)

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.

17.1 Enable it

[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.

17.2 Point the Codex CLI at shunt

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 form

The 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.

17.3 Account provisioning

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 main

With 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.)

17.4 What's different from the outbound path

  • No model-based routing by default — every inbound request goes to the one configured provider, regardless of the model field 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's Authorization + chatgpt-account-id (and strips the x-shunt-token header, plus x-api-key unconditionally — the target provider is validated chatgpt_oauth-only, so no inbound x-api-key value can ever be a valid upstream credential). So the client's real version — not shunt's pinned one — drives the backend's minimal_client_version gating (§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 WebSocket GET upgrade that relays each upstream SSE event as a text frame through the first terminal event. Neither depends on the target provider's outbound websocket = true.

See m11-inbound-codex-endpoint.md for the full spec, including the exact failover/cooldown table and reload semantics.

17.5 Route models to third-party upstreams

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 configured

While 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).