From 9808ebfe3692de578cd59bb9455122802f442bea Mon Sep 17 00:00:00 2001 From: yousiki <18757689+yousiki@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:06:04 +0900 Subject: [PATCH] fix(cliproxy): use explicit utilization unit per Claude payload form Anthropic OAuth usage returns utilization as a percent (0-100) while the older policy-limits restrictions payload returns a 0-1 ratio. The shared heuristic "value <= 1 means ratio" misinterpreted percent values like 1.0, rendering 1% Sonnet usage as 100% used. Pass the unit explicitly from each payload branch so the same boundary value is interpreted correctly in either format. --- .../quota-fetcher-claude-normalizer.ts | 50 ++++++++++++++----- .../cliproxy/quota-fetcher-claude.test.ts | 11 ++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts index a6cdda19..6bc86780 100644 --- a/src/cliproxy/quota-fetcher-claude-normalizer.ts +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -89,7 +89,27 @@ function clampUnit(value: number): number { return Math.max(0, Math.min(1, value)); } -function normalizeUtilization(raw: Record): { +/** + * Unit of the `utilization` field in a Claude quota payload. + * + * Anthropic exposes utilization in two different units depending on the + * payload form: + * - `restrictions[]` (array or object map) returns a 0..1 ratio + * (e.g. 0.25 for 25% used). + * - The OAuth `oauth/usage` endpoint returns a percent value 0..100 + * (e.g. 25 for 25% used; values like 1 for 1% used). + * + * The previous heuristic of "value <= 1 means ratio, otherwise percent" + * silently misinterpreted boundary values like 1.0 (1% used) from the + * OAuth payload as 100% used. Each call site now passes the unit it + * knows it is parsing. + */ +type UtilizationUnit = 'ratio' | 'percent'; + +function normalizeUtilization( + raw: Record, + unit: UtilizationUnit +): { utilization: number | null; usedPercent: number; remainingPercent: number; @@ -99,11 +119,12 @@ function normalizeUtilization(raw: Record): { const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']); if (utilizationRaw !== null) { - const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; - const normalizedRatio = clampUnit(ratio); - const usedPercent = clampPercent(normalizedRatio * 100); + const usedPercent = + unit === 'ratio' + ? clampPercent(clampUnit(utilizationRaw) * 100) + : clampPercent(utilizationRaw); return { - utilization: normalizedRatio, + utilization: usedPercent / 100, usedPercent, remainingPercent: clampPercent(100 - usedPercent), }; @@ -149,7 +170,8 @@ function toObject(value: unknown): Record | null { function normalizeRestriction( raw: Record, - fallbackKey?: string + fallbackKey: string | undefined, + unit: UtilizationUnit ): ClaudeQuotaWindow | null { const rateLimitType = normalizeRateLimitType( raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'], @@ -169,7 +191,7 @@ function normalizeRestriction( raw['overage_reset_at'] ) || null; - const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw); + const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw, unit); return { rateLimitType, @@ -211,14 +233,16 @@ export function buildClaudeQuotaWindows(payload: Record): Claud for (const item of rawRestrictions) { const raw = toObject(item); if (!raw) continue; - const window = normalizeRestriction(raw); + // policy-limits restrictions[] returns utilization as a 0..1 ratio. + const window = normalizeRestriction(raw, undefined, 'ratio'); if (window) windows.push(window); } } else if (toObject(rawRestrictions)) { for (const [key, value] of Object.entries(rawRestrictions as Record)) { const raw = toObject(value); if (!raw) continue; - const window = normalizeRestriction(raw, key); + // policy-limits restrictions{} returns utilization as a 0..1 ratio. + const window = normalizeRestriction(raw, key, 'ratio'); if (window) windows.push(window); } } else if (toObject(payload)) { @@ -226,13 +250,15 @@ export function buildClaudeQuotaWindows(payload: Record): Claud const raw = toObject(value); if (!raw) continue; if (!isClaudeOAuthUsageWindowCandidate(key, raw)) continue; - const window = normalizeRestriction(raw, key); + // OAuth /oauth/usage payloads return utilization as percent 0..100. + const window = normalizeRestriction(raw, key, 'percent'); if (window) windows.push(window); } - // Some responses may contain a single restriction object directly. + // Some responses may contain a single restriction object directly, + // matching the policy-limits ratio shape. if (windows.length === 0) { - const direct = normalizeRestriction(payload); + const direct = normalizeRestriction(payload, undefined, 'ratio'); if (direct) windows.push(direct); } } diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index c1bcc4ae..cdfbddaf 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -196,6 +196,17 @@ describe('Claude Quota Fetcher', () => { expect(windows[0].label).toBe('Seven Day Haiku'); expect(windows[0].remainingPercent).toBe(84); }); + + it('treats OAuth usage utilization as percent (regression for issue: Sonnet weekly shown as 0% when 1% used)', () => { + const windows = buildClaudeQuotaWindows({ + five_hour: { utilization: 34.0, resets_at: '2026-04-27T06:50:01Z' }, + seven_day: { utilization: 8.0, resets_at: '2026-04-27T18:00:00Z' }, + seven_day_sonnet: { utilization: 1.0, resets_at: '2026-04-27T18:00:00Z' }, + }); + const sonnet = windows.find((w) => w.rateLimitType === 'seven_day_sonnet'); + expect(sonnet?.usedPercent).toBe(1); + expect(sonnet?.remainingPercent).toBe(99); + }); }); describe('buildClaudeCoreUsageSummary', () => {