mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
Tighten Claude Fable usage parsing
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## Problem
|
||||
|
||||
Claude Code now exposes weekly subscription usage alongside the 5-hour window, and its live `/usage` panel can also show a Fable-specific weekly bucket. Anthropic documents `rate_limits.five_hour` and `rate_limits.seven_day` in Claude Code statusline JSON, with weekly data present for Claude.ai subscribers after the first API response. The existing Orca Claude meter already has a weekly slot in shared state, but it needs a distinct Fable weekly slot so the status bar can show all three visible meters when Claude reports them.
|
||||
Claude Code now exposes weekly subscription usage alongside the 5-hour window, and its live `/usage` panel can show an explicit Fable-specific weekly bucket. Anthropic documents `rate_limits.five_hour` and `rate_limits.seven_day` in Claude Code statusline JSON, with weekly data present for Claude.ai subscribers after the first API response. The existing Orca Claude meter already has a weekly slot in shared state, but it needs a distinct Fable weekly slot so the status bar can show all three visible meters when Claude reports them.
|
||||
|
||||
Relevant code:
|
||||
|
||||
@@ -17,6 +17,25 @@ Research:
|
||||
- Official Claude Code statusline docs: [`rate_limits.five_hour.used_percentage` and `rate_limits.seven_day.used_percentage`](https://code.claude.com/docs/en/statusline#available-data), plus matching `resets_at`, are the 5-hour and 7-day rate-limit fields.
|
||||
- `harveyxiacn/cc-usage-monitor` uses Claude Code's statusline `rate_limits` data and shows both [`5h` and `7d` windows](https://github.com/harveyxiacn/cc-usage-monitor), matching Orca's existing `session` and `weekly` model.
|
||||
- `leeguooooo/claude-code-usage-bar` independently exposes the same [`5h` and `7d` rate-limit usage](https://github.com/leeguooooo/claude-code-usage-bar) in a Claude Code statusLine integration.
|
||||
- Fable is not part of the documented statusline schema above. Orca only treats it as Fable weekly usage when the live `/usage` panel renders a standalone `Fable` label or an OAuth response uses an explicit weekly/seven-day Fable field name.
|
||||
|
||||
Redacted live `/usage` shape this parser targets:
|
||||
|
||||
```text
|
||||
Plan usage limits
|
||||
|
||||
Current session
|
||||
18% remaining
|
||||
Resets in 2h 10m
|
||||
|
||||
Current week (all models)
|
||||
84% left
|
||||
Resets in 5d 4h
|
||||
|
||||
Fable
|
||||
42% consumed
|
||||
Resets in 3d 2h
|
||||
```
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -34,7 +53,7 @@ Make Orca's existing Claude status-bar meter show the weekly Claude and Fable us
|
||||
2. Add `ProviderRateLimits.fableWeekly` as a distinct optional Claude window so the chip and popover can render Session, Weekly, and Fable simultaneously.
|
||||
3. Accept both OAuth `utilization` windows and Claude Code-style `used_percentage` windows with epoch-second `resets_at` values.
|
||||
4. Broaden the hidden Claude CLI parser so the generic weekly label accepts both old `Current week` wording and newer usage/statusline wording: `Weekly limits`, `Weekly usage`, `weekly rate limit`, and `7-day`.
|
||||
5. Parse an explicit `Fable` label into `fableWeekly` instead of collapsing it into generic `weekly`.
|
||||
5. Parse only a standalone `Fable` label into `fableWeekly` instead of collapsing it into generic `weekly`; ambiguous Fable copy is a section boundary, not a meter.
|
||||
6. Broaden percent parsing to treat `consumed` like `used`, because Anthropic describes rate-limit percentages as consumed.
|
||||
7. Add focused tests for the new weekly wording and retain existing old-copy coverage.
|
||||
|
||||
@@ -43,7 +62,8 @@ Make Orca's existing Claude status-bar meter show the weekly Claude and Fable us
|
||||
- Weekly data may be absent for API-key users or before the first Claude API response; keep `weekly: null`.
|
||||
- The hidden PTY fallback may still only return session data; the status bar should continue showing the 5-hour meter without error.
|
||||
- Reset timestamps/descriptions may be absent from CLI output; keep `resetsAt: null` and parse only visible reset text.
|
||||
- Fable data may be absent from the documented statusline payload even when the interactive `/usage` panel shows it; keep `fableWeekly: null` unless a distinct field or explicit `Fable` label is present.
|
||||
- Fable data may be absent from the documented statusline payload even when the interactive `/usage` panel shows it; keep `fableWeekly: null` unless an explicit weekly/seven-day field or standalone `Fable` label is present.
|
||||
- A bare OAuth `fable` field is ambiguous because it does not encode the window length; ignore it until the upstream contract is clearer.
|
||||
|
||||
## Rollout
|
||||
|
||||
|
||||
@@ -205,6 +205,42 @@ describe('fetchClaudeRateLimits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores bare Fable OAuth usage because the window length is ambiguous', 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',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 11 },
|
||||
seven_day: { utilization: 22 },
|
||||
fable: { utilization: 33 }
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: { usedPercent: 11 },
|
||||
weekly: { usedPercent: 22 },
|
||||
fableWeekly: null
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
|
||||
@@ -300,7 +300,6 @@ type OAuthUsageWindow = {
|
||||
type OAuthUsageResponse = {
|
||||
five_hour?: OAuthUsageWindow
|
||||
seven_day?: OAuthUsageWindow
|
||||
fable?: OAuthUsageWindow
|
||||
fable_weekly?: OAuthUsageWindow
|
||||
fable_seven_day?: OAuthUsageWindow
|
||||
seven_day_fable?: OAuthUsageWindow
|
||||
@@ -378,13 +377,12 @@ function mapWindow(
|
||||
}
|
||||
|
||||
function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null {
|
||||
// Why: the Fable weekly bucket is newer than the documented statusline
|
||||
// contract, so accept the observed likely field names without guessing usage.
|
||||
// Why: a bare "fable" field does not prove the window length. Only accept
|
||||
// explicit weekly/seven-day names for the distinct Fable meter.
|
||||
return (
|
||||
mapWindow(data.fable_weekly, 10080) ??
|
||||
mapWindow(data.fable_seven_day, 10080) ??
|
||||
mapWindow(data.seven_day_fable, 10080) ??
|
||||
mapWindow(data.fable, 10080)
|
||||
mapWindow(data.seven_day_fable, 10080)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,112 @@ describe('fetchViaPty', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let an incomplete Fable section consume later usage sections', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
|
||||
const resultPromise = fetchViaPty()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
term.emitData(`
|
||||
Plan usage limits
|
||||
|
||||
Fable
|
||||
Usage unavailable
|
||||
|
||||
Current session
|
||||
12% used
|
||||
|
||||
Current week (all models)
|
||||
84% left
|
||||
`)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: {
|
||||
usedPercent: 12
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 16
|
||||
},
|
||||
fableWeekly: null,
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat inline Fable weekly text as a parsed usage label', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
|
||||
const resultPromise = fetchViaPty()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
term.emitData(`
|
||||
Plan usage limits
|
||||
|
||||
Current session
|
||||
Fable weekly usage
|
||||
42% consumed
|
||||
|
||||
Current week (all models)
|
||||
84% left
|
||||
`)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: null,
|
||||
weekly: {
|
||||
usedPercent: 16
|
||||
},
|
||||
fableWeekly: null,
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps waiting after a bare Fable heading until another usage section renders', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
let settled = false
|
||||
|
||||
const resultPromise = fetchViaPty().finally(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
term.emitData(`
|
||||
Plan usage limits
|
||||
|
||||
Fable
|
||||
`)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
expect(settled).toBe(false)
|
||||
|
||||
term.emitData(`
|
||||
42% consumed
|
||||
|
||||
Current session
|
||||
12% used
|
||||
`)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: {
|
||||
usedPercent: 12
|
||||
},
|
||||
fableWeekly: {
|
||||
usedPercent: 42
|
||||
},
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('parses 7-day weekly labels without the old Current week heading', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
|
||||
@@ -20,7 +20,8 @@ const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit
|
||||
// Weekly labels have varied between "Current week" and "Weekly limits".
|
||||
const SESSION_RE = /current\s*session/i
|
||||
const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i
|
||||
const FABLE_RE = /\bfable\b/i
|
||||
const FABLE_WORD_RE = /\bfable\b/i
|
||||
const FABLE_LABEL_RE = /^\s*fable\s*$/i
|
||||
const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i
|
||||
const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i
|
||||
const ESC = String.fromCharCode(27)
|
||||
@@ -36,18 +37,29 @@ function stripTerminalControlSequences(output: string): string {
|
||||
* Extract percent-left from lines following a label match.
|
||||
* Scans up to 12 lines after the label to find the associated percent.
|
||||
*/
|
||||
function matchesWeeklyLabel(line: string): boolean {
|
||||
return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line)
|
||||
}
|
||||
|
||||
function matchesFableBoundary(line: string): boolean {
|
||||
return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line))
|
||||
}
|
||||
|
||||
function isSectionLabel(line: string): boolean {
|
||||
return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line)
|
||||
}
|
||||
|
||||
function extractPercentAfterLabel(
|
||||
lines: string[],
|
||||
labelRe: RegExp,
|
||||
options: { stopAtFable?: boolean } = {}
|
||||
matchesLabel: (line: string) => boolean
|
||||
): number | null {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!labelRe.test(lines[i])) {
|
||||
if (!matchesLabel(lines[i])) {
|
||||
continue
|
||||
}
|
||||
// Scan next 12 lines for a percent
|
||||
for (let j = i; j < Math.min(i + 12, lines.length); j++) {
|
||||
if (j > i && options.stopAtFable === true && FABLE_RE.test(lines[j])) {
|
||||
if (j > i && isSectionLabel(lines[j])) {
|
||||
break
|
||||
}
|
||||
const m = PERCENT_RE.exec(lines[j])
|
||||
@@ -64,15 +76,14 @@ function extractPercentAfterLabel(
|
||||
|
||||
function extractResetAfterLabel(
|
||||
lines: string[],
|
||||
labelRe: RegExp,
|
||||
options: { stopAtFable?: boolean } = {}
|
||||
matchesLabel: (line: string) => boolean
|
||||
): string | null {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!labelRe.test(lines[i])) {
|
||||
if (!matchesLabel(lines[i])) {
|
||||
continue
|
||||
}
|
||||
for (let j = i; j < Math.min(i + 14, lines.length); j++) {
|
||||
if (j > i && options.stopAtFable === true && FABLE_RE.test(lines[j])) {
|
||||
if (j > i && isSectionLabel(lines[j])) {
|
||||
break
|
||||
}
|
||||
const m = RESET_LINE_RE.exec(lines[j])
|
||||
@@ -91,9 +102,9 @@ function parsePtyUsage(output: string): {
|
||||
} {
|
||||
const lines = output.split(/\r\n|\n|\r/)
|
||||
|
||||
const sessionPct = extractPercentAfterLabel(lines, SESSION_RE)
|
||||
const weeklyPct = extractPercentAfterLabel(lines, WEEKLY_RE, { stopAtFable: true })
|
||||
const fableWeeklyPct = extractPercentAfterLabel(lines, FABLE_RE)
|
||||
const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line))
|
||||
const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel)
|
||||
const fableWeeklyPct = extractPercentAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line))
|
||||
|
||||
const session: RateLimitWindow | null =
|
||||
sessionPct !== null
|
||||
@@ -101,7 +112,7 @@ function parsePtyUsage(output: string): {
|
||||
usedPercent: Math.min(100, Math.max(0, sessionPct)),
|
||||
windowMinutes: 300,
|
||||
resetsAt: null,
|
||||
resetDescription: extractResetAfterLabel(lines, SESSION_RE)
|
||||
resetDescription: extractResetAfterLabel(lines, (line) => SESSION_RE.test(line))
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -111,7 +122,7 @@ function parsePtyUsage(output: string): {
|
||||
usedPercent: Math.min(100, Math.max(0, weeklyPct)),
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: extractResetAfterLabel(lines, WEEKLY_RE, { stopAtFable: true })
|
||||
resetDescription: extractResetAfterLabel(lines, matchesWeeklyLabel)
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -121,7 +132,7 @@ function parsePtyUsage(output: string): {
|
||||
usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)),
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: extractResetAfterLabel(lines, FABLE_RE)
|
||||
resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line))
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -140,7 +151,6 @@ const STOP_SUBSTRINGS = [
|
||||
'Weekly limit',
|
||||
'Weekly usage',
|
||||
'7-day',
|
||||
'Fable',
|
||||
'Current session',
|
||||
'Failed to load usage data',
|
||||
'failed to load usage data'
|
||||
|
||||
Reference in New Issue
Block a user