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));
}
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;
usedPercent: number;
remainingPercent: number;
@@ -99,11 +119,12 @@ function normalizeUtilization(raw: Record<string, unknown>): {
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<string, unknown> | null {
function normalizeRestriction(
raw: Record<string, unknown>,
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<string, unknown>): 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<string, unknown>)) {
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<string, unknown>): 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);
}
}
@@ -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', () => {