mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(usage): an idle Claude account whose access token lapsed reads as waiting for Claude, not signed out (#33542)
An idle saved Claude account whose ~8h access token lapsed (Orca never refreshes Claude logins; the CLI does on its next run) keeps its last-good usage for up to 24h and reads "Updates when Claude runs" instead of "signed out". Logins that can't recover (no refresh token, expired refresh token, or a revoked 401) still ask to sign in again, and the status bar keeps its warning icon when shown usage is over 30 minutes old. Kept snapshots drop windows whose reset has passed. Fixes #30289 Supersedes #31105 Co-authored-by: luijoc <96428056+luijoc@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
readClaudeOAuthCredentials,
|
||||
type ClaudeOAuthCredentialReadResult,
|
||||
resolveClaudeOAuthCredentialReadOptions
|
||||
} from './claude-oauth-credentials'
|
||||
import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request'
|
||||
@@ -62,15 +63,20 @@ export async function fetchActiveClaudeRateLimits(
|
||||
return await fetchClaudeOAuthUsage(credentials.token, options?.signal)
|
||||
} catch (error) {
|
||||
const { failureKind } = classifyClaudeOAuthUsageError(error)
|
||||
if (failureKind === 'stale-token' && !canClaudeRenewLogin(credentials, error)) {
|
||||
return makeClaudeUsageResult('error', 'Sign in again to use this account.', {
|
||||
...metadata,
|
||||
failureKind: 'missing-credentials',
|
||||
attemptedSources: ['oauth']
|
||||
})
|
||||
}
|
||||
// Retain both the 429 polling delay and its user-visible cause.
|
||||
const retryAfterMs = error instanceof OAuthUsageError ? error.retryAfterMs : null
|
||||
return makeClaudeUsageResult(
|
||||
'error',
|
||||
failureKind === 'stale-token'
|
||||
? // Claude refreshes on next use; System Default has no managed account to reauthenticate.
|
||||
metadata.authProvenance === 'system'
|
||||
? 'Claude usage updates the next time Claude runs.'
|
||||
: 'Claude usage has expired. Start Claude in this account to refresh it.'
|
||||
? // Idle login's access token lapsed, not the login; Claude refreshes it when it next runs.
|
||||
'Claude usage updates the next time Claude runs.'
|
||||
: (failureKind === 'rate-limited' || failureKind === 'missing-scope') &&
|
||||
error instanceof OAuthUsageError
|
||||
? error.message
|
||||
@@ -84,3 +90,16 @@ export async function fetchActiveClaudeRateLimits(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors Claude Code's own refreshability check; anything else needs a new sign-in.
|
||||
function canClaudeRenewLogin(
|
||||
credentials: ClaudeOAuthCredentialReadResult,
|
||||
error: unknown
|
||||
): boolean {
|
||||
const refreshTokenExpiresAt = credentials.refreshTokenExpiresAt
|
||||
return (
|
||||
credentials.hasRefreshableCredentials &&
|
||||
(refreshTokenExpiresAt === undefined || refreshTokenExpiresAt > Date.now()) &&
|
||||
!(error instanceof OAuthUsageError && /revoked/i.test(error.message))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export type ClaudeOAuthCredentialSource =
|
||||
export type ClaudeOAuthCredentialReadResult = {
|
||||
token: string | null
|
||||
hasRefreshableCredentials: boolean
|
||||
/** Epoch ms; Claude Code 2.1.x records it beside the refresh token. */
|
||||
refreshTokenExpiresAt?: number
|
||||
source: ClaudeOAuthCredentialSource
|
||||
keychainUnavailable?: boolean
|
||||
unavailable?: boolean
|
||||
@@ -30,7 +32,8 @@ const schema = z.object({
|
||||
claudeAiOauth: z
|
||||
.object({
|
||||
accessToken: z.string().optional(),
|
||||
refreshToken: z.string().optional()
|
||||
refreshToken: z.string().optional(),
|
||||
refreshTokenExpiresAt: z.unknown().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -44,6 +47,9 @@ export function parseClaudeOAuthCredentialsJson(
|
||||
return {
|
||||
token: oauth?.accessToken || null,
|
||||
hasRefreshableCredentials: Boolean(oauth?.refreshToken),
|
||||
...(typeof oauth?.refreshTokenExpiresAt === 'number'
|
||||
? { refreshTokenExpiresAt: oauth.refreshTokenExpiresAt }
|
||||
: {}),
|
||||
source
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -75,7 +75,12 @@ it('lets the server decide a locally expired token and never refreshes it', asyn
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken: 'expired-fake', refreshToken: 'must-not-be-used', expiresAt: 1 }
|
||||
claudeAiOauth: {
|
||||
accessToken: 'expired-fake',
|
||||
refreshToken: 'must-not-be-used',
|
||||
expiresAt: 1,
|
||||
refreshTokenExpiresAt: 4_102_444_800_000
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', () => {
|
||||
@@ -84,14 +89,16 @@ it('lets the server decide a locally expired token and never refreshes it', asyn
|
||||
calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null))
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'Claude usage has expired. Start Claude in this account to refresh it.',
|
||||
error: 'Claude usage updates the next time Claude runs.',
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
})
|
||||
expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake'])
|
||||
const system = systemDefault()
|
||||
writeFileSync(
|
||||
join(system.home, '.credentials.json'),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: 'expired-default', expiresAt: 1 } })
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken: 'expired-default', refreshToken: 'unused', expiresAt: 1 }
|
||||
})
|
||||
)
|
||||
calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' })
|
||||
expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' })
|
||||
@@ -100,6 +107,23 @@ it('lets the server decide a locally expired token and never refreshes it', asyn
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
})
|
||||
})
|
||||
it.each([
|
||||
['no refresh token', {}, 'Invalid OAuth token.'],
|
||||
['an expired refresh token', { refreshToken: 'fake', refreshTokenExpiresAt: 1 }, 'Invalid'],
|
||||
['a revoked refresh token', { refreshToken: 'fake' }, 'OAuth token has been revoked.']
|
||||
])('asks a login with %s to sign in again instead of waiting for Claude', async (_, oauth, msg) => {
|
||||
const f = profile()
|
||||
writeFileSync(
|
||||
join(f.home, '.credentials.json'),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: 'expired-fake', expiresAt: 1, ...oauth } })
|
||||
)
|
||||
calls.usage.mockRejectedValue(new OAuthUsageError(msg, 401, true, null))
|
||||
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'Sign in again to use this account.',
|
||||
usageMetadata: { failureKind: 'missing-credentials' }
|
||||
})
|
||||
})
|
||||
it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => {
|
||||
const f = profile()
|
||||
await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home })
|
||||
|
||||
@@ -248,7 +248,11 @@ beforeEach(async () => {
|
||||
writeFileSync(
|
||||
join(path, '.credentials.json'),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken: `synthetic-${id}`, expiresAt: 1900000000000 }
|
||||
claudeAiOauth: {
|
||||
accessToken: `synthetic-${id}`,
|
||||
refreshToken: `synthetic-refresh-${id}`,
|
||||
expiresAt: 1900000000000
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -402,6 +406,22 @@ it.each([401, 503])(
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an idle account last-good usage when its access token expires overnight', async () => {
|
||||
await service.fetchInactiveClaudeAccountsOnOpen()
|
||||
const originalAt = cached('A')?.updatedAt
|
||||
// Why: Orca never refreshes a Claude login, so an idle account's usage 401s until Claude runs in it.
|
||||
fixture.now += 2 * 60 * 60 * 1000
|
||||
fixture.status = 401
|
||||
await service.fetchInactiveClaudeAccountsOnOpen()
|
||||
expect(cached('A')).toMatchObject({
|
||||
status: 'error',
|
||||
updatedAt: originalAt,
|
||||
session: { usedPercent: 21 },
|
||||
weekly: { usedPercent: 11 },
|
||||
usageMetadata: { failureKind: 'stale-token', authProvenance: 'profile:A' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([null, '0', '-5', 'soon'])(
|
||||
'does not invent a wait for Retry-After %s',
|
||||
async (retryAfter) => {
|
||||
|
||||
@@ -284,6 +284,49 @@ describe('RateLimitService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not revive a kept window past its reset when a live post carries only the other one', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
vi.mocked(fetchClaudeRateLimits)
|
||||
.mockImplementationOnce(async () => ({
|
||||
...okProvider('claude', 18),
|
||||
session: {
|
||||
usedPercent: 18,
|
||||
windowMinutes: 300,
|
||||
resetsAt: startedAt + 3 * 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
}
|
||||
}))
|
||||
.mockImplementation(async () => ({
|
||||
...errorProvider('claude', 'Claude usage updates the next time Claude runs.'),
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
}))
|
||||
mockFreshBackgroundProviderFetches()
|
||||
|
||||
const service = new RateLimitService()
|
||||
await service.refresh()
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)
|
||||
await service.refresh()
|
||||
expect(service.getState().claude?.session?.usedPercent).toBe(18)
|
||||
|
||||
// The kept 5h window resets before the next live post, which carries only the weekly one.
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)
|
||||
service.ingestLiveClaudeRateLimits({
|
||||
configDir: null,
|
||||
fiveHour: null,
|
||||
sevenDay: { used_percentage: 41 }
|
||||
})
|
||||
|
||||
const claude = service.getState().claude
|
||||
expect(claude?.status).toBe('ok')
|
||||
expect(claude?.session).toBeNull()
|
||||
expect(claude?.weekly?.usedPercent).toBe(41)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('attributes statusline posts to System Default when it inherits CLAUDE_CONFIG_DIR', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
|
||||
import { fetchGrokRateLimits } from './grok-fetcher'
|
||||
import { fetchCursorRateLimits } from './cursor-fetcher'
|
||||
import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
|
||||
import {
|
||||
asRateLimitWindow,
|
||||
@@ -457,6 +458,183 @@ describe('RateLimitService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps last-known usage through an expired access token past the generic stale threshold', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(fetchClaudeRateLimits)
|
||||
.mockImplementationOnce(async () => okProvider('claude', 18))
|
||||
.mockImplementation(async () => ({
|
||||
...errorProvider('claude', 'Claude usage updates the next time Claude runs.'),
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
}))
|
||||
mockFreshBackgroundProviderFetches()
|
||||
|
||||
const service = new RateLimitService()
|
||||
|
||||
await service.refresh()
|
||||
expect(service.getState().claude?.status).toBe('ok')
|
||||
|
||||
// Why: Orca never refreshes a Claude login, so an idle account's usage 401s until Claude runs again.
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)
|
||||
await service.refresh()
|
||||
|
||||
const claude = service.getState().claude
|
||||
expect(claude?.status).toBe('error')
|
||||
expect(claude?.usageMetadata?.failureKind).toBe('stale-token')
|
||||
expect(claude?.session?.usedPercent).toBe(18)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets a non-Claude expired sign-in age out at the generic stale threshold', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mockFreshBackgroundProviderFetches()
|
||||
vi.mocked(fetchClaudeRateLimits).mockImplementation(async () => okProvider('claude', 18))
|
||||
vi.mocked(fetchCursorRateLimits)
|
||||
.mockImplementationOnce(async () => okProvider('cursor', 42))
|
||||
.mockImplementation(async () => ({
|
||||
...errorProvider('cursor', 'Cursor sign-in expired — run `cursor-agent login` again'),
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
}))
|
||||
|
||||
const service = new RateLimitService()
|
||||
await service.refresh()
|
||||
expect(service.getState().cursor?.session?.usedPercent).toBe(42)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)
|
||||
await service.refresh()
|
||||
|
||||
const cursor = service.getState().cursor
|
||||
expect(cursor?.status).toBe('error')
|
||||
expect(cursor?.usageMetadata?.failureKind).toBe('stale-token')
|
||||
expect(cursor?.session).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a kept window once its reset passes and keeps the rest', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
vi.mocked(fetchClaudeRateLimits)
|
||||
.mockImplementationOnce(async () => ({
|
||||
...okProvider('claude', 18),
|
||||
session: {
|
||||
usedPercent: 18,
|
||||
windowMinutes: 300,
|
||||
resetsAt: startedAt + 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 40,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: startedAt + 3 * 24 * 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
}
|
||||
}))
|
||||
.mockImplementation(async () => ({
|
||||
...errorProvider('claude', 'Claude usage updates the next time Claude runs.'),
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
}))
|
||||
mockFreshBackgroundProviderFetches()
|
||||
|
||||
const service = new RateLimitService()
|
||||
await service.refresh()
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)
|
||||
await service.refresh()
|
||||
|
||||
const claude = service.getState().claude
|
||||
expect(claude?.status).toBe('error')
|
||||
expect(claude?.session).toBeNull()
|
||||
expect(claude?.weekly?.usedPercent).toBe(40)
|
||||
expect(claude?.updatedAt).toBe(startedAt)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an outgoing account whose kept snapshot lost only its reset 5h window', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
vi.mocked(fetchClaudeRateLimits)
|
||||
.mockImplementationOnce(async () => ({
|
||||
...okProvider('claude', 18),
|
||||
session: {
|
||||
usedPercent: 18,
|
||||
windowMinutes: 300,
|
||||
resetsAt: startedAt + 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 40,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: startedAt + 3 * 24 * 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
}
|
||||
}))
|
||||
.mockImplementation(async () => ({
|
||||
...errorProvider('claude', 'Claude usage updates the next time Claude runs.'),
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
}))
|
||||
mockFreshBackgroundProviderFetches()
|
||||
|
||||
const service = new RateLimitService()
|
||||
service.setInactiveClaudeAccountsResolver(() => [{ id: 'outgoing' }])
|
||||
await service.refresh()
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)
|
||||
await service.refresh()
|
||||
await service.refreshForClaudeAccountChange('outgoing')
|
||||
|
||||
const outgoing = service
|
||||
.getState()
|
||||
.inactiveClaudeAccounts.find((row) => row.accountId === 'outgoing')
|
||||
expect(outgoing?.rateLimits?.session).toBeNull()
|
||||
expect(outgoing?.rateLimits?.weekly?.usedPercent).toBe(40)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the failure instead of a kept snapshot once every window has reset', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(fetchClaudeRateLimits)
|
||||
.mockImplementationOnce(async () => ({
|
||||
...okProvider('claude', 18),
|
||||
session: {
|
||||
usedPercent: 18,
|
||||
windowMinutes: 300,
|
||||
resetsAt: Date.now() + 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
}
|
||||
}))
|
||||
.mockImplementation(async () => ({
|
||||
...errorProvider('claude', 'Claude usage updates the next time Claude runs.'),
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
}))
|
||||
mockFreshBackgroundProviderFetches()
|
||||
|
||||
const service = new RateLimitService()
|
||||
await service.refresh()
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)
|
||||
const failedAt = Date.now()
|
||||
await service.refresh()
|
||||
|
||||
const claude = service.getState().claude
|
||||
expect(claude?.status).toBe('error')
|
||||
expect(claude?.session).toBeNull()
|
||||
expect(claude?.weekly).toBeNull()
|
||||
expect(claude?.updatedAt).toBe(failedAt)
|
||||
expect(claude?.error).toBe('Claude usage updates the next time Claude runs.')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a settled error chip settled during background refetches instead of flashing fetching', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { consumeCodexRateLimitResetCredit } from '../codex-fetcher'
|
||||
import { RateLimitServiceInactiveAccounts } from './service-inactive-accounts'
|
||||
import { hasUsageWindows } from './service-result-policy'
|
||||
import {
|
||||
normalizeCodexAccountSelectionTarget,
|
||||
normalizeClaudeAccountSelectionTarget,
|
||||
@@ -144,10 +145,11 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
): Promise<RateLimitState> {
|
||||
const nextTarget = normalizeClaudeAccountSelectionTarget(target)
|
||||
// Seed outgoing switcher data before clearing the active snapshot.
|
||||
// Seed outgoing switcher data before clearing; a weekly-only kept snapshot still counts.
|
||||
if (
|
||||
outgoingAccountId &&
|
||||
this.state.claude?.session &&
|
||||
this.state.claude &&
|
||||
hasUsageWindows(this.state.claude) &&
|
||||
this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget)
|
||||
) {
|
||||
this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RateLimitServiceFetchTargets } from './service-fetch-targets'
|
||||
import { withoutResetWindows } from './service-result-policy'
|
||||
import {
|
||||
LIVE_CLAUDE_INGEST_DEDUPE_MS,
|
||||
MIN_REFETCH_MS,
|
||||
@@ -141,9 +142,11 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT
|
||||
if (!freshSession && !freshWeekly) {
|
||||
return
|
||||
}
|
||||
// Kept snapshots can outlive a reset; never relabel a past-reset window as live.
|
||||
const inherited = previous ? withoutResetWindows(previous, now) : null
|
||||
// Partial statusline payloads leave unreported windows unchanged.
|
||||
const session = freshSession ?? previous?.session ?? null
|
||||
const weekly = freshWeekly ?? previous?.weekly ?? null
|
||||
const session = freshSession ?? inherited?.session ?? null
|
||||
const weekly = freshWeekly ?? inherited?.weekly ?? null
|
||||
// Stale posts that repeat the surviving window must not refresh live-feed age.
|
||||
const droppedStale = (incomingSession && !freshSession) || (incomingWeekly && !freshWeekly)
|
||||
if (
|
||||
@@ -170,7 +173,7 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT
|
||||
session,
|
||||
weekly,
|
||||
// Statusline lacks Fable scope; retain the latest OAuth value.
|
||||
fableWeekly: previous?.fableWeekly ?? null,
|
||||
fableWeekly: inherited?.fableWeekly ?? null,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok',
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import type { RateLimitWindow } from '../../../shared/rate-limit-types'
|
||||
import { RateLimitServiceFetchControl } from './service-fetch-control'
|
||||
import {
|
||||
LONG_STALE_THRESHOLD_MS,
|
||||
MAX_ACTIVE_FAILURE_STREAK,
|
||||
RATE_LIMITED_STALE_THRESHOLD_MS,
|
||||
STALE_THRESHOLD_MS,
|
||||
type ActiveRateLimitProvider,
|
||||
type ProviderRateLimits
|
||||
} from './service-types'
|
||||
|
||||
export function hasUsageWindows(limits: ProviderRateLimits): boolean {
|
||||
return Boolean(
|
||||
limits.session ||
|
||||
limits.weekly ||
|
||||
limits.fableWeekly ||
|
||||
limits.monthly ||
|
||||
(limits.buckets && limits.buckets.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a window past its reset no longer describes the account, and 0% would hide use on another device.
|
||||
export function withoutResetWindows(limits: ProviderRateLimits, now: number): ProviderRateLimits {
|
||||
const isCurrent = (window: RateLimitWindow): boolean =>
|
||||
typeof window.resetsAt !== 'number' || window.resetsAt > now
|
||||
const current = (window: RateLimitWindow | null | undefined): RateLimitWindow | null =>
|
||||
window && isCurrent(window) ? window : null
|
||||
return {
|
||||
...limits,
|
||||
session: current(limits.session),
|
||||
weekly: current(limits.weekly),
|
||||
...(limits.fableWeekly ? { fableWeekly: current(limits.fableWeekly) } : {}),
|
||||
...(limits.monthly ? { monthly: current(limits.monthly) } : {}),
|
||||
...(limits.buckets ? { buckets: limits.buckets.filter(isCurrent) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetchControl {
|
||||
protected applyStalePolicy(
|
||||
fresh: ProviderRateLimits,
|
||||
@@ -28,29 +55,30 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch
|
||||
return fresh
|
||||
}
|
||||
|
||||
const previousHasData = Boolean(
|
||||
previous?.session ||
|
||||
previous?.weekly ||
|
||||
previous?.fableWeekly ||
|
||||
previous?.monthly ||
|
||||
(previous?.buckets && previous.buckets.length > 0)
|
||||
)
|
||||
|
||||
if (!previous || !previousHasData) {
|
||||
if (!previous) {
|
||||
return fresh
|
||||
}
|
||||
|
||||
const failureKind = fresh.usageMetadata?.failureKind
|
||||
// Only Claude's lapsed token renews itself when its CLI next runs; others may be a real expiry.
|
||||
const staleThresholdMs =
|
||||
fresh.usageMetadata?.failureKind === 'rate-limited'
|
||||
? RATE_LIMITED_STALE_THRESHOLD_MS
|
||||
failureKind === 'rate-limited' ||
|
||||
(failureKind === 'stale-token' && fresh.provider === 'claude')
|
||||
? LONG_STALE_THRESHOLD_MS
|
||||
: STALE_THRESHOLD_MS
|
||||
if (Date.now() - previous.updatedAt > staleThresholdMs) {
|
||||
const now = Date.now()
|
||||
if (now - previous.updatedAt > staleThresholdMs) {
|
||||
return fresh
|
||||
}
|
||||
|
||||
const current = withoutResetWindows(previous, now)
|
||||
if (!hasUsageWindows(current)) {
|
||||
return fresh
|
||||
}
|
||||
|
||||
// Preserve recent display through transient errors until stale expiry, avoiding empty-bar flashes.
|
||||
return {
|
||||
...previous,
|
||||
...current,
|
||||
error: fresh.error,
|
||||
status: 'error',
|
||||
usageMetadata: {
|
||||
|
||||
@@ -108,8 +108,8 @@ export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet<ActiveRateLimitProv
|
||||
'grok'
|
||||
])
|
||||
export const STALE_THRESHOLD_MS = 30 * 60 * 1000
|
||||
// Retry-After can exceed generic stale expiry; keep informational quota visible through longer 429 windows.
|
||||
export const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000
|
||||
// 429 Retry-After and an idle Claude login's lapsed token outlast generic expiry; keep informational quota visible.
|
||||
export const LONG_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000
|
||||
// Deduplicate identical per-turn live posts to avoid renderer push floods.
|
||||
export const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000
|
||||
export const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
|
||||
import { barColor, clampUsedPercent } from './tooltip'
|
||||
import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter'
|
||||
import { formatUsagePercentageLabel } from './usage-percentage-label'
|
||||
import { getUsageRosterRowState } from './usage-roster-row-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function InlineUsageBars({
|
||||
@@ -49,6 +50,11 @@ export function InlineUsageBars({
|
||||
}
|
||||
: null
|
||||
].filter((window): window is { key: string; used: number; label: string } => window !== null)
|
||||
// Why: a lapsed token or failed refresh is not a sign-out; ask for a sign-in only when one is confirmed.
|
||||
const emptyState =
|
||||
usageWindows.length === 0 && limits.status === 'error'
|
||||
? getUsageRosterRowState(limits, false)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -71,9 +77,11 @@ export function InlineUsageBars({
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{usageWindows.length === 0 && limits.status === 'error' ? (
|
||||
{emptyState ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')}
|
||||
{emptyState.kind === 'sign-in'
|
||||
? translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')
|
||||
: emptyState.statusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,9 @@ import { getStatusBarUsageSections } from './status-bar-usage-sections'
|
||||
import { GroupedPoolUsage } from './usage-grouped-pools'
|
||||
import { formatStatusBarSectionLabel, isGroupedUsagePool } from './usage-pool-label'
|
||||
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
|
||||
import { useNow } from '@/hooks/use-now'
|
||||
|
||||
const CALM_STALE_USAGE_MAX_AGE_MS = 30 * 60_000
|
||||
|
||||
function MiniBar({
|
||||
usedPct,
|
||||
@@ -243,6 +246,7 @@ export function ProviderSegment({
|
||||
const statusLabel = p ? getProviderUsageStatusLabel(p) : ''
|
||||
// Why: not a problem; Claude updates its own login the next time it runs.
|
||||
const calm = p ? isClaudeUsageWaitingForClaude(p) : false
|
||||
const calmClock = useNow(60_000, calm && p?.status === 'error')
|
||||
const tightest = p
|
||||
? mode === 'compact'
|
||||
? getUsageHeadlineSection(p)
|
||||
@@ -308,7 +312,9 @@ export function ProviderSegment({
|
||||
)
|
||||
}
|
||||
|
||||
const isStale = p.status === 'error' && !calm
|
||||
// Why: calm is honest only while the kept bars are recent; old ones still deserve the warning.
|
||||
const isStale =
|
||||
p.status === 'error' && (!calm || calmClock - p.updatedAt > CALM_STALE_USAGE_MAX_AGE_MS)
|
||||
const showBalance = isExtraUsageActive(p)
|
||||
const showGroupedPools =
|
||||
mode === 'verbose' && groupedBuckets.length > 0 && groupedBuckets.length === sections.length
|
||||
|
||||
@@ -178,6 +178,29 @@ describe('status bar Claude account that needs a sign-in', () => {
|
||||
expect(newRow?.textContent).toContain('Sign in to see usage')
|
||||
})
|
||||
|
||||
it('does not ask an idle account whose usage token expired overnight to sign in', async () => {
|
||||
inactiveClaudeAccounts = [
|
||||
{
|
||||
accountId: 'new',
|
||||
isFetching: false,
|
||||
rateLimits: {
|
||||
...claudeProvider,
|
||||
status: 'error',
|
||||
error: 'Claude usage updates the next time Claude runs.',
|
||||
usageMetadata: {
|
||||
failureKind: 'stale-token',
|
||||
attemptedSources: ['oauth'],
|
||||
authProvenance: 'profile:new'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
await openAccounts()
|
||||
const newRow = screen.getAllByText('new@example.com').at(-1)!.closest('[role="menuitem"]')
|
||||
expect(newRow?.textContent).not.toContain('Sign in to see usage')
|
||||
expect(newRow?.textContent).toContain('Updates when Claude runs')
|
||||
})
|
||||
|
||||
it('says why a failed sign-in failed', async () => {
|
||||
reauthenticate.mockRejectedValueOnce(new Error('Claude login exited with code 1.'))
|
||||
await openAccounts()
|
||||
|
||||
@@ -86,6 +86,53 @@ describe('InlineUsageBars', () => {
|
||||
expect(markup).toContain('Sign in to see usage')
|
||||
})
|
||||
|
||||
// Why: an idle account's access token lapses overnight while its login stays valid; Claude refreshes it on its next run.
|
||||
it.each(['profile:idle', 'system', 'wsl:Ubuntu:system'])(
|
||||
'does not ask a Claude account with an expired usage token (%s) to sign in',
|
||||
async (authProvenance) => {
|
||||
const { InlineUsageBars } = await import('./StatusBar')
|
||||
const limits: ProviderRateLimits = {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: 0,
|
||||
// An older host's wording; the row speaks from the failure kind.
|
||||
error: 'Claude usage has expired. Start Claude in this account to refresh it.',
|
||||
status: 'error',
|
||||
usageMetadata: {
|
||||
failureKind: 'stale-token',
|
||||
attemptedSources: ['oauth'],
|
||||
authProvenance
|
||||
}
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(<InlineUsageBars limits={limits} isFetching={false} />)
|
||||
|
||||
expect(markup).not.toContain('Sign in to see usage')
|
||||
expect(markup).toContain('Updates when Claude runs')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ failureKind: 'missing-credentials' as const, label: 'Sign in to see usage' },
|
||||
{ failureKind: 'network' as const, label: 'Network issue' }
|
||||
])('labels a Claude row with no usage by its $failureKind failure', async (row) => {
|
||||
const { InlineUsageBars } = await import('./StatusBar')
|
||||
const limits: ProviderRateLimits = {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: 0,
|
||||
error: 'Claude usage is unavailable.',
|
||||
status: 'error',
|
||||
usageMetadata: { failureKind: row.failureKind }
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(<InlineUsageBars limits={limits} isFetching={false} />)
|
||||
|
||||
expect(markup).toContain(row.label)
|
||||
})
|
||||
|
||||
it('derives the collapsed session label from resetsAt (#5399)', async () => {
|
||||
const { InlineUsageBars } = await import('./StatusBar')
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
i18n: { language: 'en' },
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
|
||||
Object.entries(values ?? {}).reduce((text, [k, v]) => text.replace(`{{${k}}}`, v), fallback)
|
||||
}))
|
||||
vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null }))
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { usagePercentageDisplay: 'used' }) => unknown) =>
|
||||
selector({ usagePercentageDisplay: 'used' })
|
||||
}))
|
||||
|
||||
const { ProviderSegment } = await import('./StatusBarProviderSegment')
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: NOW })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function lapsedClaude(ageMs: number): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null },
|
||||
weekly: null,
|
||||
updatedAt: NOW - ageMs,
|
||||
error: 'Claude usage updates the next time Claude runs.',
|
||||
status: 'error',
|
||||
usageMetadata: { failureKind: 'stale-token' }
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
['stays calm over recent kept bars', 5 * 60_000, false],
|
||||
['warns once kept bars are older than 30 minutes', 2 * 60 * 60_000, true]
|
||||
])('Claude lapsed-token chip %s', (_, ageMs, warns) => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={lapsedClaude(ageMs)} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
expect(markup).toContain('40%')
|
||||
expect(/triangle/i.test(markup)).toBe(warns)
|
||||
})
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
ProviderIcon,
|
||||
ProviderPanel
|
||||
} from './tooltip'
|
||||
import { isClaudeUsageWaitingForClaude } from './usage-error-copy'
|
||||
|
||||
function provider(overrides: Partial<ProviderRateLimits> = {}): ProviderRateLimits {
|
||||
return {
|
||||
@@ -245,34 +246,26 @@ describe('provider usage error copy', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses structured Claude failure kinds before raw auth regexes', () => {
|
||||
const p = provider({
|
||||
error: 'Invalid OAuth token.',
|
||||
usageMetadata: {
|
||||
failureKind: 'stale-token',
|
||||
attemptedSources: ['oauth', 'cli']
|
||||
}
|
||||
})
|
||||
// Why: an expired access token is not a signed-out login; Claude refreshes it the next time it runs.
|
||||
it.each([undefined, 'system', 'wsl:Ubuntu:system', 'profile:idle'])(
|
||||
'tells every Claude login (%s) calmly that usage updates when Claude next runs',
|
||||
(authProvenance) => {
|
||||
const p = provider({
|
||||
error: 'Invalid OAuth token.',
|
||||
usageMetadata: {
|
||||
failureKind: 'stale-token',
|
||||
attemptedSources: ['oauth'],
|
||||
authProvenance
|
||||
}
|
||||
})
|
||||
|
||||
expect(getProviderUsageStatusLabel(p)).toBe('Sign-in expired')
|
||||
expect(getProviderUsageErrorMessage(p)).toBe(
|
||||
'Claude usage has expired. Start Claude in this account to refresh it.'
|
||||
)
|
||||
})
|
||||
|
||||
it('tells a System Default user calmly that usage updates when Claude next runs', () => {
|
||||
const p = provider({
|
||||
error: 'Invalid OAuth token.',
|
||||
usageMetadata: {
|
||||
failureKind: 'stale-token',
|
||||
attemptedSources: ['oauth'],
|
||||
authProvenance: 'system'
|
||||
}
|
||||
})
|
||||
|
||||
expect(getProviderUsageStatusLabel(p)).toBe('Updates when Claude runs')
|
||||
expect(getProviderUsageErrorMessage(p)).toBe('Claude usage updates the next time Claude runs.')
|
||||
})
|
||||
expect(getProviderUsageStatusLabel(p)).toBe('Updates when Claude runs')
|
||||
expect(getProviderUsageErrorMessage(p)).toBe(
|
||||
'Claude usage updates the next time Claude runs.'
|
||||
)
|
||||
expect(isClaudeUsageWaitingForClaude(p)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps refresh copy for failure kinds only a refreshing older host reports', () => {
|
||||
const p = provider({
|
||||
|
||||
@@ -84,13 +84,9 @@ function getDelegatedCliRefreshProvider(
|
||||
return p.provider === 'grok' || p.provider === 'kimi' ? p.provider : null
|
||||
}
|
||||
|
||||
/** System Default's login expired on the server: Claude refreshes it the next time it runs. */
|
||||
/** An idle login's access token lapsed, not the login: Claude refreshes it the next time it runs. */
|
||||
export function isClaudeUsageWaitingForClaude(p: ProviderRateLimits): boolean {
|
||||
return (
|
||||
p.provider === 'claude' &&
|
||||
p.usageMetadata?.failureKind === 'stale-token' &&
|
||||
p.usageMetadata.authProvenance === 'system'
|
||||
)
|
||||
return p.provider === 'claude' && p.usageMetadata?.failureKind === 'stale-token'
|
||||
}
|
||||
|
||||
export function getProviderUsageStatusLabel(p: ProviderRateLimits): string {
|
||||
@@ -116,14 +112,12 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string {
|
||||
'auto.components.status.bar.tooltip.0d8d7cfe15',
|
||||
'Waiting for Claude session'
|
||||
)
|
||||
// Why: Orca never refreshes a Claude login; only an older host that still does reports the other kinds.
|
||||
case 'stale-token':
|
||||
return isClaudeUsageWaitingForClaude(p)
|
||||
? translate(
|
||||
'auto.components.status.bar.tooltip.claude.waiting.label',
|
||||
'Updates when Claude runs'
|
||||
)
|
||||
: translate('auto.components.status.bar.tooltip.claude.expired.label', 'Sign-in expired')
|
||||
return translate(
|
||||
'auto.components.status.bar.tooltip.claude.waiting.label',
|
||||
'Updates when Claude runs'
|
||||
)
|
||||
// Why: Orca never refreshes a Claude login; only an older host that still does reports these.
|
||||
case 'refreshable-credentials-without-token':
|
||||
case 'delegated-refresh-required':
|
||||
return translate('auto.components.status.bar.tooltip.1804cd8c3f', 'Refreshing sign-in')
|
||||
@@ -213,15 +207,10 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string {
|
||||
'Claude usage will refresh after the live Claude terminal rotates its credentials.'
|
||||
)
|
||||
case 'stale-token':
|
||||
return isClaudeUsageWaitingForClaude(p)
|
||||
? translate(
|
||||
'auto.components.status.bar.tooltip.claude.waiting.message',
|
||||
'Claude usage updates the next time Claude runs.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.tooltip.claude.expired.message',
|
||||
'Claude usage has expired. Start Claude in this account to refresh it.'
|
||||
)
|
||||
return translate(
|
||||
'auto.components.status.bar.tooltip.claude.waiting.message',
|
||||
'Claude usage updates the next time Claude runs.'
|
||||
)
|
||||
case 'refreshable-credentials-without-token':
|
||||
case 'delegated-refresh-required':
|
||||
return translate(
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('getUsageRosterRowState', () => {
|
||||
}),
|
||||
false
|
||||
)
|
||||
).toEqual({ kind: 'error', statusLabel: 'Sign-in expired' })
|
||||
).toEqual({ kind: 'error', statusLabel: 'Updates when Claude runs' })
|
||||
expect(
|
||||
getUsageRosterRowState(
|
||||
provider({
|
||||
|
||||
@@ -4559,10 +4559,6 @@
|
||||
"waiting": {
|
||||
"label": "Updates when Claude runs",
|
||||
"message": "Claude usage updates the next time Claude runs."
|
||||
},
|
||||
"expired": {
|
||||
"label": "Sign-in expired",
|
||||
"message": "Claude usage has expired. Start Claude in this account to refresh it."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4236,10 +4236,6 @@
|
||||
"waiting": {
|
||||
"label": "Se actualiza cuando Claude se ejecuta",
|
||||
"message": "El uso de Claude se actualizará la próxima vez que ejecutes Claude."
|
||||
},
|
||||
"expired": {
|
||||
"label": "Inicio de sesión caducado",
|
||||
"message": "El uso de Claude ha caducado. Inicia Claude en esta cuenta para actualizarlo."
|
||||
}
|
||||
},
|
||||
"remoteOwnerNotConfigured": "No configurado en {{server}}",
|
||||
|
||||
@@ -4385,10 +4385,6 @@
|
||||
"waiting": {
|
||||
"label": "Se met à jour quand Claude s'exécute",
|
||||
"message": "L'utilisation de Claude se met à jour la prochaine fois que Claude s'exécute."
|
||||
},
|
||||
"expired": {
|
||||
"label": "Connexion expirée",
|
||||
"message": "L'utilisation de Claude a expiré. Démarrez Claude dans ce compte pour l'actualiser."
|
||||
}
|
||||
},
|
||||
"remoteOwnerNotConfigured": "Non configuré sur {{server}}",
|
||||
|
||||
@@ -4221,10 +4221,6 @@
|
||||
"waiting": {
|
||||
"label": "Claude の実行時に更新",
|
||||
"message": "Claude の使用状況は、次に Claude を実行したときに更新されます。"
|
||||
},
|
||||
"expired": {
|
||||
"label": "サインインの有効期限切れ",
|
||||
"message": "Claude の使用状況が期限切れです。このアカウントで Claude を起動して更新してください。"
|
||||
}
|
||||
},
|
||||
"remoteOwnerNotConfigured": "{{server}} では未構成",
|
||||
|
||||
@@ -4221,10 +4221,6 @@
|
||||
"waiting": {
|
||||
"label": "Claude 실행 시 업데이트됨",
|
||||
"message": "Claude 사용량은 다음에 Claude를 실행할 때 업데이트됩니다."
|
||||
},
|
||||
"expired": {
|
||||
"label": "로그인 만료",
|
||||
"message": "Claude 사용량이 만료되었습니다. 이 계정에서 Claude를 시작하면 사용량이 갱신됩니다."
|
||||
}
|
||||
},
|
||||
"remoteOwnerNotConfigured": "{{server}}에 구성되지 않음",
|
||||
|
||||
@@ -2519,10 +2519,6 @@
|
||||
"waiting": {
|
||||
"label": "Atualiza quando o Claude é executado",
|
||||
"message": "O uso do Claude será atualizado na próxima vez que ele for executado."
|
||||
},
|
||||
"expired": {
|
||||
"label": "Login expirado",
|
||||
"message": "Os dados de uso do Claude expiraram. Inicie o Claude nesta conta para atualizá-los."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4221,10 +4221,6 @@
|
||||
"waiting": {
|
||||
"label": "运行 Claude 时更新",
|
||||
"message": "Claude 用量将在下次运行 Claude 时更新。"
|
||||
},
|
||||
"expired": {
|
||||
"label": "登录已过期",
|
||||
"message": "Claude 用量已过期。请在此账户中启动 Claude 以刷新。"
|
||||
}
|
||||
},
|
||||
"remoteOwnerNotConfigured": "未在 {{server}} 上配置",
|
||||
|
||||
Reference in New Issue
Block a user