Skip to content

Codex Multi-Account

Pool ChatGPT/Codex subscription OAuth accounts with session-sticky, quota-aware proactive selection and reactive failover.

Updated View as Markdown

shunt can pool multiple ChatGPT subscription OAuth credentials behind a chatgpt_oauth provider — the built-in codex provider, or any responses provider using that auth mode. Requests are session-sticky when Claude Code supplies x-claude-code-session-id; requests without it use per-provider round-robin. Like the Anthropic account pool, selection is quota-aware: shunt records the backend’s x-codex-* 5-hour/7-day usage windows and proactively rotates off a sticky account that nears its quota, while cooldown-based reactive failover remains the safety floor.

Configure the pool

Log in with the Codex CLI first — shunt never performs its own ChatGPT login, only imports the credential codex login already wrote:

codex login

Then set auth = "chatgpt_oauth" and add explicit account entries:

[providers.codex]
kind = "responses"
base_url = "https://chatgpt.com/backend-api"
auth = "chatgpt_oauth"

# A second Codex CLI login, imported under a distinct name.
[[providers.codex.accounts]]
name = "primary"
credentials = "~/.shunt/accounts/codex/primary.json"

# A raw ChatGPT access token supplied out of band. Used verbatim; not refreshed.
[[providers.codex.accounts]]
name = "backup"
token_env = "CODEX_BACKUP_ACCESS_TOKEN"
export CODEX_BACKUP_ACCESS_TOKEN='<a valid ChatGPT access token>'
shunt check
shunt run

Store accounts by importing a Codex CLI login into shunt’s own store:

shunt login codex --name primary

Then use name-only entries:

[[providers.codex.accounts]]
name = "primary"

[[providers.codex.accounts]]
name = "backup"

Store files live at ~/.shunt/accounts/codex/<name>.json; set SHUNT_CODEX_ACCOUNTS_DIR to override the directory. If the configured accounts list is empty, shunt scans the store and uses all valid JSON account files in filename order. Store files are private (0600, with a 0700 directory on Unix) and hold the Codex CLI’s own auth.json shape verbatim — unlike the Claude store, there is no wrapper object.

There is no --long-lived flag for shunt login codex — Codex has no setup-token concept, so every store-managed account is a refreshable OAuth login imported from an existing codex login.

Account fields

Field Required Meaning
name yes Unique label containing only lowercase letters, digits, and hyphens. Without another source field, resolves the matching shunt store file.
credentials one usable source Codex CLI auth.json-shaped file. shunt refreshes near expiry and atomically writes refreshed tokens back, same as ~/.codex/auth.json itself.
token_env one usable source Environment variable containing a raw ChatGPT access token. Used verbatim; cannot be refreshed after a 401.
uuid no Stable upstream identity used to coalesce aliases. A name-only entry (resolved by a store scan) is filled in automatically before selection runs, so store-scanned aliases coalesce without any config change. A credentials- or token_env-configured entry’s account id is resolved only after selection and never populates uuid, so such an entry’s identity is its uuid when set, else its name; it coalesces with another alias whenever that identity equals the other alias’s explicit uuid or name-fallback identity — set a matching nonblank uuid on both entries for a clear, intentional coalesce (shunt also warns on an accidental cross uuid/name collision). It is not written into the Codex request body.
priority no Selection priority among available accounts; lower is preferred, default 100. Honored on the Codex path.
disabled no true removes the account from selection entirely while keeping it in config. Honored on the Codex path.

Do not set both credentials and token_env on one account.

Account id resolution

Codex has no explicit uuid field to configure. Instead, for each account shunt:

  1. prefers a stored tokens.account_id;
  2. otherwise decodes the access_token JWT and reads the chatgpt_account_id claim; and
  3. after any refresh, recomputes the id only from the new access token’s JWT claim (a refresh response has no separate account-id field).

If neither source yields an id, that account fails to resolve and is treated as a credential-resolution failure below. Only a store scan (an empty accounts list) writes this resolved id into the in-memory account’s uuid field ahead of selection — a credentials/token_env-configured account’s id is resolved per request and never feeds back into uuid. The store scan itself is cached by the store directory mtime, so steady-state discovery performs one directory stat and no credential-file reads; resolving the selected account’s actual credential still reads its credential file per request as needed to check/refresh expiry.

Selection and cooldowns

  • With x-claude-code-session-id: a stable hash picks the sticky account, same mechanism as the Anthropic pool.
  • Without the header: each provider has its own round-robin counter.
  • Successful responses record usage from x-codex-primary-* and x-codex-secondary-* headers. shunt maps each group by window-minutes: about 300 minutes appears in 5h, about 10080 minutes appears in 7d, and other windows are ignored. Codex has no 7d_oi analog.
  • Recorded usage feeds selection, exactly as in the Anthropic pool: a sticky account at or past its threshold (the built-in 0.98, or the [server.pool]/per-account soft thresholds) proactively yields to the account with the most headroom, and burn_rate_avoidance and priority/headroom ordering apply. The x-codex-rate-limit-reached-type value is recorded for display only.
  • With [server.pool] ramp_initial_concurrency set, a slow-start admission gate protects a freshly selected account from being stampeded after a failover; see [server.pool].
  • A successful response clears that account’s cooldown.
Trigger Cooldown
Credential-resolution failure (account id/tokens unresolvable) 5 minutes
Transport/connection failure 30 seconds
5xx upstream response 30 seconds
429 upstream response numeric retry-after, clamped to 1–3600s, default 60s
401 from a token_env account 5 minutes
401 from a credentials/store account, refresh fails or the retry is still 401 5 minutes

Failover rules

Response Behavior
2xx Relay and mark healthy.
429 Always cooldown and rotate. The optional Codex rejection signal is recorded for display but does not change failover classification, so every 429 is treated as exhaustion of that account.
401 with credentials/store Force-refresh, retry the same account once; if the refresh fails, or the retry is still 401, cooldown 5 minutes and rotate.
401 with token_env Cannot refresh: cooldown 5 minutes and rotate.
5xx or transport failure Cooldown 30 seconds and rotate.
Credential-resolution failure Cooldown 5 minutes and rotate.
Other status (e.g. 400) Relay without failover; mark the account healthy.

Classification happens before the response body streams, so a mid-stream failure is never replayed. If the pool exhausts its attempts after receiving at least one upstream response, shunt relays a translated Anthropic-style error envelope built from that last response — the status (e.g. 429) is preserved, but the body is re-shaped, not relayed verbatim. This is the opposite of the Anthropic pool, which relays the last upstream response byte-for-byte. If every account fails before any upstream response exists, the normal Anthropic Messages route (/v1/messages) returns a gateway-owned 502 api_error with all upstreams failed (N attempted). The separate [server.codex_endpoint] inbound path is unaffected and retains all Codex OAuth accounts failed before receiving an upstream response.

Request and response changes

For the selected account, shunt sends the same Codex-CLI identity headers as the single-account path, populated from that account’s credential:

authorization: Bearer <selected account's access token>
chatgpt-account-id: <selected account's account id>
originator: codex_cli_rs
OpenAI-Beta: responses=experimental

Pooled responses identify the account:

x-shunt-account: backup

Use neutral account names on a shared gateway — this header exposes the configured label to every authorized client that receives the response. The final translated-error relay after pool exhaustion omits x-shunt-account.

WebSocket transport

If the provider also sets websocket = true (see ChatGPT / Codex), the pooled connection cache key is prefixed per account so two accounts never share a socket or its previous_response_id continuation state. A WebSocket failure before any token streams falls back to HTTP on the same account (not a pool rotation); only an HTTP-path failure triggers the failover above. A fresh WebSocket upgrade records any rate-limit headers from its handshake; reused/prewarmed connections do not handshake again, so their dashboard usage refreshes when a new connection is established.

Security constraints

chatgpt_oauth is accepted only when:

  • base_url uses HTTPS; and
  • its host is chatgpt.com or a subdomain.

Like xai_oauth, chatgpt_oauth requires kind = "responses" — the Codex backend’s kind, shared with openai and xai. This is a bearer-leak guard: only the Responses adapter injects the Codex bearer, so a mismatched kind = "anthropic" provider would be routed to the Anthropic adapter and forward the client’s own credential off-origin to chatgpt.com instead. These startup checks prevent an OAuth bearer from being sent off-origin or over plaintext. The HTTPS and host checks are relaxed for loopback hosts (localhost, 127.0.0.1, [::1], etc.): a loopback base_url may use plain HTTP and any host, so a local debugging proxy or mock can receive the traffic. Non-loopback hosts are always held to HTTPS + chatgpt.com. On a shared deployment, also configure [server.auth] because chatgpt_oauth spends gateway-owned credentials.

Unaffected: the single-account path

A chatgpt_oauth provider with no accounts configured (the default codex provider) behaves exactly as before: it reads and refreshes ~/.codex/auth.json (or $CODEX_AUTH_FILE) directly, with no cooldowns and no x-shunt-account header.

Remaining follow-up

  • Out-of-band usage reconciliation: the Anthropic pool can poll an authoritative usage API (usage_refresh_seconds); Codex has no equivalent, so its quota state refreshes only from response headers.
  • Admin web provisioning: the opt-in admin surface can run ChatGPT OAuth in the browser, store a refreshable Codex account, show it in the shared pool table, and display reported 5h/7d usage (7d_oi remains empty because Codex has no analog).

See the M10 behavior specification for the full account-pool internals, and the ChatGPT / Codex guide for single-account setup, model routing, and effort/context configuration.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close