diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 46631c59f..e22c5bc28 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -214,7 +214,7 @@ describe('fetchClaudeRateLimits', () => { ) }) - it('tries PTY usage when OAuth credentials are expired but refreshable', async () => { + it('tries OAuth usage even when local credential metadata is expired', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { configDir, @@ -235,12 +235,58 @@ describe('fetchClaudeRateLimits', () => { await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', status: 'ok', - session: { usedPercent: 56 } + session: { usedPercent: 12 } }) - expect(netFetchMock).not.toHaveBeenCalled() + expect(netFetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer expired-oauth-token' + }) + }) + ) expect(readFileMock).not.toHaveBeenCalled() - expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation }) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('does not mask OAuth usage rate limits with the PTY fallback', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'expired-oauth-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() - 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { type: 'rate_limit_error' } }), { status: 429 }) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'error', + error: 'Claude usage is rate limited right now.' + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer expired-oauth-token' + }) + }) + ) + expect(fetchViaPty).not.toHaveBeenCalled() }) it('does not read inactive managed credentials from unowned auth paths', async () => { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 9bce76428..eb9a4dbeb 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: this module keeps Claude credential source +ordering, OAuth usage fetch semantics, and PTY fallback behavior together so +subscription usage state cannot drift across code paths. */ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' @@ -14,6 +17,7 @@ import { readClaudeManagedAuthFile, resolveOwnedClaudeManagedAuthPath } from '../claude-accounts/managed-auth-path' +import { createOAuthUsageError, OAuthUsageError } from './claude-oauth-usage-error' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' @@ -83,26 +87,36 @@ type OAuthCredentialReadResult = { } // Why: factored out so both the active-account Keychain reader and the -// managed-account reader share the same JSON parsing + expiry check. +// managed-account reader share the same JSON parsing + refreshability check. function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult { try { const parsed = JSON.parse(raw) as KeychainCredentials const oauth = parsed?.claudeAiOauth const token = oauth?.accessToken - if (!token || typeof token !== 'string') { - return { token: null, hasRefreshableCredentials: false } - } const refreshToken = oauth?.refreshToken - const expiresAt = oauth?.expiresAt - if (typeof expiresAt === 'number' && expiresAt < Date.now()) { + const hasRefreshableCredentials = typeof refreshToken === 'string' && refreshToken.trim() !== '' + if (!token || typeof token !== 'string') { return { token: null, - hasRefreshableCredentials: typeof refreshToken === 'string' && refreshToken.trim() !== '' + hasRefreshableCredentials } } - return { token, hasRefreshableCredentials: true } + // Why: Claude's local expiresAt metadata is not authoritative for the + // /api/oauth/usage endpoint. Real Claude Code 2.1 credentials have been + // observed authenticating there after expiresAt, so let the server decide. + return { + token, + hasRefreshableCredentials + } } catch { - return { token: null, hasRefreshableCredentials: false } + return emptyOAuthCredentialReadResult() + } +} + +function emptyOAuthCredentialReadResult(): OAuthCredentialReadResult { + return { + token: null, + hasRefreshableCredentials: false } } @@ -113,7 +127,7 @@ function parseOAuthCredentialsJson(raw: string): OAuthCredentialReadResult { */ async function readFromKeychain(configDir?: string): Promise { if (process.platform !== 'darwin') { - return { token: null, hasRefreshableCredentials: false } + return emptyOAuthCredentialReadResult() } if (configDir) { @@ -128,20 +142,14 @@ async function readFromKeychain(configDir?: string): Promise { try { const credentials = await readActiveClaudeKeychainCredentialsStrict(configDir) - return credentials - ? parseOAuthCredentialsJson(credentials) - : { token: null, hasRefreshableCredentials: false } + return credentials ? parseOAuthCredentialsJson(credentials) : emptyOAuthCredentialReadResult() } catch { - return { token: null, hasRefreshableCredentials: false } + return emptyOAuthCredentialReadResult() } } @@ -169,7 +175,7 @@ async function readFromCredentialsFile(configDir?: string): Promise { }) if (!res.ok) { - throw new Error(`OAuth API returned ${res.status}`) + throw await createOAuthUsageError(res) } const data = (await res.json()) as OAuthUsageResponse @@ -306,15 +311,25 @@ export async function fetchClaudeRateLimits(options?: { if (oauthCredentials.token) { try { return await fetchViaOAuth(oauthCredentials.token) - } catch { + } catch (err) { + if (err instanceof OAuthUsageError && err.skipPtyFallback) { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err.message, + status: 'error' + } + } // OAuth API failed — fall through to PTY scraping as a backup // for subscription users whose token may still be valid for the CLI. } } // Path B: PTY fallback — only for subscription plan users (Max/Pro) - // whose OAuth credentials exist. The CLI can refresh expired OAuth tokens, - // so an expired access token should not be treated like API-key billing. + // whose OAuth credentials exist. This remains a fallback for older Claude + // auth shapes and transient OAuth failures. if (oauthCredentials.token || oauthCredentials.hasRefreshableCredentials) { try { return await fetchViaPty({ authPreparation: options?.authPreparation }) diff --git a/src/main/rate-limits/claude-oauth-usage-error.ts b/src/main/rate-limits/claude-oauth-usage-error.ts new file mode 100644 index 000000000..334ca85f5 --- /dev/null +++ b/src/main/rate-limits/claude-oauth-usage-error.ts @@ -0,0 +1,34 @@ +export class OAuthUsageError extends Error { + constructor( + message: string, + readonly status: number, + readonly skipPtyFallback: boolean + ) { + super(message) + } +} + +export async function createOAuthUsageError(res: Response): Promise { + return new OAuthUsageError( + await describeOAuthUsageError(res), + res.status, + // Why: 429 is already the user-visible Claude usage API answer. + // Falling through to /usage masks it with Claude 2.1's session-stats UI. + res.status === 429 + ) +} + +async function describeOAuthUsageError(res: Response): Promise { + if (res.status === 429) { + return 'Claude usage is rate limited right now.' + } + try { + const data = (await res.json()) as { error?: { message?: string } } + if (typeof data.error?.message === 'string' && data.error.message.trim()) { + return data.error.message + } + } catch { + // Ignore malformed error bodies and use the status fallback below. + } + return `OAuth API returned ${res.status}` +} diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 06eecc255..1ef3557e9 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -19,12 +19,12 @@ export type InactiveCodexAccountInfo = { managedHomePath: string } -// Why: quota state does not need near-real-time polling, and a less aggressive -// default reduces avoidable Claude /usage pressure. We intentionally use a -// slower cadence here rather than polling every 2 minutes. -const DEFAULT_POLL_MS = 5 * 60 * 1000 // 5 minutes -const MIN_REFETCH_MS = 30 * 1000 // 30 seconds — debounce rapid refresh requests -const STALE_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes — after this, stale data is dropped +// Why: Claude's subscription usage endpoint has a tight request budget. Quota +// state is informational, so prefer keeping a recent snapshot over polling it +// into 429s during long focused Orca sessions. +const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes +const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts +const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open // Why: the internal state only tracks claude and codex. The inactiveClaudeAccounts