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.
This commit is contained in:
Supra4E8C
2026-05-02 02:53:15 +08:00
parent 8ed837c3d7
commit b25f722fef
23 changed files with 505 additions and 473 deletions
@@ -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<string, ReturnType<typeof calculateStatusBarData>>();
const cache = new Map<string, ReturnType<typeof statusBarDataFromRecentRequests>>();
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 (
<Fragment>
@@ -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<string, ReturnType<typeof calculateStatusBarData>>();
const cache = new Map<string, ReturnType<typeof statusBarDataFromRecentRequests>>();
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 (
<Fragment>
@@ -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<string, ReturnType<typeof calculateStatusBarData>>();
const cache = new Map<string, ReturnType<typeof statusBarDataFromRecentRequests>>();
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 (
<Fragment>
@@ -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<string, ReturnType<typeof calculateStatusBarData>>();
const cache = new Map<string, ReturnType<typeof statusBarDataFromRecentRequests>>();
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({
</div>
<div className={styles.apiKeyEntryList}>
{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 (
<div
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { StatusBarData, StatusBlockDetail } from '@/utils/usage';
import type { StatusBarData, StatusBlockDetail } from '@/utils/recentRequests';
import defaultStyles from '@/pages/AiProvidersPage.module.scss';
/**
@@ -6,23 +6,21 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconVertex from '@/assets/icons/vertex.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 VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
usageByProvider: ProviderRecentUsageMap;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -34,9 +32,7 @@ interface VertexSectionProps {
export function VertexSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
usageByProvider,
loading,
disableControls,
isSwitching,
@@ -50,25 +46,21 @@ export function VertexSection({
const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const cache = new Map<string, ReturnType<typeof statusBarDataFromRecentRequests>>();
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 (
<Fragment>
@@ -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<string, Map<string, RecentRequestBucket[]>>;
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<ProviderRecentRequests> | 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<string, RecentRequestBucket[]>();
Object.entries(entries).forEach(([compositeKey, buckets]) => {
usageByCompositeKey.set(compositeKey, normalizeRecentRequestBuckets(buckets));
});
usageByProvider.set(providerKey, usageByCompositeKey);
});
return usageByProvider;
};
const fetchProviderRecentRequests = async (): Promise<ProviderRecentRequests> => {
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<ProviderRecentRequests>(
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,
};
}
@@ -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 };
};
+1 -1
View File
@@ -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';
-12
View File
@@ -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<ProviderKeyConfig, 'headers'> & {
modelEntries: ModelEntry[];
excludedText: string;
};
export interface ProviderSectionProps<TConfig> {
configs: TConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
disabled: boolean;
onEdit: (index: number) => void;
onAdd: () => void;
onDelete: (index: number) => void;
onToggle?: (index: number, enabled: boolean) => void;
}
+65 -154
View File
@@ -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<string, Map<string, RecentRequestBucket[]>>;
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;
}
@@ -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<string, boolean>;
quotaFilterType: QuotaProviderType | null;
keyStats: KeyStats;
statusBarCache: Map<string, AuthFileStatusBarData>;
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());
-46
View File
@@ -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 '-';
@@ -50,12 +50,7 @@ export type UseAuthFilesDataResult = {
batchDelete: (names: string[]) => void;
};
export type UseAuthFilesDataOptions = {
refreshKeyStats: () => Promise<void>;
};
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(
@@ -58,7 +58,6 @@ export type PrefixProxyEditorState = {
export type UseAuthFilesPrefixProxyEditorOptions = {
disableControls: boolean;
loadFiles: () => Promise<void>;
loadKeyStats: () => Promise<void>;
};
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 : '';
@@ -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<void>;
refreshKeyStats: () => Promise<void>;
};
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 };
}
@@ -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<typeof calculateStatusBarData>;
export type AuthFileStatusBarData = ReturnType<typeof statusBarDataFromRecentRequests>;
export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails: UsageDetail[]) {
export function useAuthFilesStatusBarCache(files: AuthFileItem[]) {
return useMemo(() => {
const cache = new Map<string, AuthFileStatusBarData>();
const usageDetailsByAuthIndex = new Map<string, UsageDetail[]>();
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<string>();
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]);
}
+15 -30
View File
@@ -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() {
<div id="provider-gemini">
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
usageByProvider={usageByProvider}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -435,9 +428,7 @@ export function AiProvidersPage() {
<div id="provider-codex">
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
usageByProvider={usageByProvider}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -451,9 +442,7 @@ export function AiProvidersPage() {
<div id="provider-claude">
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
usageByProvider={usageByProvider}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -467,9 +456,7 @@ export function AiProvidersPage() {
<div id="provider-vertex">
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
usageByProvider={usageByProvider}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -493,9 +480,7 @@ export function AiProvidersPage() {
<div id="provider-openai">
<OpenAISection
configs={openaiProviders}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
usageByProvider={usageByProvider}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
+6 -11
View File
@@ -48,7 +48,6 @@ import { useAuthFilesData } from '@/features/authFiles/hooks/useAuthFilesData';
import { useAuthFilesModels } from '@/features/authFiles/hooks/useAuthFilesModels';
import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth';
import { useAuthFilesPrefixProxyEditor } from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import { useAuthFilesStats } from '@/features/authFiles/hooks/useAuthFilesStats';
import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import {
isAuthFilesSortMode,
@@ -106,7 +105,6 @@ export function AuthFilesPage() {
const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useAuthFilesStats();
const {
files,
selectedFiles,
@@ -133,9 +131,9 @@ export function AuthFilesPage() {
batchDownload,
batchSetStatus,
batchDelete,
} = useAuthFilesData({ refreshKeyStats });
} = useAuthFilesData();
const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails);
const statusBarCache = useAuthFilesStatusBarCache(files);
const {
excluded,
@@ -176,7 +174,6 @@ export function AuthFilesPage() {
} = useAuthFilesPrefixProxyEditor({
disableControls: connectionStatus !== 'connected',
loadFiles,
loadKeyStats: refreshKeyStats,
});
const disableControls = connectionStatus !== 'connected';
@@ -330,22 +327,21 @@ export function AuthFilesPage() {
);
const handleHeaderRefresh = useCallback(async () => {
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}
+11
View File
@@ -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<ApiKeyUsageResponse>('/api-key-usage', {
timeout: API_KEY_USAGE_TIMEOUT_MS,
}),
};
+1
View File
@@ -1,5 +1,6 @@
export * from './client';
export * from './apiCall';
export * from './apiKeyUsage';
export * from './config';
export * from './configFile';
export * from './apiKeys';
+4
View File
@@ -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;
}
+157
View File
@@ -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<string, Record<string, RecentRequestBucket[]>>;
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<string, unknown>) : {};
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,
};
}