diff --git a/.gitignore b/.gitignore index 02af05d..1f59de8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ skills # Editor directories and files settings.local.json +.codex .vscode/* !.vscode/extensions.json .idea diff --git a/src/components/providers/ClaudeSection/ClaudeSection.tsx b/src/components/providers/ClaudeSection/ClaudeSection.tsx index 63ceb32..abb4a07 100644 --- a/src/components/providers/ClaudeSection/ClaudeSection.tsx +++ b/src/components/providers/ClaudeSection/ClaudeSection.tsx @@ -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>(); - 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({ 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 ( diff --git a/src/components/providers/CodexSection/CodexSection.tsx b/src/components/providers/CodexSection/CodexSection.tsx index ea31771..1245357 100644 --- a/src/components/providers/CodexSection/CodexSection.tsx +++ b/src/components/providers/CodexSection/CodexSection.tsx @@ -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>(); - 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({ 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 ( diff --git a/src/components/providers/GeminiSection/GeminiSection.tsx b/src/components/providers/GeminiSection/GeminiSection.tsx index 6f28f3e..a551311 100644 --- a/src/components/providers/GeminiSection/GeminiSection.tsx +++ b/src/components/providers/GeminiSection/GeminiSection.tsx @@ -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>(); - 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({ 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 ( diff --git a/src/components/providers/OpenAISection/OpenAISection.tsx b/src/components/providers/OpenAISection/OpenAISection.tsx index 1782c72..6b9315d 100644 --- a/src/components/providers/OpenAISection/OpenAISection.tsx +++ b/src/components/providers/OpenAISection/OpenAISection.tsx @@ -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>(); - configs.forEach((provider) => { - const sourceIds = new Set(); - 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({ 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 ( @@ -134,9 +140,15 @@ export function OpenAISection({
{apiKeyEntries.map((entry, entryIndex) => { - const entryStats = getStatsBySource(entry.apiKey, keyStats); + const entryStats = getStatsForIdentity( + { authIndex: entry.authIndex, apiKey: entry.apiKey }, + keyStats + ); return ( -
+
{entryIndex + 1} {maskApiKey(entry.apiKey)} {entry.proxyUrl && ( diff --git a/src/components/providers/VertexSection/VertexSection.tsx b/src/components/providers/VertexSection/VertexSection.tsx index 381dc9f..716d746 100644 --- a/src/components/providers/VertexSection/VertexSection.tsx +++ b/src/components/providers/VertexSection/VertexSection.tsx @@ -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>(); - 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({ 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 ( diff --git a/src/components/providers/utils.ts b/src/components/providers/utils.ts index fb07cb6..fbce115 100644 --- a/src/components/providers/utils.ts +++ b/src/components/providers/utils.ts @@ -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(); - 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 => ({ apiKey: input?.apiKey ?? '', proxyUrl: input?.proxyUrl ?? '', diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index 821657d..8a3c437 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -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} diff --git a/src/services/api/transformers.ts b/src/services/api/transformers.ts index 929851d..8cef6f0 100644 --- a/src/services/api/transformers.ts +++ b/src/services/api/transformers.ts @@ -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; }; diff --git a/src/types/provider.ts b/src/types/provider.ts index fde6dec..5dcb0c5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -14,6 +14,7 @@ export interface ApiKeyEntry { apiKey: string; proxyUrl?: string; headers?: Record; + authIndex?: string; } export interface CloakConfig { @@ -31,6 +32,7 @@ export interface GeminiKeyConfig { models?: ModelAlias[]; headers?: Record; 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; } diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 8d53755..3515044 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -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 diff --git a/src/utils/usageIndex.ts b/src/utils/usageIndex.ts index 632f9f5..959812e 100644 --- a/src/utils/usageIndex.ts +++ b/src/utils/usageIndex.ts @@ -1,6 +1,8 @@ import type { UsageDetail } from '@/utils/usage'; +import { normalizeAuthIndex } from '@/utils/usage'; export type UsageDetailsBySource = Map; +export type UsageDetailsByAuthIndex = Map; 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 +): UsageDetail[] { + return collectUsageDetailsForKeys(usageDetailsBySource, candidates); +} + +export function collectUsageDetailsForAuthIndices( + usageDetailsByAuthIndex: UsageDetailsByAuthIndex, + authIndices: Iterable +): UsageDetail[] { + return collectUsageDetailsForKeys(usageDetailsByAuthIndex, authIndices); +} + +function collectUsageDetailsForKeys( + usageDetailsByKey: Map, + keys: Iterable ): 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) {