---
title: "Codex Multi-Account"
description: "Pool ChatGPT/Codex subscription OAuth accounts with session-sticky, quota-aware proactive selection and reactive failover."
image: "https://shunt-docs.pages.dev/og.png"
---

> Documentation Index
> Fetch the complete documentation index at: https://shunt-docs.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Codex Multi-Account

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](/guides/anthropic-multi-account/), 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.

> **Subscription terms**
>
> Use subscription credentials only where your account terms permit it. shunt is an unofficial client and does not change OpenAI's account or subscription policies.

## 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:

```bash
codex login
```

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

```toml
[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"
```

```bash
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:

```bash
shunt login codex --name primary
```

Then use name-only entries:

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

> **Duplicate names for one real account**
>
> If two store files or configured entries have the same `account_id`, shunt counts them as **one account**. They share cooldown, health, and refresh locks, and failover skips the duplicate alias rather than retrying the same ChatGPT account. Sticky hashing and round-robin operate over distinct identities. The representative is the enabled alias with the lowest `priority`, then the first entry; only its token is attempted, and shunt logs a duplicate-identity warning (configured entries are logged once per successful config load, including reload; store-discovered collisions are logged once per distinct collision set, not once per request). Consequently, an invalid representative is not rescued by a valid non-representative alias.
>
> Deleting a store-managed account through the admin web surface clears that identity's shared in-process health only once no other stored alias still resolves to the same identity; a scan failure preserves the health instead of guessing. This is admin-store delete semantics — removing an alias from the TOML config, or its credentials file directly, does not go through this cleanup.

## 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](/guides/anthropic-multi-account/): a sticky account at or past its threshold (the built-in `0.98`, or the [`[server.pool]`](/reference/configuration/#serverpool-optional)/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]`](/reference/configuration/#serverpool-optional).
- 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:

```http
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:

```http
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](/guides/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]`](/guides/shared-gateway/) 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](/guides/admin-remote-provisioning/) 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](https://github.com/pleaseai/shunt/blob/main/docs/m10-codex-multi-account.md) for the full account-pool internals, and the [ChatGPT / Codex guide](/guides/codex/) for single-account setup, model routing, and effort/context configuration.

Source: https://shunt-docs.pages.dev/guides/codex-multi-account/index.mdx
