mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-16 04:24:22 +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 type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types';
|
||||||
import { clampPercent } from '../utils/percentage';
|
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 {
|
function asString(value: unknown): string | null {
|
||||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : 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 {
|
function normalizeTimestamp(value: unknown): string | null {
|
||||||
const asNum = asNumber(value);
|
const asNum = asNumber(value);
|
||||||
if (asNum !== null) {
|
if (asNum !== null) {
|
||||||
const millis = asNum > 1e12 ? asNum : asNum * 1000;
|
const millis = asNum > EPOCH_MS_THRESHOLD ? asNum : asNum * 1000;
|
||||||
const date = new Date(millis);
|
const date = new Date(millis);
|
||||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
}
|
}
|
||||||
@@ -44,7 +47,7 @@ function normalizeTimestamp(value: unknown): string | null {
|
|||||||
if (/^\d+$/.test(str)) {
|
if (/^\d+$/.test(str)) {
|
||||||
const numeric = Number(str);
|
const numeric = Number(str);
|
||||||
if (isFinite(numeric)) {
|
if (isFinite(numeric)) {
|
||||||
const millis = numeric > 1e12 ? numeric : numeric * 1000;
|
const millis = numeric > EPOCH_MS_THRESHOLD ? numeric : numeric * 1000;
|
||||||
const date = new Date(millis);
|
const date = new Date(millis);
|
||||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
}
|
}
|
||||||
@@ -260,6 +263,19 @@ const WEEKLY_RATE_LIMIT_TYPES = new Set([
|
|||||||
'seven_day_cowork',
|
'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.
|
* 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 fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null;
|
||||||
const weeklyCandidates = windows.filter((window) =>
|
const weeklyWindow = pickMostRestrictiveClaudeWeeklyWindow(windows);
|
||||||
WEEKLY_RATE_LIMIT_TYPES.has(window.rateLimitType)
|
|
||||||
);
|
|
||||||
const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates);
|
|
||||||
|
|
||||||
if (fiveHourWindow && weeklyWindow) {
|
if (fiveHourWindow && weeklyWindow) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
|
|
||||||
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
|
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_TIMEOUT_MS = 10000;
|
||||||
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
|
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
|
||||||
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota';
|
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota';
|
||||||
@@ -61,6 +61,10 @@ function extractExpiry(data: Record<string, unknown>): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAuthExpired(expiry: string | null): boolean {
|
||||||
|
return expiry ? isTokenExpired(expiry) : false;
|
||||||
|
}
|
||||||
|
|
||||||
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
|
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
|
||||||
try {
|
try {
|
||||||
const raw = await fsp.readFile(filePath, 'utf-8');
|
const raw = await fsp.readFile(filePath, 'utf-8');
|
||||||
@@ -81,7 +85,7 @@ async function readAuthCandidate(filePath: string): Promise<ClaudeAuthData | nul
|
|||||||
const expiry = extractExpiry(data);
|
const expiry = extractExpiry(data);
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
isExpired: isTokenExpired(expiry ?? undefined),
|
isExpired: isAuthExpired(expiry),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +136,7 @@ async function readClaudeAuthData(accountId: string): Promise<ClaudeAuthData | n
|
|||||||
const expiry = extractExpiry(data);
|
const expiry = extractExpiry(data);
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
isExpired: isTokenExpired(expiry ?? undefined),
|
isExpired: isAuthExpired(expiry),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +264,11 @@ export async function fetchClaudeQuota(
|
|||||||
: 'Unknown error';
|
: 'Unknown error';
|
||||||
|
|
||||||
if (verbose) {
|
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) {
|
if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) {
|
||||||
|
|||||||
@@ -209,10 +209,14 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo
|
|||||||
async function batchedMap<T, R>(
|
async function batchedMap<T, R>(
|
||||||
items: T[],
|
items: T[],
|
||||||
fn: (item: T) => Promise<R>,
|
fn: (item: T) => Promise<R>,
|
||||||
concurrency = 10
|
concurrency = 10,
|
||||||
|
delayMs = 100
|
||||||
): Promise<R[]> {
|
): Promise<R[]> {
|
||||||
const results: R[] = [];
|
const results: R[] = [];
|
||||||
for (let i = 0; i < items.length; i += concurrency) {
|
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 batch = items.slice(i, i + concurrency);
|
||||||
const batchResults = await Promise.all(batch.map(fn));
|
const batchResults = await Promise.all(batch.map(fn));
|
||||||
results.push(...batchResults);
|
results.push(...batchResults);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher';
|
import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher';
|
||||||
import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex';
|
import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex';
|
||||||
import { fetchAllClaudeQuotas } from '../../cliproxy/quota-fetcher-claude';
|
import { fetchAllClaudeQuotas } from '../../cliproxy/quota-fetcher-claude';
|
||||||
|
import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota-fetcher-claude-normalizer';
|
||||||
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli';
|
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli';
|
||||||
import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp';
|
import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp';
|
||||||
import type {
|
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): {
|
function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): {
|
||||||
fiveHourWindow: ClaudeDisplayWindow | null;
|
fiveHourWindow: ClaudeDisplayWindow | null;
|
||||||
weeklyWindow: ClaudeDisplayWindow | null;
|
weeklyWindow: ClaudeDisplayWindow | null;
|
||||||
@@ -478,7 +455,7 @@ function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): {
|
|||||||
|
|
||||||
const fiveHourPolicy =
|
const fiveHourPolicy =
|
||||||
quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null;
|
quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null;
|
||||||
const weeklyPolicy = pickClaudeWeeklyWindow(quota.windows);
|
const weeklyPolicy = pickMostRestrictiveClaudeWeeklyWindow(quota.windows);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null,
|
fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null,
|
||||||
|
|||||||
@@ -54,6 +54,36 @@ import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
|||||||
|
|
||||||
const router = Router();
|
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).
|
* 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> => {
|
router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||||
const { accountId } = req.params;
|
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
|
// Validate accountId - prevent path traversal
|
||||||
if (
|
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> => {
|
router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||||
const { accountId } = req.params;
|
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
|
// Validate accountId - prevent path traversal
|
||||||
if (
|
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> => {
|
router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||||
const { accountId } = req.params;
|
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
|
// Validate accountId - prevent path traversal
|
||||||
if (
|
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> => {
|
router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||||
const { accountId } = req.params;
|
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
|
// Validate accountId - prevent path traversal
|
||||||
if (
|
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> => {
|
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||||
const { provider, accountId } = req.params;
|
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
|
// Validate provider - use canonical CLIPROXY_PROFILES
|
||||||
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
|
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
|
||||||
|
|||||||
@@ -377,6 +377,27 @@ describe('Claude Quota Fetcher', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(0);
|
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 () => {
|
it('retries once on transient 500 then succeeds', async () => {
|
||||||
createClaudeAccount('claude-retry@example.com', {
|
createClaudeAccount('claude-retry@example.com', {
|
||||||
access_token: 'retry-token',
|
access_token: 'retry-token',
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export function AccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
{quota && <QuotaTooltipContent quota={quota} resetTime={resetTime} />}
|
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ export function AccountItem({
|
|||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className="max-w-xs">
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
{quota && <QuotaTooltipContent quota={quota} resetTime={nextReset} />}
|
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
|
|
||||||
interface QuotaTooltipContentProps {
|
interface QuotaTooltipContentProps {
|
||||||
quota: UnifiedQuotaResult;
|
quota: UnifiedQuotaResult | null | undefined;
|
||||||
resetTime: string | null;
|
resetTime: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,13 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s
|
|||||||
* Uses type guards for proper TypeScript narrowing
|
* Uses type guards for proper TypeScript narrowing
|
||||||
*/
|
*/
|
||||||
export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) {
|
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
|
// Antigravity (agy) provider tooltip
|
||||||
if (isAgyQuotaResult(quota)) {
|
if (isAgyQuotaResult(quota)) {
|
||||||
|
|||||||
+70
-18
@@ -618,45 +618,97 @@ export type UnifiedQuotaResult =
|
|||||||
| GeminiCliQuotaResult
|
| GeminiCliQuotaResult
|
||||||
| GhcpQuotaResult;
|
| 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 */
|
/** Type guard: Check if quota result is from Antigravity (agy) provider */
|
||||||
export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult {
|
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 */
|
/** Type guard: Check if quota result is from Codex provider */
|
||||||
export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult {
|
export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult {
|
||||||
return (
|
if (!isRecord(quota)) return false;
|
||||||
'windows' in quota && 'planType' in quota && Array.isArray((quota as CodexQuotaResult).windows)
|
|
||||||
|
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 */
|
/** Type guard: Check if quota result is from Claude provider */
|
||||||
export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult {
|
export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult {
|
||||||
return (
|
if (!isRecord(quota)) return false;
|
||||||
'windows' in quota &&
|
|
||||||
!('planType' in quota) &&
|
const candidate = quota as Partial<ClaudeQuotaResult>;
|
||||||
Array.isArray((quota as ClaudeQuotaResult).windows)
|
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 */
|
/** Type guard: Check if quota result is from Gemini CLI provider */
|
||||||
export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiCliQuotaResult {
|
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 */
|
/** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */
|
||||||
export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult {
|
export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult {
|
||||||
const candidate = quota as GhcpQuotaResult;
|
if (!isRecord(quota)) return false;
|
||||||
const snapshots = candidate.snapshots as Record<string, unknown> | null | undefined;
|
|
||||||
|
|
||||||
return (
|
const candidate = quota as Partial<GhcpQuotaResult>;
|
||||||
'snapshots' in quota &&
|
const snapshots = candidate.snapshots as Record<string, unknown> | null | undefined;
|
||||||
typeof snapshots === 'object' &&
|
if (typeof candidate.success !== 'boolean') return false;
|
||||||
snapshots !== null &&
|
if (!isRecord(snapshots)) return false;
|
||||||
'premiumInteractions' in snapshots &&
|
|
||||||
'chat' in snapshots &&
|
const snapshotKeys: Array<keyof GhcpQuotaResult['snapshots']> = [
|
||||||
'completions' in 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 ====================
|
// ==================== Unified Quota Helpers ====================
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getProviderMinQuota,
|
getProviderMinQuota,
|
||||||
getProviderResetTime,
|
getProviderResetTime,
|
||||||
isAgyQuotaResult,
|
isAgyQuotaResult,
|
||||||
|
isClaudeQuotaResult,
|
||||||
isCodexQuotaResult,
|
isCodexQuotaResult,
|
||||||
isGeminiQuotaResult,
|
isGeminiQuotaResult,
|
||||||
isGhcpQuotaResult,
|
isGhcpQuotaResult,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CodexQuotaWindow,
|
CodexQuotaWindow,
|
||||||
CodexQuotaResult,
|
CodexQuotaResult,
|
||||||
|
ClaudeQuotaResult,
|
||||||
GeminiCliBucket,
|
GeminiCliBucket,
|
||||||
GeminiCliQuotaResult,
|
GeminiCliQuotaResult,
|
||||||
GhcpQuotaResult,
|
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', () => {
|
describe('isGeminiQuotaResult', () => {
|
||||||
it('returns true for valid Gemini quota result', () => {
|
it('returns true for valid Gemini quota result', () => {
|
||||||
const quota: GeminiCliQuotaResult = {
|
const quota: GeminiCliQuotaResult = {
|
||||||
|
|||||||
Reference in New Issue
Block a user