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');
+ });
+});