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..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 @@ -106,7 +106,10 @@ Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무 파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. 현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 요구하지 않습니다. 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 46a973c1265..94eec883b81 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -169,6 +169,10 @@ or also explicitly last at least 24 hours and report their usage. This follows t 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. +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 c5e8946d80c..74a159e4350 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; + // 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 57444410a85..3fcb57273a3 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -828,14 +828,15 @@ 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. 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; - // WHAM explicitly reports absent windows as null; omissions cannot prove replacement. + // 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. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 861722e63df..3bcb5ea8913 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -366,7 +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. -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 +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 fb87c15c3b9..8df7df6f4fe 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -239,6 +239,89 @@ describe("main policy window replacement", () => { expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); }); + test.each([64, 97.99, 98, 100])("allowed two-window WHAM retains stale short evidence at %s percent", percent => { + retainedShort(); + // 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).toBeUndefined(); + publish(data); + 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"); + }); + + 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: {