Skip to content

Pool routing never selects the main login automatically when the request carries that login's own bearer #5019

Closed

Description

@FredAmartey

Client or integration

Codex App

Area

Authentication and account pool

Summary

In Pool mode with the Codex app signed in as the main login and one stored account added through ocx account login openai, automatic placement never picks the main login. Every unpinned request goes to the stored account, whatever the usage numbers, the strategy or codexAccountPriorities say. Main only serves when it is pinned, or when no stored account can be selected.

The cause is in resolveCodexAuthContext (src/codex/auth-context.ts): nativeMainReadsForbidden is set whenever requestScopedMainCredential is true, and selection receives isMainAccountTokenLive: () => preserveRequestOwnedMainPin. That callback is true only when main is the effective pin with headroom, so for an unpinned request codexAccountUnusableReason("__main__") returns main_credential_unavailable, getEligiblePoolAccounts never lists main, and the strategy only compares the stored accounts. Main is reached afterwards through resolveCallerOwnedMainContext, but only as the fallback when nothing else is selectable.

The fence itself makes sense: a request that carries its own main bearer must not read or reconcile the stored auth.json. But that same bearer is exactly what would be forwarded if main were selected, so excluding main from the comparison discards the usage, priority and reset ordering the operator configured. With one stored sibling the pool degrades to "stored account until it cannot serve, then main". #3996 (fixed) made the caller-owned fallback reachable in more cases; this is about main taking part in the ordering before that fallback.

Expected: for a request carrying a forwardable main bearer, main takes part in selection as a candidate served from that bearer, ordered by the same usage, priority and strategy as the stored accounts, without touching the stored main credential.

Reproduction

  1. Canonical openai provider in Pool mode (codexAccountMode: "pool", authMode: "forward"). Codex app signed in as the main login. Add one stored account: ocx account login openai --device.
  2. Make the numbers favour main: main at lower weekly usage than the stored account (here 46% against 75%). Clear any pin: PUT /api/codex-auth/active with {"accountId": null}; GET /api/codex-auth/active then shows activeCodexAccountId and pinnedAccountId both null.
  3. Send fresh turns from the app. I used codex exec --ephemeral -c 'mcp_servers={}' "Reply with the single word yes." with a different word each time so every turn is a new conversation, then read accountLogLabel on the newest row of GET /api/logs?limit=1.
  4. Observed: every turn is served by the stored account. Same result with accountPoolStrategy quota and reset-first, with ocx account priority openai main first, on gpt-6-astra and on gpt-5.5.
  5. Control: PUT /api/codex-auth/active {"accountId": "__main__"} and the next turn is served by main. Pause the stored account and the next unpinned turn is served by main. GET /api/codex-auth/accounts?refresh=1 shows main with healthLabel: "Healthy", quotaRefresh: {"status": "ok"}, needsReauth: false, so it is not a credential or health problem.

Version

2.58.0; the same path is present in current dev (src/codex/auth-context.ts, nativeMainReadsForbidden).

Operating system

macOS 26.0 (Darwin 25.3)

Provider and model

openai, canonical ChatGPT backend; gpt-6-astra and gpt-5.5

Logs or error output

No error is produced. GET /api/logs?limit=1 after each unpinned turn carries the stored account's accountLogLabel; after a pinned-main turn the field is absent, which is how main is reported.

Screenshots and supporting files

No response

Redacted configuration

"openai": {
  "adapter": "openai-responses",
  "baseUrl": "https://chatgpt.com/backend-api/codex",
  "authMode": "forward",
  "codexAccountMode": "pool"
}

One stored pool account. accountPoolStrategy tried as quota and reset-first, autoSwitchThreshold 80, codexAccountPriorities with main at +2.

Checks

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

Activity

  1. added
    bugSomething isn't working
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    on Sep 18, 2026
  2. added a commit that references this issue on Sep 18, 2026
    0ddf137
  3. lidge-jun commented on Sep 18, 2026

    @lidge-jun
    Owner

    Confirmed at the tip, and the fix is a different shape than the report implies

    Assessed against dev at 11bc4f708c, then implemented and put through hosted CI. Static source
    only for the analysis; no local verification was run in this lane.

    The report's causal account is correct. resolveCodexAuthContext passes selection
    isMainAccountTokenLive: () => preserveRequestOwnedMainPin whenever the request carries main's own
    forwardable bearer (src/codex/auth-context.ts:1040). That value answers a different question, so
    for an unpinned request codexAccountUnusableReason returns main_credential_unavailable
    (src/codex/account-usability.ts:74), getEligiblePoolAccounts never lists main
    (src/codex/routing/selection.ts:181), and every strategy compares only the stored accounts. Main
    is reached afterwards only through resolveCallerOwnedMainContext at :1101 and :1241, both of
    which are no-candidate fallbacks. None of #4921, #4676 or #4935 changed this: #4676 touches no file
    on this path, and #4935's cohort keying never applies here because poolStateEligible excludes a
    request-owned credential from Pool state entirely.

    But flipping that predicate is not sufficient, and the reason is worth recording. I implemented
    it in #5024 and the hosted suite showed that the exclusion was doing three jobs rather than one.
    Beyond keeping main out of the comparison, it was also keeping main out of:

    1. the shared strategy cursor. With main selectable, a request-owned bearer's selection wrote
      __main__ into config.activeCodexAccountId — codex-auth-context.test.ts:1351 catches it
      exactly. A credential that exists for one request must not move durable Pool state, and nothing
      had to fence that write while main could not be selected.
    2. the cooldown-identity refusal. callerIsCooledPoolAccount is asked of the selected
      account, which was sufficient only because the cooled account always was the selection. Once
      main is selectable, a caller whose own credential is a cooled subscription gets served as main,
      resurrecting the cooldown it is inside. Three cases in the same file cover this.

    So the expected behavior in this report is right, and the implementation it suggests is not: a
    single liveness answer can make main a candidate but cannot keep that candidacy out of Pool state.

    The shape that fits: ask the existing non-persisting selector,
    previewCodexAccountForRequest, whether main wins the ordering for a request-owned bearer. If it
    does, serve resolveCallerOwnedMainContext(). If it does not, leave main ineligible for the real
    selection, so the cursor, the cooldown identity check and the probe lease keep behaving exactly as
    they do today. That answers the ordering question the report is about and writes nothing.

    Account-gated models stay out of it either way: main's roster is discovered from the stored
    credential this request may not read, so candidacy alone cannot produce a grant. That set currently
    holds one non-flagship slug (ACCOUNT_GATED_NATIVE_OPENAI_MODELS), so it does not affect the models
    in the report.

    #5024 carries the confirmed diagnosis and the failing evidence. It is not ready to merge, and I did
    not relax the three cooldown tests to make it green — they are the invariant, not the obstacle.

  4. added 2 commits that reference this issue on Sep 19, 2026
    8a07baf
    ac78d95
  5. lidge-jun commented on Sep 23, 2026

    @lidge-jun
    Owner

    Landed on dev in ac78d95 via #5024: a request carrying the main login's own bearer now lets main take part in pool ordering, so usage, priority and reset ordering apply to it instead of the pool comparing only the stored accounts. Merges into dev do not auto-close issues, so closing manually; it ships with the next release cut from dev. Please reopen if it still reproduces there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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