Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/ko/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,14 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모
| `POST /api/codex-auth/login/cancel` | Codex 로그인 흐름을 취소합니다 | — |
| `GET /api/codex-auth/login-status` | 흐름 또는 account 로그인 상태를 조회합니다. 새 계정 완료 시 복구가 필요할 때만 `catalogRefreshPending: true`를 포함합니다. | 알 수 없는 흐름은 `expired`로 보고되며, 활성 흐름이 없으면 `idle`로 보고됩니다 |

수동 리셋에서 `code: "reset"`을 받은 뒤, 같은 계정의 새롭고 완전한 사용량 조회로 복구가 확인되면
그 계정에 남아 있던 일반 `reset-derived` 대기만 해제합니다. main과 추가 계정 모두 적용되며,
리셋 전에 시작된 조회는 복구 근거로 사용하지 않습니다. 새 오류, 명시적 `Retry-After`,
Spark/Reserve 제한, 고정 선택과 일시정지는 유지합니다. `already_redeemed`나 저장된 결과 재생은
대기를 해제하지 않습니다. 리셋 성공 후 조회가 바쁘거나 실패하거나 계정이 바뀌면 확인된 성공
코드를 반환하고 대기는 유지합니다. 같은 계정의 신선한 크레딧 수를 확인하지 못하면 `remaining`은
생략합니다. 사용량 조회를 재시도하기 위해 크레딧을 다시 소비하지 마십시오.

새 account의 config row는 저장되었지만 credential setup을 완료하지 못하면 OAuth `login-status`는
`status: "error"`를 보고하며
`code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, 필요한 경우
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,12 @@ security find-generic-password -w openrouter | ocx account add-key openrouter --
Inspect Codex reset credits for an account. Consuming a credit is destructive and requires both
`--consume` and `--yes`.

After a confirmed new reset, a fresh complete usage reading for the same account can clear its
prior ordinary quota cooldown. Other limits and newer failures remain intact. If that reading
cannot complete, the confirmed reset still succeeds and may omit the remaining credit count;
retry the account usage refresh without consuming another credit. See the
[management API reset contract](/reference/management-api/#codex-authentication-delegation).

### `ocx account main <subcommand>`

Manage named native Codex main-login profiles without changing OpenCodex account-pool routing:
Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,15 @@ requests with the original ID or a known alias replay the stored result without
consume request. A previously unseen ID supplied after settlement starts a new explicit
redemption; clients retrying an existing action should keep its ID.

A manual reset returning `code: "reset"` reconciles that account's prior ordinary
`reset-derived` cooldown only after a new, complete usage reading confirms recovery for the same
identity. This applies to main and added accounts. A usage request already running before the reset
cannot supply that evidence. Newer failures, explicit `Retry-After`, Spark/Reserve limits, pins and
pauses are preserved. `already_redeemed` and durable replay do not clear cooldowns.
If the reset is confirmed but usage reconciliation is busy, fails or changes identity, the API keeps
the confirmed success code and leaves the cooldown intact; it omits `remaining` when no fresh
same-identity credit count is available. Do not consume another credit just to retry that read.

If a new account config row is saved but credential setup cannot finish, OAuth `login-status` reports
`status: "error"` with
`code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, and optional
Expand Down
57 changes: 47 additions & 10 deletions src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
setCodexAccountPriority,
} from "./account-priority";
import {
captureCodexResetCreditCooldown,
claimDueCodexQuotaRecoveryProbes,
clearCodexAccountCooldown,
clearThreadAccountMapForAccount,
Expand Down Expand Up @@ -1417,6 +1418,7 @@ async function fetchPoolAccountQuota(
forceRefresh = false,
configuredPlan?: string,
getValidToken: typeof getValidCodexToken = getValidCodexToken,
joinExisting = true,
): Promise<PoolQuotaResult> {
const existing = getAccountQuota(accountId);
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
Expand All @@ -1431,6 +1433,13 @@ async function fetchPoolAccountQuota(
// replacement credential with the same pool id start its own request.
const record = readCodexAccountRecord(accountId);
const flights = poolQuotaRefreshInFlight.get(accountId);
if (!joinExisting && flights?.size) {
// Credential transitions can leave several old flights, including one that
// later adopts the current generation. Drain the entire pre-reset set so
// none can be joined as fresh evidence or overwrite the new quota cache.
await Promise.allSettled([...flights].map(flight => flight.promise));
return fetchPoolAccountQuota(accountId, true, configuredPlan, getValidToken);
}
const current = flights && [...flights].find(flight => {
const generation = flight.state.resolvedCredentialGeneration
?? flight.state.startCredentialGeneration;
Expand Down Expand Up @@ -2382,6 +2391,11 @@ export async function handleCodexAuthAPI(
} else {
idempotencyKey = crypto.randomUUID();
}
// Establish a first main identity while the native claim is held, before capturing its generation.
if (auth.isMain) reconcileMainCodexAccountRuntimeState();
const recoverCooldown = captureCodexResetCreditCooldown(accountId);
const resetMainGeneration = auth.isMain ? captureMainAccountIdentityGeneration() : undefined;
const resetPoolRecord = auth.isMain ? undefined : readCodexAccountRecord(accountId);
let resp: Response;
try {
resp = await fetch(
Expand Down Expand Up @@ -2428,16 +2442,39 @@ export async function handleCodexAuthAPI(
// Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
if (result.code === "reset" || result.code === "already_redeemed") {
let freshResetCredits: number | undefined;
if (auth.isMain) {
({ freshResetCredits } = await fetchMainAccountInfoAttempt(
true,
1,
auth.nativeMainLease,
auth.nativeMainSharedClaimHeld === true,
));
} else {
const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan));
try {
if (auth.isMain) {
// Main force refresh starts its own WHAM request while the native claim is held.
const fresh = await fetchMainAccountInfoAttempt(
true, 1, auth.nativeMainLease, auth.nativeMainSharedClaimHeld === true,
);
const sameIdentity = resetMainGeneration !== undefined
&& fresh.identityGeneration === resetMainGeneration
&& isMainAccountIdentityGenerationLive(resetMainGeneration)
&& getMainChatgptAccountId() === auth.chatgptAccountId;
freshResetCredits = sameIdentity ? fresh.freshResetCredits : undefined;
recoverCooldown(result.code === "reset" && sameIdentity
&& isCompleteCodexQuotaRecoverySnapshot(fresh.freshQuota ?? null, fresh.info.plan));
} else {
const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
// A flight already running when the reset completes cannot prove post-reset recovery.
const fresh = await fetchPoolAccountQuota(accountId, true, account?.plan,
getValidCodexToken, result.code !== "reset");
const currentRecord = readCodexAccountRecord(accountId);
const generation = fresh.freshCredentialGeneration;
const sameCredential = resetPoolRecord != null && generation !== undefined
&& resetPoolRecord.credential?.chatgptAccountId === auth.chatgptAccountId
&& currentRecord?.credential?.chatgptAccountId === auth.chatgptAccountId
&& currentRecord.replacedAt === resetPoolRecord.replacedAt
&& (generation === resetPoolRecord.generation || generation === resetPoolRecord.generation + 1)
&& isCodexAccountGenerationLive(accountId, generation);
freshResetCredits = sameCredential ? fresh.freshResetCredits : undefined;
recoverCooldown(result.code === "reset" && sameCredential
&& isCompleteCodexQuotaRecoverySnapshot(fresh.freshQuota ?? null, fresh.freshPlan ?? account?.plan));
}
} catch {
// The credit is already spent. Failed reconciliation must not invite another spend.
recoverCooldown(false);
}
return jsonResponse({
code: result.code,
Expand Down
33 changes: 33 additions & 0 deletions src/codex/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,39 @@ export function getCodexQuotaHealthSnapshot(
};
}

/**
* Capture the ordinary reset-derived cooldowns before an authenticated manual reset.
* The caller must prove a new reset and fresh recovery for the same credential identity.
* Object identity fences newer failures and delete/recreate ABA without claiming a probe
* lease or changing selection state while the reset is in flight.
*/
export function captureCodexResetCreditCooldown(accountId: string): (recovered: boolean) => boolean {
const shared = scopedHealthFor(accountId, "shared");
const account = upstreamHealth.get(accountId);
let settled = false;
return recovered => {
if (settled) return false;
settled = true;
if (!recovered) return false;
let cleared = false;
if (shared?.cooldownSource === "reset-derived" && scopedHealthFor(accountId, "shared") === shared) {
deleteScopedHealth(accountId, "shared");
cleared = true;
}
if (account?.cooldownSource === "reset-derived" && upstreamHealth.get(accountId) === account) {
const {
cooldownUntil: _until, cooldownSince: _since, cooldownSource: _source,
probeLeaseId: _lease, probeLeaseGeneration: _leaseGeneration, ...rest
} = account;
upstreamHealth.set(accountId, {
...rest, cooldownGeneration: (account.cooldownGeneration ?? 0) + 1, lastProbeAt: Date.now(),
});
cleared = true;
}
return cleared;
};
}

export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
return getCodexAccountCooldownUntil(accountId, now) !== null;
}
Expand Down
Loading
Loading