diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 812cadd22f..ac12ca7081 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -25,7 +25,9 @@ import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./nat import type { NativeMainStartupBlockReason } from "./native-profile-startup"; import { codexQuotaScopeForModel, + computeCodexUsageScore, getCodexQuotaHealthSnapshot, + isEffectiveCodexAccountPinned, releaseCodexQuotaProbeLease, releaseCodexQuotaScopeProbeLease, tryAcquireCodexQuotaProbeLease, @@ -42,7 +44,7 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; import type { CodexCooldownSource, CodexQuotaScope } from "./routing"; import { maskAccountId } from "../lib/privacy"; import { formatErrorResponse } from "../bridge"; -import { getAccountQuota } from "./quota"; +import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; @@ -52,6 +54,21 @@ import { extractAccountId } from "../oauth/chatgpt"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; const CODEX_APP_AFFINITY_KEY = randomBytes(32); +/** + * A request-owned bearer cannot inspect the physical main credential for its plan, but cached + * WHAM usage is still valid routing evidence for the same logical main account. Score it with + * the conservative unknown-plan rule: an unobserved governing window preserves the pin, while + * any known weekly/monthly/short value at the threshold releases it through the ordinary Pool + * path. This keeps the keyring boundary intact instead of reading auth.json just to classify a + * request that already brought its own credential (#3157). + */ +function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold <= 0) return true; + const usage = computeCodexUsageScore(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)); + return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold; +} + function boundedCodexAffinityComponent(value: string | null): string | undefined { const normalized = value?.trim(); if (!normalized) return undefined; @@ -372,6 +389,12 @@ export async function resolveCodexAuthContext( const requestScopedMainCredential = options.requestScopedMainCredential === true && hasCallerCodexBearer(headers); const fixedAccountId = options.accountId; + const preserveRequestOwnedMainPin = requestScopedMainCredential + && fixedAccountId === undefined + && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID + && isEffectiveCodexAccountPinned(config) + && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && requestOwnedMainPinHasQuotaHeadroom(config); if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); } @@ -425,6 +448,19 @@ export async function resolveCodexAuthContext( directSelectionAdmission.release(); } }; + // Pool discovery excludes request-owned main credentials by design: they must never be folded + // into stored-account entitlement, affinity, or persistence state. An effective manual main pin + // is the one exception where that exclusion is selection evidence in the opposite direction. + // Validate the caller's own gated-model roster before using it, and fall through to a Pool model + // detour when it lacks the grant. This branch performs no physical-main credential read. + if (preserveRequestOwnedMainPin) { + const callerEntitled = !options.modelId + || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) + || await ( + options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel + )(headers, options.modelId); + if (callerEntitled) return { kind: "main", accountId: null }; + } // An explicit namespace binding is stronger than the provider's default mode. It must use the // selected stored credential even while the canonical OpenAI provider is globally Direct. // A request-owned bearer is deliberately not represented as `main-pool`: Pool account ids own @@ -473,7 +509,10 @@ export async function resolveCodexAuthContext( // it. Retained recovery makes main wholly ineligible so pool routing continues. nativeMainSelectionOnly, isMainAccountTokenLive: requestScopedMainCredential - ? () => false + // Main stays excluded from this request's model roster below. This synthetic liveness is + // consulted only by shared-state preservation, so a caller-owned pin survives a model + // detour without reading or selecting the physical main credential. + ? () => preserveRequestOwnedMainPin : options.isMainAccountTokenLive, modelEligibleAccountIds, }; diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index ab15088fb4..d563f4989f 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -108,6 +108,29 @@ pinned account and the effective active account are different questions, and the both (`pinned` and `pinnedAccountId`). A surface that marks only the active account loses the pin from view exactly when it is doing the most work — suppressing every higher tier. +A keyring-backed Codex request can carry its own forwardable ChatGPT bearer while the provider remains +in Pool mode. When the effective manual pin is `__main__`, main is not paused, and its cached quota still +has headroom, auth resolution validates the caller bearer's own gated-model roster and uses that +request-owned credential before stored-Pool selection. The credential never enters Pool persistence, +affinity, entitlement cache, or health state, and this decision never reads the physical main credential. +If the caller lacks the requested model, a stored-account model detour may serve the request without +clearing the healthy shared main pin. A paused or quota-drained main skips this exception and follows the +ordinary Pool promotion path. + +[Decision Log] +- 목적과 의도: Keep an explicit healthy main selection from being replaced by an exhausted stored + account merely because the client supplied main through a request-owned keyring bearer. +- 기존 구현 및 제약 조건: Request-owned credentials are deliberately excluded from stored-account + entitlement discovery, but shared-state preservation interpreted that exclusion as a dead main login. +- 검토한 주요 대안: Persist the caller credential, read the physical main token for identity, ignore + the manual pin, or validate the caller independently before stored-Pool selection. +- 선택한 방식: Use only the effective pin, pause state, cached quota, and the caller credential's own + gated-model check; synthesize shared-state liveness only while main stays request-ineligible. +- 다른 대안 대신 이 방식을 선택한 이유: It preserves credential isolation and explicit operator + intent without admitting an unentitled model or binding an ephemeral bearer into durable Pool state. +- 장점, 단점 및 영향: Healthy main pins survive keyring requests and model-only detours; cached quota + remains the only proactive drain evidence available without crossing the physical credential boundary. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option main/gpt-daybreak-blue-latest # openai; observed account-native Daybreak, Sol capability metadata diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 93653a3002..9a424f1b32 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -47,6 +47,7 @@ import { handleCodexAuthAPI, isAccountNeedsReauth, markAccountNeedsReauth, + setAccountQuotaFromParsed, } from "../src/codex/auth-api"; import { __resetGuardianState, guardianSweep } from "../src/oauth/token-guardian"; import { @@ -1088,6 +1089,91 @@ describe("Codex auth context", () => { clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); } }); + + async function resolveRequestOwnedMainPinCase(options: { + mainWeeklyPercent: number; + poolWeeklyPercent: number; + callerEntitled: boolean; + }): Promise<{ + cfg: OcxConfig; + context: Awaited>; + directEntitlementChecks: number; + }> { + const cfg = config(); + cfg.accountPoolStrategy = "quota"; + cfg.autoSwitchThreshold = 90; + cfg.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID; + cfg.activeCodexAccountPinned = MAIN_CODEX_ACCOUNT_ID; + cfg.codexAccountPriorities = { + [MAIN_CODEX_ACCOUNT_ID]: 0, + "pool-a": 0, + }; + resetCodexRoutingForManualSelection(MAIN_CODEX_ACCOUNT_ID); + saveCodexAccountCredential("pool-a", { + accessToken: "pool-token", + refreshToken: "pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool-account", + }); + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { weeklyPercent: options.mainWeeklyPercent }); + setAccountQuotaFromParsed("pool-a", { weeklyPercent: options.poolWeeklyPercent }); + let directEntitlementChecks = 0; + const context = await resolveCodexAuthContext(new Headers({ + authorization: "Bearer caller-keyring-token", + "chatgpt-account-id": "caller-keyring-account", + }), cfg, "pool", { + requestScopedMainCredential: true, + modelId: "gpt-5.6-sol", + isDirectCallerEntitledToCodexModel: async () => { + directEntitlementChecks += 1; + return options.callerEntitled; + }, + resolveCodexModelEntitlements: async () => ({ + modelsByAccount: new Map([["pool-a", new Set(["gpt-5.6-sol"])]]), + clientVersionByAccount: new Map([["pool-a", "0.150.1"]]), + confirmedAccountIds: new Set(["pool-a"]), + credentialIdentities: new Map([["pool-a", "pool:1:pool-account"]]), + }), + }); + return { cfg, context, directEntitlementChecks }; + } + + test("a healthy manual main pin keeps the validated caller bearer ahead of an exhausted pool account (#3157)", async () => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 16, + poolWeeklyPercent: 100, + callerEntitled: true, + }); + expect(context).toMatchObject({ kind: "main", accountId: null }); + expect(directEntitlementChecks).toBe(1); + expect(cfg.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("an exhausted request-owned main pin still yields to the healthy Pool account (#3157)", async () => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 100, + poolWeeklyPercent: 16, + callerEntitled: true, + }); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(directEntitlementChecks).toBe(0); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + expect(cfg.activeCodexAccountPinned).toBeUndefined(); + }); + + test("a caller entitlement miss uses a Pool model detour without clearing the healthy main pin (#3157)", async () => { + const { cfg, context, directEntitlementChecks } = await resolveRequestOwnedMainPinCase({ + mainWeeklyPercent: 16, + poolWeeklyPercent: 20, + callerEntitled: false, + }); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect(directEntitlementChecks).toBe(1); + expect(cfg.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(cfg.activeCodexAccountPinned).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token",