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];