mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
fix(cliproxy): restore live monitor provider attribution
This commit is contained in:
1 parent
7f84308a41
commit
368a625d92
4 files changed
+236
-50
No files matched your search
@@ -64,7 +64,7 @@ export interface CliproxyStats {
|
|||||||
export interface CliproxyRequestDetail {
|
export interface CliproxyRequestDetail {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
source: string;
|
source: string;
|
||||||
auth_index: number;
|
auth_index: string | number;
|
||||||
tokens: {
|
tokens: {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_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
|
* Fetch usage statistics from CLIProxyAPI management API
|
||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
@@ -111,35 +119,16 @@ export interface CliproxyUsageApiResponse {
|
|||||||
*/
|
*/
|
||||||
export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats | null> {
|
export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats | null> {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const [data, authFiles] = await Promise.all([
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
fetchCliproxyUsageRaw(port),
|
||||||
|
fetchCliproxyAuthFiles(port),
|
||||||
|
]);
|
||||||
|
|
||||||
// Dynamic target resolution
|
if (!data) {
|
||||||
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) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as CliproxyUsageApiResponse;
|
return buildCliproxyStatsFromUsageResponse(data, { authFiles: authFiles ?? [] });
|
||||||
return buildCliproxyStatsFromUsageResponse(data);
|
|
||||||
} catch {
|
} catch {
|
||||||
// CLIProxyAPI not running or stats endpoint not available
|
// CLIProxyAPI not running or stats endpoint not available
|
||||||
return null;
|
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 */
|
/** OpenAI-compatible model object from /v1/models endpoint */
|
||||||
export interface CliproxyModel {
|
export interface CliproxyModel {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,20 +1,97 @@
|
|||||||
import { buildQualifiedAccountStatsKey } from './account-stats-key';
|
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 usage = data.usage;
|
||||||
const requestsByModel: Record<string, number> = {};
|
const requestsByModel: Record<string, number> = {};
|
||||||
const requestsByProvider: Record<string, number> = {};
|
const requestsByProvider: Record<string, number> = {};
|
||||||
const accountStats: Record<string, AccountUsageStats> = {};
|
const accountStats: Record<string, AccountUsageStats> = {};
|
||||||
|
const authIndexLookup = buildAuthIndexLookup(options.authFiles);
|
||||||
let totalSuccessCount = 0;
|
let totalSuccessCount = 0;
|
||||||
let totalFailureCount = 0;
|
let totalFailureCount = 0;
|
||||||
let totalInputTokens = 0;
|
let totalInputTokens = 0;
|
||||||
let totalOutputTokens = 0;
|
let totalOutputTokens = 0;
|
||||||
|
let sawAnyDetail = false;
|
||||||
|
|
||||||
if (usage?.apis) {
|
if (usage?.apis) {
|
||||||
for (const [provider, providerData] of Object.entries(usage.apis)) {
|
for (const [provider, providerData] of Object.entries(usage.apis)) {
|
||||||
requestsByProvider[provider] = providerData.total_requests ?? 0;
|
let sawProviderDetail = false;
|
||||||
if (!providerData.models) {
|
if (!providerData.models) {
|
||||||
|
const normalizedProvider = normalizeProvider(provider);
|
||||||
|
requestsByProvider[normalizedProvider] =
|
||||||
|
(requestsByProvider[normalizedProvider] ?? 0) + (providerData.total_requests ?? 0);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,13 +102,17 @@ export function buildCliproxyStatsFromUsageResponse(data: CliproxyUsageApiRespon
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const detail of modelData.details) {
|
for (const detail of modelData.details) {
|
||||||
const source = detail.source || 'unknown';
|
sawAnyDetail = true;
|
||||||
const accountKey = buildQualifiedAccountStatsKey(provider, source);
|
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]) {
|
if (!accountStats[accountKey]) {
|
||||||
accountStats[accountKey] = {
|
accountStats[accountKey] = {
|
||||||
accountKey,
|
accountKey,
|
||||||
provider,
|
provider: resolvedProvider,
|
||||||
source,
|
source,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
failureCount: 0,
|
failureCount: 0,
|
||||||
@@ -54,13 +135,21 @@ export function buildCliproxyStatsFromUsageResponse(data: CliproxyUsageApiRespon
|
|||||||
totalOutputTokens += detail.tokens?.output_tokens ?? 0;
|
totalOutputTokens += detail.tokens?.output_tokens ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sawProviderDetail) {
|
||||||
|
const normalizedProvider = normalizeProvider(provider);
|
||||||
|
requestsByProvider[normalizedProvider] =
|
||||||
|
(requestsByProvider[normalizedProvider] ?? 0) + (providerData.total_requests ?? 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalRequests: usage?.total_requests ?? 0,
|
totalRequests: usage?.total_requests ?? 0,
|
||||||
successCount: totalSuccessCount,
|
successCount: sawAnyDetail ? totalSuccessCount : (usage?.success_count ?? 0),
|
||||||
failureCount: totalFailureCount,
|
failureCount: sawAnyDetail
|
||||||
|
? totalFailureCount
|
||||||
|
: (usage?.failure_count ?? data.failed_requests ?? 0),
|
||||||
tokens: {
|
tokens: {
|
||||||
input: totalInputTokens,
|
input: totalInputTokens,
|
||||||
output: totalOutputTokens,
|
output: totalOutputTokens,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
normalizeKiroAuthMethod,
|
normalizeKiroAuthMethod,
|
||||||
toKiroManagementMethod,
|
toKiroManagementMethod,
|
||||||
} from '../../cliproxy/auth/auth-types';
|
} 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 type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||||
import {
|
import {
|
||||||
@@ -294,24 +294,11 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
|||||||
// Fetch CLIProxyAPI usage stats to determine active providers
|
// Fetch CLIProxyAPI usage stats to determine active providers
|
||||||
const stats = await fetchCliproxyStats();
|
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
|
// Update lastUsedAt for providers with recent activity
|
||||||
if (stats?.requestsByProvider) {
|
if (stats?.requestsByProvider) {
|
||||||
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
|
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
|
||||||
if (requestCount > 0) {
|
if (requestCount > 0) {
|
||||||
const provider = statsProviderMap[statsProvider.toLowerCase()];
|
const provider = mapExternalProviderName(statsProvider.toLowerCase());
|
||||||
if (provider) {
|
if (provider) {
|
||||||
// Touch the default account for this provider (or all accounts)
|
// Touch the default account for this provider (or all accounts)
|
||||||
const accounts = getProviderAccounts(provider);
|
const accounts = getProviderAccounts(provider);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
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';
|
import { buildCliproxyStatsFromUsageResponse } from '../../../src/cliproxy/stats-transformer';
|
||||||
|
|
||||||
describe('buildCliproxyStatsFromUsageResponse', () => {
|
describe('buildCliproxyStatsFromUsageResponse', () => {
|
||||||
@@ -121,4 +124,89 @@ describe('buildCliproxyStatsFromUsageResponse', () => {
|
|||||||
expect(stats.failureCount).toBe(2);
|
expect(stats.failureCount).toBe(2);
|
||||||
expect(stats.requestsByProvider).toEqual({ codex: 3, gemini: 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