fix(rate-limits): keep Claude OAuth credits through live statusline updates (#29759)

The statusline payload carries only the 5-hour and 7-day windows, so each
live post rebuilt Claude's usage without extraUsage and cleared the credit
balance until the next OAuth poll. Retain the last OAuth extraUsage alongside
fableWeekly. Automated Claude polls on main are gated only by Retry-After, so
credits keep refreshing on cadence.

Fixes #29755

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
lurunzi
2026-10-28 01:51:52 -07:00
committed by GitHub
co-authored by Neil
parent 5e345ffa75
commit ce595faadc
2 changed files with 21 additions and 6 deletions
@@ -130,16 +130,27 @@ describe('RateLimitService', () => {
}
)
it('keeps polling on cadence for an account with a Fable window the live feed cannot carry', async () => {
it('keeps OAuth-only Fable and credit fields through live posts and polls them on cadence', async () => {
vi.useFakeTimers()
try {
const withFable = (usedPercent: number): ProviderRateLimits => ({
const withOAuthUsage = (usedPercent: number): ProviderRateLimits => ({
...okProvider('claude', 40),
fableWeekly: { usedPercent, windowMinutes: 10080, resetsAt: null, resetDescription: null }
fableWeekly: { usedPercent, windowMinutes: 10080, resetsAt: null, resetDescription: null },
extraUsage: {
balance: usedPercent,
unit: 'currency',
currencyCode: 'USD',
enabled: true,
disabledReason: null,
resetsAt: null,
spent: null,
spendLimit: null,
spentPercent: null
}
})
vi.mocked(fetchClaudeRateLimits)
.mockImplementationOnce(async () => withFable(18))
.mockImplementation(async () => withFable(39))
.mockImplementationOnce(async () => withOAuthUsage(18))
.mockImplementation(async () => withOAuthUsage(39))
mockFreshBackgroundProviderFetches()
const service = new RateLimitService()
@@ -155,11 +166,14 @@ describe('RateLimitService', () => {
fiveHour: { used_percentage: 50 + minute },
sevenDay: { used_percentage: 30 }
})
expect(service.getState().claude?.fableWeekly?.usedPercent).toBe(18)
expect(service.getState().claude?.extraUsage?.balance).toBe(18)
await vi.advanceTimersByTimeAsync(60 * 1000)
}
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
expect(service.getState().claude?.fableWeekly?.usedPercent).toBe(39)
expect(service.getState().claude?.extraUsage?.balance).toBe(39)
service.stop()
} finally {
@@ -172,8 +172,9 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT
provider: 'claude',
session,
weekly,
// Statusline lacks Fable scope; retain the latest OAuth value.
// Statusline lacks Fable scope and credits; retain the latest OAuth values.
fableWeekly: inherited?.fableWeekly ?? null,
extraUsage: inherited?.extraUsage ?? null,
updatedAt: Date.now(),
error: null,
status: 'ok',