fix(cliproxy): scope account stats by provider

This commit is contained in:
Tam Nhu Tran
2026-03-26 16:36:46 -04:00
parent 63b291785c
commit 75ccbb3ad1
4 changed files with 210 additions and 74 deletions
+3
View File
@@ -0,0 +1,3 @@
export function buildQualifiedAccountStatsKey(provider: string, source: string): string {
return `${provider.trim().toLowerCase()}:${source.trim()}`;
}
+7 -74
View File
@@ -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;
+76
View File
@@ -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 });
});
});