diff --git a/ui/src/components/cliproxy/overview/credential-health-list.tsx b/ui/src/components/cliproxy/overview/credential-health-list.tsx index 4e484036..08d7f9b3 100644 --- a/ui/src/components/cliproxy/overview/credential-health-list.tsx +++ b/ui/src/components/cliproxy/overview/credential-health-list.tsx @@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'; import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw, Clock } from 'lucide-react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; +import { getAccountStats } from '@/lib/cliproxy-account-stats'; import { cn } from '@/lib/utils'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useTranslation } from 'react-i18next'; @@ -155,8 +156,7 @@ export function CredentialHealthList() { const credentials = authData?.authStatus.flatMap((status) => (status.accounts ?? []).map((account) => { - const accountKey = account.email || account.id; - const runtimeLastUsed = stats?.accountStats?.[accountKey]?.lastUsedAt; + const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt; return { name: account.id, provider: status.provider, diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 0c3520cc..85ae03c5 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -40,6 +40,7 @@ import { isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; +import { getAccountStats } from '@/lib/cliproxy-account-stats'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; @@ -124,7 +125,7 @@ export function AccountItem({ ); // Get last used time from runtime stats (more accurate than file) - const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; + const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt; const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); // Use shared utility functions for provider-specific quota handling diff --git a/ui/src/components/monitoring/auth-monitor/hooks.ts b/ui/src/components/monitoring/auth-monitor/hooks.ts index 14868be5..a077a07e 100644 --- a/ui/src/components/monitoring/auth-monitor/hooks.ts +++ b/ui/src/components/monitoring/auth-monitor/hooks.ts @@ -4,8 +4,9 @@ import { useState, useMemo, useEffect } from 'react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; -import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { getProviderDisplayName } from '@/lib/provider-config'; +import { getAccountStats } from '@/lib/cliproxy-account-stats'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { AccountRow, ProviderStats } from './types'; import { ACCOUNT_COLORS } from './utils'; @@ -44,12 +45,6 @@ export function useAuthMonitorData(): AuthMonitorData { return () => clearInterval(interval); }, [dataUpdatedAt]); - // Build a map of account email -> usage stats from CLIProxy - const accountStatsMap = useMemo(() => { - if (!statsData?.accountStats) return new Map(); - return new Map(Object.entries(statsData.accountStats)); - }, [statsData?.accountStats]); - // Transform auth status data into account rows const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => { if (!data?.authStatus) { @@ -80,8 +75,7 @@ export function useAuthMonitorData(): AuthMonitorData { if (!providerData) return; status.accounts?.forEach((account: OAuthAccount) => { - const accountEmail = account.email || account.id; - const realStats = accountStatsMap.get(accountEmail); + const realStats = getAccountStats(statsData, account); const success = realStats?.successCount ?? 0; const failure = realStats?.failureCount ?? 0; tSuccess += success; @@ -132,7 +126,7 @@ export function useAuthMonitorData(): AuthMonitorData { totalRequests: tSuccess + tFailure, providerStats: providerStatsArr, }; - }, [data?.authStatus, accountStatsMap]); + }, [data?.authStatus, statsData]); const overallSuccessRate = totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100; diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 105049b6..ceeb6ce0 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -15,7 +15,11 @@ import type { UnifiedQuotaResult } from '@/lib/utils'; /** Per-account usage statistics */ export interface AccountUsageStats { - /** Account email or identifier */ + /** Provider-qualified lookup key (for example: "codex:user@example.com") */ + accountKey?: string; + /** Canonical provider name reported by CLIProxyAPI */ + provider?: string; + /** Raw account email or identifier */ source: string; /** Number of successful requests */ successCount: number; diff --git a/ui/src/lib/cliproxy-account-stats.ts b/ui/src/lib/cliproxy-account-stats.ts new file mode 100644 index 00000000..a9acd8ec --- /dev/null +++ b/ui/src/lib/cliproxy-account-stats.ts @@ -0,0 +1,16 @@ +import type { OAuthAccount } from '@/lib/api-client'; +import type { AccountUsageStats, CliproxyStats } from '@/hooks/use-cliproxy-stats'; + +export function buildQualifiedAccountStatsKey(provider: string, source: string): string { + return `${provider.trim().toLowerCase()}:${source.trim()}`; +} + +export function getAccountStats( + stats: Pick | null | undefined, + account: Pick +): AccountUsageStats | undefined { + const source = account.email || account.id; + const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source); + + return stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source]; +} diff --git a/ui/tests/unit/ui/lib/cliproxy-account-stats.test.ts b/ui/tests/unit/ui/lib/cliproxy-account-stats.test.ts new file mode 100644 index 00000000..6e328302 --- /dev/null +++ b/ui/tests/unit/ui/lib/cliproxy-account-stats.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { getAccountStats } from '../../../../src/lib/cliproxy-account-stats'; +import type { CliproxyStats } from '../../../../src/hooks/use-cliproxy-stats'; +import type { OAuthAccount } from '../../../../src/lib/api-client'; + +describe('getAccountStats', () => { + const baseAccount = { + id: 'shared@example.com', + email: 'shared@example.com', + isDefault: true, + tokenFile: 'shared.json', + createdAt: '2026-03-26T00:00:00.000Z', + } as const; + + it('prefers provider-qualified stats when the same email exists across providers', () => { + const stats = { + accountStats: { + 'codex:shared@example.com': { + accountKey: 'codex:shared@example.com', + provider: 'codex', + source: 'shared@example.com', + successCount: 11, + failureCount: 1, + totalTokens: 0, + lastUsedAt: '2026-03-26T10:00:00.000Z', + }, + 'gemini:shared@example.com': { + accountKey: 'gemini:shared@example.com', + provider: 'gemini', + source: 'shared@example.com', + successCount: 3, + failureCount: 2, + totalTokens: 0, + lastUsedAt: '2026-03-26T11:00:00.000Z', + }, + }, + } as Pick; + + const codexAccount: OAuthAccount = { ...baseAccount, provider: 'codex' }; + const geminiAccount: OAuthAccount = { ...baseAccount, provider: 'gemini' }; + + expect(getAccountStats(stats, codexAccount)).toMatchObject({ + successCount: 11, + failureCount: 1, + provider: 'codex', + }); + expect(getAccountStats(stats, geminiAccount)).toMatchObject({ + successCount: 3, + failureCount: 2, + provider: 'gemini', + }); + }); + + it('falls back to legacy raw-source keys for older stats payloads', () => { + const stats = { + accountStats: { + 'shared@example.com': { + source: 'shared@example.com', + successCount: 7, + failureCount: 0, + totalTokens: 0, + lastUsedAt: '2026-03-26T12:00:00.000Z', + }, + }, + } as Pick; + + const account: OAuthAccount = { ...baseAccount, provider: 'codex' }; + + expect(getAccountStats(stats, account)).toMatchObject({ + source: 'shared@example.com', + successCount: 7, + failureCount: 0, + }); + }); +});