feat: enhance usage tracking with auth index support across providers

This commit is contained in:
Supra4E8C
2026-04-18 18:14:22 +08:00
parent cf41ca3edb
commit a1401d40c0
12 changed files with 410 additions and 139 deletions
+1
View File
@@ -23,6 +23,7 @@ skills
# Editor directories and files
settings.local.json
.codex
.vscode/*
!.vscode/extensions.json
.idea
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconClaude from '@/assets/icons/claude.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import {
collectUsageDetailsForIdentity,
getProviderConfigKey,
getStatsForIdentity,
hasDisableAllModelsRule,
} from '../utils';
interface ClaudeSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function ClaudeSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function ClaudeSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
configs.forEach((config, index) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const configKey = getProviderConfigKey(config, index);
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
configKey,
calculateStatusBarData(
collectUsageDetailsForIdentity(
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -86,7 +88,7 @@ export function ClaudeSection({
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.claude_empty_title')}
emptyDescription={t('ai_providers.claude_empty_desc')}
onEdit={onEdit}
@@ -101,12 +103,16 @@ export function ClaudeSection({
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
renderContent={(item, index) => {
const stats = getStatsForIdentity(
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
keyStats
);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconCodex from '@/assets/icons/codex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import {
collectUsageDetailsForIdentity,
getProviderConfigKey,
getStatsForIdentity,
hasDisableAllModelsRule,
} from '../utils';
interface CodexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function CodexSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function CodexSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
configs.forEach((config, index) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const configKey = getProviderConfigKey(config, index);
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
configKey,
calculateStatusBarData(
collectUsageDetailsForIdentity(
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -86,7 +88,7 @@ export function CodexSection({
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.codex_empty_title')}
emptyDescription={t('ai_providers.codex_empty_desc')}
onEdit={onEdit}
@@ -101,12 +103,16 @@ export function CodexSection({
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
renderContent={(item, index) => {
const stats = getStatsForIdentity(
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
keyStats
);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconGemini from '@/assets/icons/gemini.svg';
import type { GeminiKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import {
collectUsageDetailsForIdentity,
getProviderConfigKey,
getStatsForIdentity,
hasDisableAllModelsRule,
} from '../utils';
interface GeminiSectionProps {
configs: GeminiKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function GeminiSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function GeminiSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
configs.forEach((config, index) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const configKey = getProviderConfigKey(config, index);
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
configKey,
calculateStatusBarData(
collectUsageDetailsForIdentity(
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -86,7 +88,7 @@ export function GeminiSection({
<ProviderList<GeminiKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.gemini_empty_title')}
emptyDescription={t('ai_providers.gemini_empty_desc')}
onEdit={onEdit}
@@ -102,11 +104,15 @@ export function GeminiSection({
/>
)}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const stats = getStatsForIdentity(
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
keyStats
);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
@@ -7,21 +7,24 @@ 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 {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
import {
collectOpenAIProviderUsageDetails,
getOpenAIEntryKey,
getOpenAIProviderKey,
getOpenAIProviderStats,
getStatsForIdentity,
} from '../utils';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -35,6 +38,7 @@ export function OpenAISection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -49,21 +53,22 @@ export function OpenAISection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => {
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
});
const filteredDetails = sourceIds.size
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
: [];
cache.set(provider.name, calculateStatusBarData(filteredDetails));
configs.forEach((provider, index) => {
const providerKey = getOpenAIProviderKey(provider, index);
cache.set(
providerKey,
calculateStatusBarData(
collectOpenAIProviderUsageDetails(
provider,
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -87,17 +92,18 @@ export function OpenAISection({
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(_, index) => `openai-provider-${index}`}
keyField={(item, index) => getOpenAIProviderKey(item, index)}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
renderContent={(item, index) => {
const stats = getOpenAIProviderStats(item, keyStats);
const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getOpenAIProviderKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
@@ -134,9 +140,15 @@ export function OpenAISection({
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats);
const entryStats = getStatsForIdentity(
{ authIndex: entry.authIndex, apiKey: entry.apiKey },
keyStats
);
return (
<div key={entryIndex} className={styles.apiKeyEntryCard}>
<div
key={getOpenAIEntryKey(entry, entryIndex)}
className={styles.apiKeyEntryCard}
>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && (
@@ -6,24 +6,23 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconVertex from '@/assets/icons/vertex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import {
collectUsageDetailsForCandidates,
type UsageDetailsBySource,
} from '@/utils/usageIndex';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { type UsageDetailsByAuthIndex, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import {
collectUsageDetailsForIdentity,
getProviderConfigKey,
getStatsForIdentity,
hasDisableAllModelsRule,
} from '../utils';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
usageDetailsByAuthIndex: UsageDetailsByAuthIndex;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
@@ -37,6 +36,7 @@ export function VertexSection({
configs,
keyStats,
usageDetailsBySource,
usageDetailsByAuthIndex,
loading,
disableControls,
isSwitching,
@@ -52,21 +52,23 @@ export function VertexSection({
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
configs.forEach((config, index) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const configKey = getProviderConfigKey(config, index);
cache.set(
config.apiKey,
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
configKey,
calculateStatusBarData(
collectUsageDetailsForIdentity(
{ authIndex: config.authIndex, apiKey: config.apiKey, prefix: config.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
)
)
);
});
return cache;
}, [configs, usageDetailsBySource]);
}, [configs, usageDetailsByAuthIndex, usageDetailsBySource]);
return (
<>
@@ -86,7 +88,7 @@ export function VertexSection({
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
keyField={(item, index) => getProviderConfigKey(item, index)}
emptyTitle={t('ai_providers.vertex_empty_title')}
emptyDescription={t('ai_providers.vertex_empty_desc')}
onEdit={onEdit}
@@ -102,11 +104,15 @@ export function VertexSection({
/>
)}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const stats = getStatsForIdentity(
{ authIndex: item.authIndex, apiKey: item.apiKey, prefix: item.prefix },
keyStats
);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
const statusData =
statusBarCache.get(getProviderConfigKey(item, index)) || calculateStatusBarData([]);
return (
<Fragment>
+172 -16
View File
@@ -1,5 +1,23 @@
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
import type {
AmpcodeConfig,
AmpcodeModelMapping,
AmpcodeUpstreamApiKeyMapping,
ApiKeyEntry,
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';
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
export const DISABLE_ALL_MODELS_RULE = '*';
@@ -109,25 +127,94 @@ export const getStatsBySource = (
return { success, failure };
};
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
apiKeyEntries: ApiKeyEntry[] | undefined,
keyStats: KeyStats,
providerPrefix?: string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
type UsageIdentity = {
authIndex?: unknown;
apiKey?: string;
prefix?: string;
};
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
(apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
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) {
return [];
}
return collectUsageDetailsForCandidates(usageDetailsBySource, candidates);
};
const mergeUsageDetails = (groups: UsageDetail[][]): UsageDetail[] => {
let firstDetails: UsageDetail[] | null = null;
let merged: UsageDetail[] | null = null;
groups.forEach((details) => {
if (!details.length) return;
if (!firstDetails) {
firstDetails = details;
return;
}
if (!merged) {
merged = [...firstDetails];
}
merged.push(...details);
});
return merged ?? firstDetails ?? [];
};
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
provider: OpenAIProviderConfig,
keyStats: KeyStats
): KeyStatBucket => {
let success = 0;
let failure = 0;
sourceIds.forEach((id) => {
const stats = bySource[id];
if (!stats) return;
if (!provider.apiKeyEntries?.length) {
const stats = getStatsForIdentity(
{ authIndex: provider.authIndex, prefix: provider.prefix },
keyStats
);
return { success: stats.success, failure: stats.failure };
}
if (!normalizeAuthIndex(provider.authIndex) && provider.prefix) {
const prefixStats = getStatsBySource('', keyStats, provider.prefix);
success += prefixStats.success;
failure += prefixStats.failure;
}
provider.apiKeyEntries.forEach((entry) => {
const stats = getStatsForIdentity({ authIndex: entry.authIndex, apiKey: entry.apiKey }, keyStats);
success += stats.success;
failure += stats.failure;
});
@@ -135,6 +222,75 @@ export const getOpenAIProviderStats = (
return { success, failure };
};
export const collectOpenAIProviderUsageDetails = (
provider: OpenAIProviderConfig,
usageDetailsBySource: UsageDetailsBySource,
usageDetailsByAuthIndex: UsageDetailsByAuthIndex
): UsageDetail[] => {
if (!provider.apiKeyEntries?.length) {
return collectUsageDetailsForIdentity(
{ authIndex: provider.authIndex, prefix: provider.prefix },
usageDetailsBySource,
usageDetailsByAuthIndex
);
}
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 const getProviderConfigKey = (
config: {
authIndex?: unknown;
apiKey?: string;
baseUrl?: string;
proxyUrl?: string;
},
index: number
): string => {
const authIndexKey = normalizeAuthIndex(config.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${config.apiKey ?? ''}::${config.baseUrl ?? ''}::${config.proxyUrl ?? ''}::${index}`;
};
export const getOpenAIProviderKey = (provider: OpenAIProviderConfig, index: number): string => {
const authIndexKey = normalizeAuthIndex(provider.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${provider.name}::${provider.baseUrl}::${provider.prefix ?? ''}::${index}`;
};
export const getOpenAIEntryKey = (entry: ApiKeyEntry, index: number): string => {
const authIndexKey = normalizeAuthIndex(entry.authIndex);
if (authIndexKey) {
return authIndexKey;
}
return `${entry.apiKey}::${entry.proxyUrl ?? ''}::${index}`;
};
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
apiKey: input?.apiKey ?? '',
proxyUrl: input?.proxyUrl ?? '',
+10 -1
View File
@@ -20,7 +20,7 @@ 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 { indexUsageDetailsBySource } from '@/utils/usageIndex';
import { indexUsageDetailsByAuthIndex, indexUsageDetailsBySource } from '@/utils/usageIndex';
import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() {
@@ -71,6 +71,10 @@ export function AiProvidersPage() {
() => indexUsageDetailsBySource(usageDetails),
[usageDetails]
);
const usageDetailsByAuthIndex = useMemo(
() => indexUsageDetailsByAuthIndex(usageDetails),
[usageDetails]
);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
@@ -378,6 +382,7 @@ export function AiProvidersPage() {
configs={geminiKeys}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -393,6 +398,7 @@ export function AiProvidersPage() {
configs={codexConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -408,6 +414,7 @@ export function AiProvidersPage() {
configs={claudeConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -423,6 +430,7 @@ export function AiProvidersPage() {
configs={vertexConfigs}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
@@ -448,6 +456,7 @@ export function AiProvidersPage() {
configs={openaiProviders}
keyStats={keyStats}
usageDetailsBySource={usageDetailsBySource}
usageDetailsByAuthIndex={usageDetailsByAuthIndex}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
+24 -1
View File
@@ -94,6 +94,12 @@ const normalizePrefix = (value: unknown): string | undefined => {
return trimmed ? trimmed : undefined;
};
const normalizeAuthIndex = (value: unknown): string | undefined => {
if (value === undefined || value === null) return undefined;
const trimmed = String(value).trim();
return trimmed ? trimmed : undefined;
};
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
if (entry === undefined || entry === null) return null;
const record = isRecord(entry) ? entry : null;
@@ -104,12 +110,17 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
const headers = record ? normalizeHeaders(record.headers) : undefined;
const authIndex = normalizeAuthIndex(
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
);
return {
const result: ApiKeyEntry = {
apiKey: trimmed,
proxyUrl: proxyUrl ? String(proxyUrl) : undefined,
headers
};
if (authIndex) result.authIndex = authIndex;
return result;
};
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
@@ -146,6 +157,10 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
record?.excluded_models
);
if (excludedModels.length) config.excludedModels = excludedModels;
const authIndex = normalizeAuthIndex(
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
);
if (authIndex) config.authIndex = authIndex;
const cloakRaw = record?.cloak;
if (isRecord(cloakRaw)) {
@@ -204,6 +219,10 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
if (headers) config.headers = headers;
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
if (excludedModels.length) config.excludedModels = excludedModels;
const authIndex = normalizeAuthIndex(
record?.['auth-index'] ?? record?.authIndex ?? record?.['auth_index']
);
if (authIndex) config.authIndex = authIndex;
return config;
};
@@ -241,6 +260,10 @@ const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null
if (models.length) result.models = models;
if (priority !== undefined) result.priority = Number(priority);
if (testModel) result.testModel = String(testModel);
const authIndex = normalizeAuthIndex(
provider['auth-index'] ?? provider.authIndex ?? provider['auth_index']
);
if (authIndex) result.authIndex = authIndex;
return result;
};
+4
View File
@@ -14,6 +14,7 @@ export interface ApiKeyEntry {
apiKey: string;
proxyUrl?: string;
headers?: Record<string, string>;
authIndex?: string;
}
export interface CloakConfig {
@@ -31,6 +32,7 @@ export interface GeminiKeyConfig {
models?: ModelAlias[];
headers?: Record<string, string>;
excludedModels?: string[];
authIndex?: string;
}
export interface ProviderKeyConfig {
@@ -44,6 +46,7 @@ export interface ProviderKeyConfig {
models?: ModelAlias[];
excludedModels?: string[];
cloak?: CloakConfig;
authIndex?: string;
}
export interface OpenAIProviderConfig {
@@ -55,5 +58,6 @@ export interface OpenAIProviderConfig {
models?: ModelAlias[];
priority?: number;
testModel?: string;
authIndex?: string;
[key: string]: unknown;
}
+12 -4
View File
@@ -56,7 +56,7 @@ export interface ModelPrice {
export interface UsageDetail {
timestamp: string;
source: string;
auth_index: number;
auth_index: string | number | null;
latency_ms?: number;
tokens: {
input_tokens: number;
@@ -552,7 +552,11 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
details.push({
timestamp,
source: normalizeSource(detailRaw.source),
auth_index: detailRaw.auth_index as unknown as number,
auth_index:
(detailRaw?.auth_index ??
detailRaw?.authIndex ??
detailRaw?.AuthIndex ??
null) as UsageDetail['auth_index'],
latency_ms: latencyMs ?? undefined,
tokens: tokensRaw as unknown as UsageDetail['tokens'],
failed: detailRaw.failed === true,
@@ -625,7 +629,11 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
details.push({
timestamp,
source: normalizeSource(detailRaw.source),
auth_index: detailRaw.auth_index as unknown as number,
auth_index:
(detailRaw?.auth_index ??
detailRaw?.authIndex ??
detailRaw?.AuthIndex ??
null) as UsageDetail['auth_index'],
latency_ms: latencyMs ?? undefined,
tokens: tokensRaw as unknown as UsageDetail['tokens'],
failed: detailRaw.failed === true,
@@ -1394,7 +1402,7 @@ export interface StatusBarData {
export function calculateStatusBarData(
usageDetails: UsageDetail[],
sourceFilter?: string,
authIndexFilter?: number
authIndexFilter?: string | number
): StatusBarData {
const BLOCK_COUNT = 20;
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
+36 -2
View File
@@ -1,6 +1,8 @@
import type { UsageDetail } from '@/utils/usage';
import { normalizeAuthIndex } from '@/utils/usage';
export type UsageDetailsBySource = Map<string, UsageDetail[]>;
export type UsageDetailsByAuthIndex = Map<string, UsageDetail[]>;
const EMPTY_USAGE_DETAILS: UsageDetail[] = [];
@@ -22,15 +24,47 @@ export function indexUsageDetailsBySource(usageDetails: UsageDetail[]): UsageDet
return map;
}
export function indexUsageDetailsByAuthIndex(usageDetails: UsageDetail[]): UsageDetailsByAuthIndex {
const map: UsageDetailsByAuthIndex = new Map();
usageDetails.forEach((detail) => {
const authIndexKey = normalizeAuthIndex(detail.auth_index);
if (!authIndexKey) return;
const bucket = map.get(authIndexKey);
if (bucket) {
bucket.push(detail);
} else {
map.set(authIndexKey, [detail]);
}
});
return map;
}
export function collectUsageDetailsForCandidates(
usageDetailsBySource: UsageDetailsBySource,
candidates: Iterable<string>
): UsageDetail[] {
return collectUsageDetailsForKeys(usageDetailsBySource, candidates);
}
export function collectUsageDetailsForAuthIndices(
usageDetailsByAuthIndex: UsageDetailsByAuthIndex,
authIndices: Iterable<string>
): UsageDetail[] {
return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices);
}
function collectUsageDetailsForKeys(
usageDetailsByKey: Map<string, UsageDetail[]>,
keys: Iterable<string>
): UsageDetail[] {
let firstDetails: UsageDetail[] | null = null;
let merged: UsageDetail[] | null = null;
for (const candidate of candidates) {
const details = usageDetailsBySource.get(candidate);
for (const key of keys) {
const details = usageDetailsByKey.get(key);
if (!details || details.length === 0) continue;
if (!firstDetails) {