fix(cliproxy): harden quota guards and review follow-ups

This commit is contained in:
Tam Nhu Tran
2026-02-22 02:35:07 +07:00
parent 8c790f41ff
commit ca58cb5a08
11 changed files with 251 additions and 58 deletions
@@ -7,6 +7,9 @@
import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types';
import { clampPercent } from '../utils/percentage';
// Distinguishes epoch milliseconds from seconds (1e12 ~= 2001-09-09T01:46:40Z).
const EPOCH_MS_THRESHOLD = 1e12;
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
@@ -32,7 +35,7 @@ function asNumber(value: unknown): number | null {
function normalizeTimestamp(value: unknown): string | null {
const asNum = asNumber(value);
if (asNum !== null) {
const millis = asNum > 1e12 ? asNum : asNum * 1000;
const millis = asNum > EPOCH_MS_THRESHOLD ? asNum : asNum * 1000;
const date = new Date(millis);
return isNaN(date.getTime()) ? null : date.toISOString();
}
@@ -44,7 +47,7 @@ function normalizeTimestamp(value: unknown): string | null {
if (/^\d+$/.test(str)) {
const numeric = Number(str);
if (isFinite(numeric)) {
const millis = numeric > 1e12 ? numeric : numeric * 1000;
const millis = numeric > EPOCH_MS_THRESHOLD ? numeric : numeric * 1000;
const date = new Date(millis);
return isNaN(date.getTime()) ? null : date.toISOString();
}
@@ -260,6 +263,19 @@ const WEEKLY_RATE_LIMIT_TYPES = new Set([
'seven_day_cowork',
]);
export function isClaudeWeeklyRateLimitType(rateLimitType: string): boolean {
return WEEKLY_RATE_LIMIT_TYPES.has(rateLimitType);
}
export function pickMostRestrictiveClaudeWeeklyWindow(
windows: ClaudeQuotaWindow[]
): ClaudeQuotaWindow | null {
const weeklyCandidates = windows.filter((window) =>
isClaudeWeeklyRateLimitType(window.rateLimitType)
);
return pickMostRestrictiveWeekly(weeklyCandidates);
}
/**
* Build explicit 5h + weekly usage summary from Claude policy windows.
*/
@@ -269,10 +285,7 @@ export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): Claud
}
const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null;
const weeklyCandidates = windows.filter((window) =>
WEEKLY_RATE_LIMIT_TYPES.has(window.rateLimitType)
);
const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates);
const weeklyWindow = pickMostRestrictiveClaudeWeeklyWindow(windows);
if (fiveHourWindow && weeklyWindow) {
return {
+12 -4
View File
@@ -17,7 +17,7 @@ import {
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits';
export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits';
const CLAUDE_QUOTA_TIMEOUT_MS = 10000;
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota';
@@ -61,6 +61,10 @@ function extractExpiry(data: Record<string, unknown>): string | null {
return null;
}
function isAuthExpired(expiry: string | null): boolean {
return expiry ? isTokenExpired(expiry) : false;
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
try {
const raw = await fsp.readFile(filePath, 'utf-8');
@@ -81,7 +85,7 @@ async function readAuthCandidate(filePath: string): Promise<ClaudeAuthData | nul
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isTokenExpired(expiry ?? undefined),
isExpired: isAuthExpired(expiry),
};
}
@@ -132,7 +136,7 @@ async function readClaudeAuthData(accountId: string): Promise<ClaudeAuthData | n
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isTokenExpired(expiry ?? undefined),
isExpired: isAuthExpired(expiry),
};
}
}
@@ -260,7 +264,11 @@ export async function fetchClaudeQuota(
: 'Unknown error';
if (verbose) {
console.error(`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}`);
const errorDetails =
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
console.error(
`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
);
}
if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) {
+5 -1
View File
@@ -209,10 +209,14 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo
async function batchedMap<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
concurrency = 10
concurrency = 10,
delayMs = 100
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += concurrency) {
if (i > 0 && delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
const batch = items.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map(fn));
results.push(...batchResults);
+2 -25
View File
@@ -19,6 +19,7 @@ import {
import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher';
import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex';
import { fetchAllClaudeQuotas } from '../../cliproxy/quota-fetcher-claude';
import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota-fetcher-claude-normalizer';
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli';
import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp';
import type {
@@ -438,30 +439,6 @@ function toClaudeCoreDisplayWindow(
};
}
function pickClaudeWeeklyWindow(
windows: ClaudeQuotaResult['windows']
): ClaudeQuotaResult['windows'][number] | null {
const weeklyCandidates = windows.filter((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
);
if (weeklyCandidates.length === 0) return null;
return [...weeklyCandidates].sort((a, b) => {
if (a.remainingPercent !== b.remainingPercent) {
return a.remainingPercent - b.remainingPercent;
}
const aReset = a.resetAt ? new Date(a.resetAt).getTime() : Number.POSITIVE_INFINITY;
const bReset = b.resetAt ? new Date(b.resetAt).getTime() : Number.POSITIVE_INFINITY;
return aReset - bReset;
})[0];
}
function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): {
fiveHourWindow: ClaudeDisplayWindow | null;
weeklyWindow: ClaudeDisplayWindow | null;
@@ -478,7 +455,7 @@ function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): {
const fiveHourPolicy =
quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null;
const weeklyPolicy = pickClaudeWeeklyWindow(quota.windows);
const weeklyPolicy = pickMostRestrictiveClaudeWeeklyWindow(quota.windows);
return {
fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null,
@@ -54,6 +54,36 @@ import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
const router = Router();
const QUOTA_RATE_LIMIT_WINDOW_MS = 60_000;
const QUOTA_RATE_LIMIT_MAX_REQUESTS = 120;
interface QuotaRateLimitEntry {
windowStart: number;
count: number;
}
const quotaRateLimits = new Map<string, QuotaRateLimitEntry>();
function buildQuotaRateLimitKey(req: Request, provider: string): string {
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
return `${clientIp}:${provider}`;
}
function isQuotaRouteRateLimited(req: Request, provider: string): boolean {
const key = buildQuotaRateLimitKey(req, provider);
const now = Date.now();
const current = quotaRateLimits.get(key);
if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) {
quotaRateLimits.set(key, { windowStart: now, count: 1 });
return false;
}
current.count += 1;
quotaRateLimits.set(key, current);
return current.count > QUOTA_RATE_LIMIT_MAX_REQUESTS;
}
/**
* Cache only stable failures; avoid pinning transient network failures (timeouts, 429s).
*/
@@ -592,6 +622,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
*/
router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'codex')) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate accountId - prevent path traversal
if (
@@ -633,6 +669,12 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi
*/
router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'claude')) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate accountId - prevent path traversal
if (
@@ -674,6 +716,12 @@ router.get('/quota/claude/:accountId', async (req: Request, res: Response): Prom
*/
router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'gemini')) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate accountId - prevent path traversal
if (
@@ -715,6 +763,12 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom
*/
router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'ghcp')) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate accountId - prevent path traversal
if (
@@ -757,6 +811,12 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis
*/
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
const { provider, accountId } = req.params;
if (isQuotaRouteRateLimited(req, provider)) {
res
.status(429)
.json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
return;
}
// Validate provider - use canonical CLIPROXY_PROFILES
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
@@ -377,6 +377,27 @@ describe('Claude Quota Fetcher', () => {
expect(fetchMock).toHaveBeenCalledTimes(0);
});
it('treats missing expiry as not expired', async () => {
createClaudeAccount('claude-no-expiry@example.com', {
access_token: 'no-expiry-token',
type: 'claude',
});
global.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ restrictions: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
) as typeof fetch;
const result = await fetchClaudeQuota('claude-no-expiry@example.com');
expect(result.success).toBe(true);
expect(result.windows).toHaveLength(0);
});
it('retries once on transient 500 then succeeds', async () => {
createClaudeAccount('claude-retry@example.com', {
access_token: 'retry-token',
@@ -315,7 +315,7 @@ export function AccountCard({
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{quota && <QuotaTooltipContent quota={quota} resetTime={resetTime} />}
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -424,7 +424,7 @@ export function AccountItem({
)}
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
{quota && <QuotaTooltipContent quota={quota} resetTime={nextReset} />}
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -22,7 +22,7 @@ import {
} from '@/lib/utils';
interface QuotaTooltipContentProps {
quota: UnifiedQuotaResult;
quota: UnifiedQuotaResult | null | undefined;
resetTime: string | null;
}
@@ -62,7 +62,13 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s
* Uses type guards for proper TypeScript narrowing
*/
export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) {
if (!quota?.success) return null;
if (!quota) {
return <p className="text-xs text-muted-foreground">Loading quota...</p>;
}
if (!quota.success) {
return <p className="text-xs text-destructive">{quota.error || 'Failed to load quota'}</p>;
}
// Antigravity (agy) provider tooltip
if (isAgyQuotaResult(quota)) {
+70 -18
View File
@@ -618,45 +618,97 @@ export type UnifiedQuotaResult =
| GeminiCliQuotaResult
| GhcpQuotaResult;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
/** Type guard: Check if quota result is from Antigravity (agy) provider */
export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult {
return 'models' in quota && Array.isArray((quota as QuotaResult).models);
if (!isRecord(quota)) return false;
const models = (quota as Partial<QuotaResult>).models;
return typeof quota.success === 'boolean' && Array.isArray(models);
}
/** Type guard: Check if quota result is from Codex provider */
export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult {
return (
'windows' in quota && 'planType' in quota && Array.isArray((quota as CodexQuotaResult).windows)
if (!isRecord(quota)) return false;
const candidate = quota as Partial<CodexQuotaResult>;
if (typeof candidate.success !== 'boolean') return false;
if (!Array.isArray(candidate.windows)) return false;
if (!('planType' in candidate)) return false;
return candidate.windows.every(
(window) =>
isRecord(window) &&
typeof window.label === 'string' &&
isFiniteNumber(window.usedPercent) &&
isFiniteNumber(window.remainingPercent)
);
}
/** Type guard: Check if quota result is from Claude provider */
export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult {
return (
'windows' in quota &&
!('planType' in quota) &&
Array.isArray((quota as ClaudeQuotaResult).windows)
if (!isRecord(quota)) return false;
const candidate = quota as Partial<ClaudeQuotaResult>;
if (typeof candidate.success !== 'boolean') return false;
if (!Array.isArray(candidate.windows)) return false;
if ('planType' in candidate) return false;
return candidate.windows.every(
(window) =>
isRecord(window) &&
typeof window.rateLimitType === 'string' &&
isFiniteNumber(window.remainingPercent) &&
typeof window.status === 'string'
);
}
/** Type guard: Check if quota result is from Gemini CLI provider */
export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiCliQuotaResult {
return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets);
if (!isRecord(quota)) return false;
const candidate = quota as Partial<GeminiCliQuotaResult>;
if (typeof candidate.success !== 'boolean') return false;
if (!Array.isArray(candidate.buckets)) return false;
return candidate.buckets.every(
(bucket) =>
isRecord(bucket) &&
typeof bucket.id === 'string' &&
isFiniteNumber(bucket.remainingFraction) &&
isFiniteNumber(bucket.remainingPercent) &&
Array.isArray(bucket.modelIds)
);
}
/** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */
export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult {
const candidate = quota as GhcpQuotaResult;
const snapshots = candidate.snapshots as Record<string, unknown> | null | undefined;
if (!isRecord(quota)) return false;
return (
'snapshots' in quota &&
typeof snapshots === 'object' &&
snapshots !== null &&
'premiumInteractions' in snapshots &&
'chat' in snapshots &&
'completions' in snapshots
);
const candidate = quota as Partial<GhcpQuotaResult>;
const snapshots = candidate.snapshots as Record<string, unknown> | null | undefined;
if (typeof candidate.success !== 'boolean') return false;
if (!isRecord(snapshots)) return false;
const snapshotKeys: Array<keyof GhcpQuotaResult['snapshots']> = [
'premiumInteractions',
'chat',
'completions',
];
return snapshotKeys.every((key) => {
const snapshot = snapshots[key] as Record<string, unknown> | undefined;
return (
isRecord(snapshot) &&
isFiniteNumber(snapshot.percentRemaining) &&
isFiniteNumber(snapshot.percentUsed)
);
});
}
// ==================== Unified Quota Helpers ====================
+52
View File
@@ -15,6 +15,7 @@ import {
getProviderMinQuota,
getProviderResetTime,
isAgyQuotaResult,
isClaudeQuotaResult,
isCodexQuotaResult,
isGeminiQuotaResult,
isGhcpQuotaResult,
@@ -22,6 +23,7 @@ import {
import type {
CodexQuotaWindow,
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliBucket,
GeminiCliQuotaResult,
GhcpQuotaResult,
@@ -1002,6 +1004,56 @@ describe('isCodexQuotaResult', () => {
});
});
describe('isClaudeQuotaResult', () => {
it('returns true for valid Claude quota result', () => {
const quota: ClaudeQuotaResult = {
success: true,
windows: [
{
rateLimitType: 'five_hour',
label: 'Session limit',
status: 'allowed',
utilization: 0.5,
usedPercent: 50,
remainingPercent: 50,
resetAt: '2026-01-30T12:00:00Z',
},
],
coreUsage: {
fiveHour: {
rateLimitType: 'five_hour',
label: 'Session limit',
remainingPercent: 50,
resetAt: '2026-01-30T12:00:00Z',
status: 'allowed',
},
weekly: null,
},
lastUpdated: Date.now(),
};
expect(isClaudeQuotaResult(quota)).toBe(true);
});
it('returns false for Codex quota result', () => {
const quota: CodexQuotaResult = {
success: true,
windows: [],
planType: 'free',
lastUpdated: Date.now(),
};
expect(isClaudeQuotaResult(quota as unknown as ClaudeQuotaResult)).toBe(false);
});
it('returns false when Claude windows are malformed', () => {
const malformed = {
success: true,
windows: [{ rateLimitType: 'five_hour', label: 'Session limit' }],
lastUpdated: Date.now(),
};
expect(isClaudeQuotaResult(malformed as unknown as ClaudeQuotaResult)).toBe(false);
});
});
describe('isGeminiQuotaResult', () => {
it('returns true for valid Gemini quota result', () => {
const quota: GeminiCliQuotaResult = {