mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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)
This commit is contained in:
@@ -2,7 +2,13 @@
|
|||||||
* Account Card Component for Flow Visualization
|
* 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 { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import { GripVertical, Loader2, Clock } from 'lucide-react';
|
import { GripVertical, Loader2, Clock } from 'lucide-react';
|
||||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||||
@@ -83,10 +89,8 @@ export function AccountCard({
|
|||||||
account.id,
|
account.id,
|
||||||
isAgy
|
isAgy
|
||||||
);
|
);
|
||||||
const avgQuota =
|
// Show minimum quota of Claude models (primary), fallback to min of all models
|
||||||
quota?.success && quota.models.length > 0
|
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null;
|
||||||
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -136,7 +140,7 @@ export function AccountCard({
|
|||||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||||
<span>Quota...</span>
|
<span>Quota...</span>
|
||||||
</div>
|
</div>
|
||||||
) : avgQuota !== null ? (
|
) : minQuota !== null ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -148,27 +152,27 @@ export function AccountCard({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] font-mono font-bold',
|
'text-[10px] font-mono font-bold',
|
||||||
avgQuota > 50
|
minQuota > 50
|
||||||
? 'text-emerald-600 dark:text-emerald-400'
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
: avgQuota > 20
|
: minQuota > 20
|
||||||
? 'text-amber-500'
|
? 'text-amber-500'
|
||||||
: 'text-red-500'
|
: 'text-red-500'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{avgQuota}%
|
{minQuota}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full rounded-full transition-all',
|
'h-full rounded-full transition-all',
|
||||||
avgQuota > 50
|
minQuota > 50
|
||||||
? 'bg-emerald-500'
|
? 'bg-emerald-500'
|
||||||
: avgQuota > 20
|
: minQuota > 20
|
||||||
? 'bg-amber-500'
|
? 'bg-amber-500'
|
||||||
: 'bg-red-500'
|
: 'bg-red-500'
|
||||||
)}
|
)}
|
||||||
style={{ width: `${avgQuota}%` }}
|
style={{ width: `${minQuota}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
} from 'lucide-react';
|
} 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 { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||||
import type { AccountItemProps } from './types';
|
import type { AccountItemProps } from './types';
|
||||||
@@ -98,11 +104,8 @@ export function AccountItem({
|
|||||||
const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt;
|
const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt;
|
||||||
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
||||||
|
|
||||||
// Calculate average quota across all models
|
// Show minimum quota of Claude models (primary), fallback to min of all models
|
||||||
const avgQuota =
|
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null;
|
||||||
quota?.success && quota.models.length > 0
|
|
||||||
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Get earliest reset time
|
// Get earliest reset time
|
||||||
const nextReset =
|
const nextReset =
|
||||||
@@ -179,7 +182,7 @@ export function AccountItem({
|
|||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
<span>Loading quota...</span>
|
<span>Loading quota...</span>
|
||||||
</div>
|
</div>
|
||||||
) : avgQuota !== null ? (
|
) : minQuota !== null ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* Status indicator based on runtime usage, not file state */}
|
{/* Status indicator based on runtime usage, not file state */}
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
@@ -210,11 +213,11 @@ export function AccountItem({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress
|
<Progress
|
||||||
value={avgQuota}
|
value={minQuota}
|
||||||
className="h-2 flex-1"
|
className="h-2 flex-1"
|
||||||
indicatorClassName={getQuotaColor(avgQuota)}
|
indicatorClassName={getQuotaColor(minQuota)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
|
<span className="text-xs font-medium w-10 text-right">{minQuota}%</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className="max-w-xs">
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const badgeVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface BadgeProps
|
interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
|||||||
@@ -116,3 +116,27 @@ export function getEarliestResetTime<T extends { resetTime: string | null }>(
|
|||||||
null as string | null
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user