mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 04:17:54 +00:00
Merge pull request #803 from kaitranntt/kai/fix/live-monitor-provider-stats
fix: scope cliproxy dashboard stats by provider
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export function buildQualifiedAccountStatsKey(provider: string, source: string): string {
|
||||
return `${provider.trim().toLowerCase()}:${source.trim()}`;
|
||||
}
|
||||
@@ -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<CliproxyStats |
|
||||
}
|
||||
|
||||
const data = (await response.json()) as CliproxyUsageApiResponse;
|
||||
const usage = data.usage;
|
||||
|
||||
// Extract models, providers, and per-account stats from the nested API structure
|
||||
const requestsByModel: Record<string, number> = {};
|
||||
const requestsByProvider: Record<string, number> = {};
|
||||
const accountStats: Record<string, AccountUsageStats> = {};
|
||||
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;
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
const requestsByProvider: Record<string, number> = {};
|
||||
const accountStats: Record<string, AccountUsageStats> = {};
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, AccountUsageStats>();
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CliproxyStats, 'accountStats'> | null | undefined,
|
||||
account: Pick<OAuthAccount, 'provider' | 'email' | 'id'>
|
||||
): AccountUsageStats | undefined {
|
||||
const source = account.email || account.id;
|
||||
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
|
||||
|
||||
return stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
|
||||
}
|
||||
@@ -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<CliproxyStats, 'accountStats'>;
|
||||
|
||||
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<CliproxyStats, 'accountStats'>;
|
||||
|
||||
const account: OAuthAccount = { ...baseAccount, provider: 'codex' };
|
||||
|
||||
expect(getAccountStats(stats, account)).toMatchObject({
|
||||
source: 'shared@example.com',
|
||||
successCount: 7,
|
||||
failureCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user