Skip to content
Merged
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,8 @@ In **Codex Set → Multi-auth**, enable the **Codex credits** switch in the **Co

When an account reaches 100% on a usage window and still holds credits, upstream keeps serving it and draws the balance. OpenCodex does not let that happen by default: an account at 100% is switched out while its weekly or monthly window (only monthly on 30-day plans) or its 5-hour window is full, and used again once that window resets. The order of the other accounts does not change, and when no other account is available, selection finds none rather than spending credits. A request for the main account is refused like a hard-lock refusal until the reset. A full 5-hour window without a reset time holds an account only while that reading is fresh. Allowing the main account does not lift its hard lock (on by default at 98%), which still stops it first; turn the lock off if the main account should spend credits (a lock at 100% still stops it at 100%). To let accounts keep working from their credits, turn on **Use credits** next to the **Codex credits** switch in the Codex Auth header. The switch allows every account. To choose accounts one by one, open an account card's **⋯** menu and use **Use credits after limit** there; the header switch shows a middle position when only some accounts are on. An account allowed to spend carries a **Uses credits** badge on its card. New accounts start off. The choice is stored in `creditCodexAccountIds` and never redeems reset credits; the **Codex credits** display switch only shows balances and never changes routing.

For an opted-in main account with a full usage window, the background recovery cycle renews previously spendable credit observations from three minutes of age, even while the dashboard is closed. It prepares a valid token before the authenticated usage lookup so the balance can be observed again before its five-minute freshness limit. Missing, zero, restricted or retracted credits do not trigger this renewal. A failed or incomplete lookup does not refresh old balance evidence, and requests are still refused once that evidence expires. The separate main-account hard lock and upstream retry delays still apply. This metadata lookup does not send a model validation request or set or clear the account's needs-reauth state.

Background revalidation is separate and off by default. It requires Token Guardian, the `openai` provider's `proactive` refresh policy, and `tokenGuardian.codexWarmupEnabled`. It skips accounts awaiting deferred registration validation.

### Cancelling main-account device reauthentication
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。

在 **Codex Set → Multi-auth** 中,打开 **Codex Auth** 标题栏中的 **Codex 额度** 开关,即可在 Week 下方显示主账号和各池账号最近查询到的积分。默认关闭,并保存为 `showCodexCredits`。余额按地区格式显示;上游报告时会显示无限额或超额使用上限警告。由于没有积分总上限,条形表示可用状态而非百分比。此开关仅控制显示,新登录需等待其自身的查询结果。

允许主账号在额度耗尽后使用积分且用量窗口已满时,后台恢复周期会从观察满三分钟起更新此前可用的积分余额,即使仪表盘已关闭。查询前先准备有效令牌,为五分钟有效期到期前重新观察余额留出时间。缺失、零余额、受限或已撤回的积分不会触发更新。查询失败或信息不完整不会刷新旧余额证据;证据过期后请求仍会被拒绝。独立的主账号硬锁和上游重试等待期限仍然适用。这是已认证的用量查询,不发送模型验证请求,也不会设置或清除账号的 needs-reauth 状态。

后台重新验证是独立功能,默认关闭。它要求 Token Guardian、`openai` 的 `proactive` 刷新策略及 `tokenGuardian.codexWarmupEnabled`,并跳过等待注册验证的账号。

### 账号停止处理请求的原因
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1158,7 +1158,7 @@
"main-account-hard-lock-auth.test.ts": "codex-integration",
"main-account-hard-lock-default.test.ts": "codex-integration",
"main-account-hard-lock-policy.test.ts": "codex-integration",
"main-account-hard-lock-recovery.test.ts": "codex-integration",
"main-account-hard-lock-recovery.test.ts": "codex-integration", "main-account-credit-renewal.test.ts": "codex-integration",
"main-account-policy-binding-wait.test.ts": "codex-integration",
"main-device-reauth-api.test.ts": "codex-integration",
"main-device-reauth-ui.test.ts": "gui",
Expand Down
33 changes: 25 additions & 8 deletions src/codex/auth-api/main-account-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ export async function fetchMainAccountInfo(forceRefresh = false, config?: OcxCon
return (await fetchMainAccountInfoSnapshot(forceRefresh, config)).info;
}

export interface MainAccountInfoProbeOptions {
/** Metadata-only renewal never sets or clears the traffic reauth quarantine. */
passive?: boolean;
}

export const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };

export async function retryMainAccountInfoIfIdentityChanged(
Expand All @@ -154,12 +159,13 @@ export async function retryMainAccountInfoIfIdentityChanged(
nativeMainLease: AdmissionLease,
explicitRefresh: boolean,
paced: boolean,
options: MainAccountInfoProbeOptions = {},
): Promise<MainAccountInfoFetchResult | null> {
const currentAccountId = getMainChatgptAccountId();
if (currentAccountId === null || currentAccountId === requestAccountId) return null;
reconcileMainCodexAccountRuntimeState();
return retriesRemaining > 0
? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh, false, paced)
? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh, false, paced, options)
: { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
}

Expand All @@ -171,6 +177,7 @@ export async function fetchMainAccountInfoAttempt(
explicitRefresh: boolean = forceRefresh,
postReset = false,
config?: OcxConfig,
options: MainAccountInfoProbeOptions = {},
): Promise<MainAccountInfoFetchResult> {
const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
if (!nativeMainLease) {
Expand All @@ -184,7 +191,7 @@ export async function fetchMainAccountInfoAttempt(
try {
const paced = sharedMainQuotaPacingEnabled(config ?? loadConfig());
const operation = async () => ({
...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh, postReset, paced),
...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh, postReset, paced, options),
identityGeneration: captureMainAccountIdentityGeneration(),
});
if (nativeMainSharedClaimHeld) return await operation();
Expand Down Expand Up @@ -225,6 +232,7 @@ export async function fetchMainAccountInfoWhileOwned(
/** Reset-credit consume needs a fresh post-spend observation, not an earlier read. */
postReset = false,
paced = true,
options: MainAccountInfoProbeOptions = {},
): Promise<MainAccountInfoFetchResult> {
const writerGeneration = captureConfigGeneration();
reconcileMainCodexAccountRuntimeState();
Expand Down Expand Up @@ -287,7 +295,16 @@ export async function fetchMainAccountInfoWhileOwned(
const current = credentialIsCurrent() && writerGeneration === captureConfigGeneration();
if (!current) return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
credentialChecked: true, hasCredential: true };
if (explicitRefresh && (admission.result.quotaRefresh?.status === "ok"
// Single-flight shares evidence, but each caller retains its own auth behavior.
if (!options.passive && admission.result.terminalAuthFailure) {
const diagnosticStillLive = admission.result.quotaRefreshGeneration !== undefined
&& isMainAccountIdentityGenerationLive(admission.result.quotaRefreshGeneration);
clearMainAccountInfoCache();
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
return { ...admission.result, ...(diagnosticStillLive
? { quotaRefreshGeneration: captureMainAccountIdentityGeneration() } : {}) };
}
if (!options.passive && explicitRefresh && (admission.result.quotaRefresh?.status === "ok"
|| admission.result.quotaRefresh?.status === "not_reported")) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
return admission.result;
}
Expand All @@ -297,13 +314,13 @@ export async function fetchMainAccountInfoWhileOwned(
if (!resp.ok) {
const authFailure = await classifyMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
const terminalAuthFailure = authFailure.terminal;
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced);
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced, options);
if (retried) return retried;
if (!isQuotaDispatchCurrent(dispatchSequence) || !credentialIsCurrent()) {
return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
credentialChecked: true, hasCredential: true };
}
if (terminalAuthFailure) {
if (terminalAuthFailure && !options.passive) {
// Account for this attempt's own synchronous invalidation, never prior external drift.
const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration);
clearMainAccountInfoCache();
Expand All @@ -324,7 +341,7 @@ export async function fetchMainAccountInfoWhileOwned(
quotaPhase = "body";
const data = (await resp.json()) as WhamUsageResponse;
quotaPhase = "publish";
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced);
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced, options);
if (retried) return retried;
quotaPhase = "decode";
if (data === null || typeof data !== "object" || Array.isArray(data)) {
Expand Down Expand Up @@ -376,7 +393,7 @@ export async function fetchMainAccountInfoWhileOwned(
// never settled and the dashboard kept showing nothing — the symptom #327 reported.
// An explicit refresh is an operator asking to re-evaluate, normally right after
// signing in again, so it stays authoritative.
if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
if (explicitRefresh && !options.passive) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
// Mirror main quota + plan into the shared stores so the rotation engine can
// score and auto-switch the main account exactly like a pool account (Option A).
setMainAccountPlan(result.plan);
Expand All @@ -398,7 +415,7 @@ export async function fetchMainAccountInfoWhileOwned(
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
};
} catch (error) {
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced);
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced, options);
if (retried) return retried;
if (!credentialIsCurrent()) {
return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
Expand Down
42 changes: 33 additions & 9 deletions src/codex/auth-api/pool-mode-gate.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { codexAccountUsesCreditsAfterLimit } from "../account-credit-use";
import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "../account-credit-use";
import { nextCodexUsageQueryAt, nextQuotaQueryDelay, pruneRemovedCodexPoolUsageAccounts } from "../quota-query-backoff";
import { CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "../account-priority";
import { codexQuotaHasFreshUsage } from "../quota-observation-freshness";
import { getCodexAccountCredential, getValidCodexToken, readCodexAccountRecord } from "../account-store";
import { getAccountQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota";
import { getAccountQuota, getMainPolicyQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota";
import { isCodexAccountPaused } from "../account-pause";
import { hasSpendableCodexCredits } from "../quota-types";
import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing";
import { readCodexTokens } from "../auth-collision";
Expand Down Expand Up @@ -73,17 +75,33 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
return cooldownRecoveryInFlight;
}

const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000;

let mainHardLockRecoveryInFlight: Promise<void> | null = null;
let mainHardLockRecoveryAttempt: { identity: number; credential: number; after: number; delay: number } | undefined;

/** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight;
/** Schedule a real credit observation while leaving actual-time admission unchanged. */
const creditRecoveryNeeded = (): boolean => {
const now = Date.now();
const quota = getMainPolicyQuota();
const observedAt = quota?.credits?.observedAt;
// getMainPolicyQuota binds evidence to the observed physical identity. Only renew
// valid formerly spendable funds; admission retains the actual five-minute clock.
return codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)
&& !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
&& codexUsageLimitResetAt(quota, undefined, now) !== undefined
&& observedAt !== undefined && observedAt > 0
&& now - observedAt >= MAIN_CREDIT_RENEWAL_AGE_MS
&& hasSpendableCodexCredits(quota, observedAt);
};
const status = getMainAccountHardLockStatus(config);
if (status.state !== "blocked") { mainHardLockRecoveryAttempt = undefined; return; }
if (status.state !== "blocked" && !creditRecoveryNeeded()) { mainHardLockRecoveryAttempt = undefined; return; }
if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
// A predicted reset is a scheduling hint, never proof that the hard lock can be lifted.
if (status.resetAt !== undefined && status.resetAt > Date.now()) return;
if (status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()) return;
// Reconcile the owned physical credential before skipping work for an older one.
reconcileMainCodexAccountRuntimeState();
const physical = readCodexTokens();
Expand All @@ -98,9 +116,10 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
if (!lease) return;
mainHardLockRecoveryInFlight = (async () => {
reconcileMainCodexAccountRuntimeState();
if (getMainAccountHardLockStatus(config).state !== "blocked"
if ((getMainAccountHardLockStatus(config).state !== "blocked" && !creditRecoveryNeeded())
|| isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
const identityGeneration = captureMainAccountIdentityGeneration();
const passive = getMainAccountHardLockStatus(config).state !== "blocked";
try {
// Refresh can require an exclusive credential claim: never hold WHAM's shared
// claim while obtaining a valid token. The runtime lease spans both operations.
Expand All @@ -111,7 +130,12 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
// Native refresh retains its own grant-scoped refusal; global quarantine would outlive it.
return;
}
if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
const currentStatus = getMainAccountHardLockStatus(config);
if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
|| !isMainAccountIdentityGenerationLive(identityGeneration)
|| (passive && !creditRecoveryNeeded())
|| (currentStatus.state === "blocked" && currentStatus.resetAt !== undefined
&& currentStatus.resetAt > Date.now())) return;
const credential = getMainQuotaCredentialGeneration();
// Reconcile the physical bearer before consulting the previous credential's delay.
if (previous?.identity === identityGeneration && previous.credential === credential
Expand All @@ -123,7 +147,7 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
after: currentQueryAfter };
return;
}
const result = await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config);
const result = await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config, { passive });
// Never charge a replacement credential for a late result from its predecessor.
if (isMainAccountIdentityGenerationLive(identityGeneration)
&& credential === getMainQuotaCredentialGeneration()) {
Expand All @@ -134,11 +158,11 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
const authStatus = result.quotaRefresh.status === "http_error"
? result.quotaRefresh.httpStatus : undefined;
const transientAuth = (authStatus === 401 || authStatus === 403)
&& !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
&& !result.terminalAuthFailure && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
if (transientAuth) mainHardLockRecoveryAttempt = undefined;
else {
const delay = nextQuotaQueryDelay(previousDelay || undefined);
mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked"
mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked" || creditRecoveryNeeded()
? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter) }
: undefined;
}
Expand Down
2 changes: 2 additions & 0 deletions structure/providers/openai-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@ false. Exact-account and Direct routes are unchanged.

## Main-account policy observations

Opted-in main-account credit renewal follows the [spendable credit contract](openai-tiers.md#spendable-codex-credits). The existing background sweep prepares a token before WHAM, rechecks eligibility, and uses a passive probe that neither sets nor clears needs-reauth, including after identity retries. Other probes keep their existing auth behavior. A non-passive caller joining renewal applies terminal-auth quarantine, or clears it on a successful explicit refresh, only while its credential and configuration fences remain current; shared evidence does not inherit the owner's passive intent.

The main-account admission policy defaults to 90% for short windows and 98% for long windows;
`codexMainAccountHardLockThresholds` permits ordered integer thresholds from 80 through 100.
Policy evidence retention uses the minimum configurable 80%, so a partial update or elapsed reset
Expand Down
Loading
Loading