mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +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));
|
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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user