mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(cliproxy): restore live monitor provider attribution
This commit is contained in:
@@ -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<CliproxyStats | null> {
|
||||
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<CliproxyManagementAuthFile[] | null> {
|
||||
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;
|
||||
|
||||
@@ -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<string, ResolvedAuthFile> {
|
||||
const lookup = new Map<string, ResolvedAuthFile>();
|
||||
|
||||
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, ResolvedAuthFile>
|
||||
): 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, ResolvedAuthFile>
|
||||
): 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<string, number> = {};
|
||||
const requestsByProvider: Record<string, number> = {};
|
||||
const accountStats: Record<string, AccountUsageStats> = {};
|
||||
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,
|
||||
|
||||
@@ -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<void> => {
|
||||
// Fetch CLIProxyAPI usage stats to determine active providers
|
||||
const stats = await fetchCliproxyStats();
|
||||
|
||||
// Map CLIProxyAPI provider names to our internal provider names
|
||||
const statsProviderMap: Record<string, CLIProxyProvider> = {
|
||||
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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user