From 9371a72c1638d72f2a3e1fcb3fe9416c79f7cd1a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 01:35:32 +0700 Subject: [PATCH] fix(cliproxy): split claude quota fetcher for maintainability gate --- .../quota-fetcher-claude-normalizer.ts | 294 ++++++++++++++ src/cliproxy/quota-fetcher-claude.ts | 379 +++--------------- 2 files changed, 355 insertions(+), 318 deletions(-) create mode 100644 src/cliproxy/quota-fetcher-claude-normalizer.ts diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts new file mode 100644 index 00000000..a082fd18 --- /dev/null +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -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): { + 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 | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function normalizeRestriction( + raw: Record, + 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): 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)) { + 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(); + 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), + }; +} diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts index 2be1c248..745a5b03 100644 --- a/src/cliproxy/quota-fetcher-claude.ts +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -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): { - 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 | null { if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; return value as Record; } -function normalizeRestriction( - raw: Record, - 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): 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)) { - 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(); - 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 | null { const direct = asString(data['access_token']); if (direct) return direct; @@ -333,34 +61,51 @@ function extractExpiry(data: Record): string | null { return null; } -function readClaudeAuthData(accountId: string): ClaudeAuthData | null { +async function readJsonFile(filePath: string): Promise | 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 { + 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 { 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; - 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; - 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 { - const authData = readClaudeAuthData(accountId); + const authData = await readClaudeAuthData(accountId); if (!authData) { return buildEmptyResult('Auth file not found for Claude account', accountId); }