mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 02:15:36 +00:00
fix(cliproxy): harden quota guards and review follow-ups
This commit is contained in:
1 parent
8c790f41ff
commit
ca58cb5a08
11 files changed
+251
-58
No files matched your search
@@ -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