Handle Claude 2.1 usage output (#2162)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-17 17:23:35 -04:00 committed by GitHub
parent ad232d119c
commit 21a9c9909d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 148 additions and 14 deletions

View File

@ -135,7 +135,8 @@ describe('fetchClaudeRateLimits', () => {
'https://api.anthropic.com/api/oauth/usage',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer oauth-token'
Authorization: 'Bearer oauth-token',
'User-Agent': 'claude-code/2.1.0'
})
})
)

View File

@ -17,6 +17,7 @@ import {
const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
const OAUTH_BETA_HEADER = 'oauth-2025-04-20'
const CLAUDE_CODE_USER_AGENT = 'claude-code/2.1.0'
const API_TIMEOUT_MS = 10_000
let proxyConfigured = false
@ -266,7 +267,10 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
const res = await net.fetch(OAUTH_USAGE_URL, {
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': OAUTH_BETA_HEADER
'anthropic-beta': OAUTH_BETA_HEADER,
// Why: Claude's OAuth usage endpoint is the Claude Code usage API;
// matching the CLI user-agent keeps Orca aligned with that contract.
'User-Agent': CLAUDE_CODE_USER_AGENT
},
signal: controller.signal
})

View File

@ -19,6 +19,29 @@ function makeDisposable() {
return { dispose: vi.fn() }
}
type MockTerm = {
onData: ReturnType<typeof vi.fn>
onExit: ReturnType<typeof vi.fn>
write: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
}
function makeMockTerm(): MockTerm & {
emitData: (data: string) => void
} {
let dataHandler: ((data: string) => void) | null = null
return {
onData: vi.fn((handler: (data: string) => void) => {
dataHandler = handler
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
kill: vi.fn(),
emitData: (data: string) => dataHandler?.(data)
}
}
describe('fetchViaPty', () => {
beforeEach(() => {
vi.useFakeTimers()
@ -49,4 +72,64 @@ describe('fetchViaPty', () => {
term.kill.mock.invocationCallOrder[0]
)
})
it('treats Claude 2.1 tabbed /usage session stats as rendered but unavailable', async () => {
const term = makeMockTerm()
spawnMock.mockReturnValue(term)
const resultPromise = fetchViaPty()
await vi.advanceTimersByTimeAsync(2_000)
expect(term.write).toHaveBeenCalledWith('/usage\r')
term.emitData(`
Settings Status Config Usage Stats
Session
Total cost: $0.0000
Usage: 0 input, 0 output, 0 cache read, 0 cache write
`)
await vi.advanceTimersByTimeAsync(8_000)
await expect(resultPromise).resolves.toMatchObject({
provider: 'claude',
status: 'error',
session: null,
weekly: null,
error: 'Claude plan usage is unavailable for this Claude CLI session.'
})
expect(term.write).not.toHaveBeenCalledWith('\x1b[D\x1b[D')
})
it('keeps waiting for plan windows after the Claude 2.1 usage shell renders', async () => {
const term = makeMockTerm()
spawnMock.mockReturnValue(term)
const resultPromise = fetchViaPty()
await vi.advanceTimersByTimeAsync(2_000)
term.emitData(`
Settings Status Config Usage Stats
Session
Total cost: $0.0000
`)
await vi.advanceTimersByTimeAsync(1_000)
term.emitData('Current session\r12% used\rResets 4:00pm\rCurrent week (all models)\r34% used\r')
await vi.advanceTimersByTimeAsync(2_000)
await expect(resultPromise).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: {
usedPercent: 12,
resetDescription: '4:00pm'
},
weekly: {
usedPercent: 34
},
error: null
})
})
})

View File

@ -17,6 +17,14 @@ const SESSION_RE = /current\s*session/i
const WEEKLY_RE = /current\s*week/i
const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|left|remaining|available)/i
const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g')
const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g')
function stripTerminalControlSequences(output: string): string {
return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '')
}
/**
* Extract percent-left from lines following a label match.
@ -60,7 +68,7 @@ function parsePtyUsage(output: string): {
session: RateLimitWindow | null
weekly: RateLimitWindow | null
} {
const lines = output.split(/\r?\n/)
const lines = output.split(/\r\n|\n|\r/)
const sessionPct = extractPercentAfterLabel(lines, SESSION_RE)
const weeklyPct = extractPercentAfterLabel(lines, WEEKLY_RE)
@ -110,8 +118,11 @@ const COMMAND_PALETTE_RE = /show plan|usage limits/i
const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i
const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i
const LOAD_FAILED_RE = /failed to load usage data/i
const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i
const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i
const STARTUP_DELAY_MS = 2_000
const SETTLE_AFTER_STOP_MS = 2_000
const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000
function describeClaudeUsageFailure(output: string): string {
if (RATE_LIMITED_RE.test(output)) {
@ -122,6 +133,10 @@ function describeClaudeUsageFailure(output: string): string {
return 'Claude usage is unavailable right now.'
}
if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) {
return 'Claude plan usage is unavailable for this Claude CLI session.'
}
// Why: parser failures are an implementation detail of Orca's PTY fallback.
// The UI should explain the user-visible outcome, not leak internal parsing
// mechanics that the user cannot act on.
@ -138,6 +153,8 @@ export async function fetchViaPty(options?: {
let resolved = false
let sentUsage = false
let stopDetected = false
let claude21UsageDetected = false
let claude21UsageSettleTimer: ReturnType<typeof setTimeout> | null = null
const claudeCommand = resolveClaudeCommand()
@ -173,11 +190,18 @@ export async function fetchViaPty(options?: {
// Why: node-pty's NAPI callbacks can outlive the Electron JS
// environment if we kill the hidden PTY without disposing them first,
// which matches Orca's documented SIGABRT failure mode on shutdown.
if (claude21UsageSettleTimer) {
clearTimeout(claude21UsageSettleTimer)
claude21UsageSettleTimer = null
}
if (enterInterval) {
clearInterval(enterInterval)
enterInterval = null
}
disposeTermListeners()
term.kill()
// Even on timeout, try to parse whatever we collected
// eslint-disable-next-line no-control-regex
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const clean = stripTerminalControlSequences(output)
const { session, weekly } = parsePtyUsage(clean)
if (session || weekly) {
resolve({
@ -194,7 +218,10 @@ export async function fetchViaPty(options?: {
session: null,
weekly: null,
updatedAt: Date.now(),
error: 'PTY timeout — /usage panel did not render',
error:
CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean)
? describeClaudeUsageFailure(clean)
: 'PTY timeout — /usage panel did not render',
status: 'error'
})
}
@ -222,14 +249,17 @@ export async function fetchViaPty(options?: {
}
resolved = true
clearTimeout(timeout)
if (claude21UsageSettleTimer) {
clearTimeout(claude21UsageSettleTimer)
claude21UsageSettleTimer = null
}
if (enterInterval) {
clearInterval(enterInterval)
}
disposeTermListeners()
term.kill()
// eslint-disable-next-line no-control-regex
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const clean = stripTerminalControlSequences(output)
const { session, weekly } = parsePtyUsage(clean)
if (!session && !weekly) {
@ -271,8 +301,7 @@ export async function fetchViaPty(options?: {
output = output.slice(-MAX_OUTPUT_LENGTH)
}
// eslint-disable-next-line no-control-regex
const cleanChunk = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const cleanChunk = stripTerminalControlSequences(data)
// Why: the Claude CLI may prompt for first-run setup (trust files,
// workspace directory). Auto-accept so we can reach /usage.
@ -290,8 +319,21 @@ export async function fetchViaPty(options?: {
// Check if we've hit a stop substring indicating the panel rendered
if (sentUsage && !stopDetected) {
// eslint-disable-next-line no-control-regex
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const clean = stripTerminalControlSequences(output)
if (
!claude21UsageDetected &&
(CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean))
) {
claude21UsageDetected = true
if (enterInterval) {
clearInterval(enterInterval)
enterInterval = null
}
// Why: Claude 2.1 may render session stats without subscription
// plan windows. Give async usage loading a grace period, then finish
// with a user-facing unavailable state instead of a false PTY timeout.
claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS)
}
for (const sub of STOP_SUBSTRINGS) {
if (clean.includes(sub)) {
stopDetected = true
@ -309,14 +351,18 @@ export async function fetchViaPty(options?: {
const onExitDisposable = term.onExit(() => {
disposeTermListeners()
if (claude21UsageSettleTimer) {
clearTimeout(claude21UsageSettleTimer)
claude21UsageSettleTimer = null
}
if (enterInterval) {
clearInterval(enterInterval)
enterInterval = null
}
if (!resolved) {
resolved = true
clearTimeout(timeout)
// eslint-disable-next-line no-control-regex
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const clean = stripTerminalControlSequences(output)
const { session, weekly } = parsePtyUsage(clean)
resolve({
provider: 'claude',