fix(rate-limits): restore Claude Fable OAuth usage (#8323)
This commit is contained in:
parent
8a4ec4e856
commit
d5b47b1452
|
|
@ -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.
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue