mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 20:24:01 +00:00
fix(cliproxy): scope account 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,
|
buildProxyHeaders,
|
||||||
buildManagementHeaders,
|
buildManagementHeaders,
|
||||||
} from './proxy-target-resolver';
|
} from './proxy-target-resolver';
|
||||||
|
import { buildCliproxyStatsFromUsageResponse } from './stats-transformer';
|
||||||
|
|
||||||
/** Per-account usage statistics */
|
/** Per-account usage statistics */
|
||||||
export interface AccountUsageStats {
|
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;
|
source: string;
|
||||||
/** Number of successful requests */
|
/** Number of successful requests */
|
||||||
successCount: number;
|
successCount: number;
|
||||||
@@ -134,79 +139,7 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as CliproxyUsageApiResponse;
|
const data = (await response.json()) as CliproxyUsageApiResponse;
|
||||||
const usage = data.usage;
|
return buildCliproxyStatsFromUsageResponse(data);
|
||||||
|
|
||||||
// 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(),
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
// CLIProxyAPI not running or stats endpoint not available
|
// CLIProxyAPI not running or stats endpoint not available
|
||||||
return null;
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user