diff --git a/src/components/CloudAccountCard.tsx b/src/components/CloudAccountCard.tsx index edbf7d4..6cb068a 100644 --- a/src/components/CloudAccountCard.tsx +++ b/src/components/CloudAccountCard.tsx @@ -22,6 +22,9 @@ import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide- import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; +import { useAppConfig } from '@/hooks/useAppConfig'; +import { useProviderGrouping } from '@/hooks/useProviderGrouping'; +import { ProviderGroup } from '@/components/ProviderGroup'; interface CloudAccountCardProps { account: CloudAccount; @@ -49,6 +52,13 @@ export function CloudAccountCard({ isSwitching, }: CloudAccountCardProps) { const { t } = useTranslation(); + const { config } = useAppConfig(); + const { + enabled: providerGroupingsEnabled, + getAccountStats, + isProviderCollapsed, + toggleProviderCollapse, + } = useProviderGrouping(); // Helpers to get quota color const getQuotaColor = (percentage: number) => { @@ -71,6 +81,13 @@ export function CloudAccountCard({ return 'bg-rose-500'; }; + const getQuotaLabel = (percentage: number) => { + if (percentage === 0) { + return t('cloud.card.rateLimitedQuota'); + } + return `${percentage}%`; + }; + const formatTimeRemaining = (dateStr: string) => { const targetDate = new Date(dateStr); if (Number.isNaN(targetDate.getTime())) { @@ -120,17 +137,19 @@ export function CloudAccountCard({ return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`; }; - const modelQuotas = Object.entries(account.quota?.models || {}); + const modelQuotas = Object.entries(account.quota?.models || {}).filter( + ([modelName]) => config?.model_visibility?.[modelName] !== false, + ); return ( {/* Selection Checkbox - Visible on hover or selected */} {onToggleSelection && (
- {modelQuotas.length > 0 ? ( + {providerGroupingsEnabled ? ( + // Grouped view with provider sections + (() => { + const accountStats = getAccountStats(account); + if (accountStats.visibleModels === 0) { + return ( +
+ + {t('cloud.card.noQuota')} +
+ ); + } + return ( + <> + {/* Account-level overall stats */} +
+ {t('settings.providerGroupings.overall')} +
+ + {getQuotaLabel(accountStats.overallPercentage)} + +
+
+
+
+
+ {/* Provider groups */} + {accountStats.providers.map((providerStats) => ( + + toggleProviderCollapse(account.id, providerStats.providerKey) + } + getQuotaColor={getQuotaColor} + getQuotaBarColor={getQuotaBarColor} + getQuotaLabel={getQuotaLabel} + getResetTimeLabel={getResetTimeLabel} + getResetTimeTitle={getResetTimeTitle} + leftLabel={t('cloud.card.left')} + /> + ))} + + ); + })() + ) : modelQuotas.length > 0 ? ( modelQuotas.map(([modelName, info]) => (
{modelName.replace('models/', '')} @@ -254,15 +326,17 @@ export function CloudAccountCard({ > {getResetTimeLabel(info.resetTime)} -
+
- {info.percentage}% - - - {t('cloud.card.left')} + {getQuotaLabel(info.percentage)} + {info.percentage > 0 && ( + + {t('cloud.card.left')} + + )}
= { + auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3', + '2-col': 'grid gap-4 grid-cols-2', + '3-col': 'grid gap-4 grid-cols-3', + list: 'grid gap-4 grid-cols-1', +}; + export function CloudAccountList() { const { t } = useTranslation(); const { data: accounts, isLoading, isError, error, errorUpdatedAt, refetch } = useCloudAccounts(); + const { config, saveConfig } = useAppConfig(); const refreshMutation = useRefreshQuota(); const deleteMutation = useDeleteCloudAccount(); const addMutation = useAddGoogleAccount(); @@ -62,6 +78,63 @@ export function CloudAccountList() { const { toast } = useToast(); const lastCloudLoadErrorToastAt = useRef(0); + const gridLayout: GridLayout = (config?.grid_layout as GridLayout) || 'auto'; + + const setGridLayout = async (layout: GridLayout) => { + if (config) { + await saveConfig({ ...config, grid_layout: layout }); + } + }; + + // Calculate global quota across all accounts + const globalQuota = useMemo(() => { + if (!accounts || accounts.length === 0) { + return null; + } + + const visibilitySettings = config?.model_visibility ?? {}; + let totalPercentage = 0; + let modelCount = 0; + + accounts.forEach((account) => { + if (!account.quota?.models) { + return; + } + Object.entries(account.quota.models).forEach(([modelName, info]) => { + if (visibilitySettings[modelName] !== false) { + totalPercentage += info.percentage; + modelCount++; + } + }); + }); + + if (modelCount === 0) { + return null; + } + + return Math.round((totalPercentage / modelCount) * 10) / 10; + }, [accounts, config?.model_visibility]); + + const getGlobalQuotaColor = (percentage: number) => { + if (percentage > 80) { + return 'bg-emerald-500'; + } + if (percentage > 20) { + return 'bg-amber-500'; + } + return 'bg-rose-500'; + }; + + const getGlobalQuotaTextColor = (percentage: number) => { + if (percentage > 80) { + return 'text-emerald-600 dark:text-emerald-400'; + } + if (percentage > 20) { + return 'text-amber-600 dark:text-amber-400'; + } + return 'text-rose-600 dark:text-rose-400'; + }; + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [authCode, setAuthCode] = useState(''); const [identityAccount, setIdentityAccount] = useState(null); @@ -328,7 +401,7 @@ export function CloudAccountList() { return (
-
+

{t('cloud.title')}

@@ -336,22 +409,49 @@ export function CloudAccountList() {
-
{t('cloud.card.actions')}
+
+ {t('cloud.card.actions')} +
{totalAccounts}
-
{t('cloud.card.active')}
+
+ {t('cloud.card.active')} +
{activeAccounts}
-
{t('cloud.card.rateLimited')}
+
+ {t('cloud.card.rateLimited')} +
{rateLimitedAccounts}
+ {/* Global Quota */} + {globalQuota !== null && ( +
+
+ {t('cloud.globalQuota')} +
+
+ + {globalQuota}% + +
+
+
+
+
+ )}
-
+
- @@ -427,13 +528,9 @@ export function CloudAccountList() { id="code" placeholder={t('cloud.authDialog.placeholder')} value={authCode} - onChange={(e: React.ChangeEvent) => - setAuthCode(e.target.value) - } + onChange={(e: React.ChangeEvent) => setAuthCode(e.target.value)} /> -

- {t('cloud.authDialog.instruction')} -

+

{t('cloud.authDialog.instruction')}

@@ -447,9 +544,67 @@ export function CloudAccountList() { + + {/* Layout Selector */} +
+ + + + + + {t('cloud.layout.auto')} + + + + + + {t('cloud.layout.twoCol')} + + + + + + {t('cloud.layout.threeCol')} + + + + + + {t('cloud.layout.list')} + + +
-
+
{accounts?.map((account) => ( = () => { + const { t } = useTranslation(); + const { config, saveConfig } = useAppConfig(); + const { data: accounts, isLoading: accountsLoading } = useCloudAccounts(); + + const [searchTerm, setSearchTerm] = useState(''); + const [modelVisibility, setModelVisibility] = useState>({}); + const [providerGroupingsEnabled, setProviderGroupingsEnabled] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + // Initialize model visibility and provider groupings from config + React.useEffect(() => { + if (config?.model_visibility) { + setModelVisibility(config.model_visibility); + } + if (config?.provider_groupings_enabled !== undefined) { + setProviderGroupingsEnabled(config.provider_groupings_enabled); + } + }, [config?.model_visibility, config?.provider_groupings_enabled]); + + // Get all unique models from all accounts + const allModels = useMemo(() => { + if (!accounts) return []; + + const modelSet = new Set(); + accounts.forEach((account) => { + if (account.quota?.models) { + Object.keys(account.quota.models).forEach((model) => { + modelSet.add(model); + }); + } + }); + + return Array.from(modelSet).sort(); + }, [accounts]); + + // Filter models based on search term + const filteredModels = useMemo(() => { + return allModels.filter((model) => model.toLowerCase().includes(searchTerm.toLowerCase())); + }, [allModels, searchTerm]); + + // Handle model visibility toggle + const handleModelToggle = (modelName: string, checked: boolean) => { + setModelVisibility((prev) => ({ + ...prev, + [modelName]: checked, + })); + }; + + // Reset to defaults (all models visible) + const handleReset = () => { + setModelVisibility({}); + }; + + // Save configuration + const handleSave = async () => { + if (!config) return; + + setIsSaving(true); + try { + await saveConfig({ + ...config, + model_visibility: modelVisibility, + provider_groupings_enabled: providerGroupingsEnabled, + }); + } catch (error) { + console.error('Failed to save model visibility settings:', error); + } finally { + setIsSaving(false); + } + }; + + if (accountsLoading) { + return ( + + + + {t('common.loading')} + + + ); + } + + return ( + + + + {t('settings.modelVisibility.title')} + {filteredModels.length} models + + {t('settings.modelVisibility.description')} + + + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Provider Groupings Toggle */} +
+
+ +

+ {t('settings.providerGroupings.description')} +

+
+ +
+ + {/* Model List */} +
+ {filteredModels.length === 0 ? ( +
+ {searchTerm + ? t('settings.modelVisibility.noModelsFound') + : t('settings.modelVisibility.noModels')} +
+ ) : ( + filteredModels.map((modelName) => { + const isVisible = modelVisibility[modelName] !== false; // Default to true + return ( +
+ handleModelToggle(modelName, checked as boolean)} + /> + + {!isVisible && ( + + {t('settings.modelVisibility.hidden')} + + )} +
+ ); + }) + )} +
+ + {/* Action Buttons */} +
+ + +
+ + {/* Statistics */} +
+
+ + {t('settings.modelVisibility.totalModels')}: {allModels.length} + + + {t('settings.modelVisibility.visibleModels')}:{' '} + {allModels.length - Object.values(modelVisibility).filter((v) => !v).length} + + + {t('settings.modelVisibility.hiddenModels')}:{' '} + {Object.values(modelVisibility).filter((v) => !v).length} + +
+
+
+
+ ); +}; diff --git a/src/components/ProviderGroup.tsx b/src/components/ProviderGroup.tsx new file mode 100644 index 0000000..848ae85 --- /dev/null +++ b/src/components/ProviderGroup.tsx @@ -0,0 +1,139 @@ +import React from 'react'; + +import { useTranslation } from 'react-i18next'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import type { ProviderStats } from '@/utils/provider-grouping'; + +interface ProviderGroupProps { + stats: ProviderStats; + isCollapsed: boolean; + onToggleCollapse: () => void; + getQuotaColor: (percentage: number) => string; + getQuotaBarColor: (percentage: number) => string; + getQuotaLabel: (percentage: number) => string; + getResetTimeLabel: (resetTime?: string) => string; + getResetTimeTitle: (resetTime?: string) => string | undefined; + leftLabel: string; +} + +export const ProviderGroup: React.FC = ({ + stats, + isCollapsed, + onToggleCollapse, + getQuotaColor, + getQuotaBarColor, + getQuotaLabel, + getResetTimeLabel, + getResetTimeTitle, + leftLabel, +}) => { + const { t } = useTranslation(); + const { providerInfo, visibleModels, avgPercentage, earliestReset } = stats; + + if (visibleModels.length === 0) { + return null; + } + + return ( +
+ {/* Provider Header */} + + + {/* Individual model rows (shown when expanded) */} + {!isCollapsed && ( +
+ {visibleModels.map((model, index) => ( +
+ + {model.id.replace('models/', '')} + +
+ + {getResetTimeLabel(model.resetTime)} + +
+ + {getQuotaLabel(model.percentage)} + + {model.percentage > 0 && ( + {leftLabel} + )} +
+
+
+
+
+
+ ))} +
+ )} +
+ ); +}; diff --git a/src/hooks/useProviderGrouping.ts b/src/hooks/useProviderGrouping.ts new file mode 100644 index 0000000..b32fabd --- /dev/null +++ b/src/hooks/useProviderGrouping.ts @@ -0,0 +1,86 @@ +import { useState, useMemo, useCallback } from 'react'; + +import { useAppConfig } from '@/hooks/useAppConfig'; +import { CloudAccount } from '@/types/cloudAccount'; +import { groupModelsByProvider, type AccountStats } from '@/utils/provider-grouping'; + +export interface UseProviderGroupingResult { + enabled: boolean; + getAccountStats: (account: CloudAccount) => AccountStats; + isProviderCollapsed: (accountId: string, providerKey: string) => boolean; + toggleProviderCollapse: (accountId: string, providerKey: string) => void; + isAccountCollapsed: (accountId: string) => boolean; + toggleAccountCollapse: (accountId: string) => void; +} + +export function useProviderGrouping(): UseProviderGroupingResult { + const { config } = useAppConfig(); + + // Collapse state is managed locally per session for responsive toggling. + // Provider groups default to expanded on each page load. + const [collapsedProviders, setCollapsedProviders] = useState>({}); + const [collapsedAccounts, setCollapsedAccounts] = useState([]); + + const enabled = config?.provider_groupings_enabled ?? false; + const visibilitySettings = config?.model_visibility ?? {}; + + const getAccountStats = useCallback( + (account: CloudAccount): AccountStats => { + const models = account.quota?.models ?? {}; + return groupModelsByProvider(models, visibilitySettings); + }, + [visibilitySettings], + ); + + const isProviderCollapsed = useCallback( + (accountId: string, providerKey: string): boolean => { + return collapsedProviders[accountId]?.includes(providerKey) ?? false; + }, + [collapsedProviders], + ); + + const toggleProviderCollapse = useCallback((accountId: string, providerKey: string): void => { + setCollapsedProviders((prev) => { + const accountCollapsed = prev[accountId] ?? []; + const isCollapsed = accountCollapsed.includes(providerKey); + return { + ...prev, + [accountId]: isCollapsed + ? accountCollapsed.filter((k) => k !== providerKey) + : [...accountCollapsed, providerKey], + }; + }); + }, []); + + const isAccountCollapsed = useCallback( + (accountId: string): boolean => { + return collapsedAccounts.includes(accountId); + }, + [collapsedAccounts], + ); + + const toggleAccountCollapse = useCallback((accountId: string): void => { + setCollapsedAccounts((prev) => + prev.includes(accountId) ? prev.filter((id) => id !== accountId) : [...prev, accountId], + ); + }, []); + + return useMemo( + () => ({ + enabled, + getAccountStats, + isProviderCollapsed, + toggleProviderCollapse, + isAccountCollapsed, + toggleAccountCollapse, + }), + [ + enabled, + getAccountStats, + isProviderCollapsed, + toggleProviderCollapse, + isAccountCollapsed, + toggleAccountCollapse, + ], + ); +} diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index fc47afb..78fbdc1 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -138,6 +138,38 @@ i18n default: 'Default', restoreDefaults: 'Restore Defaults', }, + modelVisibility: { + title: 'Model Visibility', + description: + "Control which models are visible in account cards. Hidden models won't appear in the quota display.", + searchPlaceholder: 'Search models...', + showAll: 'Show All', + hideAll: 'Hide All', + reset: 'Reset to Defaults', + save: 'Save Changes', + noModels: 'No models found', + modelsShown: '{{visible}} of {{total}} models visible', + quotaManagement: 'Quota Management', + hidden: 'Hidden', + noModelsFound: 'No models found', + totalModels: 'Total', + visibleModels: 'Visible', + hiddenModels: 'Hidden', + saving: 'Saving...', + }, + providerGroupings: { + title: 'Provider Groupings', + description: 'Group models by provider for better organization', + enabled: 'Enable Provider Groupings', + models: '{{count}} models', + avgLabel: 'avg', + resetLabel: 'reset', + overall: 'Overall', + healthy: 'Healthy', + degraded: 'Degraded', + limited: 'Limited', + critical: 'Critical', + }, examples: { title: 'Usage Examples', @@ -215,10 +247,18 @@ i18n title: 'Accounts', description: 'Manage your Google Gemini account pool.', autoSwitch: 'Auto-Switch', + providerGroupings: 'Provider Groupings', addAccount: 'Add Account', syncFromIDE: 'Sync from IDE', checkQuota: 'Check Quota Now', polling: 'Polling triggered', + globalQuota: 'Global Quota', + layout: { + auto: 'Auto', + twoCol: '2 Columns', + threeCol: '3 Columns', + list: 'List', + }, authDialog: { title: 'Add Google Account', description: 'To add an account, you need to authorize the application.', @@ -242,6 +282,7 @@ i18n refresh: 'Refresh Quota', delete: 'Delete Account', noQuota: 'No quota data', + rateLimitedQuota: 'Rate Limited', resetPrefix: 'reset', resetTime: 'Reset time', resetUnknown: 'Unknown', @@ -480,6 +521,37 @@ i18n default: '默认', restoreDefaults: '恢复默认配置', }, + modelVisibility: { + title: '模型可见性', + description: '控制哪些模型在账号卡片中可见。隐藏的模型不会在配额显示中出现。', + searchPlaceholder: '搜索模型...', + showAll: '显示全部', + hideAll: '隐藏全部', + reset: '重置为默认', + save: '保存更改', + noModels: '未找到模型', + modelsShown: '{{visible}} / {{total}} 个模型可见', + quotaManagement: '配额管理', + hidden: '已隐藏', + noModelsFound: '未找到模型', + totalModels: '总数', + visibleModels: '可见', + hiddenModels: '隐藏', + saving: '保存中...', + }, + providerGroupings: { + title: '模型分组', + description: '按供应商分组模型以便更好地管理', + enabled: '启用供应商分组', + models: '{{count}} 个模型', + avgLabel: '平均', + resetLabel: '重置', + overall: '总览', + healthy: '正常', + degraded: '降级', + limited: '受限', + critical: '严重', + }, examples: { title: '使用示例', @@ -556,10 +628,18 @@ i18n title: '账号列表', description: '管理您的 Google Gemini 账号池。', autoSwitch: '自动切换', + providerGroupings: '提供商分组', addAccount: '添加账号', syncFromIDE: '从 IDE 同步', checkQuota: '立即检查配额', polling: '已触发轮询', + globalQuota: '全局配额', + layout: { + auto: '自动', + twoCol: '2 列', + threeCol: '3 列', + list: '列表', + }, authDialog: { title: '添加 Google 账号', description: '添加账号需要进行应用授权。', @@ -582,6 +662,7 @@ i18n refresh: '刷新配额', delete: '删除账号', noQuota: '无配额数据', + rateLimitedQuota: '已达限额', resetPrefix: '重置', resetTime: '重置时间', resetUnknown: '未知', @@ -822,6 +903,38 @@ i18n default: 'По умолчанию', restoreDefaults: 'Сбросить по умолчанию', }, + modelVisibility: { + title: 'Видимость моделей', + description: + 'Управляйте тем, какие модели отображаются в карточках аккаунтов. Скрытые модели не будут показаны в отображении квот.', + searchPlaceholder: 'Поиск моделей...', + showAll: 'Показать все', + hideAll: 'Скрыть все', + reset: 'Сбросить по умолчанию', + save: 'Сохранить изменения', + noModels: 'Модели не найдены', + modelsShown: '{{visible}} из {{total}} моделей видно', + quotaManagement: 'Управление квотами', + hidden: 'Скрыто', + noModelsFound: 'Модели не найдены', + totalModels: 'Всего', + visibleModels: 'Видно', + hiddenModels: 'Скрыто', + saving: 'Сохранение...', + }, + providerGroupings: { + title: 'Группировка провайдеров', + description: 'Группировка моделей по провайдерам для лучшей организации', + enabled: 'Включить группировку провайдеров', + models: '{{count}} моделей', + avgLabel: 'сред.', + resetLabel: 'сброс', + overall: 'Общее', + healthy: 'Исправно', + degraded: 'Снижено', + limited: 'Ограничено', + critical: 'Критично', + }, examples: { title: 'Примеры использования', description: 'Примеры команд для вызова локального API прокси.', @@ -898,10 +1011,18 @@ i18n title: 'Аккаунты', description: 'Управление пулом аккаунтов Google Gemini.', autoSwitch: 'Авто-переключение', + providerGroupings: 'Группировка по провайдерам', addAccount: 'Добавить аккаунт', syncFromIDE: 'Синхр. из IDE', checkQuota: 'Проверить квоту', polling: 'Опрос запущен', + globalQuota: 'Глобальная квота', + layout: { + auto: 'Авто', + twoCol: '2 колонки', + threeCol: '3 колонки', + list: 'Список', + }, authDialog: { title: 'Добавить Google Аккаунт', description: 'Для добавления аккаунта необходимо авторизовать приложение.', @@ -925,6 +1046,7 @@ i18n refresh: 'Обновить квоту', delete: 'Удалить аккаунт', noQuota: 'Нет данных', + rateLimitedQuota: 'Лимит исчерпан', resetPrefix: 'Сброс', resetTime: 'Время сброса', resetUnknown: 'Неизвестно', diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 5ed7dc5..72c15b0 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -18,7 +18,8 @@ import { useTranslation } from 'react-i18next'; import { setAppLanguage } from '@/actions/language'; import { useAppConfig } from '@/hooks/useAppConfig'; import { Loader2, FolderOpen } from 'lucide-react'; -import { useState, useEffect } from 'react'; +import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings'; +import { useEffect, useState } from 'react'; import { ProxyConfig } from '@/types/config'; import { openLogDirectory } from '@/actions/system'; @@ -80,8 +81,9 @@ function SettingsPage() {
- + {t('settings.general', 'General')} + {t('settings.models', 'Models')} {t('settings.proxy_tab', 'Proxy')} @@ -297,6 +299,11 @@ function SettingsPage() { + {/* --- MODELS TAB --- */} + + + + {/* --- PROXY TAB (Upstream Proxy Config Only) --- */} diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index a4cd86c..9563bb2 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -119,8 +119,8 @@ export class ProxyService { ); return this.toAnthropicChatResponse(transformResponse(response)); } - } catch (fallbackError) { - error = fallbackError; + } catch (fallbackErr) { + lastError = fallbackErr; } } @@ -158,8 +158,8 @@ export class ProxyService { model: request.model, }; } - } catch (downgradeError) { - error = downgradeError; + } catch (downgradeErr) { + lastError = downgradeErr; } } @@ -313,10 +313,10 @@ export class ProxyService { ); return this.normalizeGeminiGenerateResponse(response); - } catch (error) { - if (error instanceof Error && this.isProjectContextError(error.message)) { + } catch (err) { + if (err instanceof Error && this.isProjectContextError(err.message)) { this.logger.warn( - `Gemini request hit project context issue, retrying without project: ${error.message}`, + `Gemini request hit project context issue, retrying without project: ${err.message}`, ); try { const fallbackBody = this.createGeminiInternalRequest( @@ -332,15 +332,16 @@ export class ProxyService { extraHeaders, ); return this.normalizeGeminiGenerateResponse(response); - } catch (fallbackError) { - error = fallbackError; + } catch (fallbackErr) { + lastError = fallbackErr; } + } else { + lastError = err; } - lastError = error; - if (error instanceof Error) { - this.logger.warn(`Gemini request failed with account ${token.email}: ${error.message}`); - const decision = this.classifyUpstreamError(error.message); + if (lastError instanceof Error) { + this.logger.warn(`Gemini request failed with account ${token.email}: ${lastError.message}`); + const decision = this.classifyUpstreamError(lastError.message); if (decision.retry) { if (decision.markAsForbidden) { this.tokenManager.markAsForbidden(token.id); @@ -400,10 +401,10 @@ export class ProxyService { extraHeaders, ); return this.passthroughSseStream(stream); - } catch (error) { - if (error instanceof Error && this.isProjectContextError(error.message)) { + } catch (err) { + if (err instanceof Error && this.isProjectContextError(err.message)) { this.logger.warn( - `Gemini stream request hit project context issue, retrying without project: ${error.message}`, + `Gemini stream request hit project context issue, retrying without project: ${err.message}`, ); try { const fallbackBody = this.createGeminiInternalRequest( @@ -419,17 +420,18 @@ export class ProxyService { extraHeaders, ); return this.passthroughSseStream(stream); - } catch (fallbackError) { - error = fallbackError; + } catch (fallbackErr) { + lastError = fallbackErr; } + } else { + lastError = err; } - lastError = error; - if (error instanceof Error) { + if (lastError instanceof Error) { this.logger.warn( - `Gemini stream request failed with account ${token.email}: ${error.message}`, + `Gemini stream request failed with account ${token.email}: ${lastError.message}`, ); - const decision = this.classifyUpstreamError(error.message); + const decision = this.classifyUpstreamError(lastError.message); if (decision.retry) { if (decision.markAsForbidden) { this.tokenManager.markAsForbidden(token.id); @@ -627,10 +629,10 @@ export class ProxyService { this.logger.log(`Claude Response: ${JSON.stringify(claudeResponse).substring(0, 500)}`); return this.convertClaudeToOpenAIResponse(claudeResponse, request.model); } - } catch (error) { - if (error instanceof Error && this.isProjectContextError(error.message)) { + } catch (err) { + if (err instanceof Error && this.isProjectContextError(err.message)) { this.logger.warn( - `OpenAI compatibility request hit project context issue, retrying without project: ${error.message}`, + `OpenAI compatibility request hit project context issue, retrying without project: ${err.message}`, ); try { const claudeRequest = this.convertOpenAIToClaude(request); @@ -653,16 +655,17 @@ export class ProxyService { ); const claudeResponse = transformResponse(response); return this.convertClaudeToOpenAIResponse(claudeResponse, request.model); - } catch (fallbackError) { - error = fallbackError; + } catch (fallbackErr) { + lastError = fallbackErr; } + } else { + lastError = err; } - lastError = error; - if (error instanceof Error) { - this.logger.warn(`Request failed with account ${token.email}: ${error.message}`); + if (lastError instanceof Error) { + this.logger.warn(`Request failed with account ${token.email}: ${lastError.message}`); - const decision = this.classifyUpstreamError(error.message); + const decision = this.classifyUpstreamError(lastError.message); if (decision.retry) { if (decision.markAsForbidden) { this.tokenManager.markAsForbidden(token.id); diff --git a/src/tests/unit/provider-grouping.test.ts b/src/tests/unit/provider-grouping.test.ts new file mode 100644 index 0000000..a81cb42 --- /dev/null +++ b/src/tests/unit/provider-grouping.test.ts @@ -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'); + }); +}); diff --git a/src/tests/unit/proxy-controller.integration.test.ts b/src/tests/unit/proxy-controller.integration.test.ts index 0d6d8f4..b43a5b4 100644 --- a/src/tests/unit/proxy-controller.integration.test.ts +++ b/src/tests/unit/proxy-controller.integration.test.ts @@ -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({ diff --git a/src/types/config.ts b/src/types/config.ts index 150a55c..f9d4094 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -28,6 +28,9 @@ export const AppConfigSchema = z.object({ error_reporting_enabled: z.boolean(), privacy_consent_asked: z.boolean().optional().default(false), // Optional for backward compatibility default_export_path: z.string().nullable().optional(), // 导出路径 + model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences + provider_groupings_enabled: z.boolean().default(false), // Enable provider groupings UI + grid_layout: z.enum(['auto', '2-col', '3-col', 'list']).default('auto'), // Account card grid layout proxy: ProxyConfigSchema, }); @@ -46,6 +49,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = { error_reporting_enabled: true, // Default to disabled for privacy privacy_consent_asked: false, // Whether the user has been asked for consent default_export_path: null, + model_visibility: {}, // Model visibility preferences + provider_groupings_enabled: false, // Enable provider groupings UI + grid_layout: 'auto' as const, // Account card grid layout proxy: { enabled: false, port: 8045, diff --git a/src/utils/provider-grouping.ts b/src/utils/provider-grouping.ts new file mode 100644 index 0000000..496f07b --- /dev/null +++ b/src/utils/provider-grouping.ts @@ -0,0 +1,174 @@ +/** + * Provider grouping utilities for categorizing AI models by provider + * and calculating aggregated statistics. + */ + +export interface ProviderInfo { + name: string; + company: string; + color: string; +} + +export const PROVIDER_REGISTRY: Record = { + 'claude-': { + name: 'Claude', + company: 'Anthropic', + color: '#D97757', + }, + 'gemini-': { + name: 'Gemini', + company: 'Google', + color: '#4285F4', + }, + others: { + name: 'Other', + company: 'Various', + color: '#6B7280', + }, +}; + +export type ProviderKey = keyof typeof PROVIDER_REGISTRY; + +/** + * Detect the provider key for a given model name based on prefix matching. + */ +export function detectProvider(modelName: string): ProviderKey { + for (const prefix of Object.keys(PROVIDER_REGISTRY)) { + if (prefix !== 'others' && modelName.startsWith(prefix)) { + return prefix; + } + } + return 'others'; +} + +/** + * Get the provider display info for a given model name. + */ +export function getProviderInfo(modelName: string): ProviderInfo { + const key = detectProvider(modelName); + return PROVIDER_REGISTRY[key]; +} + +export interface ModelQuota { + id: string; + percentage: number; + resetTime: string; +} + +export interface ProviderStats { + providerKey: ProviderKey; + providerInfo: ProviderInfo; + models: ModelQuota[]; + visibleModels: ModelQuota[]; + avgPercentage: number; + earliestReset: string | null; +} + +/** + * Calculate aggregated stats for a group of models belonging to one provider. + */ +export function calculateProviderStats( + providerKey: ProviderKey, + models: ModelQuota[], + visibilitySettings: Record, +): ProviderStats { + const visibleModels = models.filter((m) => visibilitySettings[m.id] !== false); + + if (visibleModels.length === 0) { + return { + providerKey, + providerInfo: PROVIDER_REGISTRY[providerKey], + models, + visibleModels: [], + avgPercentage: 0, + earliestReset: null, + }; + } + + const avgPercentage = + visibleModels.reduce((sum, m) => sum + m.percentage, 0) / visibleModels.length; + + const resetTimes = visibleModels + .map((m) => m.resetTime) + .filter(Boolean) + .map((time) => new Date(time).getTime()) + .filter((t) => !Number.isNaN(t)); + + const earliestReset = + resetTimes.length > 0 ? new Date(Math.min(...resetTimes)).toISOString() : null; + + return { + providerKey, + providerInfo: PROVIDER_REGISTRY[providerKey], + models, + visibleModels, + avgPercentage: Math.round(avgPercentage * 10) / 10, + earliestReset, + }; +} + +export interface AccountStats { + providers: ProviderStats[]; + totalModels: number; + visibleModels: number; + overallPercentage: number; + healthStatus: 'healthy' | 'degraded' | 'limited' | 'critical'; +} + +/** + * Group models by provider and calculate per-provider and overall account stats. + */ +export function groupModelsByProvider( + models: Record, + visibilitySettings: Record, +): AccountStats { + const grouped: Record = {}; + + for (const [modelName, info] of Object.entries(models)) { + const key = detectProvider(modelName); + if (!grouped[key]) { + grouped[key] = []; + } + grouped[key].push({ + id: modelName, + percentage: info.percentage, + resetTime: info.resetTime, + }); + } + + const providers: ProviderStats[] = []; + for (const [key, groupModels] of Object.entries(grouped)) { + providers.push(calculateProviderStats(key, groupModels, visibilitySettings)); + } + + // Sort: known providers first (claude-, gemini-), then others + const providerOrder = Object.keys(PROVIDER_REGISTRY); + providers.sort( + (a, b) => providerOrder.indexOf(a.providerKey) - providerOrder.indexOf(b.providerKey), + ); + + const allVisibleModels = providers.flatMap((p) => p.visibleModels); + const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0); + + const overallPercentage = + allVisibleModels.length > 0 + ? allVisibleModels.reduce((sum, m) => sum + m.percentage, 0) / allVisibleModels.length + : 0; + + let healthStatus: AccountStats['healthStatus'] = 'healthy'; + if (overallPercentage < 10) { + healthStatus = 'critical'; + } else if (overallPercentage < 25) { + healthStatus = 'limited'; + } else if (overallPercentage < 50) { + healthStatus = 'degraded'; + } + + return { + providers, + totalModels, + visibleModels: allVisibleModels.length, + overallPercentage: Math.round(overallPercentage * 10) / 10, + healthStatus, + }; +}