Skip to content

fix(codex): stop selecting accounts that cannot serve the request (#4768, #4778) - #4797

Merged
lidge-jun merged 6 commits into
devfrom
codex/account-selection-2570
Sep 16, 2026
Merged

lidge-jun merged 6 commits into
devfrom
codex/account-selection-2570

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Summary

Two pool-selection failures users hit today, fixed inside the existing eligibility gate.

A pooled account whose plan cannot serve the requested model could be selected, and the upstream then returned an unsupported-model 400 (#4768). The entitlement machinery already existed but only ran for ACCOUNT_GATED_NATIVE_OPENAI_MODELS, which holds Daybreak alone. Adding the flagships to that set would have failed closed: a timed-out fetch or a lagging shard would make Sol or Astra vanish from the picker, which is #3022 and which the 2026-09-04 owner decision deliberately removed them from. Instead this reports evidence of the opposite polarity, listing only accounts whose own confirmed roster definitively omits the model and dropping those inside getEligiblePoolAccounts ahead of the priority tier. Unknown, unconfirmed, expired and too-old-client rosters stay unknown, a grant under any client version clears a denial recorded under another, and the full list is restored whenever filtering would leave no candidate. The read is synchronous and cache-only, so no upstream fetch joins the request path.

A conversation carrying uploaded files was refused after rotation instead of staying on the account that issued the files (#4778). conversationCarriesUploadedFiles is the predicate the existing refusal already uses, so it is reused rather than duplicated, and the retention applies the default cache-affinity bar even when pool.cacheAffinity is false. That flag trades cache locality for capacity; it was never asking to trade correctness for capacity, and under the default it already retains a healthy bound account, so this is inert there.

The third commit fixes a regression found while auditing against the release contract: the denial filter ran before selectPriorityTier, so a pinned account a roster denied was removed from the list entirely. That is worse than a demotion, because selectPriorityTier reads the pin to lower the tier ceiling, so a filtered-out pin silently re-enables the tiers the operator excluded. The pin is now exempt, with a regression that fails on the pre-fix code.

isCodexAccountSelectable and codexAccountBlockReason are byte-identical to dev. Both new fields are ignored by account-usability.ts and absent from sharedStateSelectionOptions, so neither can become an eligibility boundary or leak into shared routing state.

Closes #4768
Closes #4778

Verification

  • Ten new cases, each paired with a negative: denials never empty the candidate set, a denial outside the pool changes nothing, the pin is never dropped, an expired or too-old-client roster is unknown rather than denied, a grant clears a denial, retention yields to genuine exhaustion, and retention is inert under the default cache affinity.
  • Static and source-oracle review against current dev; the behavioural assertions are proven by exact-head hosted CI on this branch, which is the gate for this PR.
  • No local suite, focused test, typecheck, build, install, or service command was run.

Checklist

  • Targets dev.
  • Documented invariant change recorded: structure/providers/openai-tiers.md gains an Uploaded-file account retention section, and the forward reference in structure/transports/responses.md is resolved. File-carrying conversations are now exempt from the bound-thread rebind list when pool.cacheAffinity is off.
  • No default is altered and no authorization surface is widened.
  • Exact-head hosted CI is green.
  • Maintainer review is complete.

Summary by CodeRabbit

  • New Features

    • Improved account selection to avoid accounts confirmed unable to serve the requested model, while preserving manually pinned accounts and ensuring a fallback remains available.
    • Conversations referencing uploaded files now retain their issuing account during voluntary quota-based reassignments, improving attachment continuity.
    • Flagship models remain visible even when account-specific availability differs.
  • Documentation

    • Clarified account retention and model availability behavior for uploaded-file conversations and flagship models.

…es the model

A pool holding a Plus account and a Free account handed `gpt-5.6-sol` and
`gpt-6-astra` to whichever account rotation reached first, and the Free account
answered with the upstream unsupported-model 400. The roster evidence needed to
avoid that already existed; selection never consulted it, because entitlement is
only activated for `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` and the flagships were
deliberately removed from that set on 2026-09-04.

Adding them back is not the fix. That set fails closed on ABSENCE of evidence --
membership hides the row from the catalog and refuses before dispatch -- so a
timed-out fetch or a lagging shard would make the model vanish from the picker,
which is #3022.

So this adds evidence of the opposite polarity. `cachedDeniedCodexAccountIdsForModel`
reports only accounts whose own confirmed roster definitively omits the model, and
`getEligiblePoolAccounts` drops them before the priority tier -- unless that would
leave no candidate, in which case the full list is restored. Unknown, stale, expired
and too-old-client rosters stay unknown and change nothing. Nothing refuses, so the
existing bounded alternate-account retry on an exact unsupported-model 400 remains
the safety net, and it now picks an entitled alternate for the same reason.

The read is synchronous and cache-only. The gated path may await discovery because a
gated model is rare; the flagships are the most requested models in the product, and
an authenticated fetch per account on that path would trade one occasional 400 for
latency on every turn. Background catalog sync already warms the full roster per
account, so the evidence is there without asking for it.

Worst case on the happy path is unchanged selection: a single-account pool, an
install with no cached rosters, and a pool where every account is denied all resolve
exactly as they do today.

The source-oracle assertion in the fallback-preview test is relaxed from one pinned
property to "both sites still forward `modelEligibleAccountIds`", which is what it
says it is protecting; the options object now also carries the per-candidate
preference.

Closes #4768
…loaded files

#4710 made an orphaning account change fail closed with
`409 account_change_file_scope`. That stops the invalid request but does not
restore the conversation: the file reference stays in history, so every later turn
presents it and is refused the same way. Pool rotation is automatic, so any
conversation with an attachment was one rotation away from being permanently
blocked, and both escapes the error names -- re-upload, or start over -- ask the
user to absorb a routing decision they never made and cannot see.

The refusal was the middle step. This is the other half: retain the binding so the
rotation that causes it happens less often.

`conversationCarriesUploadedFiles` already answers from the body alone, and it is
the same predicate the refusal guard uses, so routing and refusal cannot disagree
about which conversations are in scope. `resolveResponsesCodexAuth` carries the
answer into `retainAccountForUploadedFiles`, and `mayRebindAffinityForQuota`
applies the default cache-affinity bar even when `pool.cacheAffinity` is false.
That flag trades cache locality for capacity; it was never asking to trade
correctness for capacity, and under the default it already retains a healthy bound
account, so this is inert there.

Voluntary moves only. Genuine exhaustion and an unusable account still release the
binding, and every involuntary release that runs earlier -- quota refusal, failover
streak, pause, cooldown, lost generation, expiry -- is untouched. A pinned
conversation therefore cannot be wedged on an account that cannot serve it, which is
why the #4710 refusal stays: this reduces how often it fires and can never replace
it. The preview path carries the same flag so subagent fallback is not scored
against a move the request will not make.

Structure: adds "Uploaded-file account retention" to openai-tiers.md and resolves
the forward reference in transports/responses.md, which reserved this to routing.
Also records the #4768 always-visible-flagship selection contract in the
account-gated natives section, which that commit changed without documenting.

Closes #4778
The entitlement ordering added for #4768 runs before `selectPriorityTier`, so a
pinned account that a roster denies was removed from the candidate list. That is
more than a demotion: `selectPriorityTier` reads the pin to lower the tier ceiling,
so a pin filtered out beforehand stops acting as a ceiling at all and silently
re-enables the tiers the operator had excluded.

Roster evidence orders the pool's own discretion; it does not overrule an explicit
human choice. An operator who pins an account that upstream will refuse still gets
the bounded alternate-account retry -- what they do not get is the pool quietly
deciding they were wrong.

Eligibility is unchanged either way. `isCodexAccountSelectable` remains the sole
authority for pause, plan exclusion, quota cooldown and avoidance, soft avoidance,
refresh cooling and usability, and `codexAccountBlockReason` still reports which
guard fired; this rule only ever narrows a list those guards already produced, and
still restores it in full when narrowing would leave nothing.

Refs #4768
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 6e451dda-5729-471b-9d6a-3c9d7bc97488

📥 Commits

Reviewing files that changed from the base of the PR and between ada3a9b and fe3d006.

📒 Files selected for processing (17)
  • devlog/_plan/260916_release_2570_stabilization/031_openai_tiers_split.md
  • scripts/test-layout/layout.json
  • src/codex/account-usability.ts
  • src/codex/auth-context.ts
  • src/codex/model-entitlements.ts
  • src/codex/routing.ts
  • src/codex/routing/cache-affinity.ts
  • src/codex/routing/selection.ts
  • src/server/responses/core-auth.ts
  • src/server/responses/request-prepare.ts
  • structure/manifest.json
  • structure/providers/openai-tiers.md
  • structure/transports/responses.md
  • tests/codex-integration/codex-account-selection-preferences.test.ts
  • tests/codex-integration/codex-model-entitlements.test.ts
  • tests/fixtures/test-layout-expected.json
  • tests/routing/subagent-fallback-handle-responses.test.ts

📝 Walkthrough

Walkthrough

The change adds cached model-entitlement evidence to Codex account selection and retains accounts for conversations with uploaded files during voluntary quota moves. It updates request wiring, routing predicates, tests, documentation, and layout metadata.

Changes

Account routing behavior

Layer / File(s) Summary
Cached model entitlement evidence
src/codex/model-entitlements.ts, tests/codex-integration/codex-model-entitlements.test.ts
The new cache reader reports confirmed denials for preferred native models. Unknown, stale, unsupported, and mismatched evidence does not produce a denial.
Model-aware account selection
src/codex/account-usability.ts, src/codex/routing/selection.ts, src/codex/routing.ts, src/codex/auth-context.ts
Selection filters denied accounts before priority selection and corrects a denied active shared cursor without persisting the correction. Pinned accounts and empty candidate lists remain protected.
Uploaded-file quota retention
src/codex/routing/cache-affinity.ts, src/codex/routing.ts, src/codex/auth-context.ts, src/server/responses/core-auth.ts, src/server/responses/request-prepare.ts, tests/codex-integration/codex-account-selection-preferences.test.ts
Conversations with uploaded-file references retain their bound account during voluntary quota moves. Unusable or fully exhausted accounts can still rebind.
Request wiring, documentation, and validation
src/server/responses/request-prepare.ts, structure/providers/openai-tiers.md, structure/transports/responses.md, structure/manifest.json, devlog/_plan/..., scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json, tests/routing/subagent-fallback-handle-responses.test.ts
Preview paths receive model-denial evidence, documentation records both routing contracts, the oversized document is tracked, and tests and layout metadata are updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: luvs01

Sequence Diagram(s)

sequenceDiagram
  participant ResponsesRequest
  participant EntitlementCache
  participant AccountSelection
  participant AuthContext
  participant AccountPool
  ResponsesRequest->>EntitlementCache: read cached denial evidence for model
  EntitlementCache-->>ResponsesRequest: denied account IDs or no evidence
  ResponsesRequest->>AccountSelection: preview with denial and retention options
  AccountSelection->>AccountPool: choose entitled account or retain bound account
  AccountPool-->>AccountSelection: selected account
  AccountSelection->>AuthContext: resolve final account with same options
  AuthContext-->>ResponsesRequest: resolved account
Loading

Suggested reviewers: luvs01

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/account-selection-2570

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the bug Something isn't working label Sep 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

CI caught this, and the diagnosis matters more than the patch: ordering
`getEligiblePoolAccounts` was not enough, because that list is not the only door
into selection. An account that is already ACTIVE is served straight from
`isCodexAccountSelectable` and never passes through it, so once the denied account
became the shared cursor every request kept going to it -- which is precisely the
case #4768 reports. `pickPriorityPreemption` does not rescue it either: it refuses
to move toward a tier that does not strictly outrank the active one, and a Free
account sitting in the lower tier is exactly that shape.

`preferModelEntitledAccount` closes it, and stays inside "order the already-eligible
set" by construction. It admits nothing, because the replacement is drawn from
`getEligiblePoolAccounts` and every eligibility guard has already passed on it. It
cannot fail, because with no entitled alternative it returns the active account
unchanged, so a served request can never become `none`. It changes nothing without
evidence, so absent `deniedModelAccountIds` it is the identity function. The result
is deliberately NOT persisted -- one request's correction for one model, like a model
detour -- and a pinned active account is exempt outright. Preview applies the same
correction so subagent fallback cannot score a model against an account the request
will not use.

`mayRebindAffinityForQuota` and `retainsBoundAccountForQuota` move to the new
`src/codex/routing/cache-affinity.ts`. The retention policy is a question two call
sites ask and it reads better owned by one module than inlined in `routing.ts`; it
also returns that file to its file-size ratchet cap, which the previous head had
exceeded.

Refs #4768 #4778
Three files had grown past their file-size ratchet caps. The ratchet only ever
lowers a baseline, so the cases move rather than shrink.

`tests/codex-integration/codex-routing.test.ts` returns byte-for-byte to its `dev`
contents, and the seven cases it had gained now live in
`codex-account-selection-preferences.test.ts` alongside two new ones. It is
registered in both `scripts/test-layout/layout.json` and
`tests/fixtures/test-layout-expected.json` with identical values, which
`test-layout-tooling` compares directly.

`subagent-fallback-handle-responses.test.ts` was over by exactly the comment lines
added with the relaxed source-oracle regex, so the comment is rewritten to say the
same thing in the space it already had.

The two new cases cover the gap that let the active-cursor defect ship. Every
existing case supplied denial evidence, so none of them pinned the shared-cursor
path or what happens with NO evidence at all -- which is why ten green tests sat
beside a broken one. One now pins that a denial moves the request off the cursor
without persisting the move, and its companion pins that absent evidence, and
evidence about an account the cursor does not name, both leave that path exactly
as it was.

The baseline file is untouched.

Refs #4768 #4778
@lidge-jun
lidge-jun marked this pull request as ready for review September 16, 2026 10:54
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 16, 2026 10:54
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-16T10:59:01.984301Z fe3d006 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@lidge-jun
lidge-jun merged commit f35fbe8 into dev Sep 16, 2026
29 of 30 checks passed
@lidge-jun
lidge-jun deleted the codex/account-selection-2570 branch September 16, 2026 10:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe3d006db5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1226 to +1227
const identity = currentCredentialIdentity(accountId);
if (identity !== undefined && identity !== entry.credentialIdentity) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep cached denial reads behind the main-profile fence

When a flagship request is resolved while native-main reads are forbidden—such as during profile switching/recovery or for a caller-owned bearer—this loop still processes a cached __main__ entry and currentCredentialIdentity() calls getMainAccountToken(), reading auth.json inside the fence. The gated entitlement path explicitly excludes main before credential access, but this projection has no equivalent exclusion, so pass the excluded account IDs or validate from already-cached identity state without touching the physical credential.

AGENTS.md reference: src/AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

Comment on lines +1226 to +1227
const identity = currentCredentialIdentity(accountId);
if (identity !== undefined && identity !== entry.credentialIdentity) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid re-reading the account store for every cache entry

For every Sol/Terra/Luna/Astra request with warm roster evidence, this loop invokes currentCredentialIdentity() once per cached account-version entry; for pool accounts that calls readCodexAccountRecord(), which synchronously hardens, reads, and parses the entire account store. With the documented bounds of four versions per account and 64 accounts, one request can perform up to 256 full synchronous store reads on Bun's request thread. Snapshot each distinct account identity once, or retain an epoch-backed identity alongside the cache, rather than doing filesystem work per entry.

Useful? React with 👍 / 👎.

Comment on lines +941 to +947
const finalAuth = await resolveResponsesCodexAuth(
req,
config,
route,
options,
credentialDomainWasRewritten,
conversationCarriesUploadedFiles(parsed._rawBody),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain file-bound accounts during native compaction

The retention flag is wired only through the regular Responses authentication path. Native compact dispatch resolves the same pool/thread context directly in src/server/responses/compact.ts:731 without retainAccountForUploadedFiles; therefore, with pool.cacheAffinity: false and an over-threshold issuing account, a compact request containing file_id can still voluntarily move to another account and immediately fail the existing account-change guard. Thread the same body predicate into compact's initial auth resolution.

Useful? React with 👍 / 👎.

Comment on lines +692 to +696
{
...recoverySelectionOptions,
modelEligibleAccountIds,
deniedModelAccountIds: cachedDeniedCodexAccountIdsForModel(modelId, previewNow),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve file retention in recovery previews

When encrypted task recovery reruns subagent fallback, this reconstructed preview options object includes model eligibility and denial evidence but drops retainAccountForUploadedFiles. For a file-carrying bound thread with cache affinity disabled and quota above the threshold, the recovery preview can therefore move to account B and choose a fallback model based on B, while final authentication retains account A. Carry the uploaded-file retention bit into recoverySelectionOptions so preview and final resolution remain consistent.

Useful? React with 👍 / 👎.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant