Client or integration
Codex CLI
Area
Authentication and account pool
Summary
Since v2.23.0 (#1686 / PR #1861), any request admitted via Authorization: Bearer
fails with a 401 when no ChatGPT credential is stored — even when the request
routes to a non-OpenAI provider that has its own API key and never touches the
Codex backend.
No usable Codex main credential to serve this request
I use opencodex solely to reach Cloudflare AI Gateway models from Codex. I have
deliberately never configured a ChatGPT/OpenAI login, so ~/.codex/auth.json has
no token. This worked through v2.7.x and broke on upgrade.
Expected: a request routed to a key-authenticated provider should not require a
stored Codex main credential, because that credential is irrelevant to the
upstream it is being sent to.
Reproduction
-
Run opencodex bound to a non-loopback hostname (mine is in Docker, so 0.0.0.0)
with a data-plane credential configured — either OPENCODEX_API_AUTH_TOKEN or an
entry in config.apiKeys. isApiAuthRequired is then true.
-
Configure a non-OpenAI provider (e.g: Cloudflare AI Gateway).
-
Ensure ~/.codex/auth.json has no token — no ChatGPT login, no OpenAI API key.
-
Point Codex at the proxy so the admission secret travels in Authorization:
[model_providers.opencodex]
name = "opencodex"
base_url = "http://localhost:10100/v1"
wire_api = "responses"
[model_providers.opencodex.auth]
command = "sh"
args = ["-c", "printf %s \"$OPENCODEX_KEY\""]
[.auth] is used rather than env_key deliberately: should_refresh_models()
in codex-rs is uses_codex_backend() || has_command_auth()
(models-manager/src/manager.rs:438), so declaring it is what makes Codex query
/models?client_version=… and get the rich model shape. Note the command's stdout
becomes CodexAuth::from_api_key(...) (login/src/auth/external_bearer.rs:51)
and is sent as Authorization: Bearer, exactly like env_key.
-
Send any turn → 401.
Admission itself is fine — this is not a misconfigured key
Worth ruling out up front, because the error mentions a credential:
- The proxy admits the request. An unrecognised secret would return
401 "opencodex API key required" from resolveResponsesApiAuth; the
main-credential error is only reachable after admission has already succeeded
(core.ts:1114 runs only once resolveDataPlaneAdmissionSecret has matched).
- The bind really is non-loopback.
assertServerAuthConfig
(src/server/auth-cors.ts:287-295) refuses to start a non-loopback bind that has
no data-plane credential, and my instance starts and serves normally.
So both preconditions hold and the credential in play is correct. What is missing is
a Codex main credential, which I have deliberately never configured and which this
request has no reason to need.
Where it goes wrong
-
src/server/responses/core.ts:1088
const substituteMainCredential = options.admission?.source === "bearer";
Keyed on how the caller authenticated, never on where the request routes.
-
src/server/responses/core.ts:1093-1102 — route.codexAccountMode is only ever
set for the provider named openai (src/providers/registry.ts:2675, enforced
again at src/server/management/provider-routes.ts:582). Every other provider
falls through to the placeholder authCtx = { kind: "main", accountId: null }.
-
src/codex/auth-context.ts:490 — materializeCodexUpstreamAuth sees
kind === "main" plus the substitute flag, requires a stored main token, finds
none, and throws CodexMainSubstitutionUnavailableError → the 401 at
core.ts:1152.
The safety rationale in the #1686 comments is sound but scoped too widely. The
concern is forwarding an admission secret upstream, which only applies to routes
that actually forward Authorization — the openai/forward-auth path. A key-auth
provider builds its own headers from scratch (src/adapters/openai-chat.ts:81-83),
so there is nothing to leak and nothing to protect; the ChatGPT credential
requirement is collateral damage.
Regression point
v2.7.13 had one admission path for the whole data plane, and simply served the
request once the secret was recognised:
export function hasValidApiAuth(req, config) {
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
|| req.headers.get("x-api-key")?.trim();
return actual ? isProxyAdmissionSecret(actual, config) : false;
}
The Responses transport was later split into resolveResponsesApiAuth
(dedicated header only); #1686 re-admitted bearers but coupled that admission to
mandatory substitution.
Also affected
src/server/responses/compact.ts:325 carries the identical unconditional flag, so
compaction fails the same way.
#2076 (v2.26.0) widens the blast radius for everyone else: it switched the
auto-injected provider block from env_http_headers = { "x-opencodex-api-key" = … }
to env_key = "OPENCODEX_API_AUTH_TOKEN", which moves existing non-loopback
auto-injected installs from the dedicated header onto the failing bearer path.
(This did not affect me — my opencodex runs in a container with no codex binary,
so inject.ts never runs and my config is hand-written.)
Suggested fix
Gate the flag on the route actually targeting the Codex backend, in both files:
const substituteMainCredential = !!route.codexAccountMode
&& options.admission?.source === "bearer";
For a non-Codex route the placeholder { kind: "main" } context has no credential
to substitute and needs none, so materializeCodexUpstreamAuth returns the
forwarded headers and the provider adapter supplies its own auth as before.
Workaround (for anyone else hitting this)
Keep the auth block and add the dedicated header, which wins in
resolveResponsesApiAuth so substitution never runs:
[model_providers.opencodex]
name = "opencodex"
base_url = "http://localhost:10100/v1"
wire_api = "responses"
env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_KEY" }
[model_providers.opencodex.auth]
command = "sh"
args = ["-c", "printf %s \"$OPENCODEX_KEY\""]
env_http_headers must sit above the [.auth] sub-table header or TOML parses
it as auth.env_http_headers. It is not in codex-rs's conflict list for auth
(only env_key, experimental_bearer_token, requires_openai_auth conflict), so
the combination is accepted and model refresh is preserved.
Version
2.26.0
Operating system
Debian Testing for host, node:22-alpine for the base docker image
Provider and model
cloudflare-workers-ai / @cf/zai-org/glm-5.2
Logs or error output
unexpected status 401 Unauthorized: No usable Codex main credential to serve this request, url: http://localhost:10100/v1/responses
Screenshots and supporting files
No response
Redacted configuration
Codex config:
[model_providers.opencodex]
name = "opencodex"
base_url = "http://localhost:10100/v1"
wire_api = "responses"
[model_providers.opencodex.auth]
command = "sh"
args = ["-c", "printf %s \"$OPENCODEX_KEY\""]
Opencodex config:
{
"port": 10100,
"managementUsageMaxReadBytes": 67108864,
"appOwnedMemoryBudgetMb": 256,
"hostname": "0.0.0.0",
"providers": {
"cloudflare-workers-ai": {
"adapter": "openai-chat",
"baseUrl": "https://api.cloudflare.com/client/v4/accounts/XXXX/ai/v1",
"authMode": "key",
"apiKey": "XXXX",
"models": [
"@cf/zai-org/glm-5.2",
"@cf/moonshotai/kimi-k2.7-code"
]
},
"openai": {
"adapter": "openai-responses",
"baseUrl": "https://chatgpt.com/backend-api/codex",
"authMode": "forward",
"codexAccountMode": "pool"
}
},
"defaultProvider": "cloudflare-workers-ai",
"subagentModels": [
"gpt-5.5",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.4-mini"
],
"websockets": false,
"codexAutoStart": false,
"openaiProviderTierVersion": 2
}
Checks
Client or integration
Codex CLI
Area
Authentication and account pool
Summary
Since v2.23.0 (#1686 / PR #1861), any request admitted via
Authorization: Bearerfails with a 401 when no ChatGPT credential is stored — even when the request
routes to a non-OpenAI provider that has its own API key and never touches the
Codex backend.
I use opencodex solely to reach Cloudflare AI Gateway models from Codex. I have
deliberately never configured a ChatGPT/OpenAI login, so
~/.codex/auth.jsonhasno token. This worked through v2.7.x and broke on upgrade.
Expected: a request routed to a key-authenticated provider should not require a
stored Codex main credential, because that credential is irrelevant to the
upstream it is being sent to.
Reproduction
Run opencodex bound to a non-loopback hostname (mine is in Docker, so
0.0.0.0)with a data-plane credential configured — either
OPENCODEX_API_AUTH_TOKENor anentry in
config.apiKeys.isApiAuthRequiredis then true.Configure a non-OpenAI provider (e.g: Cloudflare AI Gateway).
Ensure
~/.codex/auth.jsonhas no token — no ChatGPT login, no OpenAI API key.Point Codex at the proxy so the admission secret travels in
Authorization:[.auth]is used rather thanenv_keydeliberately:should_refresh_models()in codex-rs is
uses_codex_backend() || has_command_auth()(
models-manager/src/manager.rs:438), so declaring it is what makes Codex query/models?client_version=…and get the rich model shape. Note the command's stdoutbecomes
CodexAuth::from_api_key(...)(login/src/auth/external_bearer.rs:51)and is sent as
Authorization: Bearer, exactly likeenv_key.Send any turn → 401.
Admission itself is fine — this is not a misconfigured key
Worth ruling out up front, because the error mentions a credential:
401 "opencodex API key required"fromresolveResponsesApiAuth; themain-credential error is only reachable after admission has already succeeded
(
core.ts:1114runs only onceresolveDataPlaneAdmissionSecrethas matched).assertServerAuthConfig(
src/server/auth-cors.ts:287-295) refuses to start a non-loopback bind that hasno data-plane credential, and my instance starts and serves normally.
So both preconditions hold and the credential in play is correct. What is missing is
a Codex main credential, which I have deliberately never configured and which this
request has no reason to need.
Where it goes wrong
src/server/responses/core.ts:1088Keyed on how the caller authenticated, never on where the request routes.
src/server/responses/core.ts:1093-1102—route.codexAccountModeis only everset for the provider named
openai(src/providers/registry.ts:2675, enforcedagain at
src/server/management/provider-routes.ts:582). Every other providerfalls through to the placeholder
authCtx = { kind: "main", accountId: null }.src/codex/auth-context.ts:490—materializeCodexUpstreamAuthseeskind === "main"plus the substitute flag, requires a stored main token, findsnone, and throws
CodexMainSubstitutionUnavailableError→ the 401 atcore.ts:1152.The safety rationale in the #1686 comments is sound but scoped too widely. The
concern is forwarding an admission secret upstream, which only applies to routes
that actually forward
Authorization— theopenai/forward-auth path. A key-authprovider builds its own headers from scratch (
src/adapters/openai-chat.ts:81-83),so there is nothing to leak and nothing to protect; the ChatGPT credential
requirement is collateral damage.
Regression point
v2.7.13 had one admission path for the whole data plane, and simply served the
request once the secret was recognised:
The Responses transport was later split into
resolveResponsesApiAuth(dedicated header only); #1686 re-admitted bearers but coupled that admission to
mandatory substitution.
Also affected
src/server/responses/compact.ts:325carries the identical unconditional flag, socompaction fails the same way.
#2076(v2.26.0) widens the blast radius for everyone else: it switched theauto-injected provider block from
env_http_headers = { "x-opencodex-api-key" = … }to
env_key = "OPENCODEX_API_AUTH_TOKEN", which moves existing non-loopbackauto-injected installs from the dedicated header onto the failing bearer path.
(This did not affect me — my opencodex runs in a container with no
codexbinary,so
inject.tsnever runs and my config is hand-written.)Suggested fix
Gate the flag on the route actually targeting the Codex backend, in both files:
For a non-Codex route the placeholder
{ kind: "main" }context has no credentialto substitute and needs none, so
materializeCodexUpstreamAuthreturns theforwarded headers and the provider adapter supplies its own auth as before.
Workaround (for anyone else hitting this)
Keep the
authblock and add the dedicated header, which wins inresolveResponsesApiAuthso substitution never runs:env_http_headersmust sit above the[.auth]sub-table header or TOML parsesit as
auth.env_http_headers. It is not in codex-rs's conflict list forauth(only
env_key,experimental_bearer_token,requires_openai_authconflict), sothe combination is accepted and model refresh is preserved.
Version
2.26.0
Operating system
Debian Testing for host, node:22-alpine for the base docker image
Provider and model
cloudflare-workers-ai / @cf/zai-org/glm-5.2
Logs or error output
Screenshots and supporting files
No response
Redacted configuration
Codex config:
Opencodex config:
{ "port": 10100, "managementUsageMaxReadBytes": 67108864, "appOwnedMemoryBudgetMb": 256, "hostname": "0.0.0.0", "providers": { "cloudflare-workers-ai": { "adapter": "openai-chat", "baseUrl": "https://api.cloudflare.com/client/v4/accounts/XXXX/ai/v1", "authMode": "key", "apiKey": "XXXX", "models": [ "@cf/zai-org/glm-5.2", "@cf/moonshotai/kimi-k2.7-code" ] }, "openai": { "adapter": "openai-responses", "baseUrl": "https://chatgpt.com/backend-api/codex", "authMode": "forward", "codexAccountMode": "pool" } }, "defaultProvider": "cloudflare-workers-ai", "subagentModels": [ "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini" ], "websockets": false, "codexAutoStart": false, "openaiProviderTierVersion": 2 }Checks