Skip to content

[Bug] non-OpenAI providers unusable without a ChatGPT login after v2.23.0 #2132

Description

@marmeladema

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

  1. 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.

  2. Configure a non-OpenAI provider (e.g: Cloudflare AI Gateway).

  3. Ensure ~/.codex/auth.json has no token — no ChatGPT login, no OpenAI API key.

  4. 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.

  5. 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-1102route.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:490materializeCodexUpstreamAuth 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

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions