From 095a960b29a418d86d12af1d6fe20c084aba1ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=B2=A0?= Date: Fri, 25 Sep 2026 18:09:48 +0900 Subject: [PATCH 1/2] fix(codex): recover stale main locks from two-window usage --- .../ko/reference/cli/providers-accounts.md | 5 +- .../docs/reference/cli/providers-accounts.md | 6 ++- src/codex/quota-types.ts | 3 +- src/codex/quota.ts | 9 ++-- structure/providers/openai-tiers.md | 4 +- .../main-quota-evidence-validation.test.ts | 47 +++++++++++++++++++ 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 7f8c672ca7f..66d7d782037 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -105,7 +105,10 @@ Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무 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차 창의 기간을 모르거나, 응답 헤더만 일부 도착한 경우에는 이전 차단을 해제하지 않습니다. 지연 응답을 반영하기 전에 저장된 인증정보를 다시 확인합니다. 파일을 읽을 수 없거나 같은 계정의 인증 토큰이 교체되었다면 별도 사용량 조회가 없어도 이전 응답은 사용량 캐시나 차단 상태를 갱신하거나 새 토큰을 재인증 대상으로 표시하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 46a973c1265..c678c0093cd 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -168,7 +168,11 @@ 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. The proxy checks the stored credential again before applying a delayed response. An unreadable file or replaced bearer cannot update the usage cache, release the lock, or quarantine the new credential, even for the same account with no second quota read. diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index c5e8946d80c..0c20e990c5a 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -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; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 57444410a85..e4aa5247074 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -828,21 +828,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; diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 861722e63df..fd3540099b5 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -366,7 +366,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 diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index fb87c15c3b9..32f2e9ad0ba 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -239,6 +239,53 @@ describe("main policy window replacement", () => { expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); }); + test.each([64, 97.99, 98, 100])("allowed two-window WHAM retires stale short evidence at %s percent", percent => { + retainedShort(); + // Sanitized shape observed on an updated Windows install: tertiary is absent, not null. + const data: WhamUsageResponse = { plan_type: "prolite", rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: percent, limit_window_seconds: weeklySeconds }, secondary_window: null, + } }; + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBe(true); + publish(data); + expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(percent); + expect(getMainPolicyQuota()).not.toHaveProperty("shortWindowAbsent"); + expect(getMainAccountHardLockStatus(cfg).state).toBe(percent < 98 ? "ready" : "blocked"); + }); + + test.each([ + { allowed: undefined }, { allowed: false }, { allowed: "true" }, + { limit_reached: undefined }, { limit_reached: true }, { limit_reached: "false" }, + { secondary_window: undefined }, { secondary_window: {} }, + { tertiary_window: undefined }, { tertiary_window: {} }, + { tertiary_window: { used_percent: 0, limit_window_seconds: 18_000 } }, + { primary_window: { used_percent: 64 } }, + { primary_window: { used_percent: 101, limit_window_seconds: weeklySeconds } }, + ])("incomplete or contradictory two-window evidence retains the block: %j", patch => { + retainedShort(); + const data = { rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: 64, limit_window_seconds: weeklySeconds }, + secondary_window: null, ...patch, + } } as WhamUsageResponse; + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBeUndefined(); + publish(data); + expect(getMainPolicyQuota()?.shortPercent).toBe(100); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + test("two-window evidence from a superseded writer cannot retire the block", () => { + const staleWriter = writerFor("fixture-main-b"); + retainedShort(); + const data: WhamUsageResponse = { rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: 64, limit_window_seconds: weeklySeconds }, secondary_window: null, + } }; + setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, staleWriter, parseMainPolicyUsageQuota(data)); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + test("an explicit null secondary and long tertiary permit replacement", () => { retainedShort(); publish({ rate_limit: { From 453bcb94634dc38782bb21ef5f4f45ddd11fd334 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 10:53:53 +0900 Subject: [PATCH 2/2] fix(codex): preserve main locks on partial WHAM usage --- .../ko/reference/cli/providers-accounts.md | 10 ++--- .../docs/reference/cli/providers-accounts.md | 10 ++--- src/codex/quota-types.ts | 2 +- src/codex/quota.ts | 10 ++--- structure/providers/openai-tiers.md | 10 +++-- .../main-quota-evidence-validation.test.ts | 44 +++++++++++++++++-- 6 files changed, 62 insertions(+), 24 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 66d7d782037..406f382a249 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -105,11 +105,11 @@ Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무 2차·3차 창이 명시적 `null`이거나 그 기간도 24시간 이상으로 명시되고 사용량 수치도 함께 오면 이전 5h 수치를 대체합니다. 파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. 현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 -요구하지 않습니다. WHAM이 선택적인 3차 필드를 생략한 경우에도, 2차 창이 명시적 `null`이고 -`rate_limit.allowed`가 `true`, `rate_limit.limit_reached`가 `false`이며 1차 창이 앞의 조건을 -만족하고 유효한 사용량 수치가 있으면 이전 5h 수치를 대체합니다. 화면은 98% 미만인데 정책 캐시에 오래된 5h 100%가 남아 -차단되던 주간 전용 계정도 사용량 새로고침으로 복구됩니다. 그 외 필드 누락, 1차 창의 기간을 모르거나, 응답 헤더만 일부 -도착한 경우에는 이전 차단을 해제하지 않습니다. +요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부 +도착한 경우에는 이전 차단을 해제하지 않습니다. 장기 1차 창과 `secondary_window: null`만 있고 +3차 창이 빠진 응답은 `rate_limit.allowed`가 `true`, `rate_limit.limit_reached`가 `false`여도 +불완전한 정보로 봅니다. 모든 요금제에서 장기 창의 새 사용량은 반영하되, 기존 단기 창 차단은 유지합니다. +단기 창을 새로 측정한 사용량이 98% 미만이면 차단을 해제할 수 있습니다. 예상 리셋 시각이 지났다는 이유만으로는 해제하지 않습니다. 지연 응답을 반영하기 전에 저장된 인증정보를 다시 확인합니다. 파일을 읽을 수 없거나 같은 계정의 인증 토큰이 교체되었다면 별도 사용량 조회가 없어도 이전 응답은 사용량 캐시나 차단 상태를 갱신하거나 새 토큰을 재인증 대상으로 표시하지 않습니다. 해당 요청자에게 파싱된 조회 결과를 반환할 수는 있지만, 공유 상태나 차단 해제 근거에는 반영하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index c678c0093cd..94eec883b81 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -168,11 +168,11 @@ 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. -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. +Omitted secondary/tertiary fields, an unknown primary duration, or partial response headers cannot clear a previous block. +A two-window response with a long primary, `secondary_window: null`, and omitted tertiary remains +partial even when `rate_limit.allowed` is `true` and `rate_limit.limit_reached` is `false`. +It updates the measured long-window usage while retaining any known short-window block, for every plan. +A fresh measured short-window reading below 98% can release that block; an elapsed reset alone cannot. The proxy checks the stored credential again before applying a delayed response. An unreadable file or replaced bearer cannot update the usage cache, release the lock, or quarantine the new credential, even for the same account with no second quota read. diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index 0c20e990c5a..74a159e4350 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -114,7 +114,7 @@ export type WhamUsageResponse = { rate_limit?: { allowed?: unknown; limit_reached?: unknown; - // WHAM can omit optional tertiary; an absent secondary is explicitly null. + // Omitted windows remain unknown to policy; explicit null reports an absent window. primary_window?: WhamUsageWindow | null; secondary_window?: WhamUsageWindow | null; tertiary_window?: WhamUsageWindow | null; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index e4aa5247074..3fcb57273a3 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -828,24 +828,22 @@ 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. An allowed, non-exhausted two-window response may omit - * tertiary only when secondary is explicitly null. A null result supplies no policy observation. + * are explicitly null or at least 24h. Flags cannot establish omitted-window completeness. + * A null result supplies no usable 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; - // 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; + // An omitted window remains unknown even when allowed=true and 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) || allowedTwoWindow)) { + && (tertiary === null || isMeasuredLongWindow(tertiary))) { return { ...quota, shortWindowAbsent: true }; } return quota; diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index fd3540099b5..3bcb5ea8913 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -366,9 +366,13 @@ 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. -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 +Omitted secondary/tertiary fields remain partial even when `rate_limit.allowed` is true and +`rate_limit.limit_reached` is false: these flags do not establish response completeness or bound an +omitted window's usage below 98%. For every plan, a measured long primary with null secondary and +omitted tertiary updates long-window usage but preserves the known blocking short tuple, including +its observation and reset timestamps. A fresh measured short-window reading below 98% can release +that short block; an elapsed reset alone cannot. 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 diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index 32f2e9ad0ba..8df7df6f4fe 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -239,18 +239,54 @@ describe("main policy window replacement", () => { expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); }); - test.each([64, 97.99, 98, 100])("allowed two-window WHAM retires stale short evidence at %s percent", percent => { + test.each([64, 97.99, 98, 100])("allowed two-window WHAM retains stale short evidence at %s percent", percent => { retainedShort(); - // Sanitized shape observed on an updated Windows install: tertiary is absent, not null. + // Synthetic partial topology: flags do not establish the omitted short window's usage. const data: WhamUsageResponse = { plan_type: "prolite", rate_limit: { allowed: true, limit_reached: false, primary_window: { used_percent: percent, limit_window_seconds: weeklySeconds }, secondary_window: null, } }; - expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBe(true); + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBeUndefined(); publish(data); - expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + expect(getMainPolicyQuota()?.shortPercent).toBe(100); expect(getMainPolicyQuota()?.weeklyPercent).toBe(percent); expect(getMainPolicyQuota()).not.toHaveProperty("shortWindowAbsent"); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + for (const plan of [undefined, "prolite", "plus", "team"]) { + test.each([99, 100])(`${plan} two-window flags cannot erase a live short%s block`, percent => { + const reset = Math.floor(Date.now() / 1000) + 3600; + publish({ plan_type: plan, rate_limit: { + primary_window: { used_percent: percent, limit_window_seconds: 18_000, reset_at: reset }, + secondary_window: { used_percent: 35, limit_window_seconds: weeklySeconds }, + } }); + const before = getMainPolicyQuota()!; + publish({ plan_type: plan, rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: 64, limit_window_seconds: weeklySeconds }, secondary_window: null, + } }); + const after = getMainPolicyQuota()!; + for (const key of ["shortPercent", "shortResetAt", "shortObservedAt", "shortWindowSeconds"] as const) { + expect(after[key]).toBe(before[key]); + } + expect(after.weeklyPercent).toBe(64); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked", resetAt: reset * 1000 }); + }); + } + + test.each([97.99, 98, 100])("a measured short reading recovers partial two-window usage only below 98: %s", percent => { + retainedShort(); + publish({ rate_limit: { + allowed: true, limit_reached: false, + primary_window: { used_percent: 64, limit_window_seconds: weeklySeconds }, secondary_window: null, + } }); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + publish({ rate_limit: { + primary_window: { used_percent: percent, limit_window_seconds: 18_000 }, + secondary_window: { used_percent: 64, limit_window_seconds: weeklySeconds }, + } }); + expect(getMainPolicyQuota()?.shortPercent).toBe(percent); expect(getMainAccountHardLockStatus(cfg).state).toBe(percent < 98 ? "ready" : "blocked"); });