From 8ce749581ef7667bec29bdfe17d02f824e06bed4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 22:17:57 -0500 Subject: [PATCH] refactor(ui): extract duplicated quota helpers to shared utils Address PR review feedback: - Add type guards (isAgyQuotaResult, isCodexQuotaResult, isGeminiQuotaResult) - Add unified getProviderMinQuota() and getProviderResetTime() helpers - Export QUOTA_SUPPORTED_PROVIDERS constant from hooks - Update account-item.tsx and account-card.tsx to use shared functions - Eliminate code duplication across provider-specific quota handling Closes #400 review feedback --- .../account/flow-viz/account-card.tsx | 58 ++--- .../cliproxy/provider-editor/account-item.tsx | 245 +++++++----------- ui/src/hooks/use-cliproxy-stats.ts | 5 +- ui/src/lib/utils.ts | 92 ++++++- 4 files changed, 208 insertions(+), 192 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index c5773214..f52ca1f6 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -5,20 +5,19 @@ import { cn, formatResetTime, - getClaudeResetTime, - getMinClaudeQuota, getModelsWithTiers, groupModelsByTier, - getMinCodexQuota, - getMinGeminiQuota, - getCodexResetTime, - getGeminiResetTime, + getProviderMinQuota, + getProviderResetTime, + isAgyQuotaResult, + isCodexQuotaResult, + isGeminiQuotaResult, type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react'; -import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; -import type { CodexQuotaResult, GeminiCliQuotaResult, QuotaResult } from '@/lib/api-client'; +import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats'; +import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; @@ -95,28 +94,17 @@ export function AccountCard({ const connectorPosition = CONNECTOR_POSITION_MAP[zone]; // Quota for CLIProxy accounts (agy, codex, gemini) - const isCliproxyProvider = ['agy', 'codex', 'gemini'].includes(account.provider); + const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes( + account.provider as QuotaSupportedProvider + ); const { data: quota, isLoading: quotaLoading } = useAccountQuota( account.provider, account.id, isCliproxyProvider ); - // Get provider-specific minimum quota - const getProviderMinQuota = () => { - if (!quota?.success) return null; - switch (account.provider) { - case 'agy': - return getMinClaudeQuota((quota as QuotaResult).models); - case 'codex': - return getMinCodexQuota((quota as CodexQuotaResult).windows); - case 'gemini': - return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets); - default: - return null; - } - }; - const minQuota = getProviderMinQuota(); + // Use shared helper for provider-specific minimum quota + const minQuota = getProviderMinQuota(account.provider, quota); // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -266,11 +254,11 @@ export function AccountCard({ - {account.provider === 'agy' ? ( + {quota && isAgyQuotaResult(quota) ? (

Model Quotas:

{(() => { - const tiered = getModelsWithTiers((quota as QuotaResult)?.models || []); + const tiered = getModelsWithTiers(quota.models || []); const groups = groupModelsByTier(tiered); const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; return tierOrder.map((tier, idx) => { @@ -297,7 +285,7 @@ export function AccountCard({ }); })()} {(() => { - const resetTime = getClaudeResetTime((quota as QuotaResult)?.models || []); + const resetTime = getProviderResetTime('agy', quota); return resetTime ? (
@@ -308,10 +296,10 @@ export function AccountCard({ ) : null; })()}
- ) : account.provider === 'codex' ? ( + ) : quota && isCodexQuotaResult(quota) ? (

Rate Limits:

- {(quota as CodexQuotaResult)?.windows?.map((w) => ( + {quota.windows?.map((w) => (
{w.label} @@ -320,9 +308,7 @@ export function AccountCard({
))} {(() => { - const resetTime = getCodexResetTime( - (quota as CodexQuotaResult)?.windows || [] - ); + const resetTime = getProviderResetTime('codex', quota); return resetTime ? (
@@ -333,10 +319,10 @@ export function AccountCard({ ) : null; })()}
- ) : account.provider === 'gemini' ? ( + ) : quota && isGeminiQuotaResult(quota) ? (

Buckets:

- {(quota as GeminiCliQuotaResult)?.buckets?.map((b) => ( + {quota.buckets?.map((b) => (
{b.label} @@ -345,9 +331,7 @@ export function AccountCard({
))} {(() => { - const resetTime = getGeminiResetTime( - (quota as GeminiCliQuotaResult)?.buckets || [] - ); + const resetTime = getProviderResetTime('gemini', quota); return resetTime ? (
diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index ef705c09..474d4db4 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -32,24 +32,17 @@ import { import { cn, formatResetTime, - getClaudeResetTime, - getMinClaudeQuota, getModelsWithTiers, groupModelsByTier, - getMinCodexQuota, - getMinGeminiQuota, - getCodexResetTime, - getGeminiResetTime, + getProviderMinQuota, + getProviderResetTime, + isAgyQuotaResult, + isCodexQuotaResult, + isGeminiQuotaResult, type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { - useAccountQuota, - useCliproxyStats, - type QuotaResult, - type CodexQuotaResult, - type GeminiCliQuotaResult, -} from '@/hooks/use-cliproxy-stats'; +import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import type { AccountItemProps } from './types'; /** @@ -128,151 +121,99 @@ export function AccountItem({ const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); - // Compute min quota based on provider - const getProviderMinQuota = (): number | null => { - if (!quota?.success) return null; - switch (account.provider) { - case 'agy': - return getMinClaudeQuota((quota as QuotaResult).models); - case 'codex': - return getMinCodexQuota((quota as CodexQuotaResult).windows); - case 'gemini': - return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets); - default: - return null; - } - }; - const minQuota = getProviderMinQuota(); + // Use shared utility functions for provider-specific quota handling + const minQuota = getProviderMinQuota(account.provider, quota); + const nextReset = getProviderResetTime(account.provider, quota); - // Compute reset time based on provider - const getProviderResetTime = (): string | null => { - if (!quota?.success) return null; - switch (account.provider) { - case 'agy': - return getClaudeResetTime((quota as QuotaResult).models); - case 'codex': - return getCodexResetTime((quota as CodexQuotaResult).windows); - case 'gemini': - return getGeminiResetTime((quota as GeminiCliQuotaResult).buckets); - default: - return null; - } - }; - const nextReset = getProviderResetTime(); - - // Render Antigravity (agy) provider tooltip - const renderAgyTooltip = () => { - const tiered = getModelsWithTiers((quota as QuotaResult).models || []); - const groups = groupModelsByTier(tiered); - const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; - return tierOrder.map((tier, idx) => { - const models = groups.get(tier); - if (!models || models.length === 0) return null; - const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); - return ( -
- {!isFirst &&
} - {models.map((m) => ( -
- {m.displayName} - - {m.percentage}% - -
- ))} -
- ); - }); - }; - - // Render Codex provider tooltip - const renderCodexTooltip = () => { - const windows = (quota as CodexQuotaResult).windows; - const planType = (quota as CodexQuotaResult).planType; - return ( - <> - {planType &&

Plan: {planType}

} - {windows.map((w) => ( -
- {w.label} - {w.remainingPercent}% -
- ))} - - ); - }; - - // Render Gemini provider tooltip - const renderGeminiTooltip = () => { - const buckets = (quota as GeminiCliQuotaResult).buckets; - return ( - <> - {buckets.map((b) => ( -
- - {b.label} - {b.tokenType ? ` (${b.tokenType})` : ''} - - {b.remainingPercent}% -
- ))} - - ); - }; - - // Provider-specific tooltip content renderer + // Provider-specific tooltip content renderer using type guards const renderQuotaTooltip = () => { if (!quota?.success) return null; - switch (account.provider) { - case 'agy': - return ( -
-

Model Quotas:

- {renderAgyTooltip()} - {nextReset && ( -
- - - Resets {formatResetTime(nextReset)} - + // Antigravity (agy) provider tooltip + if (isAgyQuotaResult(quota)) { + const tiered = getModelsWithTiers(quota.models || []); + const groups = groupModelsByTier(tiered); + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + return ( +
+

Model Quotas:

+ {tierOrder.map((tier, idx) => { + const models = groups.get(tier); + if (!models || models.length === 0) return null; + const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); + return ( +
+ {!isFirst &&
} + {models.map((m) => ( +
+ + {m.displayName} + + + {m.percentage}% + +
+ ))}
- )} -
- ); - case 'codex': - return ( -
-

Rate Limit Windows:

- {renderCodexTooltip()} - {nextReset && ( -
- - - Resets {formatResetTime(nextReset)} - -
- )} -
- ); - case 'gemini': - return ( -
-

Model Buckets:

- {renderGeminiTooltip()} - {nextReset && ( -
- - - Resets {formatResetTime(nextReset)} - -
- )} -
- ); - default: - return null; + ); + })} + {nextReset && ( +
+ + Resets {formatResetTime(nextReset)} +
+ )} +
+ ); } + + // Codex provider tooltip + if (isCodexQuotaResult(quota)) { + return ( +
+

Rate Limit Windows:

+ {quota.planType &&

Plan: {quota.planType}

} + {quota.windows.map((w) => ( +
+ {w.label} + {w.remainingPercent}% +
+ ))} + {nextReset && ( +
+ + Resets {formatResetTime(nextReset)} +
+ )} +
+ ); + } + + // Gemini provider tooltip + if (isGeminiQuotaResult(quota)) { + return ( +
+

Model Buckets:

+ {quota.buckets.map((b) => ( +
+ + {b.label} + {b.tokenType ? ` (${b.tokenType})` : ''} + + {b.remainingPercent}% +
+ ))} + {nextReset && ( +
+ + Resets {formatResetTime(nextReset)} +
+ )} +
+ ); + } + + return null; }; return ( diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 587e2dcc..05fa0682 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -204,7 +204,8 @@ export function useCliproxyErrorLogContent(name: string | null) { export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult }; /** Providers with quota API support */ -const SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const; +export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const; +export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number]; /** * Fetch account quota from API (Antigravity only) @@ -290,7 +291,7 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t queryFn: () => fetchQuotaByProvider(provider, accountId), enabled: enabled && - SUPPORTED_PROVIDERS.includes(provider as (typeof SUPPORTED_PROVIDERS)[number]) && + QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !!accountId, staleTime: 60000, // Match refetchInterval to prevent early refetching refetchInterval: 60000, // Refresh every 1 minute diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 54e665f1..6c2df585 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,6 +1,12 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import type { CodexQuotaWindow, GeminiCliBucket } from './api-client'; +import type { + CodexQuotaWindow, + CodexQuotaResult, + GeminiCliBucket, + GeminiCliQuotaResult, + QuotaResult, +} from './api-client'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -337,3 +343,87 @@ export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null { if (resets.length === 0) return null; return resets.sort()[0]; } + +// ==================== Unified Quota Type Guards ==================== + +/** Unified quota result type for provider-agnostic handling */ +export type UnifiedQuotaResult = QuotaResult | CodexQuotaResult | GeminiCliQuotaResult; + +/** 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); +} + +/** Type guard: Check if quota result is from Codex provider */ +export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult { + return 'windows' in quota && Array.isArray((quota as CodexQuotaResult).windows); +} + +/** 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); +} + +// ==================== Unified Quota Helpers ==================== + +/** + * Get minimum quota percentage for any provider + * Centralizes provider-specific logic to eliminate duplication + */ +export function getProviderMinQuota( + provider: string, + quota: UnifiedQuotaResult | null | undefined +): number | null { + if (!quota?.success) return null; + + switch (provider) { + case 'agy': + if (isAgyQuotaResult(quota)) { + return getMinClaudeQuota(quota.models); + } + return null; + case 'codex': + if (isCodexQuotaResult(quota)) { + return getMinCodexQuota(quota.windows); + } + return null; + case 'gemini': + if (isGeminiQuotaResult(quota)) { + return getMinGeminiQuota(quota.buckets); + } + return null; + default: + return null; + } +} + +/** + * Get earliest reset time for any provider + * Centralizes provider-specific logic to eliminate duplication + */ +export function getProviderResetTime( + provider: string, + quota: UnifiedQuotaResult | null | undefined +): string | null { + if (!quota?.success) return null; + + switch (provider) { + case 'agy': + if (isAgyQuotaResult(quota)) { + return getClaudeResetTime(quota.models); + } + return null; + case 'codex': + if (isCodexQuotaResult(quota)) { + return getCodexResetTime(quota.windows); + } + return null; + case 'gemini': + if (isGeminiQuotaResult(quota)) { + return getGeminiResetTime(quota.buckets); + } + return null; + default: + return null; + } +}