mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user