From b25f722fefbf197952ab3494b37136e977043adf Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 2 May 2026 02:53:15 +0800 Subject: [PATCH] Refactor provider usage tracking to utilize recent requests data - Introduced `useProviderRecentRequests` hook to manage recent request data for providers. - Replaced usage of `useProviderStats` with `useProviderRecentRequests` in relevant components. - Updated `VertexSection` and other provider components to consume recent request data instead of key stats. - Removed obsolete `useProviderStats` hook and related usage details. - Added new utility functions for handling recent request data, including normalization and aggregation. - Updated API service to fetch recent request usage data. - Refactored `AuthFileCard` and related components to utilize recent request statistics. - Cleaned up unused code and types related to previous usage stats implementation. --- .../providers/ClaudeSection/ClaudeSection.tsx | 39 ++-- .../providers/CodexSection/CodexSection.tsx | 39 ++-- .../providers/GeminiSection/GeminiSection.tsx | 39 ++-- .../providers/OpenAISection/OpenAISection.tsx | 50 ++-- .../providers/ProviderStatusBar.tsx | 2 +- .../providers/VertexSection/VertexSection.tsx | 39 ++-- .../hooks/useProviderRecentRequests.ts | 126 ++++++++++ .../providers/hooks/useProviderStats.ts | 37 --- src/components/providers/index.ts | 2 +- src/components/providers/types.ts | 12 - src/components/providers/utils.ts | 219 ++++++------------ .../authFiles/components/AuthFileCard.tsx | 18 +- src/features/authFiles/constants.ts | 46 ---- .../authFiles/hooks/useAuthFilesData.ts | 10 +- .../hooks/useAuthFilesPrefixProxyEditor.ts | 4 +- .../authFiles/hooks/useAuthFilesStats.ts | 26 --- .../hooks/useAuthFilesStatusBarCache.ts | 35 +-- src/pages/AiProvidersPage.tsx | 45 ++-- src/pages/AuthFilesPage.tsx | 17 +- src/services/api/apiKeyUsage.ts | 11 + src/services/api/index.ts | 1 + src/types/authFile.ts | 4 + src/utils/recentRequests.ts | 157 +++++++++++++ 23 files changed, 505 insertions(+), 473 deletions(-) create mode 100644 src/components/providers/hooks/useProviderRecentRequests.ts delete mode 100644 src/components/providers/hooks/useProviderStats.ts delete mode 100644 src/features/authFiles/hooks/useAuthFilesStats.ts create mode 100644 src/services/api/apiKeyUsage.ts create mode 100644 src/utils/recentRequests.ts diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index 20aa817..2203b15 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -6,23 +6,21 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconClaude from '@/assets/icons/claude.svg'; import type { ProviderKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; -import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; +import { statusBarDataFromRecentRequests } from '@/utils/recentRequests'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { - collectUsageDetailsForIdentity, getProviderConfigKey, - getStatsForIdentity, + getProviderRecentBuckets, + getProviderRecentStats, hasDisableAllModelsRule, + type ProviderRecentUsageMap, } from '../utils'; interface ClaudeSectionProps { configs: ProviderKeyConfig[]; - keyStats: KeyStats; - usageDetailsBySource: UsageDetailsBySource; - usageDetailsByAuthIndex: UsageDetailsByAuthIndex; + usageByProvider: ProviderRecentUsageMap; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -34,9 +32,7 @@ interface ClaudeSectionProps { export function ClaudeSection({ configs, - keyStats, - usageDetailsBySource, - usageDetailsByAuthIndex, + usageByProvider, loading, disableControls, isSwitching, @@ -50,25 +46,21 @@ export function ClaudeSection({ const toggleDisabled = disableControls || loading || isSwitching; const statusBarCache = useMemo(() => { - const cache = new Map>(); + const cache = new Map>(); configs.forEach((config, index) => { if (!config.apiKey) return; const configKey = getProviderConfigKey(config, index); cache.set( configKey, - calculateStatusBarData( - collectUsageDetailsForIdentity( - { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, - usageDetailsBySource, - usageDetailsByAuthIndex - ) + statusBarDataFromRecentRequests( + getProviderRecentBuckets(usageByProvider, 'claude', config.apiKey, config.baseUrl) ) ); }); return cache; - }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); + }, [configs, usageByProvider]); return ( <> @@ -104,15 +96,18 @@ export function ClaudeSection({ /> )} renderContent={(item, index) => { - const stats = getStatsForIdentity( - { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, - keyStats + const stats = getProviderRecentStats( + usageByProvider, + 'claude', + item.apiKey, + item.baseUrl ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; const statusData = - statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); + statusBarCache.get(getProviderConfigKey(item, index)) || + statusBarDataFromRecentRequests([]); return ( diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index 7db1150..8a2f7b3 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -6,23 +6,21 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconCodex from '@/assets/icons/codex.svg'; import type { ProviderKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; -import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; +import { statusBarDataFromRecentRequests } from '@/utils/recentRequests'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { - collectUsageDetailsForIdentity, getProviderConfigKey, - getStatsForIdentity, + getProviderRecentBuckets, + getProviderRecentStats, hasDisableAllModelsRule, + type ProviderRecentUsageMap, } from '../utils'; interface CodexSectionProps { configs: ProviderKeyConfig[]; - keyStats: KeyStats; - usageDetailsBySource: UsageDetailsBySource; - usageDetailsByAuthIndex: UsageDetailsByAuthIndex; + usageByProvider: ProviderRecentUsageMap; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -34,9 +32,7 @@ interface CodexSectionProps { export function CodexSection({ configs, - keyStats, - usageDetailsBySource, - usageDetailsByAuthIndex, + usageByProvider, loading, disableControls, isSwitching, @@ -50,25 +46,21 @@ export function CodexSection({ const toggleDisabled = disableControls || loading || isSwitching; const statusBarCache = useMemo(() => { - const cache = new Map>(); + const cache = new Map>(); configs.forEach((config, index) => { if (!config.apiKey) return; const configKey = getProviderConfigKey(config, index); cache.set( configKey, - calculateStatusBarData( - collectUsageDetailsForIdentity( - { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, - usageDetailsBySource, - usageDetailsByAuthIndex - ) + statusBarDataFromRecentRequests( + getProviderRecentBuckets(usageByProvider, 'codex', config.apiKey, config.baseUrl) ) ); }); return cache; - }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); + }, [configs, usageByProvider]); return ( <> @@ -104,15 +96,18 @@ export function CodexSection({ /> )} renderContent={(item, index) => { - const stats = getStatsForIdentity( - { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, - keyStats + const stats = getProviderRecentStats( + usageByProvider, + 'codex', + item.apiKey, + item.baseUrl ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; const statusData = - statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); + statusBarCache.get(getProviderConfigKey(item, index)) || + statusBarDataFromRecentRequests([]); return ( diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index 48b141e..6f8e83a 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -6,23 +6,21 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import iconGemini from '@/assets/icons/gemini.svg'; import type { GeminiKeyConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; -import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; +import { statusBarDataFromRecentRequests } from '@/utils/recentRequests'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderList } from '../ProviderList'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { - collectUsageDetailsForIdentity, getProviderConfigKey, - getStatsForIdentity, + getProviderRecentBuckets, + getProviderRecentStats, hasDisableAllModelsRule, + type ProviderRecentUsageMap, } from '../utils'; interface GeminiSectionProps { configs: GeminiKeyConfig[]; - keyStats: KeyStats; - usageDetailsBySource: UsageDetailsBySource; - usageDetailsByAuthIndex: UsageDetailsByAuthIndex; + usageByProvider: ProviderRecentUsageMap; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -34,9 +32,7 @@ interface GeminiSectionProps { export function GeminiSection({ configs, - keyStats, - usageDetailsBySource, - usageDetailsByAuthIndex, + usageByProvider, loading, disableControls, isSwitching, @@ -50,25 +46,21 @@ export function GeminiSection({ const toggleDisabled = disableControls || loading || isSwitching; const statusBarCache = useMemo(() => { - const cache = new Map>(); + const cache = new Map>(); configs.forEach((config, index) => { if (!config.apiKey) return; const configKey = getProviderConfigKey(config, index); cache.set( configKey, - calculateStatusBarData( - collectUsageDetailsForIdentity( - { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, - usageDetailsBySource, - usageDetailsByAuthIndex - ) + statusBarDataFromRecentRequests( + getProviderRecentBuckets(usageByProvider, 'gemini', config.apiKey, config.baseUrl) ) ); }); return cache; - }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); + }, [configs, usageByProvider]); return ( <> @@ -104,15 +96,18 @@ export function GeminiSection({ /> )} renderContent={(item, index) => { - const stats = getStatsForIdentity( - { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, - keyStats + const stats = getProviderRecentStats( + usageByProvider, + 'gemini', + item.apiKey, + item.baseUrl ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; const statusData = - statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); + statusBarCache.get(getProviderConfigKey(item, index)) || + statusBarDataFromRecentRequests([]); return ( diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index 07317f3..b95c444 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -18,16 +18,16 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import type { OpenAIProviderConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; -import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex'; +import { statusBarDataFromRecentRequests } from '@/utils/recentRequests'; import styles from '@/pages/AiProvidersPage.module.scss'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; import { - collectOpenAIProviderUsageDetails, + getOpenAIProviderRecentStats, + getOpenAIProviderRecentStatusData, getOpenAIProviderKey, - getOpenAIProviderStats, - getStatsForIdentity, + getProviderRecentStats, + type ProviderRecentUsageMap, } from '../utils'; type SortOption = 'name' | 'priority' | 'recent-success'; @@ -40,13 +40,11 @@ interface FloatingToolbarStyle { visible: boolean; } -const EMPTY_STATUS_BAR = calculateStatusBarData([]); +const EMPTY_STATUS_BAR = statusBarDataFromRecentRequests([]); interface OpenAISectionProps { configs: OpenAIProviderConfig[]; - keyStats: KeyStats; - usageDetailsBySource: UsageDetailsBySource; - usageDetailsByAuthIndex: UsageDetailsByAuthIndex; + usageByProvider: ProviderRecentUsageMap; loading: boolean; disableControls: boolean; isSwitching: boolean; @@ -72,9 +70,7 @@ const getApiKeyEntryRenderKey = ( export function OpenAISection({ configs, - keyStats, - usageDetailsBySource, - usageDetailsByAuthIndex, + usageByProvider, loading, disableControls, isSwitching, @@ -253,20 +249,15 @@ export function OpenAISection({ : t('ai_providers.model_search_placeholder'); const statusBarCache = useMemo(() => { - const cache = new Map>(); + const cache = new Map>(); configs.forEach((provider, index) => { const providerKey = getOpenAIProviderKey(provider, index); - cache.set( - providerKey, - calculateStatusBarData( - collectOpenAIProviderUsageDetails(provider, usageDetailsBySource, usageDetailsByAuthIndex) - ) - ); + cache.set(providerKey, getOpenAIProviderRecentStatusData(provider, usageByProvider)); }); return cache; - }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); + }, [configs, usageByProvider]); const sortOptions = useMemo( () => [ @@ -288,7 +279,12 @@ export function OpenAISection({ const direction = sortDirection === 'desc' ? -1 : 1; const providerStats = sortOption === 'recent-success' - ? new Map(sorted.map(({ config }) => [config, getOpenAIProviderStats(config, keyStats)])) + ? new Map( + sorted.map(({ config }) => [ + config, + getOpenAIProviderRecentStats(config, usageByProvider), + ]) + ) : null; switch (sortOption) { @@ -326,7 +322,7 @@ export function OpenAISection({ } return sorted; - }, [configs, sortOption, sortDirection, keyStats, selectedModels]); + }, [configs, sortOption, sortDirection, usageByProvider, selectedModels]); const toggleModelSelection = (modelName: string) => { setSelectedModels((prev) => { @@ -528,7 +524,7 @@ export function OpenAISection({ ); const renderProviderCard = ({ config: provider, originalIndex }: IndexedOpenAIProvider) => { - const stats = getOpenAIProviderStats(provider, keyStats); + const stats = getOpenAIProviderRecentStats(provider, usageByProvider); const headerEntries = Object.entries(provider.headers || {}); const apiKeyEntries = provider.apiKeyEntries || []; const statusData = @@ -580,9 +576,11 @@ export function OpenAISection({
{apiKeyEntries.map((entry, entryIndex) => { - const entryStats = getStatsForIdentity( - { authIndex: entry.authIndex, apiKey: entry.apiKey }, - keyStats + const entryStats = getProviderRecentStats( + usageByProvider, + provider.name, + entry.apiKey, + provider.baseUrl ); return (
{ - const cache = new Map>(); + const cache = new Map>(); configs.forEach((config, index) => { if (!config.apiKey) return; const configKey = getProviderConfigKey(config, index); cache.set( configKey, - calculateStatusBarData( - collectUsageDetailsForIdentity( - { authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix }, - usageDetailsBySource, - usageDetailsByAuthIndex - ) + statusBarDataFromRecentRequests( + getProviderRecentBuckets(usageByProvider, 'vertex', config.apiKey, config.baseUrl) ) ); }); return cache; - }, [configs, usageDetailsByAuthIndex, usageDetailsBySource]); + }, [configs, usageByProvider]); return ( <> @@ -104,15 +96,18 @@ export function VertexSection({ /> )} renderContent={(item, index) => { - const stats = getStatsForIdentity( - { authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix }, - keyStats + const stats = getProviderRecentStats( + usageByProvider, + 'vertex', + item.apiKey, + item.baseUrl ); const headerEntries = Object.entries(item.headers || {}); const configDisabled = hasDisableAllModelsRule(item.excludedModels); const excludedModels = item.excludedModels ?? []; const statusData = - statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]); + statusBarCache.get(getProviderConfigKey(item, index)) || + statusBarDataFromRecentRequests([]); return ( diff --git a/src/components/providers/hooks/useProviderRecentRequests.ts b/src/components/providers/hooks/useProviderRecentRequests.ts new file mode 100644 index 0000000..44cf11d --- /dev/null +++ b/src/components/providers/hooks/useProviderRecentRequests.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useInterval } from '@/hooks/useInterval'; +import { apiKeyUsageApi } from '@/services/api'; +import { + normalizeRecentRequestBuckets, + type ApiKeyUsageResponse, + type RecentRequestBucket, +} from '@/utils/recentRequests'; + +const PROVIDER_RECENT_REQUESTS_STALE_TIME_MS = 240_000; + +export type ProviderRecentRequests = Map>; + +export type UseProviderRecentRequestsOptions = { + enabled?: boolean; +}; + +const EMPTY_USAGE_BY_PROVIDER: ProviderRecentRequests = new Map(); + +let cachedUsageByProvider: ProviderRecentRequests = EMPTY_USAGE_BY_PROVIDER; +let cachedAt = 0; +let inFlightRequest: Promise | null = null; + +const normalizeProviderKey = (value: unknown): string => String(value ?? '').trim().toLowerCase(); + +const normalizeApiKeyUsageResponse = (payload: ApiKeyUsageResponse): ProviderRecentRequests => { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return EMPTY_USAGE_BY_PROVIDER; + } + + const usageByProvider: ProviderRecentRequests = new Map(); + + Object.entries(payload).forEach(([provider, entries]) => { + const providerKey = normalizeProviderKey(provider); + if (!providerKey || !entries || typeof entries !== 'object' || Array.isArray(entries)) { + return; + } + + const usageByCompositeKey = new Map(); + Object.entries(entries).forEach(([compositeKey, buckets]) => { + usageByCompositeKey.set(compositeKey, normalizeRecentRequestBuckets(buckets)); + }); + + usageByProvider.set(providerKey, usageByCompositeKey); + }); + + return usageByProvider; +}; + +const fetchProviderRecentRequests = async (): Promise => { + if (!inFlightRequest) { + inFlightRequest = apiKeyUsageApi + .getUsage() + .then((payload) => { + const normalized = normalizeApiKeyUsageResponse(payload); + cachedUsageByProvider = normalized; + cachedAt = Date.now(); + return normalized; + }) + .finally(() => { + inFlightRequest = null; + }); + } + + return inFlightRequest; +}; + +export function useProviderRecentRequests(options: UseProviderRecentRequestsOptions = {}) { + const enabled = options.enabled ?? true; + const [usageByProvider, setUsageByProvider] = useState( + cachedUsageByProvider + ); + const [isLoading, setIsLoading] = useState(false); + + const loadRecentRequests = useCallback( + async (loadOptions: { force?: boolean } = {}) => { + if (!enabled) { + return EMPTY_USAGE_BY_PROVIDER; + } + + const hasFreshCache = + cachedAt > 0 && + Date.now() - cachedAt < PROVIDER_RECENT_REQUESTS_STALE_TIME_MS; + + if (!loadOptions.force && hasFreshCache) { + setUsageByProvider(cachedUsageByProvider); + return cachedUsageByProvider; + } + + setIsLoading(true); + try { + const nextUsage = await fetchProviderRecentRequests(); + setUsageByProvider(nextUsage); + return nextUsage; + } catch { + if (cachedAt > 0) { + setUsageByProvider(cachedUsageByProvider); + } + return cachedUsageByProvider; + } finally { + setIsLoading(false); + } + }, + [enabled] + ); + + const refreshRecentRequests = useCallback( + async () => loadRecentRequests({ force: true }), + [loadRecentRequests] + ); + + useEffect(() => { + setUsageByProvider(enabled ? cachedUsageByProvider : EMPTY_USAGE_BY_PROVIDER); + }, [enabled]); + + useInterval(() => { + void refreshRecentRequests().catch(() => {}); + }, enabled ? PROVIDER_RECENT_REQUESTS_STALE_TIME_MS : null); + + return { + usageByProvider: enabled ? usageByProvider : EMPTY_USAGE_BY_PROVIDER, + isLoading: enabled ? isLoading : false, + loadRecentRequests, + refreshRecentRequests, + }; +} diff --git a/src/components/providers/hooks/useProviderStats.ts b/src/components/providers/hooks/useProviderStats.ts deleted file mode 100644 index 7cf216c..0000000 --- a/src/components/providers/hooks/useProviderStats.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useCallback } from 'react'; -import { useInterval } from '@/hooks/useInterval'; -import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; -import type { KeyStats, UsageDetail } from '@/utils/usage'; - -const EMPTY_KEY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} }; -const EMPTY_USAGE_DETAILS: UsageDetail[] = []; - -export type UseProviderStatsOptions = { - enabled?: boolean; -}; - -export const useProviderStats = (options: UseProviderStatsOptions = {}) => { - const enabled = options.enabled ?? true; - const keyStats = useUsageStatsStore((state) => (enabled ? state.keyStats : EMPTY_KEY_STATS)); - const usageDetails = useUsageStatsStore((state) => - enabled ? state.usageDetails : EMPTY_USAGE_DETAILS - ); - const isLoading = useUsageStatsStore((state) => (enabled ? state.loading : false)); - const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats); - - // 首次进入页面优先复用缓存,避免跨页面重复拉取 /usage。 - const loadKeyStats = useCallback(async () => { - await loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }); - }, [loadUsageStats]); - - // 定时器触发时强制刷新共享 usage。 - const refreshKeyStats = useCallback(async () => { - await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS }); - }, [loadUsageStats]); - - useInterval(() => { - void refreshKeyStats().catch(() => {}); - }, enabled ? 240_000 : null); - - return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading }; -}; diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts index 83d85a6..ac881e0 100644 --- a/src/components/providers/index.ts +++ b/src/components/providers/index.ts @@ -7,6 +7,6 @@ export { VertexSection } from './VertexSection'; export { ProviderList } from './ProviderList'; export { ProviderStatusBar } from './ProviderStatusBar'; export { ProviderNav } from './ProviderNav'; -export * from './hooks/useProviderStats'; +export * from './hooks/useProviderRecentRequests'; export * from './types'; export * from './utils'; diff --git a/src/components/providers/types.ts b/src/components/providers/types.ts index 7c8d8d6..f891673 100644 --- a/src/components/providers/types.ts +++ b/src/components/providers/types.ts @@ -1,6 +1,5 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types'; import type { HeaderEntry } from '@/utils/headers'; -import type { KeyStats, UsageDetail } from '@/utils/usage'; export interface ModelEntry { name: string; @@ -48,14 +47,3 @@ export type VertexFormState = Omit & { modelEntries: ModelEntry[]; excludedText: string; }; - -export interface ProviderSectionProps { - configs: TConfig[]; - keyStats: KeyStats; - usageDetails: UsageDetail[]; - disabled: boolean; - onEdit: (index: number) => void; - onAdd: () => void; - onDelete: (index: number) => void; - onToggle?: (index: number, enabled: boolean) => void; -} diff --git a/src/components/providers/utils.ts b/src/components/providers/utils.ts index fbce115..1238bfe 100644 --- a/src/components/providers/utils.ts +++ b/src/components/providers/utils.ts @@ -6,18 +6,14 @@ import type { OpenAIProviderConfig, } from '@/types'; import { - buildCandidateUsageSourceIds, - normalizeAuthIndex, - type KeyStatBucket, - type KeyStats, - type UsageDetail, -} from '@/utils/usage'; -import { - collectUsageDetailsForAuthIndices, - collectUsageDetailsForCandidates, - type UsageDetailsByAuthIndex, - type UsageDetailsBySource, -} from '@/utils/usageIndex'; + buildRecentRequestCompositeKey, + mergeRecentRequestBucketGroups, + normalizeRecentRequestAuthIndex, + statusBarDataFromRecentRequests, + sumRecentRequests, + type RecentRequestBucket, + type StatusBarData, +} from '@/utils/recentRequests'; import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types'; export const DISABLE_ALL_MODELS_RULE = '*'; @@ -103,161 +99,76 @@ export const buildClaudeMessagesEndpoint = (baseUrl: string): string => { return `${trimmed}/v1/messages`; }; -// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致 -export const getStatsBySource = ( - apiKey: string, - keyStats: KeyStats, - prefix?: string -): KeyStatBucket => { - const bySource = keyStats.bySource ?? {}; - const candidates = buildCandidateUsageSourceIds({ apiKey, prefix }); - if (!candidates.length) { - return { success: 0, failure: 0 }; - } +export type ProviderRecentUsageMap = Map>; - let success = 0; - let failure = 0; - candidates.forEach((candidate) => { - const stats = bySource[candidate]; - if (!stats) return; - success += stats.success; - failure += stats.failure; - }); +const normalizeProviderRecentKey = (value: unknown): string => + String(value ?? '').trim().toLowerCase(); - return { success, failure }; -}; - -type UsageIdentity = { - authIndex?: unknown; - apiKey?: string; - prefix?: string; -}; - -export const getStatsForIdentity = ( - identity: UsageIdentity, - keyStats: KeyStats -): KeyStatBucket => { - const authIndexKey = normalizeAuthIndex(identity.authIndex); - if (authIndexKey) { - const stats = keyStats.byAuthIndex?.[authIndexKey]; - if (stats) { - return { success: stats.success, failure: stats.failure }; - } - } - - return getStatsBySource(identity.apiKey ?? '', keyStats, identity.prefix); -}; - -export const collectUsageDetailsForIdentity = ( - identity: UsageIdentity, - usageDetailsBySource: UsageDetailsBySource, - usageDetailsByAuthIndex: UsageDetailsByAuthIndex -): UsageDetail[] => { - const authIndexKey = normalizeAuthIndex(identity.authIndex); - if (authIndexKey) { - const details = collectUsageDetailsForAuthIndices(usageDetailsByAuthIndex, [authIndexKey]); - if (details.length > 0) { - return details; - } - } - - const candidates = buildCandidateUsageSourceIds({ - apiKey: identity.apiKey, - prefix: identity.prefix, - }); - if (!candidates.length) { +export function getProviderRecentBuckets( + usageByProvider: ProviderRecentUsageMap, + provider: string, + apiKey?: string, + baseUrl?: string +): RecentRequestBucket[] { + if (!String(apiKey ?? '').trim()) { return []; } - return collectUsageDetailsForCandidates(usageDetailsBySource, candidates); -}; + const providerKey = normalizeProviderRecentKey(provider); + const compositeKey = buildRecentRequestCompositeKey(baseUrl, apiKey); + return usageByProvider.get(providerKey)?.get(compositeKey) ?? []; +} -const mergeUsageDetails = (groups: UsageDetail[][]): UsageDetail[] => { - let firstDetails: UsageDetail[] | null = null; - let merged: UsageDetail[] | null = null; +export function getProviderRecentStats( + usageByProvider: ProviderRecentUsageMap, + provider: string, + apiKey?: string, + baseUrl?: string +): { success: number; failure: number } { + return sumRecentRequests(getProviderRecentBuckets(usageByProvider, provider, apiKey, baseUrl)); +} - groups.forEach((details) => { - if (!details.length) return; - if (!firstDetails) { - firstDetails = details; - return; - } - if (!merged) { - merged = [...firstDetails]; - } - merged.push(...details); - }); +export function getProviderRecentStatusData( + usageByProvider: ProviderRecentUsageMap, + provider: string, + apiKey?: string, + baseUrl?: string +): StatusBarData { + return statusBarDataFromRecentRequests( + getProviderRecentBuckets(usageByProvider, provider, apiKey, baseUrl) + ); +} - return merged ?? firstDetails ?? []; -}; - -// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致 -export const getOpenAIProviderStats = ( +export function collectOpenAIProviderRecentBuckets( provider: OpenAIProviderConfig, - keyStats: KeyStats -): KeyStatBucket => { - let success = 0; - let failure = 0; - + usageByProvider: ProviderRecentUsageMap +): RecentRequestBucket[] { if (!provider.apiKeyEntries?.length) { - const stats = getStatsForIdentity( - { authIndex: provider.authIndex, prefix: provider.prefix }, - keyStats - ); - return { success: stats.success, failure: stats.failure }; + return []; } - if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) { - const prefixStats = getStatsBySource('', keyStats, provider.prefix); - success += prefixStats.success; - failure += prefixStats.failure; - } + const groups = provider.apiKeyEntries.map((entry) => + getProviderRecentBuckets(usageByProvider, provider.name, entry.apiKey, provider.baseUrl) + ); - provider.apiKeyEntries.forEach((entry) => { - const stats = getStatsForIdentity({ authIndex: entry.authIndex, apiKey: entry.apiKey }, keyStats); - success += stats.success; - failure += stats.failure; - }); + return mergeRecentRequestBucketGroups(groups); +} - return { success, failure }; -}; - -export const collectOpenAIProviderUsageDetails = ( +export function getOpenAIProviderRecentStats( provider: OpenAIProviderConfig, - usageDetailsBySource: UsageDetailsBySource, - usageDetailsByAuthIndex: UsageDetailsByAuthIndex -): UsageDetail[] => { - if (!provider.apiKeyEntries?.length) { - return collectUsageDetailsForIdentity( - { authIndex: provider.authIndex, prefix: provider.prefix }, - usageDetailsBySource, - usageDetailsByAuthIndex - ); - } + usageByProvider: ProviderRecentUsageMap +): { success: number; failure: number } { + return sumRecentRequests(collectOpenAIProviderRecentBuckets(provider, usageByProvider)); +} - const groups: UsageDetail[][] = []; - if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) { - groups.push( - collectUsageDetailsForIdentity( - { prefix: provider.prefix }, - usageDetailsBySource, - usageDetailsByAuthIndex - ) - ); - } - - provider.apiKeyEntries.forEach((entry) => { - groups.push( - collectUsageDetailsForIdentity( - { authIndex: entry.authIndex, apiKey: entry.apiKey }, - usageDetailsBySource, - usageDetailsByAuthIndex - ) - ); - }); - - return mergeUsageDetails(groups); -}; +export function getOpenAIProviderRecentStatusData( + provider: OpenAIProviderConfig, + usageByProvider: ProviderRecentUsageMap +): StatusBarData { + return statusBarDataFromRecentRequests( + collectOpenAIProviderRecentBuckets(provider, usageByProvider) + ); +} export const getProviderConfigKey = ( config: { @@ -268,7 +179,7 @@ export const getProviderConfigKey = ( }, index: number ): string => { - const authIndexKey = normalizeAuthIndex(config.authIndex); + const authIndexKey = normalizeRecentRequestAuthIndex(config.authIndex); if (authIndexKey) { return authIndexKey; } @@ -276,7 +187,7 @@ export const getProviderConfigKey = ( }; export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: number): string => { - const authIndexKey = normalizeAuthIndex(provider.authIndex); + const authIndexKey = normalizeRecentRequestAuthIndex(provider.authIndex); if (authIndexKey) { return authIndexKey; } @@ -284,7 +195,7 @@ export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: numb }; export const getOpenAIEntryKey = (entry: ApiKeyEntry, index: number): string => { - const authIndexKey = normalizeAuthIndex(entry.authIndex); + const authIndexKey = normalizeRecentRequestAuthIndex(entry.authIndex); if (authIndexKey) { return authIndexKey; } diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx index ed67182..3934efb 100644 --- a/src/features/authFiles/components/AuthFileCard.tsx +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -13,7 +13,12 @@ import { import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar'; import type { AuthFileItem } from '@/types'; import { resolveAuthProvider } from '@/utils/quota'; -import { calculateStatusBarData, normalizeAuthIndex, type KeyStats } from '@/utils/usage'; +import { + normalizeRecentRequestAuthIndex, + normalizeRecentRequestBuckets, + statusBarDataFromRecentRequests, + sumRecentRequests, +} from '@/utils/recentRequests'; import { formatFileSize } from '@/utils/format'; import { QUOTA_PROVIDER_TYPES, @@ -24,7 +29,6 @@ import { getTypeLabel, isRuntimeOnlyAuthFile, parsePriorityValue, - resolveAuthFileStats, type QuotaProviderType, type ResolvedTheme, } from '@/features/authFiles/constants'; @@ -43,7 +47,6 @@ export type AuthFileCardProps = { deleting: string | null; statusUpdating: Record; quotaFilterType: QuotaProviderType | null; - keyStats: KeyStats; statusBarCache: Map; onShowModels: (file: AuthFileItem) => void; onDownload: (name: string) => void; @@ -70,7 +73,6 @@ export function AuthFileCard(props: AuthFileCardProps) { deleting, statusUpdating, quotaFilterType, - keyStats, statusBarCache, onShowModels, onDownload, @@ -80,7 +82,8 @@ export function AuthFileCard(props: AuthFileCardProps) { onToggleSelect, } = props; - const fileStats = resolveAuthFileStats(file, keyStats); + const recentBuckets = normalizeRecentRequestBuckets(file.recent_requests ?? file.recentRequests); + const fileStats = sumRecentRequests(recentBuckets); const isRuntimeOnly = isRuntimeOnlyAuthFile(file); const isAistudio = (file.type || '').toLowerCase() === 'aistudio'; const showModelsButton = !isRuntimeOnly || isAistudio; @@ -107,9 +110,10 @@ export function AuthFileCard(props: AuthFileCardProps) { : ''; const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const authIndexKey = normalizeAuthIndex(rawAuthIndex); + const authIndexKey = normalizeRecentRequestAuthIndex(rawAuthIndex); const statusData = - (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]); + (authIndexKey && statusBarCache.get(authIndexKey)) || + statusBarDataFromRecentRequests(recentBuckets); const rawStatusMessage = getAuthFileStatusMessage(file); const hasStatusWarning = Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase()); diff --git a/src/features/authFiles/constants.ts b/src/features/authFiles/constants.ts index c8c1a4a..23d85c8 100644 --- a/src/features/authFiles/constants.ts +++ b/src/features/authFiles/constants.ts @@ -10,12 +10,6 @@ import iconQwen from '@/assets/icons/qwen.svg'; import iconVertex from '@/assets/icons/vertex.svg'; import type { AuthFileItem } from '@/types'; import { parseTimestamp } from '@/utils/timestamp'; -import { - normalizeAuthIndex, - normalizeUsageSourceId, - type KeyStatBucket, - type KeyStats, -} from '@/utils/usage'; export type ThemeColors = { bg: string; text: string; border?: string }; export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors }; @@ -233,46 +227,6 @@ export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean { return false; } -export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket { - const defaultStats: KeyStatBucket = { success: 0, failure: 0 }; - const rawFileName = file?.name || ''; - - // 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index) - const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const authIndexKey = normalizeAuthIndex(rawAuthIndex); - - // 尝试根据 authIndex 匹配 - if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) { - return stats.byAuthIndex[authIndexKey]; - } - - // 尝试根据 source (文件名) 匹配 - const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : ''; - if (fileNameId && stats.bySource?.[fileNameId]) { - const fromName = stats.bySource[fileNameId]; - if (fromName.success > 0 || fromName.failure > 0) { - return fromName; - } - } - - // 尝试去掉扩展名后匹配 - if (rawFileName) { - const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, ''); - if (nameWithoutExt && nameWithoutExt !== rawFileName) { - const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt); - const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined; - if ( - fromNameWithoutExt && - (fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0) - ) { - return fromNameWithoutExt; - } - } - } - - return defaultStats; -} - export const formatModified = (item: AuthFileItem): string => { const raw = item['modtime'] ?? item.modified; if (!raw) return '-'; diff --git a/src/features/authFiles/hooks/useAuthFilesData.ts b/src/features/authFiles/hooks/useAuthFilesData.ts index 879f09c..1baca36 100644 --- a/src/features/authFiles/hooks/useAuthFilesData.ts +++ b/src/features/authFiles/hooks/useAuthFilesData.ts @@ -50,12 +50,7 @@ export type UseAuthFilesDataResult = { batchDelete: (names: string[]) => void; }; -export type UseAuthFilesDataOptions = { - refreshKeyStats: () => Promise; -}; - -export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult { - const { refreshKeyStats } = options; +export function useAuthFilesData(): UseAuthFilesDataResult { const { t } = useTranslation(); const { showNotification, showConfirmation } = useNotificationStore(); @@ -230,7 +225,6 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles result.failed.length ? 'warning' : 'success' ); await loadFiles(); - await refreshKeyStats(); } if (result.failed.length > 0) { @@ -247,7 +241,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles event.target.value = ''; } }, - [loadFiles, refreshKeyStats, showNotification, t] + [loadFiles, showNotification, t] ); const handleDelete = useCallback( diff --git a/src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts b/src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts index 2878aea..de69071 100644 --- a/src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts +++ b/src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts @@ -58,7 +58,6 @@ export type PrefixProxyEditorState = { export type UseAuthFilesPrefixProxyEditorOptions = { disableControls: boolean; loadFiles: () => Promise; - loadKeyStats: () => Promise; }; export type UseAuthFilesPrefixProxyEditorResult = { @@ -172,7 +171,7 @@ const buildPrefixProxyUpdatedText = ( export function useAuthFilesPrefixProxyEditor( options: UseAuthFilesPrefixProxyEditorOptions ): UseAuthFilesPrefixProxyEditorResult { - const { disableControls, loadFiles, loadKeyStats } = options; + const { disableControls, loadFiles } = options; const { t } = useTranslation(); const showNotification = useNotificationStore((state) => state.showNotification); @@ -383,7 +382,6 @@ export function useAuthFilesPrefixProxyEditor( await authFilesApi.saveText(name, payload); showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success'); await loadFiles(); - await loadKeyStats(); setPrefixProxyEditor(null); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; diff --git a/src/features/authFiles/hooks/useAuthFilesStats.ts b/src/features/authFiles/hooks/useAuthFilesStats.ts deleted file mode 100644 index b1622ab..0000000 --- a/src/features/authFiles/hooks/useAuthFilesStats.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useCallback } from 'react'; -import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; -import type { KeyStats, UsageDetail } from '@/utils/usage'; - -export type UseAuthFilesStatsResult = { - keyStats: KeyStats; - usageDetails: UsageDetail[]; - loadKeyStats: () => Promise; - refreshKeyStats: () => Promise; -}; - -export function useAuthFilesStats(): UseAuthFilesStatsResult { - const keyStats = useUsageStatsStore((state) => state.keyStats); - const usageDetails = useUsageStatsStore((state) => state.usageDetails); - const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats); - - const loadKeyStats = useCallback(async () => { - await loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }); - }, [loadUsageStats]); - - const refreshKeyStats = useCallback(async () => { - await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS }); - }, [loadUsageStats]); - - return { keyStats, usageDetails, loadKeyStats, refreshKeyStats }; -} diff --git a/src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts b/src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts index 9c2135d..26e6bb4 100644 --- a/src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts +++ b/src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts @@ -1,41 +1,30 @@ import { useMemo } from 'react'; import type { AuthFileItem } from '@/types'; -import { calculateStatusBarData, normalizeAuthIndex, type UsageDetail } from '@/utils/usage'; +import { + normalizeRecentRequestAuthIndex, + normalizeRecentRequestBuckets, + statusBarDataFromRecentRequests, +} from '@/utils/recentRequests'; -export type AuthFileStatusBarData = ReturnType; +export type AuthFileStatusBarData = ReturnType; -export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails: UsageDetail[]) { +export function useAuthFilesStatusBarCache(files: AuthFileItem[]) { return useMemo(() => { const cache = new Map(); - const usageDetailsByAuthIndex = new Map(); - usageDetails.forEach((detail) => { - const authIndexKey = normalizeAuthIndex(detail.auth_index); - if (!authIndexKey) return; - - const list = usageDetailsByAuthIndex.get(authIndexKey); - if (list) { - list.push(detail); - } else { - usageDetailsByAuthIndex.set(authIndexKey, [detail]); - } - }); - - const uniqueAuthIndexKeys = new Set(); files.forEach((file) => { const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const authIndexKey = normalizeAuthIndex(rawAuthIndex); + const authIndexKey = normalizeRecentRequestAuthIndex(rawAuthIndex); if (!authIndexKey) return; - uniqueAuthIndexKeys.add(authIndexKey); - }); - uniqueAuthIndexKeys.forEach((authIndexKey) => { cache.set( authIndexKey, - calculateStatusBarData(usageDetailsByAuthIndex.get(authIndexKey) ?? []) + statusBarDataFromRecentRequests( + normalizeRecentRequestBuckets(file.recent_requests ?? file.recentRequests) + ) ); }); return cache; - }, [files, usageDetails]); + }, [files]); } diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index 5986047..c357a88 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { @@ -9,7 +9,7 @@ import { OpenAISection, VertexSection, ProviderNav, - useProviderStats, + useProviderRecentRequests, } from '@/components/providers'; import { withDisableAllModelsRule, @@ -20,7 +20,6 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { ampcodeApi, providersApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; -import { indexUsageDetailsByAuthIndex, indexUsageDetailsBySource } from '@/utils/usageIndex'; import styles from './AiProvidersPage.module.scss'; export function AiProvidersPage() { @@ -64,17 +63,9 @@ export function AiProvidersPage() { const pageTransitionLayer = usePageTransitionLayer(); const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true; - const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats({ + const { usageByProvider, loadRecentRequests, refreshRecentRequests } = useProviderRecentRequests({ enabled: isCurrentLayer, }); - const usageDetailsBySource = useMemo( - () => indexUsageDetailsBySource(usageDetails), - [usageDetails] - ); - const usageDetailsByAuthIndex = useMemo( - () => indexUsageDetailsByAuthIndex(usageDetails), - [usageDetails] - ); const getErrorMessage = (err: unknown) => { if (err instanceof Error) return err.message; @@ -139,8 +130,8 @@ export function AiProvidersPage() { useEffect(() => { if (!isCurrentLayer) return; - void loadKeyStats().catch(() => {}); - }, [isCurrentLayer, loadKeyStats]); + void loadRecentRequests().catch(() => {}); + }, [isCurrentLayer, loadRecentRequests]); useEffect(() => { if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys); @@ -156,7 +147,11 @@ export function AiProvidersPage() { config?.openaiCompatibility, ]); - useHeaderRefresh(refreshKeyStats, isCurrentLayer); + const handleRecentRequestsRefresh = useCallback(async () => { + await refreshRecentRequests(); + }, [refreshRecentRequests]); + + useHeaderRefresh(handleRecentRequestsRefresh, isCurrentLayer); const openEditor = useCallback( (path: string) => { @@ -419,9 +414,7 @@ export function AiProvidersPage() {
{ - await Promise.all([loadFiles(), refreshKeyStats(), loadExcluded(), loadModelAlias()]); - }, [loadFiles, refreshKeyStats, loadExcluded, loadModelAlias]); + await Promise.all([loadFiles(), loadExcluded(), loadModelAlias()]); + }, [loadFiles, loadExcluded, loadModelAlias]); useHeaderRefresh(handleHeaderRefresh); useEffect(() => { if (!isCurrentLayer) return; loadFiles(); - void loadKeyStats().catch(() => {}); loadExcluded(); loadModelAlias(); - }, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]); + }, [isCurrentLayer, loadFiles, loadExcluded, loadModelAlias]); useInterval( () => { - void refreshKeyStats().catch(() => {}); + void loadFiles().catch(() => {}); }, isCurrentLayer ? 240_000 : null ); @@ -830,7 +826,6 @@ export function AuthFilesPage() { deleting={deleting} statusUpdating={statusUpdating} quotaFilterType={quotaFilterType} - keyStats={keyStats} statusBarCache={statusBarCache} onShowModels={showModels} onDownload={handleDownload} diff --git a/src/services/api/apiKeyUsage.ts b/src/services/api/apiKeyUsage.ts new file mode 100644 index 0000000..a42a96e --- /dev/null +++ b/src/services/api/apiKeyUsage.ts @@ -0,0 +1,11 @@ +import { apiClient } from './client'; +import type { ApiKeyUsageResponse } from '@/utils/recentRequests'; + +const API_KEY_USAGE_TIMEOUT_MS = 15 * 1000; + +export const apiKeyUsageApi = { + getUsage: () => + apiClient.get('/api-key-usage', { + timeout: API_KEY_USAGE_TIMEOUT_MS, + }), +}; diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 990db81..23c585b 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -1,5 +1,6 @@ export * from './client'; export * from './apiCall'; +export * from './apiKeyUsage'; export * from './config'; export * from './configFile'; export * from './apiKeys'; diff --git a/src/types/authFile.ts b/src/types/authFile.ts index 5061efa..ee31f6d 100644 --- a/src/types/authFile.ts +++ b/src/types/authFile.ts @@ -3,6 +3,8 @@ * 基于原项目 src/modules/auth-files.js */ +import type { RecentRequestBucket } from '@/utils/recentRequests'; + export type AuthFileType = | 'qwen' | 'kimi' @@ -30,6 +32,8 @@ export interface AuthFileItem { statusMessage?: string; lastRefresh?: string | number; modified?: number; + recent_requests?: RecentRequestBucket[]; + recentRequests?: RecentRequestBucket[]; [key: string]: unknown; } diff --git a/src/utils/recentRequests.ts b/src/utils/recentRequests.ts new file mode 100644 index 0000000..1bd5a25 --- /dev/null +++ b/src/utils/recentRequests.ts @@ -0,0 +1,157 @@ +import type { StatusBarData, StatusBlockDetail, StatusBlockState } from '@/utils/usage'; + +export type { StatusBarData, StatusBlockDetail, StatusBlockState }; + +export interface RecentRequestBucket { + time?: string; + success: number; + failed: number; +} + +export type ApiKeyUsageResponse = Record>; + +const RECENT_REQUEST_BLOCK_COUNT = 20; +const RECENT_REQUEST_BLOCK_DURATION_MS = 10 * 60 * 1000; + +const toFiniteNumber = (value: unknown): number => { + const numberValue = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numberValue) ? numberValue : 0; +}; + +export function buildRecentRequestCompositeKey(baseUrl: unknown, apiKey: unknown): string { + const normalizedBaseUrl = String(baseUrl ?? '').trim(); + const normalizedApiKey = String(apiKey ?? '').trim(); + return `${normalizedBaseUrl}|${normalizedApiKey}`; +} + +export function normalizeRecentRequestAuthIndex(value: unknown): string | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + return null; +} + +export function normalizeRecentRequestBuckets(input: unknown): RecentRequestBucket[] { + if (!Array.isArray(input)) { + return []; + } + + return input.slice(-RECENT_REQUEST_BLOCK_COUNT).map((item) => { + const record = item && typeof item === 'object' ? (item as Record) : {}; + const time = typeof record.time === 'string' ? record.time : undefined; + + return { + ...(time ? { time } : {}), + success: toFiniteNumber(record.success), + failed: toFiniteNumber(record.failed), + }; + }); +} + +export function mergeRecentRequestBucketGroups( + groups: RecentRequestBucket[][] +): RecentRequestBucket[] { + const normalizedGroups = groups + .map((group) => normalizeRecentRequestBuckets(group)) + .filter((group) => group.length > 0); + + if (normalizedGroups.length === 0) { + return []; + } + + const mergedLength = Math.min( + RECENT_REQUEST_BLOCK_COUNT, + Math.max(...normalizedGroups.map((group) => group.length)) + ); + const merged: RecentRequestBucket[] = Array.from({ length: mergedLength }, () => ({ + success: 0, + failed: 0, + })); + + normalizedGroups.forEach((group) => { + const tail = group.slice(-mergedLength); + const offset = mergedLength - tail.length; + + tail.forEach((bucket, index) => { + const target = merged[offset + index]; + target.success += bucket.success; + target.failed += bucket.failed; + if (!target.time && bucket.time) { + target.time = bucket.time; + } + }); + }); + + return merged; +} + +export function sumRecentRequests( + buckets: RecentRequestBucket[] +): { success: number; failure: number } { + return normalizeRecentRequestBuckets(buckets).reduce( + (total, bucket) => ({ + success: total.success + bucket.success, + failure: total.failure + bucket.failed, + }), + { success: 0, failure: 0 } + ); +} + +export function statusBarDataFromRecentRequests(buckets: RecentRequestBucket[]): StatusBarData { + const normalizedBuckets = normalizeRecentRequestBuckets(buckets); + const emptyBucketCount = Math.max(0, RECENT_REQUEST_BLOCK_COUNT - normalizedBuckets.length); + const blockStats = [ + ...Array.from({ length: emptyBucketCount }, () => ({ success: 0, failed: 0 })), + ...normalizedBuckets.slice(-RECENT_REQUEST_BLOCK_COUNT), + ]; + + const now = Date.now(); + const windowStart = now - RECENT_REQUEST_BLOCK_COUNT * RECENT_REQUEST_BLOCK_DURATION_MS; + + const blocks: StatusBlockState[] = []; + const blockDetails: StatusBarData['blockDetails'] = []; + let totalSuccess = 0; + let totalFailure = 0; + + blockStats.forEach((bucket, index) => { + const success = bucket.success; + const failure = bucket.failed; + const total = success + failure; + + totalSuccess += success; + totalFailure += failure; + + if (total === 0) { + blocks.push('idle'); + } else if (failure === 0) { + blocks.push('success'); + } else if (success === 0) { + blocks.push('failure'); + } else { + blocks.push('mixed'); + } + + const blockStartTime = windowStart + index * RECENT_REQUEST_BLOCK_DURATION_MS; + blockDetails.push({ + success, + failure, + rate: total > 0 ? success / total : -1, + startTime: blockStartTime, + endTime: blockStartTime + RECENT_REQUEST_BLOCK_DURATION_MS, + }); + }); + + const total = totalSuccess + totalFailure; + + return { + blocks, + blockDetails, + successRate: total > 0 ? (totalSuccess / total) * 100 : 100, + totalSuccess, + totalFailure, + }; +}