mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 08:18:23 +00:00
fix(cliproxy): harden quota guards and review follow-ups
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user