OpenAI Codex / ChatGPT Subscription OAuth Plugin
Registers the Codex public OAuth client as a connectable provider for the
OAuth plugin, so a user can connect their own ChatGPT/Codex
subscription (Plus, Pro, Business, Edu, Enterprise) from **Settings →
Extensions** and use it as a model instead of an API key. The plugin owns no
storage and runs no flow of its own: it declares the Codex client's endpoints
and scopes, and the OAuth plugin does the connecting, encrypting and
refreshing.
The connected token is stored per user in the OAuth plugin's encrypted
token store (keyed by the connecting user's id) and refreshed by the plugin's
locking refresher. The subscription model that consumes the token is
OpenAICodexSubscriptionModel (codumentor.models.codex_subscription,
Phase 1); this plugin is the auth-acquisition half only.
Flow: device-code
The Codex client_id's only registered redirect is
http://localhost:1455/auth/callback, so a server-side callback URL is almost
certainly not an allowed redirect_uri for this public client. This
plugin therefore registers the device-code flow (no redirect needed)
rather than browser-redirect PKCE. The user is shown a verification URL and a
code to enter in their browser — the same UX the GitHub
provider uses.
Caveat: per the design doc, device-code for this client is
admin-gated on workspaces and must be enabled in ChatGPT security
settings. That gate could not be verified without a live account — treat
the connect flow as experimental until confirmed.
Endpoints: OpenAI's device authorization is not RFC 8628. It uses JSON
requests at/api/accounts/deviceauth/{usercode,token}; the poll returns an
authorization code and PKCE verifier which are exchanged at/oauth/token.
The account id is read from the namespaced claim in the returned JWT.
Connected-account details
The connected-account panel shows an email when the stored ID or access token
contains a non-empty top-level email claim (ID token preferred). Email
availability in real Codex tokens has not been verified; absent or malformed
claims fall back to the account ID. Existing connections benefit on the next
panel load without reconnecting. The account ID remains visible and is still
the key used for account operations; email is display-only, not an authorization
or identity input. Raw tokens and other claims are not returned to the panel.
Token expires describes the access-token expiry, not the end of the connection.
The panel also shows the original connection time and, when available, Token (the store timestamps both refreshes and reauthorization).
last updated
Configuration
The personal (community) default configuration already loads both plugins, so
Sign in with ChatGPT is available in Settings → Models out of the box.
For a custom configuration, load this plugin and the OAuth plugin. This plugin's priority
must be lower than OAuth's (3 vs 4): the OAuth plugin emits
onRegisterOAuthProviders from inside its own register(), so a provider
whose handler is not in place yet is never asked to register.
plugins:
- module: codumentor.plugins.oauth
class: OAuthPlugin
priority: 4
args:
enabled: true
- module: codumentor.plugins.openai_codex
class: OpenAICodexOAuthPlugin
priority: 3
args:
enabled: true
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master on/off. When false the provider is not registered and the connect control does not appear. |
client_id | string | app_EMoamEEZ73f0CkXaXp7hrann | The Codex public client id (hardcoded in the Codex CLI, reused by all third-party integrations). Falls back to the OPENAI_CODEX_OAUTH_CLIENT_ID env var. It is a public client — there is no secret. |
default_scopes | list[str] | openid profile email offline_access | Scopes requested on connect. offline_access earns the long-lived refresh token the refresher relies on. |
device_code_url | string | https://auth.openai.com/api/accounts/deviceauth/usercode | The device-authorization endpoint. |
device_token_url | string | https://auth.openai.com/api/accounts/deviceauth/token | The device authorization polling endpoint. |
device_verification_uri | string | https://auth.openai.com/codex/device | The browser page on which the user enters the code. |
token_url | string | https://auth.openai.com/oauth/token | The token endpoint (Phase-0-verified). |
userinfo_url | string | https://auth.openai.com/v1/userinfo | Fallback userinfo endpoint if the token lacks the account-id claim. |
account_id_claim | string | id | Fallback userinfo claim carrying the account id. |
models_url | string | https://chatgpt.com/backend-api/codex/models | The plan's model listing, read with the user's own token after a sign-in. Overridable, like the endpoints above, for test servers and compatible mirrors. |
Who may connect
Connecting needs the models:connect_personal permission, which the
built-in user, power_user and admin roles all hold — a sign-in carries
none of the URL and key risks a typed-in endpoint does, so every user may
connect their own plan by default (the provider registers with
required_permission: models:connect_personal instead of the OAuth plugin's
default oauth:manage). The account is a personal model connection, so the
door also closes when personal connections are off for the user:
model_selection.personal_connections: false (for everybody) or
access.profiles.<p>.personal_connections: false (for that profile's users).
The refusal holds at the start of the device flow and again before it
completes, and the resulting 403 carries the reason. A connected account stays
connected when the policy tightens — it is withheld from the picker and every
turn instead, and the user can still disconnect it. See
Access profiles.
Using it as a model
A ModelProfile with provider: openai-subscription consumes the token. It
needs two fields, not one — provider picks the backend, and
codex_credentials_source picks which credential store that backend reads:
models:
profiles:
codex-main:
model: gpt-5.5
base_url: https://chatgpt.com/backend-api/codex/responses
provider: openai-subscription
codex_credentials_source: oauth # required for this plugin's tokens
codex_credentials_source: oauthis required and is not the default.
The field defaults toimport— the Phase 1 single-operator flow, which
reads only the bundle imported once from~/.codex/auth.jsonby
codumentor codex import. A profile that omits the field therefore ignores
every subscription connected through this plugin and fails the turn with
"No ChatGPT/Codex subscription credential configured" — even while
Settings → Extensions correctly shows the account as connected, because
the two sources are different stores:
codex_credentials_sourceReads Populated by oauththe OAuth plugin's per-user token store, keyed by (user_id, provider, account_id)the user connecting in Settings → Extensions import(default)one encrypted operator row, __codex_operator__/codex_subscriptioncodex login+codumentor codex import, once, by the host operatorThe choice is static, resolved when the model is constructed
(OpenAICodexSubscriptionModel._resolve_credentials). Neither source falls
back to the other, so connecting an account cannot rescue animport
profile and importing a bundle cannot rescue anoauthone.
With oauth, the access token and account id are read for the current user
from the OAuth registry at call time, and refreshed by the OAuth plugin's
locking refresher.
Fallback on rate-limit
The subscription's 5-hour / weekly caps are shared with the account's own
ChatGPT/Codex usage, and enforcement arrives as 429s (or as a
response.failed stream event with a rate-limit code). A profile can name a
fallback_profile to rotate to — any provider, typically a local model that
does not draw on the same allowance:
models:
profiles:
codex-main:
model: gpt-5.5
base_url: https://chatgpt.com/backend-api/codex/responses
provider: openai-subscription
codex_credentials_source: oauth
fallback_profile: local-think
local-think:
model: ardincoder-think
base_url: http://llm-server:8000/v1/
api_key: ${secret:llm_key}
Each 429 is classified from the fields the official codex CLI reads — the
body's error.type / error.resets_at / error.resets_in_seconds, the
x-codex-{primary,secondary}-used-percent / -reset-at window headers,
Retry-After, and a "try again in N s" hint in the message:
| 429 says | What Codumentor does |
|---|---|
| throttle, wait ≤ 30 s | waits it out and retries the same profile (up to 3 times) |
| throttle, no wait given | retries with exponential backoff (1 s, 2 s, 4 s), then treats it as below with a 5-minute block |
usage cap (usage_limit_reached, an exhausted window), or a wait > 30 s | blocks that profile until the reset and delegates to the fallback at once |
While a profile is blocked, calls to it go straight to the fallback without
touching the capped endpoint — main agent and subagents alike, since the block
is process-wide and keyed by profile name (a sibling profile is unaffected;
with codex_credentials_source: oauth, by profile name and user, since each
user's allowance is their own).
When the reset passes, the profile is used again. The chain may continue (a
subscription fallback may itself name a fallback); a cycle is rejected at
config load. A key-based fallback samples with its own profile's settings.
A profile with no connected credential also delegates to its fallback (without
blocking), so a user who has not connected a subscription still gets answers.
Nothing is re-routed once a reply has started streaming — the partial reply
ends with the error — but the block is recorded, so the next call rotates.
Every 429 is logged at WARNING with what was parsed (type=… usage_cap=…) and the first 300 characters of the body.
reset_source=…
The plan's model listing
GET https://chatgpt.com/backend-api/codex/models?client_version=<v> answers
with the models the account's plan can call — the request the official Codex
client makes to fill ~/.codex/models_cache.json (openai/codex
codex-rs/codex-api/src/endpoint/models.rs, base URL
CHATGPT_CODEX_BASE_URL in codex-rs/model-provider-info/src/lib.rs). The
body is {"models": [ModelInfo, …]}: slug, display_name, description,
visibility (list models are shown; hide/none are internal),
priority, supported_reasoning_levels, context_window and upgrade (the
model replacing a retiring one). models/codex_catalog.py makes the request
with the shared headers (models/codex_http.py) and our own originator.
client_version filters which models come back (a model whose
minimal_client_version is newer is omitted); it does not select a response
schema. We send 99.0.0, ahead of any Codex release, so a newly gated model
appears without a Codumentor update. 0.0.0 is a frozen older snapshot, and
a version such as 0.0.1 returns an empty list.
codumentor codex models prints the listing for the operator's credential and
caches it; when the endpoint does not answer it says why and prints the static
list below. The endpoint is private and undocumented, like the Responses one.
Your ChatGPT connection (derived)
A user with an openai_codex account has a ChatGPT connection — no YAML
(thinking-modes-ux-rethink F5). This plugin's derived_connections(user_id)
says what the account implies; the Model Profiles plugin,
which owns connections, finds it by a duck-typed sibling probe, keeps the
user's preferences and the last listing, and lists the plan's models under
Yours in the model picker as chatgpt/<model>. They run as
provider: openai-subscription with codex_credentials_source: oauth — the
user's own sign-in, never the operator import — so the rate-limit block and
fallback rotation above apply unchanged. The connection is always
personal. Disconnecting the account removes it.
- One connection per account. A user who connects two ChatGPT accounts has two connections,
chatgptandchatgpt-2(titled ChatGPT and ChatGPT 2, in the order they were connected). Each lists and runs on its own account's plan: its models carrycodex_account_id, so a turn asks the OAuth registry for that account's token by name, andlist_models(user_id, account_id)reads that plan's listing. An id is kept for its account: Model Profiles stores the connection's row the first time it sees it, sochatgpt-2/<model>keeps naming the same plan when the other account is disconnected, and a connection the user makes cannot take the id.
- Models come from the plan's listing, read when the account is connected, when Settings shows it stale (6 h), and in the background when a turn finds it stale. Until one is read, the provider catalog's static list (
CHATGPT_SUBSCRIPTION.models) stands in. - Shown by default: the newest model of each tier. An older version of a tier, an untiered slug when its family has a newer model, or one the listing marks for replacement (
upgrade) is listed but hidden; the user ticks it back in Settings. - The light model is the catalog's (
gpt-6-luna, thengpt-5.6-luna, thengpt-5.4-mini), unless the user picks another on the connection. - Rate-limit blocks of a per-user credential are per user: every user's ChatGPT connection has the same model names, and one user's cap pauses only their own turns.
User-facing documentation
The end-user caveats (private endpoint that can break, shared usage caps,
account-gated model list, reduced context, no embeddings/TTS/realtime, one
account = one human, the informal ToS posture) are written up for users in
user-guide/en/chatgpt-codex-subscription.md.
See doc/core-dev/design/codex-subscription-backend.md for the full design
and limitations.