fix(cliproxy): split claude quota fetcher for maintainability gate

This commit is contained in:
Tam Nhu Tran
2026-02-22 01:35:32 +07:00
parent d47620592d
commit 9371a72c16
2 changed files with 355 additions and 318 deletions
@@ -0,0 +1,294 @@
/**
* Claude Quota Response Normalization Helpers
*
* Parses Anthropic policy limits payload into normalized windows and core usage summary.
*/
import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types';
import { clampPercent } from '../utils/percentage';
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function asBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
if (value === 'true') return true;
if (value === 'false') return false;
}
return undefined;
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
return isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeTimestamp(value: unknown): string | null {
const asNum = asNumber(value);
if (asNum !== null) {
const millis = asNum > 1e12 ? asNum : asNum * 1000;
const date = new Date(millis);
return isNaN(date.getTime()) ? null : date.toISOString();
}
const str = asString(value);
if (!str) return null;
// Numeric strings can be either epoch seconds or epoch milliseconds.
if (/^\d+$/.test(str)) {
const numeric = Number(str);
if (isFinite(numeric)) {
const millis = numeric > 1e12 ? numeric : numeric * 1000;
const date = new Date(millis);
return isNaN(date.getTime()) ? null : date.toISOString();
}
}
const date = new Date(str);
return isNaN(date.getTime()) ? null : date.toISOString();
}
function getClaudeWindowLabel(rateLimitType: string): string {
switch (rateLimitType) {
case 'five_hour':
return 'Session limit';
case 'seven_day':
return 'Weekly limit';
case 'seven_day_opus':
return 'Opus limit';
case 'seven_day_sonnet':
return 'Sonnet limit';
case 'overage':
return 'Extra usage';
default:
return rateLimitType || 'Unknown limit';
}
}
function normalizeUtilization(raw: Record<string, unknown>): {
utilization: number | null;
usedPercent: number;
remainingPercent: number;
} {
const utilizationRaw = asNumber(raw['utilization']);
const usedPercentRaw = asNumber(raw['usedPercent'] ?? raw['used_percent']);
const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']);
if (utilizationRaw !== null) {
const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100;
const usedPercent = clampPercent(ratio * 100);
return {
utilization: ratio,
usedPercent,
remainingPercent: clampPercent(100 - usedPercent),
};
}
if (usedPercentRaw !== null) {
const usedPercent = clampPercent(usedPercentRaw);
return {
utilization: usedPercent / 100,
usedPercent,
remainingPercent: clampPercent(100 - usedPercent),
};
}
if (remainingPercentRaw !== null) {
const remainingPercent = clampPercent(remainingPercentRaw);
const usedPercent = clampPercent(100 - remainingPercent);
return {
utilization: usedPercent / 100,
usedPercent,
remainingPercent,
};
}
return {
utilization: null,
usedPercent: 0,
remainingPercent: 100,
};
}
function normalizeRateLimitType(value: unknown, fallbackKey?: string): string {
const direct = asString(value);
if (direct) return direct;
if (fallbackKey) return fallbackKey;
return 'unknown';
}
function toObject(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function normalizeRestriction(
raw: Record<string, unknown>,
fallbackKey?: string
): ClaudeQuotaWindow | null {
const rateLimitType = normalizeRateLimitType(
raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'],
fallbackKey
);
if (!rateLimitType || rateLimitType === 'unknown') return null;
const status = asString(raw['status']) || 'unknown';
const resetAt =
normalizeTimestamp(raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at']) ||
null;
const overageResetsAt =
normalizeTimestamp(
raw['overageResetsAt'] ??
raw['overage_resets_at'] ??
raw['overageResetAt'] ??
raw['overage_reset_at']
) || null;
const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw);
return {
rateLimitType,
label: getClaudeWindowLabel(rateLimitType),
status,
utilization,
usedPercent,
remainingPercent,
resetAt,
surpassedThreshold: asBoolean(raw['surpassedThreshold'] ?? raw['surpassed_threshold']),
severity: asString(raw['severity']) || undefined,
overageStatus: asString(raw['overageStatus'] ?? raw['overage_status']) || undefined,
overageResetsAt,
overageDisabledReason:
asString(raw['overageDisabledReason'] ?? raw['overage_disabled_reason']) || undefined,
isUsingOverage: asBoolean(raw['isUsingOverage'] ?? raw['is_using_overage']),
hasExtraUsageEnabled: asBoolean(raw['hasExtraUsageEnabled'] ?? raw['has_extra_usage_enabled']),
};
}
/**
* Parse raw policy limits response into normalized windows.
* Supports both array and object-map `restrictions` shapes.
*/
export function buildClaudeQuotaWindows(payload: Record<string, unknown>): ClaudeQuotaWindow[] {
const rawRestrictions = payload['restrictions'];
const windows: ClaudeQuotaWindow[] = [];
if (Array.isArray(rawRestrictions)) {
for (const item of rawRestrictions) {
const raw = toObject(item);
if (!raw) continue;
const window = normalizeRestriction(raw);
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);
if (window) windows.push(window);
}
} else if (toObject(payload)) {
// Some responses may contain a single restriction object directly.
const direct = normalizeRestriction(payload);
if (direct) windows.push(direct);
}
const seen = new Set<string>();
const unique: ClaudeQuotaWindow[] = [];
for (const window of windows) {
const key = `${window.rateLimitType}:${window.resetAt ?? ''}:${window.status}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(window);
}
return unique.sort((a, b) => a.rateLimitType.localeCompare(b.rateLimitType));
}
function toEpochMs(iso: string | null): number | null {
if (!iso) return null;
const value = new Date(iso).getTime();
return isNaN(value) ? null : value;
}
function pickMostRestrictiveWeekly(windows: ClaudeQuotaWindow[]): ClaudeQuotaWindow | null {
if (windows.length === 0) return null;
return [...windows].sort((a, b) => {
if (a.remainingPercent !== b.remainingPercent) {
return a.remainingPercent - b.remainingPercent;
}
const aReset = toEpochMs(a.resetAt);
const bReset = toEpochMs(b.resetAt);
if (aReset === null && bReset === null) return 0;
if (aReset === null) return 1;
if (bReset === null) return -1;
return aReset - bReset;
})[0];
}
function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary['fiveHour'] {
if (!window) return null;
return {
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
};
}
/**
* Build explicit 5h + weekly usage summary from Claude policy windows.
*/
export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): ClaudeCoreUsageSummary {
if (!windows || windows.length === 0) {
return { fiveHour: null, weekly: null };
}
const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null;
const weeklyCandidates = windows.filter((window) =>
['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType)
);
const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates);
// Fallback: infer shortest/longest reset windows from non-overage limits.
if (!fiveHourWindow || !weeklyWindow) {
const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage');
const withReset = nonOverage
.map((window) => ({
window,
resetMs: toEpochMs(window.resetAt),
}))
.filter((entry) => entry.resetMs !== null)
.sort((a, b) => (a.resetMs as number) - (b.resetMs as number));
const inferredFiveHour =
fiveHourWindow ||
(withReset.length > 0
? withReset[0].window
: nonOverage.length > 0
? pickMostRestrictiveWeekly(nonOverage)
: null);
const inferredWeekly =
weeklyWindow ||
(withReset.length > 1
? withReset[withReset.length - 1].window
: nonOverage.find((window) => window !== inferredFiveHour) || null);
return {
fiveHour: mapCoreWindow(inferredFiveHour),
weekly: mapCoreWindow(inferredWeekly),
};
}
return {
fiveHour: mapCoreWindow(fiveHourWindow),
weekly: mapCoreWindow(weeklyWindow),
};
}
+61 -318
View File
@@ -4,13 +4,18 @@
* Fetches policy limits from Claude API and normalizes 5h + weekly windows.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as fsp from 'node:fs/promises';
import { getAuthDir } from './config-generator';
import { getPausedDir, getProviderAccounts } from './account-manager';
import { sanitizeEmail, isTokenExpired } from './auth-utils';
import type { ClaudeQuotaResult, ClaudeQuotaWindow, ClaudeCoreUsageSummary } from './quota-types';
import { clampPercent } from '../utils/percentage';
import type { ClaudeQuotaResult } from './quota-types';
import {
buildClaudeQuotaWindows,
buildClaudeCoreUsageSummary,
} from './quota-fetcher-claude-normalizer';
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits';
const CLAUDE_QUOTA_TIMEOUT_MS = 10000;
@@ -26,288 +31,11 @@ function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function asBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
if (value === 'true') return true;
if (value === 'false') return false;
}
return undefined;
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
return isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeTimestamp(value: unknown): string | null {
const asNum = asNumber(value);
if (asNum !== null) {
const millis = asNum > 1e12 ? asNum : asNum * 1000;
const date = new Date(millis);
return isNaN(date.getTime()) ? null : date.toISOString();
}
const str = asString(value);
if (!str) return null;
// Numeric strings can be either epoch seconds or epoch milliseconds.
if (/^\d+$/.test(str)) {
const numeric = Number(str);
if (isFinite(numeric)) {
const millis = numeric > 1e12 ? numeric : numeric * 1000;
const date = new Date(millis);
return isNaN(date.getTime()) ? null : date.toISOString();
}
}
const date = new Date(str);
return isNaN(date.getTime()) ? null : date.toISOString();
}
function getClaudeWindowLabel(rateLimitType: string): string {
switch (rateLimitType) {
case 'five_hour':
return 'Session limit';
case 'seven_day':
return 'Weekly limit';
case 'seven_day_opus':
return 'Opus limit';
case 'seven_day_sonnet':
return 'Sonnet limit';
case 'overage':
return 'Extra usage';
default:
return rateLimitType || 'Unknown limit';
}
}
function normalizeUtilization(raw: Record<string, unknown>): {
utilization: number | null;
usedPercent: number;
remainingPercent: number;
} {
const utilizationRaw = asNumber(raw['utilization']);
const usedPercentRaw = asNumber(raw['usedPercent'] ?? raw['used_percent']);
const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']);
if (utilizationRaw !== null) {
const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100;
const usedPercent = clampPercent(ratio * 100);
return {
utilization: ratio,
usedPercent,
remainingPercent: clampPercent(100 - usedPercent),
};
}
if (usedPercentRaw !== null) {
const usedPercent = clampPercent(usedPercentRaw);
return {
utilization: usedPercent / 100,
usedPercent,
remainingPercent: clampPercent(100 - usedPercent),
};
}
if (remainingPercentRaw !== null) {
const remainingPercent = clampPercent(remainingPercentRaw);
const usedPercent = clampPercent(100 - remainingPercent);
return {
utilization: usedPercent / 100,
usedPercent,
remainingPercent,
};
}
return {
utilization: null,
usedPercent: 0,
remainingPercent: 100,
};
}
function normalizeRateLimitType(value: unknown, fallbackKey?: string): string {
const direct = asString(value);
if (direct) return direct;
if (fallbackKey) return fallbackKey;
return 'unknown';
}
function toObject(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function normalizeRestriction(
raw: Record<string, unknown>,
fallbackKey?: string
): ClaudeQuotaWindow | null {
const rateLimitType = normalizeRateLimitType(
raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'],
fallbackKey
);
if (!rateLimitType || rateLimitType === 'unknown') return null;
const status = asString(raw['status']) || 'unknown';
const resetAt =
normalizeTimestamp(raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at']) ||
null;
const overageResetsAt =
normalizeTimestamp(
raw['overageResetsAt'] ??
raw['overage_resets_at'] ??
raw['overageResetAt'] ??
raw['overage_reset_at']
) || null;
const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw);
return {
rateLimitType,
label: getClaudeWindowLabel(rateLimitType),
status,
utilization,
usedPercent,
remainingPercent,
resetAt,
surpassedThreshold: asBoolean(raw['surpassedThreshold'] ?? raw['surpassed_threshold']),
severity: asString(raw['severity']) || undefined,
overageStatus: asString(raw['overageStatus'] ?? raw['overage_status']) || undefined,
overageResetsAt,
overageDisabledReason:
asString(raw['overageDisabledReason'] ?? raw['overage_disabled_reason']) || undefined,
isUsingOverage: asBoolean(raw['isUsingOverage'] ?? raw['is_using_overage']),
hasExtraUsageEnabled: asBoolean(raw['hasExtraUsageEnabled'] ?? raw['has_extra_usage_enabled']),
};
}
/**
* Parse raw policy limits response into normalized windows.
* Supports both array and object-map `restrictions` shapes.
*/
export function buildClaudeQuotaWindows(payload: Record<string, unknown>): ClaudeQuotaWindow[] {
const rawRestrictions = payload['restrictions'];
const windows: ClaudeQuotaWindow[] = [];
if (Array.isArray(rawRestrictions)) {
for (const item of rawRestrictions) {
const raw = toObject(item);
if (!raw) continue;
const window = normalizeRestriction(raw);
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);
if (window) windows.push(window);
}
} else if (toObject(payload)) {
// Some responses may contain a single restriction object directly.
const direct = normalizeRestriction(payload);
if (direct) windows.push(direct);
}
const seen = new Set<string>();
const unique: ClaudeQuotaWindow[] = [];
for (const window of windows) {
const key = `${window.rateLimitType}:${window.resetAt ?? ''}:${window.status}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(window);
}
return unique.sort((a, b) => a.rateLimitType.localeCompare(b.rateLimitType));
}
function toEpochMs(iso: string | null): number | null {
if (!iso) return null;
const value = new Date(iso).getTime();
return isNaN(value) ? null : value;
}
function pickMostRestrictiveWeekly(windows: ClaudeQuotaWindow[]): ClaudeQuotaWindow | null {
if (windows.length === 0) return null;
return [...windows].sort((a, b) => {
if (a.remainingPercent !== b.remainingPercent) {
return a.remainingPercent - b.remainingPercent;
}
const aReset = toEpochMs(a.resetAt);
const bReset = toEpochMs(b.resetAt);
if (aReset === null && bReset === null) return 0;
if (aReset === null) return 1;
if (bReset === null) return -1;
return aReset - bReset;
})[0];
}
function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary['fiveHour'] {
if (!window) return null;
return {
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
};
}
/**
* Build explicit 5h + weekly usage summary from Claude policy windows.
*/
export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): ClaudeCoreUsageSummary {
if (!windows || windows.length === 0) {
return { fiveHour: null, weekly: null };
}
const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null;
const weeklyCandidates = windows.filter((window) =>
['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType)
);
const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates);
// Fallback: infer shortest/longest reset windows from non-overage limits.
if (!fiveHourWindow || !weeklyWindow) {
const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage');
const withReset = nonOverage
.map((window) => ({
window,
resetMs: toEpochMs(window.resetAt),
}))
.filter((entry) => entry.resetMs !== null)
.sort((a, b) => (a.resetMs as number) - (b.resetMs as number));
const inferredFiveHour =
fiveHourWindow ||
(withReset.length > 0
? withReset[0].window
: nonOverage.length > 0
? pickMostRestrictiveWeekly(nonOverage)
: null);
const inferredWeekly =
weeklyWindow ||
(withReset.length > 1
? withReset[withReset.length - 1].window
: nonOverage.find((window) => window !== inferredFiveHour) || null);
return {
fiveHour: mapCoreWindow(inferredFiveHour),
weekly: mapCoreWindow(inferredWeekly),
};
}
return {
fiveHour: mapCoreWindow(fiveHourWindow),
weekly: mapCoreWindow(weeklyWindow),
};
}
function extractAccessToken(data: Record<string, unknown>): string | null {
const direct = asString(data['access_token']);
if (direct) return direct;
@@ -333,34 +61,51 @@ function extractExpiry(data: Record<string, unknown>): string | null {
return null;
}
function readClaudeAuthData(accountId: string): ClaudeAuthData | null {
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
try {
const raw = await fsp.readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
return toObject(parsed);
} catch {
return null;
}
}
async function readAuthCandidate(filePath: string): Promise<ClaudeAuthData | null> {
const data = await readJsonFile(filePath);
if (!data) return null;
const accessToken = extractAccessToken(data);
if (!accessToken) return null;
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isTokenExpired(expiry ?? undefined),
};
}
async function readClaudeAuthData(accountId: string): Promise<ClaudeAuthData | null> {
const authDirs = [getAuthDir(), getPausedDir()];
const sanitizedId = sanitizeEmail(accountId);
const expectedFiles = [`claude-${sanitizedId}.json`, `anthropic-${sanitizedId}.json`];
for (const authDir of authDirs) {
if (!fs.existsSync(authDir)) continue;
for (const expectedFile of expectedFiles) {
const filePath = path.join(authDir, expectedFile);
if (!fs.existsSync(filePath)) continue;
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
const accessToken = extractAccessToken(data);
if (!accessToken) continue;
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isTokenExpired(expiry ?? undefined),
};
} catch {
continue;
const authData = await readAuthCandidate(filePath);
if (authData) {
return authData;
}
}
const files = fs.readdirSync(authDir);
let files: string[];
try {
files = await fsp.readdir(authDir);
} catch {
continue;
}
for (const file of files) {
if (
!file.endsWith('.json') ||
@@ -370,27 +115,25 @@ function readClaudeAuthData(accountId: string): ClaudeAuthData | null {
}
const filePath = path.join(authDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
const accessToken = extractAccessToken(data);
if (!accessToken) continue;
const data = await readJsonFile(filePath);
if (!data) continue;
const fileEmail = asString(data['email']);
const typeValue = asString(data['type']);
const isClaudeType =
typeValue === null || typeValue === 'claude' || typeValue === 'anthropic';
const matchesEmail = fileEmail === accountId;
const matchesFile = file.includes(sanitizedId);
const accessToken = extractAccessToken(data);
if (!accessToken) continue;
if ((matchesEmail || matchesFile) && isClaudeType) {
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isTokenExpired(expiry ?? undefined),
};
}
} catch {
continue;
const fileEmail = asString(data['email']);
const typeValue = asString(data['type']);
const isClaudeType =
typeValue === null || typeValue === 'claude' || typeValue === 'anthropic';
const matchesEmail = fileEmail === accountId;
const matchesFile = file.includes(sanitizedId);
if ((matchesEmail || matchesFile) && isClaudeType) {
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isTokenExpired(expiry ?? undefined),
};
}
}
}
@@ -421,7 +164,7 @@ export async function fetchClaudeQuota(
accountId: string,
verbose = false
): Promise<ClaudeQuotaResult> {
const authData = readClaudeAuthData(accountId);
const authData = await readClaudeAuthData(accountId);
if (!authData) {
return buildEmptyResult('Auth file not found for Claude account', accountId);
}