From 75ccbb3ad13ee6d8b84ad7ef3aacb6b8994256c5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 16:36:46 -0400 Subject: [PATCH 1/2] fix(cliproxy): scope account stats by provider --- src/cliproxy/account-stats-key.ts | 3 + src/cliproxy/stats-fetcher.ts | 81 +----------- src/cliproxy/stats-transformer.ts | 76 +++++++++++ tests/unit/cliproxy/stats-transformer.test.ts | 124 ++++++++++++++++++ 4 files changed, 210 insertions(+), 74 deletions(-) create mode 100644 src/cliproxy/account-stats-key.ts create mode 100644 src/cliproxy/stats-transformer.ts create mode 100644 tests/unit/cliproxy/stats-transformer.test.ts diff --git a/src/cliproxy/account-stats-key.ts b/src/cliproxy/account-stats-key.ts new file mode 100644 index 00000000..207155d6 --- /dev/null +++ b/src/cliproxy/account-stats-key.ts @@ -0,0 +1,3 @@ +export function buildQualifiedAccountStatsKey(provider: string, source: string): string { + return `${provider.trim().toLowerCase()}:${source.trim()}`; +} diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 72aacd0c..4c006576 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -12,10 +12,15 @@ import { buildProxyHeaders, buildManagementHeaders, } from './proxy-target-resolver'; +import { buildCliproxyStatsFromUsageResponse } from './stats-transformer'; /** 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; @@ -134,79 +139,7 @@ export async function fetchCliproxyStats(port?: number): Promise = {}; - const requestsByProvider: Record = {}; - const accountStats: Record = {}; - let totalSuccessCount = 0; - let totalFailureCount = 0; - let totalInputTokens = 0; - let totalOutputTokens = 0; - - if (usage?.apis) { - for (const [provider, providerData] of Object.entries(usage.apis)) { - requestsByProvider[provider] = providerData.total_requests ?? 0; - if (providerData.models) { - for (const [model, modelData] of Object.entries(providerData.models)) { - requestsByModel[model] = modelData.total_requests ?? 0; - - // Aggregate per-account stats from request details - if (modelData.details) { - for (const detail of modelData.details) { - const source = detail.source || 'unknown'; - - // Initialize account stats if not exists - if (!accountStats[source]) { - accountStats[source] = { - source, - successCount: 0, - failureCount: 0, - totalTokens: 0, - }; - } - - // Update account stats - if (detail.failed) { - accountStats[source].failureCount++; - totalFailureCount++; - } else { - accountStats[source].successCount++; - totalSuccessCount++; - } - - const tokens = detail.tokens?.total_tokens ?? 0; - accountStats[source].totalTokens += tokens; - accountStats[source].lastUsedAt = detail.timestamp; - - // Aggregate token breakdowns - totalInputTokens += detail.tokens?.input_tokens ?? 0; - totalOutputTokens += detail.tokens?.output_tokens ?? 0; - } - } - } - } - } - } - - // Normalize the response to our interface - return { - totalRequests: usage?.total_requests ?? 0, - successCount: totalSuccessCount, - failureCount: totalFailureCount, - tokens: { - input: totalInputTokens, - output: totalOutputTokens, - total: usage?.total_tokens ?? 0, - }, - requestsByModel, - requestsByProvider, - accountStats, - quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0, - retryCount: 0, // API doesn't track retries separately - collectedAt: new Date().toISOString(), - }; + return buildCliproxyStatsFromUsageResponse(data); } catch { // CLIProxyAPI not running or stats endpoint not available return null; diff --git a/src/cliproxy/stats-transformer.ts b/src/cliproxy/stats-transformer.ts new file mode 100644 index 00000000..deb3249e --- /dev/null +++ b/src/cliproxy/stats-transformer.ts @@ -0,0 +1,76 @@ +import { buildQualifiedAccountStatsKey } from './account-stats-key'; +import type { AccountUsageStats, CliproxyStats, CliproxyUsageApiResponse } from './stats-fetcher'; + +export function buildCliproxyStatsFromUsageResponse(data: CliproxyUsageApiResponse): CliproxyStats { + const usage = data.usage; + const requestsByModel: Record = {}; + const requestsByProvider: Record = {}; + const accountStats: Record = {}; + let totalSuccessCount = 0; + let totalFailureCount = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + + if (usage?.apis) { + for (const [provider, providerData] of Object.entries(usage.apis)) { + requestsByProvider[provider] = providerData.total_requests ?? 0; + if (!providerData.models) { + continue; + } + + for (const [model, modelData] of Object.entries(providerData.models)) { + requestsByModel[model] = modelData.total_requests ?? 0; + if (!modelData.details) { + continue; + } + + for (const detail of modelData.details) { + const source = detail.source || 'unknown'; + const accountKey = buildQualifiedAccountStatsKey(provider, source); + + if (!accountStats[accountKey]) { + accountStats[accountKey] = { + accountKey, + provider, + source, + successCount: 0, + failureCount: 0, + totalTokens: 0, + }; + } + + if (detail.failed) { + accountStats[accountKey].failureCount++; + totalFailureCount++; + } else { + accountStats[accountKey].successCount++; + totalSuccessCount++; + } + + const tokens = detail.tokens?.total_tokens ?? 0; + accountStats[accountKey].totalTokens += tokens; + accountStats[accountKey].lastUsedAt = detail.timestamp; + totalInputTokens += detail.tokens?.input_tokens ?? 0; + totalOutputTokens += detail.tokens?.output_tokens ?? 0; + } + } + } + } + + return { + totalRequests: usage?.total_requests ?? 0, + successCount: totalSuccessCount, + failureCount: totalFailureCount, + tokens: { + input: totalInputTokens, + output: totalOutputTokens, + total: usage?.total_tokens ?? 0, + }, + requestsByModel, + requestsByProvider, + accountStats, + quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0, + retryCount: 0, + collectedAt: new Date().toISOString(), + }; +} diff --git a/tests/unit/cliproxy/stats-transformer.test.ts b/tests/unit/cliproxy/stats-transformer.test.ts new file mode 100644 index 00000000..8579bb18 --- /dev/null +++ b/tests/unit/cliproxy/stats-transformer.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'bun:test'; +import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; +import { buildCliproxyStatsFromUsageResponse } from '../../../src/cliproxy/stats-transformer'; + +describe('buildCliproxyStatsFromUsageResponse', () => { + it('keeps duplicate emails isolated by provider', () => { + const usage: CliproxyUsageApiResponse = { + usage: { + total_requests: 5, + apis: { + codex: { + total_requests: 3, + models: { + 'gpt-5': { + total_requests: 3, + details: [ + { + timestamp: '2026-03-26T10:00:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 10, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 15, + }, + failed: false, + }, + { + timestamp: '2026-03-26T10:01:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 12, + output_tokens: 7, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 19, + }, + failed: false, + }, + { + timestamp: '2026-03-26T10:02:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 8, + output_tokens: 2, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 10, + }, + failed: true, + }, + ], + }, + }, + }, + gemini: { + total_requests: 2, + models: { + 'gemini-2.5-pro': { + total_requests: 2, + details: [ + { + timestamp: '2026-03-26T11:00:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 20, + output_tokens: 10, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 30, + }, + failed: false, + }, + { + timestamp: '2026-03-26T11:01:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 14, + output_tokens: 6, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 20, + }, + failed: true, + }, + ], + }, + }, + }, + }, + }, + }; + + const stats = buildCliproxyStatsFromUsageResponse(usage); + + expect(stats.accountStats['codex:shared@example.com']).toMatchObject({ + accountKey: 'codex:shared@example.com', + provider: 'codex', + source: 'shared@example.com', + successCount: 2, + failureCount: 1, + totalTokens: 44, + lastUsedAt: '2026-03-26T10:02:00.000Z', + }); + expect(stats.accountStats['gemini:shared@example.com']).toMatchObject({ + accountKey: 'gemini:shared@example.com', + provider: 'gemini', + source: 'shared@example.com', + successCount: 1, + failureCount: 1, + totalTokens: 50, + lastUsedAt: '2026-03-26T11:01:00.000Z', + }); + expect(stats.successCount).toBe(3); + expect(stats.failureCount).toBe(2); + expect(stats.requestsByProvider).toEqual({ codex: 3, gemini: 2 }); + }); +}); From 1edc10362adf1be3a6f06041929e2548614c73a6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 16:37:37 -0400 Subject: [PATCH 2/2] fix(ui): resolve cliproxy account stats by provider --- .../overview/credential-health-list.tsx | 4 +- .../cliproxy/provider-editor/account-item.tsx | 3 +- .../monitoring/auth-monitor/hooks.ts | 14 +--- ui/src/hooks/use-cliproxy-stats.ts | 6 +- ui/src/lib/cliproxy-account-stats.ts | 16 ++++ .../ui/lib/cliproxy-account-stats.test.ts | 75 +++++++++++++++++++ 6 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 ui/src/lib/cliproxy-account-stats.ts create mode 100644 ui/tests/unit/ui/lib/cliproxy-account-stats.test.ts 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, + }); + }); +});