mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +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];
|
||||
|
||||
@@ -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
@@ -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 ====================
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user