fix(native-chat): show when a Codex usage limit resets, like Claude (#40992)

* fix(codex): keep the reset time when a usage limit rejects before the report reaches 100%

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(codex): read the usage-limit error code from the renamed error info

* fix(codex): skip windows that already reset when blaming a nearly spent one

---------

Co-authored-by: moncha3 <moncha3@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kelvin Amoaba
2026-11-11 02:53:37 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 moncha3
parent 7a3aacef0b
commit 71404c99be
2 changed files with 45 additions and 10 deletions
@@ -62,6 +62,33 @@ describe('a Codex usage limit', () => {
])
translator.dispose()
})
it('takes the reset from a nearly spent window when the rejection outruns the last report', () => {
const limits = new CodexUsageLimits()
const rejectedWith = (primaryPercent: number, code = 'usageLimitExceeded') => {
limits.observe('notification:account/rateLimits/updated', {
rateLimits: {
limitId: 'codex',
primary: { usedPercent: primaryPercent, windowDurationMins: 300, resetsAt: RESETS_AT_S },
secondary: { usedPercent: 70, windowDurationMins: 10080, resetsAt: RESETS_AT_S + 3600 }
}
})
return limits.failure({ error: { codexErrorInfo: code, message: CODEX_WORDS } })?.usageLimit
}
expect(rejectedWith(99)).toEqual({ window: 'session', resetsAt: RESETS_AT_S * 1000 })
expect(rejectedWith(50)).toBeUndefined()
expect(rejectedWith(99, 'rateLimitExceeded')).toBeUndefined()
limits.observe('notification:account/rateLimits/updated', {
rateLimits: {
primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_000_000 },
secondary: { usedPercent: 95, windowDurationMins: 10080, resetsAt: RESETS_AT_S }
}
})
expect(
limits.failure({ error: { codexErrorInfo: 'usageLimitExceeded', message: CODEX_WORDS } })
?.usageLimit
).toEqual({ window: 'weekly', resetsAt: RESETS_AT_S * 1000 })
})
it('merges null account updates and keeps unnamed exhausted windows in reset calculation', () => {
const limits = new CodexUsageLimits()
const observe = (rateLimits: Record<string, unknown>) =>
@@ -16,6 +16,8 @@ import {
const RATE_LIMITS_UPDATED = 'notification:account/rateLimits/updated'
const USAGE_LIMIT_ERRORS = ['usageLimitExceeded', 'rateLimitExceeded']
/** The last report can trail a rejection by the request that went over, so a window this full counts as the spent one. */
const NEARLY_SPENT_PERCENT = 90
type CodexAccountRateLimits = CodexRateLimitWindowsSnapshot & {
rateLimitReachedType?: unknown
@@ -62,7 +64,7 @@ export class CodexUsageLimits {
const text = [message, additional && additional !== message ? additional : null]
.filter((part): part is string => part !== null)
.join('\n')
const limit = this.limit()
const limit = this.limit(info.error === 'usageLimitExceeded')
return agentSessionFailureFact('usageLimitReached', {
...(limit ? { usageLimit: limit } : {}),
detail: providerDiagnostic(text, 'person')
@@ -70,21 +72,16 @@ export class CodexUsageLimits {
}
/** All spent windows must reset; report the latest only if every reset is known. */
private limit(): AgentSessionUsageLimit | null {
private limit(rejected: boolean): AgentSessionUsageLimit | null {
const monthlyPlan = this.snapshot.planType === 'free' || this.snapshot.planType === 'go'
const spent = (
const reported = (
[
['primary', monthlyPlan ? 43200 : 300],
['secondary', 10080]
] as const
).flatMap(([position, fallback]) => {
const raw = this.snapshot[position]
if (
!raw ||
typeof raw.usedPercent !== 'number' ||
!Number.isFinite(raw.usedPercent) ||
raw.usedPercent < 100
) {
if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) {
return []
}
const duration =
@@ -99,8 +96,19 @@ export class CodexUsageLimits {
? ('weekly' as const)
: ('session' as const)
: undefined
return [{ window, resetsAt: raw.resetsAt }]
return [{ window, resetsAt: raw.resetsAt, usedPercent: raw.usedPercent }]
})
// A window that already reset cannot be the one that just rejected.
const live = reported.filter(
(entry) => typeof entry.resetsAt !== 'number' || entry.resetsAt * 1000 > this.now()
)
const fullest = Math.max(...live.map((entry) => entry.usedPercent))
const spent =
rejected &&
fullest >= NEARLY_SPENT_PERCENT &&
!reported.some((entry) => entry.usedPercent >= 100)
? live.filter((entry) => entry.usedPercent >= fullest)
: reported.filter((entry) => entry.usedPercent >= 100)
const reached = this.snapshot.rateLimitReachedType
const credits = typeof reached === 'string' && reached.endsWith('credits_depleted')
const window: AgentSessionUsageLimitWindow | undefined = credits