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..e8f355f4 100644 --- a/src/cliproxy/stats-transformer.ts +++ b/src/cliproxy/stats-transformer.ts @@ -1,20 +1,97 @@ 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 || !authFile.provider) { + continue; + } + + lookup.set(String(authFile.auth_index), { + provider: normalizeProvider(authFile.provider), + source: authFile.email?.trim() || authFile.name?.trim() || undefined, + }); + } + + 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 +102,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 +135,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..2d74a224 100644 --- a/tests/unit/cliproxy/stats-transformer.test.ts +++ b/tests/unit/cliproxy/stats-transformer.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; +import type { + CliproxyManagementAuthFile, + CliproxyUsageApiResponse, +} from '../../../src/cliproxy/stats-fetcher'; import { buildCliproxyStatsFromUsageResponse } from '../../../src/cliproxy/stats-transformer'; describe('buildCliproxyStatsFromUsageResponse', () => { @@ -121,4 +124,89 @@ 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: CliproxyUsageApiResponse = { + usage: { + total_requests: 3, + success_count: 2, + failure_count: 1, + apis: { + 'ccs-internal-managed': { + total_requests: 3, + models: { + 'gpt-5': { + total_requests: 3, + details: [ + { + timestamp: '2026-03-26T10:00:00.000Z', + source: 'shared@example.com', + auth_index: 'codex-1', + 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: 'gemini-1', + 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: '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 }); + }); });