From a011908b3cf439b4a6e0a88ebaef03b8e527fb68 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 1 Jan 2026 14:35:41 -0500 Subject: [PATCH] fix(ui): show min Claude quota instead of avg all models Main quota bar now displays minimum of Claude model quotas rather than averaging all models. Accurately reflects primary constraint. Changes: - Add getMinClaudeQuota() utility in utils.ts (DRY) - Apply fix to both account-card.tsx and account-item.tsx - Add comprehensive test suite (23 tests covering edge cases) Before: 99% (avg of Claude 95% + Gemini 100%...) After: 95% (min of Claude models) --- .../account/flow-viz/account-card.tsx | 28 ++- .../cliproxy/provider-editor/account-item.tsx | 23 +- ui/src/components/ui/badge.tsx | 3 +- ui/src/lib/utils.ts | 24 ++ ui/tests/unit/ui/lib/quota-utils.test.ts | 218 ++++++++++++++++++ 5 files changed, 273 insertions(+), 23 deletions(-) create mode 100644 ui/tests/unit/ui/lib/quota-utils.test.ts diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 25e8b68a..1b07c358 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -2,7 +2,13 @@ * Account Card Component for Flow Visualization */ -import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils'; +import { + cn, + sortModelsByPriority, + formatResetTime, + getEarliestResetTime, + getMinClaudeQuota, +} from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Clock } from 'lucide-react'; import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; @@ -83,10 +89,8 @@ export function AccountCard({ account.id, isAgy ); - const avgQuota = - quota?.success && quota.models.length > 0 - ? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length) - : null; + // Show minimum quota of Claude models (primary), fallback to min of all models + const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; return (
Quota...
- ) : avgQuota !== null ? ( + ) : minQuota !== null ? ( @@ -148,27 +152,27 @@ export function AccountCard({ 50 + minQuota > 50 ? 'text-emerald-600 dark:text-emerald-400' - : avgQuota > 20 + : minQuota > 20 ? 'text-amber-500' : 'text-red-500' )} > - {avgQuota}% + {minQuota}%
50 + minQuota > 50 ? 'bg-emerald-500' - : avgQuota > 20 + : minQuota > 20 ? 'bg-amber-500' : 'bg-red-500' )} - style={{ width: `${avgQuota}%` }} + style={{ width: `${minQuota}%` }} />
diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 98c9eef9..0ef2c276 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -23,7 +23,13 @@ import { CheckCircle2, HelpCircle, } from 'lucide-react'; -import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils'; +import { + cn, + sortModelsByPriority, + formatResetTime, + getEarliestResetTime, + getMinClaudeQuota, +} from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import type { AccountItemProps } from './types'; @@ -98,11 +104,8 @@ export function AccountItem({ const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); - // Calculate average quota across all models - const avgQuota = - quota?.success && quota.models.length > 0 - ? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length) - : null; + // Show minimum quota of Claude models (primary), fallback to min of all models + const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; // Get earliest reset time const nextReset = @@ -179,7 +182,7 @@ export function AccountItem({ Loading quota... - ) : avgQuota !== null ? ( + ) : minQuota !== null ? (
{/* Status indicator based on runtime usage, not file state */}
@@ -210,11 +213,11 @@ export function AccountItem({
- {avgQuota}% + {minQuota}%
diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index ab895f8e..45811bfd 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -23,7 +23,8 @@ const badgeVariants = cva( ); interface BadgeProps - extends React.HTMLAttributes, VariantProps {} + extends React.HTMLAttributes, + VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 2e1a70ee..104ebf3c 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -116,3 +116,27 @@ export function getEarliestResetTime( null as string | null ); } + +/** + * Calculate the minimum quota percentage from Claude models (primary usage). + * Falls back to minimum of all models if no Claude models exist. + * Returns null if no valid models or quota data. + */ +export function getMinClaudeQuota< + T extends { name: string; displayName?: string; percentage: number }, +>(models: T[]): number | null { + if (models.length === 0) return null; + + const claudeModels = models.filter((m) => { + const name = (m.displayName || m.name || '').toLowerCase(); + return name.includes('claude'); + }); + + const targetModels = claudeModels.length > 0 ? claudeModels : models; + const percentages = targetModels + .map((m) => m.percentage) + .filter((p) => typeof p === 'number' && isFinite(p)); + + if (percentages.length === 0) return null; + return Math.min(...percentages); +} diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts new file mode 100644 index 00000000..af30823d --- /dev/null +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for quota utility functions + */ + +import { describe, it, expect } from 'vitest'; +import { getMinClaudeQuota, sortModelsByPriority, getEarliestResetTime } from '@/lib/utils'; + +describe('getMinClaudeQuota', () => { + describe('basic functionality', () => { + it('returns null for empty models array', () => { + expect(getMinClaudeQuota([])).toBeNull(); + }); + + it('returns minimum of Claude models when present', () => { + const models = [ + { name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 95 }, + { name: 'claude-sonnet-4', displayName: 'Claude Sonnet 4', percentage: 90 }, + { name: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(90); + }); + + it('falls back to minimum of all models when no Claude models', () => { + const models = [ + { name: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 }, + { name: 'gemini-3-pro', displayName: 'Gemini 3 Pro', percentage: 98 }, + ]; + expect(getMinClaudeQuota(models)).toBe(98); + }); + + it('handles single Claude model', () => { + const models = [{ name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 85 }]; + expect(getMinClaudeQuota(models)).toBe(85); + }); + }); + + describe('Claude detection', () => { + it('detects Claude from displayName', () => { + const models = [ + { name: 'model-abc', displayName: 'Claude Opus 4.5', percentage: 75 }, + { name: 'gemini-flash', displayName: 'Gemini Flash', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(75); + }); + + it('detects Claude from name when displayName is missing', () => { + const models = [ + { name: 'claude-sonnet-4-thinking', percentage: 80 }, + { name: 'gemini-2.5-flash', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(80); + }); + + it('is case-insensitive for Claude detection', () => { + const models = [ + { name: 'CLAUDE-OPUS', displayName: 'CLAUDE OPUS', percentage: 70 }, + { name: 'gemini', displayName: 'Gemini', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(70); + }); + }); + + describe('edge cases', () => { + it('handles models with 0% quota', () => { + const models = [ + { name: 'claude-opus', displayName: 'Claude Opus', percentage: 0 }, + { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 50 }, + ]; + expect(getMinClaudeQuota(models)).toBe(0); + }); + + it('handles models with 100% quota', () => { + const models = [{ name: 'claude-opus', displayName: 'Claude Opus', percentage: 100 }]; + expect(getMinClaudeQuota(models)).toBe(100); + }); + + it('handles undefined displayName gracefully', () => { + const models = [{ name: 'claude-opus', displayName: undefined, percentage: 85 }]; + expect(getMinClaudeQuota(models)).toBe(85); + }); + + it('handles empty string displayName', () => { + const models = [{ name: 'claude-opus', displayName: '', percentage: 85 }]; + expect(getMinClaudeQuota(models)).toBe(85); + }); + + it('filters out NaN percentages', () => { + const models = [ + { name: 'claude-opus', displayName: 'Claude Opus', percentage: NaN }, + { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 80 }, + ]; + expect(getMinClaudeQuota(models)).toBe(80); + }); + + it('filters out Infinity percentages', () => { + const models = [ + { name: 'claude-opus', displayName: 'Claude Opus', percentage: Infinity }, + { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 75 }, + ]; + expect(getMinClaudeQuota(models)).toBe(75); + }); + + it('returns null when all percentages are invalid', () => { + const models = [ + { name: 'claude-opus', displayName: 'Claude Opus', percentage: NaN }, + { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: Infinity }, + ]; + expect(getMinClaudeQuota(models)).toBeNull(); + }); + }); + + describe('real-world scenarios', () => { + it('matches screenshot scenario - Claude 95%, Gemini 100%', () => { + const models = [ + { + name: 'claude-opus-4-5-thinking', + displayName: 'Claude Opus 4.5 (Thinking)', + percentage: 95, + }, + { name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5', percentage: 95 }, + { + name: 'claude-sonnet-4-5-thinking', + displayName: 'Claude Sonnet 4.5 (Thinking)', + percentage: 95, + }, + { name: 'gemini-2-5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 }, + { + name: 'gemini-2-5-flash-thinking', + displayName: 'Gemini 2.5 Flash (Thinking)', + percentage: 100, + }, + { name: 'gemini-2-5-flash-lite', displayName: 'Gemini 2.5 Flash Lite', percentage: 100 }, + { name: 'gemini-2-5-pro', displayName: 'Gemini 2.5 Pro', percentage: 100 }, + { name: 'gemini-3-flash', displayName: 'Gemini 3 Flash', percentage: 98 }, + { name: 'gemini-3-pro-high', displayName: 'Gemini 3 Pro (High)', percentage: 100 }, + { name: 'gemini-3-pro-low', displayName: 'Gemini 3 Pro (Low)', percentage: 100 }, + { name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)', percentage: 95 }, + ]; + // Should return 95 (min of Claude), not 99 (avg of all) + expect(getMinClaudeQuota(models)).toBe(95); + }); + + it('handles mixed Claude percentages', () => { + const models = [ + { name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 80 }, + { name: 'claude-sonnet-4', displayName: 'Claude Sonnet 4', percentage: 95 }, + { name: 'claude-haiku-3', displayName: 'Claude Haiku 3', percentage: 100 }, + { name: 'gemini-flash', displayName: 'Gemini Flash', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(80); + }); + }); +}); + +describe('sortModelsByPriority', () => { + it('sorts Claude models first', () => { + const models = [ + { name: 'gemini-flash', displayName: 'Gemini Flash' }, + { name: 'claude-opus', displayName: 'Claude Opus' }, + { name: 'gpt-4', displayName: 'GPT-4' }, + ]; + const sorted = sortModelsByPriority(models); + expect(sorted[0].name).toBe('claude-opus'); + expect(sorted[1].name).toBe('gemini-flash'); + expect(sorted[2].name).toBe('gpt-4'); + }); + + it('sorts alphabetically within same priority', () => { + const models = [ + { name: 'claude-sonnet', displayName: 'Claude Sonnet' }, + { name: 'claude-opus', displayName: 'Claude Opus' }, + { name: 'claude-haiku', displayName: 'Claude Haiku' }, + ]; + const sorted = sortModelsByPriority(models); + expect(sorted[0].displayName).toBe('Claude Haiku'); + expect(sorted[1].displayName).toBe('Claude Opus'); + expect(sorted[2].displayName).toBe('Claude Sonnet'); + }); + + it('does not mutate original array', () => { + const models = [ + { name: 'gemini-flash', displayName: 'Gemini Flash' }, + { name: 'claude-opus', displayName: 'Claude Opus' }, + ]; + const original = [...models]; + sortModelsByPriority(models); + expect(models).toEqual(original); + }); +}); + +describe('getEarliestResetTime', () => { + it('returns null for empty array', () => { + expect(getEarliestResetTime([])).toBeNull(); + }); + + it('returns null when all resetTime are null', () => { + const models = [{ resetTime: null }, { resetTime: null }]; + expect(getEarliestResetTime(models)).toBeNull(); + }); + + it('returns earliest reset time', () => { + const models = [ + { resetTime: '2026-01-01T16:00:00Z' }, + { resetTime: '2026-01-01T14:00:00Z' }, + { resetTime: '2026-01-01T18:00:00Z' }, + ]; + expect(getEarliestResetTime(models)).toBe('2026-01-01T14:00:00Z'); + }); + + it('handles mixed null and valid reset times', () => { + const models = [ + { resetTime: null }, + { resetTime: '2026-01-01T14:00:00Z' }, + { resetTime: null }, + ]; + expect(getEarliestResetTime(models)).toBe('2026-01-01T14:00:00Z'); + }); +});