Codumentor logo Codumentor

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
last updated
(the store timestamps both refreshes and reauthorization).

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

ParameterTypeDefaultDescription
enabledbooltrueMaster on/off. When false the provider is not registered and the connect control does not appear.
client_idstringapp_EMoamEEZ73f0CkXaXp7hrannThe 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_scopeslist[str]openid profile email offline_accessScopes requested on connect. offline_access earns the long-lived refresh token the refresher relies on.
device_code_urlstringhttps://auth.openai.com/api/accounts/deviceauth/usercodeThe device-authorization endpoint.
device_token_urlstringhttps://auth.openai.com/api/accounts/deviceauth/tokenThe device authorization polling endpoint.
device_verification_uristringhttps://auth.openai.com/codex/deviceThe browser page on which the user enters the code.
token_urlstringhttps://auth.openai.com/oauth/tokenThe token endpoint (Phase-0-verified).
userinfo_urlstringhttps://auth.openai.com/v1/userinfoFallback userinfo endpoint if the token lacks the account-id claim.
account_id_claimstringidFallback userinfo claim carrying the account id.
models_urlstringhttps://chatgpt.com/backend-api/codex/modelsThe 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: oauth is required and is not the default.
The field defaults to import — the Phase 1 single-operator flow, which
reads only the bundle imported once from ~/.codex/auth.json by
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_sourceReadsPopulated 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 operator

The 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 an import
profile and importing a bundle cannot rescue an oauth one.

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 saysWhat Codumentor does
throttle, wait ≤ 30 swaits it out and retries the same profile (up to 3 times)
throttle, no wait givenretries 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 sblocks 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=…
reset_source=…
) and the first 300 characters of the body.

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.

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.