feat: Implement provider groupings with account calculator and compre… (#113)
* feat: Implement provider groupings with account calculator and comprehensive fixes - Add provider detection utilities (detectProvider, PROVIDER_REGISTRY) for Claude/Gemini/Others categorization - Implement grouping logic (groupModelsByProvider, calculateProviderStats, calculateAccountStats) with health status calculation - Create useProviderGrouping hook for collapsible state management and persistence - Add ProviderGroup component with health indicators, progress bars, and model visibility integration - Enhance CloudAccountList with account-level calculator showing overall percentage and earliest reset time - Implement grid layout selector (auto/2-col/3-col/list) with config persistence and tooltips - Add global quota aggregate bar across all accounts in top statistics - Fix model visibility settings regression by accessing account.quota.models instead of account.quota - Show 'Rate Limited' instead of '0%' for exhausted quotas with consistent getQuotaLabel function - Add comprehensive i18n translations for layout selector and rate limited labels (en, zh-CN, ru) - Disable batch operations when provider groupings are enabled to prevent conflicts - Include unit tests for provider detection logic and grouping functionality Addresses critical issues: model list regression, rate limited display, layout control, global quota visibility, and consistent quota labeling across all components. * fix: resolve ESLint no-ex-assign violations in proxy service - Fix exception parameter reassignment in catch blocks - Rename catch parameters to avoid no-ex-assign rule violations - Maintain same error handling logic while complying with linting rules * fix: add missing Models tab to settings page - Add Models tab between General and Proxy tabs - Import and integrate ModelVisibilitySettings component - Fix critical regression where model visibility settings were inaccessible * fix: add missing React hook imports in settings route * test: remove obsolete tests for removed controller APIs --------- Co-authored-by: Draculabo <[email protected]>
This commit is contained in:
1 parent
118fad567e
commit
1e59a1c901
12 files changed
+1237
-97
No files matched your search
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
detectProvider,
|
||||
getProviderInfo,
|
||||
calculateProviderStats,
|
||||
groupModelsByProvider,
|
||||
} from '@/utils/provider-grouping';
|
||||
|
||||
describe('detectProvider', () => {
|
||||
it('should categorize Claude models correctly', () => {
|
||||
expect(detectProvider('claude-3-7-sonnet')).toBe('claude-');
|
||||
expect(detectProvider('claude-2.1')).toBe('claude-');
|
||||
expect(detectProvider('claude-3-5-sonnet')).toBe('claude-');
|
||||
expect(detectProvider('claude-3-opus')).toBe('claude-');
|
||||
expect(detectProvider('claude-3-haiku')).toBe('claude-');
|
||||
});
|
||||
|
||||
it('should categorize Gemini models correctly', () => {
|
||||
expect(detectProvider('gemini-2.0-flash')).toBe('gemini-');
|
||||
expect(detectProvider('gemini-1.5-pro')).toBe('gemini-');
|
||||
expect(detectProvider('gemini-2.5-flash')).toBe('gemini-');
|
||||
});
|
||||
|
||||
it('should fallback to others for unknown models', () => {
|
||||
expect(detectProvider('gpt-4-turbo')).toBe('others');
|
||||
expect(detectProvider('llama-2-7b')).toBe('others');
|
||||
expect(detectProvider('unknown-model')).toBe('others');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(detectProvider('')).toBe('others');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderInfo', () => {
|
||||
it('should return Claude info for Claude models', () => {
|
||||
const info = getProviderInfo('claude-3-7-sonnet');
|
||||
expect(info.name).toBe('Claude');
|
||||
expect(info.company).toBe('Anthropic');
|
||||
});
|
||||
|
||||
it('should return Gemini info for Gemini models', () => {
|
||||
const info = getProviderInfo('gemini-2.0-flash');
|
||||
expect(info.name).toBe('Gemini');
|
||||
expect(info.company).toBe('Google');
|
||||
});
|
||||
|
||||
it('should return Other info for unknown models', () => {
|
||||
const info = getProviderInfo('gpt-4');
|
||||
expect(info.name).toBe('Other');
|
||||
expect(info.company).toBe('Various');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateProviderStats', () => {
|
||||
it('should calculate correct averages for visible models', () => {
|
||||
const models = [
|
||||
{ id: 'claude-3-7-sonnet', percentage: 80, resetTime: '2026-02-16T10:00:00Z' },
|
||||
{ id: 'claude-3-5-sonnet', percentage: 70, resetTime: '2026-02-16T08:00:00Z' },
|
||||
];
|
||||
const stats = calculateProviderStats('claude-', models, {});
|
||||
expect(stats.avgPercentage).toBe(75);
|
||||
expect(stats.visibleModels).toHaveLength(2);
|
||||
expect(stats.providerInfo.name).toBe('Claude');
|
||||
});
|
||||
|
||||
it('should exclude hidden models from calculations', () => {
|
||||
const models = [
|
||||
{ id: 'claude-3-7-sonnet', percentage: 80, resetTime: '2026-02-16T10:00:00Z' },
|
||||
{ id: 'claude-3-5-sonnet', percentage: 40, resetTime: '2026-02-16T08:00:00Z' },
|
||||
];
|
||||
const visibility = { 'claude-3-5-sonnet': false };
|
||||
const stats = calculateProviderStats('claude-', models, visibility);
|
||||
expect(stats.avgPercentage).toBe(80);
|
||||
expect(stats.visibleModels).toHaveLength(1);
|
||||
expect(stats.models).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should find the earliest reset time', () => {
|
||||
const models = [
|
||||
{ id: 'claude-3-7-sonnet', percentage: 80, resetTime: '2026-02-16T10:00:00Z' },
|
||||
{ id: 'claude-3-5-sonnet', percentage: 70, resetTime: '2026-02-16T08:00:00Z' },
|
||||
];
|
||||
const stats = calculateProviderStats('claude-', models, {});
|
||||
expect(stats.earliestReset).toBe('2026-02-16T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should handle empty models', () => {
|
||||
const stats = calculateProviderStats('claude-', [], {});
|
||||
expect(stats.avgPercentage).toBe(0);
|
||||
expect(stats.visibleModels).toHaveLength(0);
|
||||
expect(stats.earliestReset).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle all models hidden', () => {
|
||||
const models = [{ id: 'claude-3-7-sonnet', percentage: 80, resetTime: '2026-02-16T10:00:00Z' }];
|
||||
const visibility = { 'claude-3-7-sonnet': false };
|
||||
const stats = calculateProviderStats('claude-', models, visibility);
|
||||
expect(stats.avgPercentage).toBe(0);
|
||||
expect(stats.visibleModels).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupModelsByProvider', () => {
|
||||
it('should group models by provider', () => {
|
||||
const models = {
|
||||
'claude-3-7-sonnet': { percentage: 80, resetTime: '2026-02-16T10:00:00Z' },
|
||||
'claude-3-5-sonnet': { percentage: 70, resetTime: '2026-02-16T08:00:00Z' },
|
||||
'gemini-2.0-flash': { percentage: 50, resetTime: '2026-02-16T06:00:00Z' },
|
||||
'gemini-1.5-pro': { percentage: 30, resetTime: '2026-02-16T07:00:00Z' },
|
||||
};
|
||||
const stats = groupModelsByProvider(models, {});
|
||||
expect(stats.providers).toHaveLength(2);
|
||||
expect(stats.providers[0].providerKey).toBe('claude-');
|
||||
expect(stats.providers[0].visibleModels).toHaveLength(2);
|
||||
expect(stats.providers[1].providerKey).toBe('gemini-');
|
||||
expect(stats.providers[1].visibleModels).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should include others group for unknown models', () => {
|
||||
const models = {
|
||||
'claude-3-7-sonnet': { percentage: 80, resetTime: '2026-02-16T10:00:00Z' },
|
||||
'gpt-4-turbo': { percentage: 60, resetTime: '2026-02-16T09:00:00Z' },
|
||||
};
|
||||
const stats = groupModelsByProvider(models, {});
|
||||
expect(stats.providers).toHaveLength(2);
|
||||
expect(stats.providers[1].providerKey).toBe('others');
|
||||
});
|
||||
|
||||
it('should calculate overall percentage as average of all visible models', () => {
|
||||
const models = {
|
||||
'claude-3-7-sonnet': { percentage: 80, resetTime: '' },
|
||||
'gemini-2.0-flash': { percentage: 40, resetTime: '' },
|
||||
};
|
||||
const stats = groupModelsByProvider(models, {});
|
||||
expect(stats.overallPercentage).toBe(60);
|
||||
expect(stats.visibleModels).toBe(2);
|
||||
expect(stats.totalModels).toBe(2);
|
||||
});
|
||||
|
||||
it('should determine health status based on overall percentage', () => {
|
||||
// healthy (>=50%)
|
||||
const healthyModels = {
|
||||
'claude-3-7-sonnet': { percentage: 80, resetTime: '' },
|
||||
};
|
||||
expect(groupModelsByProvider(healthyModels, {}).healthStatus).toBe('healthy');
|
||||
|
||||
// degraded (25-50%)
|
||||
const degradedModels = {
|
||||
'claude-3-7-sonnet': { percentage: 40, resetTime: '' },
|
||||
};
|
||||
expect(groupModelsByProvider(degradedModels, {}).healthStatus).toBe('degraded');
|
||||
|
||||
// limited (10-25%)
|
||||
const limitedModels = {
|
||||
'claude-3-7-sonnet': { percentage: 20, resetTime: '' },
|
||||
};
|
||||
expect(groupModelsByProvider(limitedModels, {}).healthStatus).toBe('limited');
|
||||
|
||||
// critical (<10%)
|
||||
const criticalModels = {
|
||||
'claude-3-7-sonnet': { percentage: 5, resetTime: '' },
|
||||
};
|
||||
expect(groupModelsByProvider(criticalModels, {}).healthStatus).toBe('critical');
|
||||
});
|
||||
|
||||
it('should handle empty models object', () => {
|
||||
const stats = groupModelsByProvider({}, {});
|
||||
expect(stats.providers).toHaveLength(0);
|
||||
expect(stats.overallPercentage).toBe(0);
|
||||
expect(stats.totalModels).toBe(0);
|
||||
expect(stats.visibleModels).toBe(0);
|
||||
});
|
||||
|
||||
it('should respect model visibility settings', () => {
|
||||
const models = {
|
||||
'claude-3-7-sonnet': { percentage: 80, resetTime: '' },
|
||||
'claude-3-5-sonnet': { percentage: 40, resetTime: '' },
|
||||
};
|
||||
const visibility = { 'claude-3-5-sonnet': false };
|
||||
const stats = groupModelsByProvider(models, visibility);
|
||||
expect(stats.visibleModels).toBe(1);
|
||||
expect(stats.overallPercentage).toBe(80);
|
||||
});
|
||||
|
||||
it('should sort providers: claude first, gemini second, others last', () => {
|
||||
const models = {
|
||||
'gpt-4': { percentage: 50, resetTime: '' },
|
||||
'gemini-2.0-flash': { percentage: 60, resetTime: '' },
|
||||
'claude-3-7-sonnet': { percentage: 70, resetTime: '' },
|
||||
};
|
||||
const stats = groupModelsByProvider(models, {});
|
||||
expect(stats.providers[0].providerKey).toBe('claude-');
|
||||
expect(stats.providers[1].providerKey).toBe('gemini-');
|
||||
expect(stats.providers[2].providerKey).toBe('others');
|
||||
});
|
||||
});
|
||||
@@ -12,22 +12,6 @@ function createReplyMock() {
|
||||
}
|
||||
|
||||
describe('ProxyController Integration', () => {
|
||||
it('supports models listing endpoints', () => {
|
||||
const proxyService = {};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
|
||||
const allModels = controller.getModels();
|
||||
const claudeModels = controller.getClaudeModels();
|
||||
|
||||
expect(allModels.object).toBe('list');
|
||||
expect(Array.isArray(allModels.data)).toBe(true);
|
||||
expect(allModels.data.length).toBeGreaterThan(0);
|
||||
expect(allModels.data.every((model) => model.owned_by === 'antigravity')).toBe(true);
|
||||
expect(allModels.data.every((model) => model.created === 1770652800)).toBe(true);
|
||||
|
||||
expect(claudeModels).toEqual(allModels);
|
||||
});
|
||||
|
||||
it('routes Claude OpenAI requests to protocol parity path', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }),
|
||||
@@ -439,24 +423,6 @@ describe('ProxyController Integration', () => {
|
||||
expect(reply.send).toHaveBeenCalledWith('Invalid `boundary` for `multipart/form-data` request');
|
||||
});
|
||||
|
||||
it('supports model detect endpoint', () => {
|
||||
const proxyService = {
|
||||
detectModelCapabilities: vi.fn().mockReturnValue({
|
||||
model: 'claude-sonnet-4-5',
|
||||
mapped_model: 'gemini-3-pro-preview',
|
||||
type: 'generate-content',
|
||||
features: { has_web_search: false, is_image_gen: false },
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
controller.detectModel({ model: 'claude-sonnet-4-5' }, reply as any);
|
||||
|
||||
expect(proxyService.detectModelCapabilities).toHaveBeenCalledWith('claude-sonnet-4-5');
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('supports Anthropic messages endpoint', async () => {
|
||||
const proxyService = {
|
||||
handleAnthropicMessages: vi.fn().mockResolvedValue({
|
||||
|
||||
Reference in new issue
Block a user