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 }); + }); +});