diff --git a/docs/claude-scoped-oauth-usage-limits.md b/docs/claude-scoped-oauth-usage-limits.md new file mode 100644 index 000000000..e5eb3c9e2 --- /dev/null +++ b/docs/claude-scoped-oauth-usage-limits.md @@ -0,0 +1,84 @@ +# Claude Scoped OAuth Usage Limits + +## Problem + +Anthropic's current OAuth usage response reports Fable in `limits` as a model-scoped weekly limit instead of one of the legacy top-level Fable fields. Orca ignores `limits`, maps `fableWeekly` to `null`, and then depends on a hidden Claude `/usage` PTY read that is disabled on Windows and can fail silently elsewhere. + +- `src/main/rate-limits/claude-fetcher.ts:300` models only top-level OAuth windows. +- `src/main/rate-limits/claude-fetcher.ts:393` maps only legacy Fable field names. +- `src/main/rate-limits/service.ts:1203` disables the PTY supplement on Windows. +- `src/renderer/src/components/status-bar/tooltip.tsx:172` renders Fable whenever `fableWeekly` is populated. + +## Root Cause + +The OAuth response contract evolved from dedicated model fields to generic entries shaped like `kind: "weekly_scoped"`, `percent`, `resets_at`, and `scope.model.display_name`. Orca's response type and mapper were not updated for that shape. + +## Non-goals + +- Do not change polling, credentials, token refresh, account switching, renderer layout, or usage percentage semantics. +- Do not remove the existing PTY supplement or legacy field compatibility. +- Do not generalize shared renderer state to arbitrary model windows in this targeted bug fix. + +## Design + +1. Extend the private OAuth response type with an optional `limits` array containing only the fields needed for safe parsing. +2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite. +3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp. +4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks. +5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility. + +## Data Flow + +- OAuth response + - `limits[].weekly_scoped` Fable -> `fableWeekly` + - otherwise legacy explicit Fable field -> `fableWeekly` + - otherwise existing optional PTY supplement +- Existing provider state -> existing status-bar and details rendering + +## Edge Cases + +- `limits` is missing, null, malformed, or contains null entries. +- A scoped entry names another model. +- Fable percent is missing, non-numeric, or non-finite. +- Fable is inactive and should not be rendered. +- `is_active` is omitted by an older server response but the remaining scoped entry is valid. +- Both current and legacy fields exist; the current scoped entry wins. +- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent. +- Windows, WSL, SSH, and remote runtimes use the same OAuth mapper and require no platform-specific execution. + +## Test Plan + +- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt. +- Unit: assert scoped data wins over a legacy field. +- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available. +- Regression: retain existing legacy-field and bare-`fable` behavior tests. +- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet. +- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface. + +## UI Quality Bar + +No UI implementation changes. The existing Fable row must reappear with the same typography, spacing, progress bar, percentage semantics, and reset copy as adjacent Session and Weekly rows. + +## Review Screenshots + +1. Claude usage details showing Session, Weekly, and Fable from a live OAuth refresh. +2. Adjacent status-bar context showing the Claude provider remains visually unchanged outside the restored row. + +## Rollout + +1. Add the scoped OAuth response types and mapper. +2. Add focused current-schema and compatibility regression tests. +3. Run focused and repository checks. +4. Validate the restored row in Electron and capture review screenshots. +5. Commit, push, and open an unmerged PR. + +## Lightweight Eng Review + +- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable. +- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement. +- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution. +- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback. +- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work. +- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row. +- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context. +- Residual risks: Anthropic may rename the scoped model display label; legacy and PTY fallbacks remain available. diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 6571177a9..da85dc691 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -214,6 +214,87 @@ describe('fetchClaudeRateLimits', () => { }) }) + it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 36 }, + seven_day: { utilization: 73 }, + fable_weekly: { utilization: 12 }, + limits: [ + { kind: 'weekly_scoped', percent: 55, scope: null }, + { + kind: 'weekly_scoped', + percent: 100, + resets_at: '2026-07-17T20:00:00.099908+00:00', + is_active: true, + scope: { model: { display_name: 'Fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + fableWeekly: { + usedPercent: 100, + resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') + }, + usageMetadata: { attemptedSources: ['oauth'] } + }) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('ignores inactive scoped Fable usage and retains the legacy OAuth fallback', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + fable_weekly: { utilization: 33 }, + limits: [ + { + kind: 'weekly_scoped', + percent: 90, + is_active: false, + scope: { model: { display_name: 'fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + fableWeekly: { usedPercent: 33 } + }) + }) + it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index f84e1d118..af0563762 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -300,12 +300,21 @@ type OAuthUsageWindow = { resets_at?: string | number } +type OAuthUsageLimit = { + kind?: string + percent?: number + resets_at?: string | number + is_active?: boolean + scope?: { model?: { display_name?: string } | null } | null +} + type OAuthUsageResponse = { five_hour?: OAuthUsageWindow seven_day?: OAuthUsageWindow fable_weekly?: OAuthUsageWindow fable_seven_day?: OAuthUsageWindow seven_day_fable?: OAuthUsageWindow + limits?: OAuthUsageLimit[] | null } type ClaudeUsageAttemptState = { @@ -391,9 +400,22 @@ function mapWindow( } function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { - // Why: a bare "fable" field does not prove the window length. Only accept - // explicit weekly/seven-day names for the distinct Fable meter. + // Why: model quotas moved into structured scoped limits; prefer that current + // contract while retaining explicit legacy weekly fields for older responses. + const scoped = Array.isArray(data.limits) + ? data.limits.find( + (limit) => + limit?.kind === 'weekly_scoped' && + limit.is_active !== false && + Number.isFinite(limit.percent) && + limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable' + ) + : undefined return ( + mapWindow( + scoped ? { used_percentage: scoped.percent, resets_at: scoped.resets_at } : undefined, + 10080 + ) ?? mapWindow(data.fable_weekly, 10080) ?? mapWindow(data.fable_seven_day, 10080) ?? mapWindow(data.seven_day_fable, 10080)