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.
This commit is contained in:
yousiki
2026-04-27 15:06:04 +09:00
parent 10cf27f63e
commit 9808ebfe36
2 changed files with 49 additions and 12 deletions
+38 -12
View File
@@ -89,7 +89,27 @@ function clampUnit(value: number): number {
return Math.max(0, Math.min(1, value)); return Math.max(0, Math.min(1, value));
} }
function normalizeUtilization(raw: Record<string, unknown>): { /**
* 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<string, unknown>,
unit: UtilizationUnit
): {
utilization: number | null; utilization: number | null;
usedPercent: number; usedPercent: number;
remainingPercent: number; remainingPercent: number;
@@ -99,11 +119,12 @@ function normalizeUtilization(raw: Record<string, unknown>): {
const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']); const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']);
if (utilizationRaw !== null) { if (utilizationRaw !== null) {
const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; const usedPercent =
const normalizedRatio = clampUnit(ratio); unit === 'ratio'
const usedPercent = clampPercent(normalizedRatio * 100); ? clampPercent(clampUnit(utilizationRaw) * 100)
: clampPercent(utilizationRaw);
return { return {
utilization: normalizedRatio, utilization: usedPercent / 100,
usedPercent, usedPercent,
remainingPercent: clampPercent(100 - usedPercent), remainingPercent: clampPercent(100 - usedPercent),
}; };
@@ -149,7 +170,8 @@ function toObject(value: unknown): Record<string, unknown> | null {
function normalizeRestriction( function normalizeRestriction(
raw: Record<string, unknown>, raw: Record<string, unknown>,
fallbackKey?: string fallbackKey: string | undefined,
unit: UtilizationUnit
): ClaudeQuotaWindow | null { ): ClaudeQuotaWindow | null {
const rateLimitType = normalizeRateLimitType( const rateLimitType = normalizeRateLimitType(
raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'], raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'],
@@ -169,7 +191,7 @@ function normalizeRestriction(
raw['overage_reset_at'] raw['overage_reset_at']
) || null; ) || null;
const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw); const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw, unit);
return { return {
rateLimitType, rateLimitType,
@@ -211,14 +233,16 @@ export function buildClaudeQuotaWindows(payload: Record<string, unknown>): Claud
for (const item of rawRestrictions) { for (const item of rawRestrictions) {
const raw = toObject(item); const raw = toObject(item);
if (!raw) continue; 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); if (window) windows.push(window);
} }
} else if (toObject(rawRestrictions)) { } else if (toObject(rawRestrictions)) {
for (const [key, value] of Object.entries(rawRestrictions as Record<string, unknown>)) { for (const [key, value] of Object.entries(rawRestrictions as Record<string, unknown>)) {
const raw = toObject(value); const raw = toObject(value);
if (!raw) continue; 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); if (window) windows.push(window);
} }
} else if (toObject(payload)) { } else if (toObject(payload)) {
@@ -226,13 +250,15 @@ export function buildClaudeQuotaWindows(payload: Record<string, unknown>): Claud
const raw = toObject(value); const raw = toObject(value);
if (!raw) continue; if (!raw) continue;
if (!isClaudeOAuthUsageWindowCandidate(key, 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); 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) { if (windows.length === 0) {
const direct = normalizeRestriction(payload); const direct = normalizeRestriction(payload, undefined, 'ratio');
if (direct) windows.push(direct); if (direct) windows.push(direct);
} }
} }
@@ -196,6 +196,17 @@ describe('Claude Quota Fetcher', () => {
expect(windows[0].label).toBe('Seven Day Haiku'); expect(windows[0].label).toBe('Seven Day Haiku');
expect(windows[0].remainingPercent).toBe(84); 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', () => { describe('buildClaudeCoreUsageSummary', () => {