55 isCodexAccountGenerationLive ,
66} from "./account-store" ;
77import { markAccountNeedsReauth } from "./account-runtime-state" ;
8+ import { isCodexAccountPaused } from "./account-pause" ;
89import { isCodexAccountUsable } from "./account-usability" ;
910import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle" ;
1011import { MAIN_CODEX_ACCOUNT_ID , getMainAccountToken } from "./main-account" ;
@@ -33,6 +34,8 @@ export type CodexAuthContext =
3334 generation : number ;
3435 accessToken : string ;
3536 chatgptAccountId : string ;
37+ /** Prevent pool selection, affinity, and active-account mutation for an exact selector. */
38+ fixedAccount ?: boolean ;
3639 /**
3740 * Set when this request was admitted through an active quota cooldown as
3841 * the account's single probe. Must be echoed into the upstream outcome so
@@ -51,6 +54,8 @@ export type CodexAuthContext =
5154 accountId : string ;
5255 accessToken : string ;
5356 chatgptAccountId : string ;
57+ /** Prevent pool selection, affinity, and active-account mutation for an exact selector. */
58+ fixedAccount ?: boolean ;
5459 /** See `pool.probeLeaseId`. */
5560 probeLeaseId ?: string ;
5661 quotaScope ?: CodexQuotaScope ;
@@ -148,22 +153,31 @@ export function cooldownAccountLabel(accountId: string): string {
148153 * as HTTP. The bare "cooling down" string left users with no route but commenting out the
149154 * injected `openai_base_url` in config.toml.
150155 */
151- export function cooldownErrorMessage ( err : CodexAccountCooldownError ) : string {
156+ export function cooldownErrorMessage ( err : CodexAccountCooldownError , accountSelector ?: string ) : string {
152157 const until = new Date ( err . cooldownUntil ) . toISOString ( ) ;
153158 const scope = err . quotaScope === "spark"
154159 ? "Spark quota"
155160 : err . quotaScope === "shared"
156161 ? "shared native quota"
157162 : null ;
158- return `Selected Codex account (${ cooldownAccountLabel ( err . accountId ) } )${ scope ? ` ${ scope } is` : " is" } cooling down until ${ until } `
159- + ` (source: ${ err . cooldownSource ?? "default" } ).`
160- + ` Run 'ocx account list openai' to find the id, then`
161- + ` 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.` ;
163+ const selected = accountSelector
164+ ? `Selected Codex account selector (${ accountSelector } )`
165+ : `Selected Codex account (${ cooldownAccountLabel ( err . accountId ) } )` ;
166+ const recovery = accountSelector
167+ ? " This request is pinned to that selector and will not switch accounts; choose another account-qualified model or retry later."
168+ : " Run 'ocx account list openai' to find the id, then"
169+ + " 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'." ;
170+ return `${ selected } ${ scope ? ` ${ scope } is` : " is" } cooling down until ${ until } `
171+ + ` (source: ${ err . cooldownSource ?? "default" } ).${ recovery } ` ;
162172}
163173
164174/** HTTP form of {@link cooldownErrorMessage}, carrying Retry-After for well-behaved clients. */
165- export function cooldownErrorResponse ( err : CodexAccountCooldownError , now = Date . now ( ) ) : Response {
166- const res = formatErrorResponse ( 429 , "rate_limit_error" , cooldownErrorMessage ( err ) ) ;
175+ export function cooldownErrorResponse (
176+ err : CodexAccountCooldownError ,
177+ now = Date . now ( ) ,
178+ accountSelector ?: string ,
179+ ) : Response {
180+ const res = formatErrorResponse ( 429 , "rate_limit_error" , cooldownErrorMessage ( err , accountSelector ) ) ;
167181 const headers = new Headers ( res . headers ) ;
168182 headers . set ( "Retry-After" , String ( Math . max ( 1 , Math . ceil ( ( err . cooldownUntil - now ) / 1000 ) ) ) ) ;
169183 return new Response ( res . body , { status : res . status , headers } ) ;
@@ -185,6 +199,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
185199
186200export interface ResolveCodexAuthContextOptions {
187201 excludeAccountId ?: string ;
202+ /** Resolve exactly this account without consulting or mutating Pool selection. */
203+ accountId ?: string ;
188204 /** Final native model selected for this request, used to select its quota group. */
189205 modelId ?: string ;
190206}
@@ -195,14 +211,22 @@ export async function resolveCodexAuthContext(
195211 mode : CodexAccountMode ,
196212 options : ResolveCodexAuthContextOptions = { } ,
197213) : Promise < CodexAuthContext > {
198- if ( mode === "direct" ) {
214+ const fixedAccountId = options . accountId ;
215+ if ( fixedAccountId !== undefined && options . excludeAccountId !== undefined ) {
216+ throw new Error ( "Codex auth context cannot select and exclude an account simultaneously" ) ;
217+ }
218+ // An explicit namespace binding is stronger than the provider's default mode. It must use the
219+ // selected stored credential even while the canonical OpenAI provider is globally Direct.
220+ if ( mode === "direct" && fixedAccountId === undefined ) {
199221 if ( ! hasCallerCodexBearer ( headers ) ) throw new CodexDirectAuthenticationError ( ) ;
200222 return { kind : "main" , accountId : null } ;
201223 }
202224 reconcileMainCodexAccountRuntimeState ( ) ;
203225 const threadId = headers . get ( "x-codex-parent-thread-id" ) ;
204226 const quotaScope = codexQuotaScopeForModel ( options . modelId ) ;
205- const resolution = options . excludeAccountId
227+ const resolution = fixedAccountId !== undefined
228+ ? { status : "selected" as const , accountId : fixedAccountId }
229+ : options . excludeAccountId
206230 ? ( ( ) => {
207231 const accountId = pickAlternateCodexAccount ( config , options . excludeAccountId ! , Date . now ( ) , quotaScope ) ;
208232 return accountId
@@ -213,12 +237,16 @@ export async function resolveCodexAuthContext(
213237 if ( resolution . status === "expired" ) throw new CodexThreadAffinityExpiredError ( resolution . accountId ) ;
214238 let accountId = resolution . status === "selected" ? resolution . accountId : null ;
215239 if ( ! accountId ) throw new CodexPoolAuthenticationError ( ) ;
240+ if ( fixedAccountId !== undefined
241+ && ( isCodexAccountPaused ( config , accountId ) || ! isCodexAccountUsable ( config , accountId ) ) ) {
242+ throw new CodexPoolAuthenticationError ( ) ;
243+ }
216244 // Lazy prime: if the selected account has no quota yet, the pool is likely
217245 // unprimed (dashboard never opened, or startup prime was blocked). Kick a
218246 // best-effort prime so the NEXT routing decision has real scores. This never
219247 // blocks the current request, and the helper's single-flight guard collapses
220248 // repeated triggers into one pass.
221- if ( ! getAccountQuota ( accountId ) ) {
249+ if ( fixedAccountId === undefined && ! getAccountQuota ( accountId ) ) {
222250 import ( "./auth-api" )
223251 . then ( ( { primeCodexPoolQuotas } ) => primeCodexPoolQuotas ( config , "pre-route" ) )
224252 . catch ( ( ) => { } ) ;
@@ -233,6 +261,11 @@ export async function resolveCodexAuthContext(
233261 let probeLeaseId : string | undefined ;
234262 let probeQuotaScope : CodexQuotaScope | undefined ;
235263 if ( cooldownUntil ) {
264+ // Exact bindings are not Pool recovery traffic. Fail closed instead of consuming the Pool's
265+ // one probe lease or selecting another account.
266+ if ( fixedAccountId !== undefined ) {
267+ throw new CodexAccountCooldownError ( accountId , cooldownUntil , cooldown ?. cooldownSource , cooldown ?. quotaScope ) ;
268+ }
236269 probeQuotaScope = cooldown ?. quotaScope ;
237270 probeLeaseId = probeQuotaScope
238271 ? tryAcquireCodexQuotaScopeProbeLease ( accountId , probeQuotaScope ) ?? undefined
@@ -256,6 +289,7 @@ export async function resolveCodexAuthContext(
256289 accountId,
257290 accessToken : token . accessToken ,
258291 chatgptAccountId : token . chatgptAccountId ,
292+ ...( fixedAccountId !== undefined ? { fixedAccount : true } : { } ) ,
259293 ...( quotaScope ? { quotaScope } : { } ) ,
260294 ...( probeLeaseId ? { probeLeaseId } : { } ) ,
261295 ...( probeQuotaScope ? { probeQuotaScope } : { } ) ,
@@ -270,6 +304,7 @@ export async function resolveCodexAuthContext(
270304 generation : token . generation ,
271305 accessToken : token . accessToken ,
272306 chatgptAccountId : token . chatgptAccountId ,
307+ ...( fixedAccountId !== undefined ? { fixedAccount : true } : { } ) ,
273308 ...( quotaScope ? { quotaScope } : { } ) ,
274309 ...( probeLeaseId ? { probeLeaseId } : { } ) ,
275310 ...( probeQuotaScope ? { probeQuotaScope } : { } ) ,
0 commit comments