diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 4c006576..ce3f82e1 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -64,7 +64,7 @@ export interface CliproxyStats { export interface CliproxyRequestDetail { timestamp: string; source: string; - auth_index: number; + auth_index: string | number; tokens: { input_tokens: number; output_tokens: number; @@ -104,6 +104,14 @@ export interface CliproxyUsageApiResponse { }; } +/** Auth file metadata from CLIProxyAPI /v0/management/auth-files */ +export interface CliproxyManagementAuthFile { + auth_index?: string | number; + provider?: string; + email?: string; + name?: string; +} + /** * Fetch usage statistics from CLIProxyAPI management API * @param port CLIProxyAPI port (default: 8317) @@ -111,35 +119,16 @@ export interface CliproxyUsageApiResponse { */ export async function fetchCliproxyStats(port?: number): Promise { try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout + const [data, authFiles] = await Promise.all([ + fetchCliproxyUsageRaw(port), + fetchCliproxyAuthFiles(port), + ]); - // Dynamic target resolution - const target = getProxyTarget(); - // Allow port override for local testing only - if (port !== undefined && !target.isRemote) { - target.port = port; - } - const url = buildProxyUrl(target, '/v0/management/usage'); - - // For management endpoints, use management key for remote, local management secret for local - const headers = target.isRemote - ? buildManagementHeaders(target) - : { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` }; - - const response = await fetch(url, { - signal: controller.signal, - headers, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { + if (!data) { return null; } - const data = (await response.json()) as CliproxyUsageApiResponse; - return buildCliproxyStatsFromUsageResponse(data); + return buildCliproxyStatsFromUsageResponse(data, { authFiles: authFiles ?? [] }); } catch { // CLIProxyAPI not running or stats endpoint not available return null; @@ -184,6 +173,39 @@ export async function fetchCliproxyUsageRaw( } } +async function fetchCliproxyAuthFiles(port?: number): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const target = getProxyTarget(); + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/auth-files'); + + const headers = target.isRemote + ? buildManagementHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` }; + + const response = await fetch(url, { + signal: controller.signal, + headers, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { files?: CliproxyManagementAuthFile[] }; + return Array.isArray(data.files) ? data.files : null; + } catch { + return null; + } +} + /** OpenAI-compatible model object from /v1/models endpoint */ export interface CliproxyModel { id: string; diff --git a/src/cliproxy/stats-transformer.ts b/src/cliproxy/stats-transformer.ts index deb3249e..74c10aa0 100644 --- a/src/cliproxy/stats-transformer.ts +++ b/src/cliproxy/stats-transformer.ts @@ -1,20 +1,103 @@ import { buildQualifiedAccountStatsKey } from './account-stats-key'; -import type { AccountUsageStats, CliproxyStats, CliproxyUsageApiResponse } from './stats-fetcher'; +import { mapExternalProviderName } from './provider-capabilities'; +import type { + AccountUsageStats, + CliproxyManagementAuthFile, + CliproxyRequestDetail, + CliproxyStats, + CliproxyUsageApiResponse, +} from './stats-fetcher'; -export function buildCliproxyStatsFromUsageResponse(data: CliproxyUsageApiResponse): CliproxyStats { +interface BuildCliproxyStatsOptions { + authFiles?: CliproxyManagementAuthFile[]; +} + +interface ResolvedAuthFile { + provider?: string; + source?: string; +} + +function normalizeProvider(provider: string): string { + const normalized = provider.trim().toLowerCase(); + if (!normalized) { + return 'unknown'; + } + + return mapExternalProviderName(normalized) ?? normalized; +} + +function buildAuthIndexLookup( + authFiles: CliproxyManagementAuthFile[] | undefined +): ReadonlyMap { + const lookup = new Map(); + + for (const authFile of authFiles ?? []) { + if (authFile.auth_index === undefined || authFile.auth_index === null) { + continue; + } + + const provider = authFile.provider ? normalizeProvider(authFile.provider) : undefined; + const source = authFile.email?.trim() || authFile.name?.trim() || undefined; + if (!provider && !source) { + continue; + } + + lookup.set(String(authFile.auth_index), { + provider, + source, + }); + } + + return lookup; +} + +function resolveProviderForDetail( + usageProvider: string, + detail: CliproxyRequestDetail, + authIndexLookup: ReadonlyMap +): string { + const resolvedAuthFile = authIndexLookup.get(String(detail.auth_index)); + if (resolvedAuthFile?.provider) { + return resolvedAuthFile.provider; + } + + return normalizeProvider(usageProvider); +} + +function resolveSourceForDetail( + detail: CliproxyRequestDetail, + authIndexLookup: ReadonlyMap +): string { + const source = detail.source?.trim(); + if (source) { + return source; + } + + return authIndexLookup.get(String(detail.auth_index))?.source ?? 'unknown'; +} + +export function buildCliproxyStatsFromUsageResponse( + data: CliproxyUsageApiResponse, + options: BuildCliproxyStatsOptions = {} +): CliproxyStats { const usage = data.usage; const requestsByModel: Record = {}; const requestsByProvider: Record = {}; const accountStats: Record = {}; + const authIndexLookup = buildAuthIndexLookup(options.authFiles); let totalSuccessCount = 0; let totalFailureCount = 0; let totalInputTokens = 0; let totalOutputTokens = 0; + let sawAnyDetail = false; if (usage?.apis) { for (const [provider, providerData] of Object.entries(usage.apis)) { - requestsByProvider[provider] = providerData.total_requests ?? 0; + let sawProviderDetail = false; if (!providerData.models) { + const normalizedProvider = normalizeProvider(provider); + requestsByProvider[normalizedProvider] = + (requestsByProvider[normalizedProvider] ?? 0) + (providerData.total_requests ?? 0); continue; } @@ -25,13 +108,17 @@ export function buildCliproxyStatsFromUsageResponse(data: CliproxyUsageApiRespon } for (const detail of modelData.details) { - const source = detail.source || 'unknown'; - const accountKey = buildQualifiedAccountStatsKey(provider, source); + sawAnyDetail = true; + sawProviderDetail = true; + const source = resolveSourceForDetail(detail, authIndexLookup); + const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup); + const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source); + requestsByProvider[resolvedProvider] = (requestsByProvider[resolvedProvider] ?? 0) + 1; if (!accountStats[accountKey]) { accountStats[accountKey] = { accountKey, - provider, + provider: resolvedProvider, source, successCount: 0, failureCount: 0, @@ -54,13 +141,21 @@ export function buildCliproxyStatsFromUsageResponse(data: CliproxyUsageApiRespon totalOutputTokens += detail.tokens?.output_tokens ?? 0; } } + + if (!sawProviderDetail) { + const normalizedProvider = normalizeProvider(provider); + requestsByProvider[normalizedProvider] = + (requestsByProvider[normalizedProvider] ?? 0) + (providerData.total_requests ?? 0); + } } } return { totalRequests: usage?.total_requests ?? 0, - successCount: totalSuccessCount, - failureCount: totalFailureCount, + successCount: sawAnyDetail ? totalSuccessCount : (usage?.success_count ?? 0), + failureCount: sawAnyDetail + ? totalFailureCount + : (usage?.failure_count ?? data.failed_requests ?? 0), tokens: { input: totalInputTokens, output: totalOutputTokens, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 9db43f01..471174c7 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -51,7 +51,7 @@ import { normalizeKiroAuthMethod, toKiroManagementMethod, } from '../../cliproxy/auth/auth-types'; -import { getOAuthFlowType } from '../../cliproxy/provider-capabilities'; +import { getOAuthFlowType, mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { @@ -294,24 +294,11 @@ router.get('/', async (_req: Request, res: Response): Promise => { // Fetch CLIProxyAPI usage stats to determine active providers const stats = await fetchCliproxyStats(); - // Map CLIProxyAPI provider names to our internal provider names - const statsProviderMap: Record = { - gemini: 'gemini', - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - kiro: 'kiro', - copilot: 'ghcp', // CLIProxyAPI returns 'copilot', we map to 'ghcp' - anthropic: 'claude', // CLIProxyAPI returns 'anthropic', we map to 'claude' - claude: 'claude', - }; - // Update lastUsedAt for providers with recent activity if (stats?.requestsByProvider) { for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { if (requestCount > 0) { - const provider = statsProviderMap[statsProvider.toLowerCase()]; + const provider = mapExternalProviderName(statsProvider.toLowerCase()); if (provider) { // Touch the default account for this provider (or all accounts) const accounts = getProviderAccounts(provider); diff --git a/tests/unit/cliproxy/stats-transformer.test.ts b/tests/unit/cliproxy/stats-transformer.test.ts index 8579bb18..a58482be 100644 --- a/tests/unit/cliproxy/stats-transformer.test.ts +++ b/tests/unit/cliproxy/stats-transformer.test.ts @@ -1,7 +1,51 @@ import { describe, expect, it } from 'bun:test'; -import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; +import type { + CliproxyManagementAuthFile, + CliproxyRequestDetail, + CliproxyUsageApiResponse, +} from '../../../src/cliproxy/stats-fetcher'; import { buildCliproxyStatsFromUsageResponse } from '../../../src/cliproxy/stats-transformer'; +function createDetail(overrides: Partial = {}): CliproxyRequestDetail { + return { + timestamp: '2025-03-26T10:00:00.000Z', + source: 'shared@example.com', + auth_index: 'shared-auth-index', + tokens: { + input_tokens: 10, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 15, + }, + failed: false, + ...overrides, + }; +} + +function createInternallyBucketedUsage( + details: CliproxyRequestDetail[] +): CliproxyUsageApiResponse { + return { + usage: { + total_requests: details.length, + success_count: details.filter((detail) => !detail.failed).length, + failure_count: details.filter((detail) => detail.failed).length, + apis: { + 'ccs-internal-managed': { + total_requests: details.length, + models: { + 'gpt-5': { + total_requests: details.length, + details, + }, + }, + }, + }, + }, + }; +} + describe('buildCliproxyStatsFromUsageResponse', () => { it('keeps duplicate emails isolated by provider', () => { const usage: CliproxyUsageApiResponse = { @@ -121,4 +165,127 @@ describe('buildCliproxyStatsFromUsageResponse', () => { expect(stats.failureCount).toBe(2); expect(stats.requestsByProvider).toEqual({ codex: 3, gemini: 2 }); }); + + it('resolves canonical providers from auth_index when usage is internally bucketed', () => { + const usage = createInternallyBucketedUsage([ + createDetail({ auth_index: 'codex-1' }), + createDetail({ + timestamp: '2025-03-26T10:01:00.000Z', + auth_index: 'gemini-1', + tokens: { + input_tokens: 12, + output_tokens: 7, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 19, + }, + }), + createDetail({ + timestamp: '2025-03-26T10:02:00.000Z', + auth_index: 'agy-1', + tokens: { + input_tokens: 8, + output_tokens: 2, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 10, + }, + failed: true, + }), + ]); + const authFiles: CliproxyManagementAuthFile[] = [ + { auth_index: 'codex-1', provider: 'codex', email: 'shared@example.com' }, + { auth_index: 'gemini-1', provider: 'gemini-cli', email: 'shared@example.com' }, + { auth_index: 'agy-1', provider: 'antigravity', email: 'shared@example.com' }, + ]; + + const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles }); + + expect(stats.accountStats['codex:shared@example.com']).toMatchObject({ + provider: 'codex', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['gemini:shared@example.com']).toMatchObject({ + provider: 'gemini', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['agy:shared@example.com']).toMatchObject({ + provider: 'agy', + successCount: 0, + failureCount: 1, + }); + expect(stats.requestsByProvider).toEqual({ codex: 1, gemini: 1, agy: 1 }); + }); + + it('falls back to the usage provider when auth_index lookup cannot resolve a provider', () => { + const usage = createInternallyBucketedUsage([createDetail({ auth_index: 'codex-1' })]); + const scenarios: Array<{ label: string; authFiles: CliproxyManagementAuthFile[] }> = [ + { label: 'empty authFiles', authFiles: [] }, + { + label: 'missing auth_index match', + authFiles: [{ auth_index: 'other-auth-index', provider: 'codex', email: 'shared@example.com' }], + }, + { + label: 'matching auth_index without provider metadata', + authFiles: [{ auth_index: 'codex-1', email: 'shared@example.com' }], + }, + ]; + + for (const scenario of scenarios) { + const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles: scenario.authFiles }); + + expect(stats.accountStats['ccs-internal-managed:shared@example.com'], scenario.label).toMatchObject({ + provider: 'ccs-internal-managed', + source: 'shared@example.com', + successCount: 1, + failureCount: 0, + }); + expect(stats.requestsByProvider, scenario.label).toEqual({ 'ccs-internal-managed': 1 }); + } + }); + + it('falls back to auth-file source metadata when detail source is blank and supports mixed auth_index outcomes', () => { + const usage = createInternallyBucketedUsage([ + createDetail({ source: ' ', auth_index: 'codex-1' }), + createDetail({ + timestamp: '2025-03-26T10:01:00.000Z', + source: 'unmatched@example.com', + auth_index: 'missing-auth-index', + }), + createDetail({ + timestamp: '2025-03-26T10:02:00.000Z', + source: ' ', + auth_index: 'providerless-auth-index', + failed: true, + }), + ]); + const authFiles: CliproxyManagementAuthFile[] = [ + { auth_index: 'codex-1', provider: 'codex', email: 'fallback@example.com' }, + { auth_index: 'providerless-auth-index', email: 'providerless-fallback@example.com' }, + ]; + + const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles }); + + expect(stats.accountStats['codex:fallback@example.com']).toMatchObject({ + provider: 'codex', + source: 'fallback@example.com', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['ccs-internal-managed:unmatched@example.com']).toMatchObject({ + provider: 'ccs-internal-managed', + source: 'unmatched@example.com', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['ccs-internal-managed:providerless-fallback@example.com']).toMatchObject({ + provider: 'ccs-internal-managed', + source: 'providerless-fallback@example.com', + successCount: 0, + failureCount: 1, + }); + expect(stats.requestsByProvider).toEqual({ codex: 1, 'ccs-internal-managed': 2 }); + }); });