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
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,18 @@ Reserve입니다. 차단 중에는 그 메인 계정의 Reserve를 활성화할
실제 사용량을 다시 확인하며, 조회 실패나 잘못된 수치는 차단을 풀지 않습니다.
일시정지, 재인증, 서버의 사용량 제한은 별도로 적용됩니다.

새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고,
새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고 유효한 사용량 수치가 있으며,
2차·3차 창이 명시적 `null`이거나 그 기간도 24시간 이상으로 명시되고 사용량 수치도 함께 오면 이전 5h 수치를 대체합니다.
파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다.
현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을
요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부
요구하지 않습니다. WHAM이 선택적인 3차 필드를 생략한 경우에도, 2차 창이 명시적 `null`이고
`rate_limit.allowed`가 `true`, `rate_limit.limit_reached`가 `false`이며 1차 창이 앞의 조건을
만족하고 유효한 사용량 수치도 함께 있으면 이전 5h 수치를 대체합니다. 화면은 98% 미만인데 정책 캐시에 오래된 5h 100%가 남아
차단되던 주간 전용 계정도 사용량 새로고침으로 복구됩니다. 그 외 필드 누락, 1차 창의 기간을 모르거나, 응답 헤더만 일부
도착한 경우에는 이전 차단을 해제하지 않습니다.
같은 계정에서도 인증 토큰 교체가 관측되면 교체 전 요청의 지연 응답은 사용량 캐시나 차단 상태를 갱신하지 않습니다.
해당 요청자에게 파싱된 조회 결과를 반환할 수는 있지만, 공유 상태나 차단 해제 근거에는 반영하지 않습니다.
계정 정보가 충돌하거나 이전 토큰의 401/403 응답이 늦게 도착한 경우에는 현재 캐시를 유지하고 재인증 상태를 변경하지 않습니다.

저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock"`입니다. 값이 없거나
`true`이면 켜짐이고, `false`일 때만 꺼집니다. 스위치를 끄면 이 `false`가 저장됩니다. 기본값이
Expand Down
11 changes: 10 additions & 1 deletion docs-site/src/content/docs/reference/cli/providers-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,16 @@ its primary window explicitly lasts **at least 24 hours** and secondary/tertiary
or also explicitly last at least 24 hours and report their usage. This follows the parser's short/long boundary, so a
one-day window qualifies as well as weekly/monthly windows. The current window still uses the same
98% threshold. This relies on the single reported snapshot; repeated observations are not required.
Omitted secondary/tertiary fields, an unknown primary duration, or partial response headers cannot clear a previous block.
WHAM can omit the optional tertiary field. That two-window response also replaces the old 5h value
when secondary is explicitly `null`, `rate_limit.allowed` is `true`, and `rate_limit.limit_reached`
is `false`, with the same measured long primary requirement. Other omissions, an unknown primary
duration, or partial response headers cannot clear a previous block. This lets a successful quota
refresh recover a weekly-only account whose display is below 98% but whose policy retained an old 5h 100% value.
Once a credential replacement is observed, a delayed response from an earlier request cannot update
the usage cache or release the lock, even for the same account or after restoring the original token.
Its parsed ordinary usage can still be returned to the requesting caller, without shared-state updates
or recovery evidence. Conflicting account identities and stale 401/403 replies retain the current
cached info and cannot clear or set the current account's reauthentication state.

The persisted option is `"codexMainAccountHardLock"` in OpenCodex's `config.json`. An absent key or
`true` means on; only an explicit `false` turns it off, and that is what switching the setting off
Expand Down
41 changes: 27 additions & 14 deletions src/codex/auth-api/main-account-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ export interface MainAccountInfoFetchResult {
hasCredential: boolean;
/** Main identity generation captured while the native-main claim was held. */
identityGeneration?: number;
/** Present only when this call freshly parsed a WHAM usage response. */
/** Freshly parsed usage from the current credential; stale ordinary return values are excluded. */
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
/** Current-credential response's `rate_limit_reset_credits.available_count`, when present. */
freshResetCredits?: number;
}

Expand Down Expand Up @@ -174,6 +174,11 @@ export async function fetchMainAccountInfoAttempt(
}
}

/**
* Read native-main usage while ownership is held, publishing only current credential evidence.
* A replaced same-account bearer may return its parsed ordinary info without mutating shared
* state or supplying recovery proof. Conflicting identities and stale errors return cached info.
*/
export async function fetchMainAccountInfoWhileOwned(
forceRefresh: boolean,
retriesRemaining: number,
Expand Down Expand Up @@ -212,6 +217,11 @@ export async function fetchMainAccountInfoWhileOwned(
? observeMainQuotaCredential(tokens.access_token, tokens.account_id)
: undefined;
const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration();
/** Revalidate identity, bearer and its generation after each upstream await. */
const credentialIsCurrent = (): boolean => mainQuotaWriter !== undefined
&& isMainQuotaWriterLive(mainQuotaWriter)
&& mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
&& matchesMainQuotaCredential(tokens.access_token, tokens.account_id);
// Keep diagnostics separate from authentication and freshness policy. Never serialize errors.
const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS);
let quotaPhase: "request" | "body" | "decode" | "publish" = "request";
Expand All @@ -227,7 +237,7 @@ export async function fetchMainAccountInfoWhileOwned(
const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
if (retried) return retried;
if (!isQuotaDispatchCurrent(dispatchSequence)) {
if (!isQuotaDispatchCurrent(dispatchSequence) || !credentialIsCurrent()) {
return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
credentialChecked: true, hasCredential: true };
}
Expand All @@ -253,17 +263,15 @@ export async function fetchMainAccountInfoWhileOwned(
if (data === null || typeof data !== "object" || Array.isArray(data)) {
throw new Error("Invalid WHAM usage object");
}
// Check after body/retry awaits and before any cache, credits, policy or
// Reserve publication. Returning cached state supplies no fresh recovery proof.
// A newer published response wins over this attempt, including its returned display info.
if (!isQuotaDispatchCurrent(dispatchSequence)) {
return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
credentialChecked: true, hasCredential: true };
}
quotaPhase = "publish";
// A delayed response from a replaced bearer cannot revoke a newer Reserve grant,
// even in the same workspace or after an A→B→A credential transition.
if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
&& matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) {
if (credentialIsCurrent()) {
observeMainReserveRevocation(data, mainQuotaWriter);
}
quotaPhase = "decode";
Expand All @@ -272,16 +280,23 @@ export async function fetchMainAccountInfoWhileOwned(
const quota = parseUsageQuota(usage);
const policyQuota = parseMainPolicyUsageQuota(usage);
quotaPhase = "publish";
const freshResetCredits = quota?.resetCredits;
// Tag the count with the identity it was read from, so a later response that omits the
// summary can restore the badge without ever crossing an account boundary.
rememberMainResetCredits(requestAccountId, freshResetCredits);
const result = {
email: data.email ?? null,
plan,
quota,
ts: Date.now(),
};
if (!credentialIsCurrent()) {
// Preserve the ordinary same-identity return contract, but publish no cache, plan,
// reauth, credits or hard-lock evidence. A missing writer is never permission to publish.
return { info: mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter)
? result : getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
credentialChecked: true, hasCredential: true };
}
const freshResetCredits = quota?.resetCredits;
// Tag the count with the identity it was read from, so a later response that omits the
// summary can restore the badge without ever crossing an account boundary.
rememberMainResetCredits(requestAccountId, freshResetCredits);
setMainAccountInfoCache(result);
// Only an explicit refresh may retract a reauth quarantine. A 200 from
// /wham/usage proves the token authenticates to the usage endpoint; it does not
Expand All @@ -307,9 +322,7 @@ export async function fetchMainAccountInfoWhileOwned(
credentialChecked: true,
hasCredential: true,
...(quota ? { freshQuota: quota } : {}),
...(quota && mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter)
&& mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
&& matchesMainQuotaCredential(tokens.access_token, tokens.account_id)
...(quota && mainQuotaWriter && credentialIsCurrent()
? { resetRecoveryProof: { writer: mainQuotaWriter, credentialGeneration: mainQuotaCredentialGeneration, dispatchSequence } }
: {}),
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
Expand Down
3 changes: 2 additions & 1 deletion src/codex/quota-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export type WhamUsageResponse = {
rate_limit_upsell?: { banner_type?: unknown } | null;
rate_limit?: {
allowed?: unknown;
// WHAM sends explicit nulls for absent windows.
limit_reached?: unknown;
// WHAM can omit optional tertiary; an absent secondary is explicitly null.
primary_window?: WhamUsageWindow | null;
secondary_window?: WhamUsageWindow | null;
tertiary_window?: WhamUsageWindow | null;
Expand Down
9 changes: 6 additions & 3 deletions src/codex/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,21 +823,24 @@ function filterMainPolicyMonthlyQuota(
/**
* Parse ordinary main-policy usage, rejecting messages with invalid numeric window percentages.
* Mark a valid primary of at least 24h as replacement evidence only when both other windows
* are explicitly null or at least 24h. A null result supplies no usable policy observation.
* are explicitly null or at least 24h. An allowed, non-exhausted two-window response may omit
* tertiary only when secondary is explicitly null. A null result supplies no policy observation.
*/
export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null {
const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null;
const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type));
const [primary, secondary, tertiary] = windows;
// WHAM explicitly reports absent windows as null; omissions cannot prove replacement.
// The two-window WHAM shape can omit tertiary; secondary must still be explicit.
const allowedTwoWindow = secondary === null && !Object.hasOwn(data.rate_limit!, "tertiary_window")
&& data.rate_limit?.allowed === true && data.rate_limit?.limit_reached === false;
// Policy trusts one complete snapshot only when every non-null window is >=24h AND
// carries a valid usage reading: a long window without used_percent leaves that
// window's usage unknown, and unknown usage must never release a block.
// Headers never supply this proof, and reset time alone still cannot release a block.
if (quota && normalizeUsagePercent(primary?.used_percent) !== undefined && isExplicitLongWindow(primary)
&& (secondary === null || isMeasuredLongWindow(secondary))
&& (tertiary === null || isMeasuredLongWindow(tertiary))) {
&& (tertiary === null || isMeasuredLongWindow(tertiary) || allowedTwoWindow)) {
return { ...quota, shortWindowAbsent: true };
}
return quota;
Expand Down
14 changes: 13 additions & 1 deletion structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,9 @@ short-window tuple when secondary and tertiary windows are explicitly null or al
Long means **at least 24 hours**, matching the parser's short/long discriminator; a one-day primary
qualifies, not only a seven-day or monthly window. The policy trusts that one reported topology;
it does not require repeated observations or independently confirm upstream window completeness.
Omitted secondary/tertiary fields, a long auxiliary window without a usage reading, an unknown primary duration, partial headers, or invalid usage cannot prove that the
An omitted tertiary is also accepted for the two-window WHAM shape only when secondary is explicitly null,
`rate_limit.allowed` is exactly true, and `rate_limit.limit_reached` is exactly false; the measured long primary is still required.
Other omissions, a long auxiliary window without a usage reading, an unknown primary duration, partial headers, or invalid usage cannot prove that the
short window disappeared. Replacement proof belongs only to that observation and is never persisted;
the resulting weekly/monthly window still blocks at 98%. This prevents old short-window exhaustion
from surviving indefinitely on a now weekly/monthly account. Coverage lives in
Expand All @@ -344,6 +346,16 @@ invalidates old evidence. Request-owned bearers are matched only against a crede
workspace already observed under native ownership; an unrelated or unmatched keyring credential
is not attributed to stored main and introduces no physical-main read. Credential equality tags
remain process-local and never enter disk, logs, or management DTOs.
`src/codex/auth-api/main-account-probe.ts` rechecks the captured credential generation and bearer
after body/retry awaits, before publishing main usage, credits, plan, reauth or Reserve state,
including terminal 401/403 mutations. A missing identity writer cannot bypass this check.
An observed same-account credential replacement, including A→B→A, prevents publication even
when a newer read fails without publishing; an unchanged credential still permits an older success.
Successful same-identity responses may still return parsed ordinary info to their caller, without
shared-state updates, fresh quota or recovery proof. Conflicting identities and stale errors return
cached info. The request/body races are covered by
`tests/codex-integration/main-account-hard-lock-recovery.test.ts`; the ordinary return and Reserve
revocation contract remains covered by `tests/codex-integration/reserve-passive-revocation.test.ts`.

Owned startup rebuilds this binding from its pinned auth path under the native owner and exclusive
claim, after journal recovery and stage cleanup, before publishing ready. That work now runs for
Expand Down
4 changes: 2 additions & 2 deletions tests/codex-integration/codex-auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,8 +484,8 @@ describe("main quota refresh diagnostics", () => {
} else {
expect(result).not.toHaveProperty("quotaRefresh");
}
// The existing terminal-auth decision still applies, independently of diagnostic freshness.
if (outcome === "terminal_http") expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true);
// Terminal errors can quarantine only the still-current identity and credential.
if (outcome === "terminal_http") expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(invalidation === "none");
expect(result).not.toHaveProperty("quotaRefreshGeneration");
expect(JSON.stringify(result)).not.toContain("quotaRefreshGeneration");
expect(JSON.stringify(result)).not.toContain("canary");
Expand Down
Loading
Loading