mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
fix: count CLIProxy OAuth usage in dashboard stats (#1190)
* fix: count CLIProxy OAuth usage in dashboard stats * fix: fill cliproxy oauth usage details from logs * fix: keep mixed cliproxy oauth usage details * fix: avoid aggregate usage enrichment inflation * fix: cover oauth source prefixes and full log scans * fix: preserve distinct oauth usage details * fix: dedupe oauth log usage by auth identity * fix: drain usage queue and preserve repeated oauth requests * fix: guard usage queue drain against repeated full batches * fix(cliproxy): harden oauth usage stats merging
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -334,4 +334,211 @@ describe('buildCliproxyStatsFromUsageResponse', () => {
|
||||
failureCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes OAuth auth filenames before building account stats keys', () => {
|
||||
const usage: CliproxyUsageApiResponse = {
|
||||
usage: {
|
||||
total_requests: 1,
|
||||
apis: {
|
||||
codex: {
|
||||
total_requests: 1,
|
||||
models: {
|
||||
'gpt-5.5': {
|
||||
total_requests: 1,
|
||||
details: [
|
||||
createDetail({
|
||||
source: 'provider=codex auth_file=codex-user@example.com-pro.json',
|
||||
auth_index: 'codex-user@example.com-pro.json',
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage);
|
||||
|
||||
expect(stats.accountStats['codex:user@example.com']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'user@example.com',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes OAuth-prefixed auth filename sources before building account stats keys', () => {
|
||||
const usage: CliproxyUsageApiResponse = {
|
||||
usage: {
|
||||
total_requests: 2,
|
||||
apis: {
|
||||
codex: {
|
||||
total_requests: 2,
|
||||
models: {
|
||||
'gpt-5.5': {
|
||||
total_requests: 2,
|
||||
details: [
|
||||
createDetail({
|
||||
source: 'oauth|codex-user@example.com-pro.json',
|
||||
auth_index: 'oauth|codex-user@example.com-pro.json',
|
||||
}),
|
||||
createDetail({
|
||||
source: 'provider=codex auth_file=oauth|codex-user@example.com-pro.json',
|
||||
auth_index: 'oauth|codex-user@example.com-pro.json',
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage);
|
||||
|
||||
expect(stats.accountStats['codex:user@example.com']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'user@example.com',
|
||||
successCount: 2,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['codex:oauth|codex-user@example.com']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses detail-derived success and failure counts when aggregate counters are stale', () => {
|
||||
const usage: CliproxyUsageApiResponse = {
|
||||
failed_requests: 0,
|
||||
usage: {
|
||||
total_requests: 2,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
apis: {
|
||||
codex: {
|
||||
total_requests: 2,
|
||||
models: {
|
||||
'gpt-5.5': {
|
||||
total_requests: 2,
|
||||
details: [
|
||||
createDetail({
|
||||
source: 'provider=codex auth_file=codex-user@example.com-pro.json',
|
||||
auth_index: 'codex-user@example.com-pro.json',
|
||||
}),
|
||||
createDetail({
|
||||
source: 'provider=codex auth_file=codex-user@example.com-pro.json',
|
||||
auth_index: 'codex-user@example.com-pro.json',
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
failed: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage);
|
||||
|
||||
expect(stats.successCount).toBe(1);
|
||||
expect(stats.failureCount).toBe(1);
|
||||
expect(stats.quotaExceededCount).toBe(1);
|
||||
expect(stats.accountStats['codex:user@example.com']).toMatchObject({
|
||||
successCount: 1,
|
||||
failureCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses detail-derived totals and latest timestamps when aggregate request counts are stale', () => {
|
||||
const usage: CliproxyUsageApiResponse = {
|
||||
failed_requests: 0,
|
||||
usage: {
|
||||
total_requests: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
apis: {
|
||||
codex: {
|
||||
total_requests: 0,
|
||||
models: {
|
||||
'gpt-5.5': {
|
||||
total_requests: 0,
|
||||
details: [
|
||||
createDetail({
|
||||
timestamp: '2025-03-26T10:02:00.000Z',
|
||||
source: 'provider=codex auth_file=codex-user@example.com-pro.json',
|
||||
auth_index: 'codex-user@example.com-pro.json',
|
||||
failed: true,
|
||||
}),
|
||||
createDetail({
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
source: 'provider=codex auth_file=codex-user@example.com-pro.json',
|
||||
auth_index: 'codex-user@example.com-pro.json',
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage);
|
||||
|
||||
expect(stats.totalRequests).toBe(2);
|
||||
expect(stats.requestsByModel['gpt-5.5']).toBe(2);
|
||||
expect(stats.requestsByProvider.codex).toBe(2);
|
||||
expect(stats.successCount).toBe(1);
|
||||
expect(stats.failureCount).toBe(1);
|
||||
expect(stats.accountStats['codex:user@example.com']).toMatchObject({
|
||||
successCount: 1,
|
||||
failureCount: 1,
|
||||
lastUsedAt: '2025-03-26T10:02:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not strip plan-like suffixes from raw account identifiers', () => {
|
||||
const usage: CliproxyUsageApiResponse = {
|
||||
usage: {
|
||||
total_requests: 2,
|
||||
apis: {
|
||||
codex: {
|
||||
total_requests: 2,
|
||||
models: {
|
||||
'gpt-5.5': {
|
||||
total_requests: 2,
|
||||
details: [
|
||||
createDetail({
|
||||
source: 'provider=codex auth_file=codex-user-free@example.com.json',
|
||||
auth_index: 'codex-user-free@example.com.json',
|
||||
}),
|
||||
createDetail({
|
||||
source: 'codex-user@example.com-pro',
|
||||
auth_index: 'codex-user@example.com-pro',
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage);
|
||||
|
||||
expect(stats.accountStats['codex:user-free@example.com']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'user-free@example.com',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['codex:user@example.com-pro']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'user@example.com-pro',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['codex:user@example.com']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getCliproxyWritablePath } from '../config/path-resolver';
|
||||
import type { CliproxyUsageApiResponse } from './stats-fetcher';
|
||||
import {
|
||||
buildUsageResponseFromQueueRecords,
|
||||
hasUsageDetails,
|
||||
} from './usage-compatibility-transformer';
|
||||
|
||||
interface PendingOAuthRequest {
|
||||
timestamp?: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
authFile: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
interface ProviderCompletion {
|
||||
timestamp?: string;
|
||||
provider: string;
|
||||
requestId?: string;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
return value.trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function extractTimestamp(line: string): string | undefined {
|
||||
const isoTimestamp = line.match(/\d{4}-\d{2}-\d{2}T[^\s\]]+/)?.[0];
|
||||
if (isoTimestamp) {
|
||||
return isoTimestamp.replace(/[,\]]+$/, '');
|
||||
}
|
||||
|
||||
const bracketedTimestamp = line
|
||||
.match(/^\[(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})\]/)
|
||||
?.slice(1, 3);
|
||||
if (!bracketedTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [datePart, timePart] = bracketedTimestamp;
|
||||
const [year, month, day] = datePart.split('-').map(Number);
|
||||
const [hour, minute, second] = timePart.split(':').map(Number);
|
||||
return new Date(year, month - 1, day, hour, minute, second).toISOString();
|
||||
}
|
||||
|
||||
function extractRequestId(line: string): string | undefined {
|
||||
const explicitRequestId = line.match(
|
||||
/\b(?:request[_-]?id|req[_-]?id|rid)[=:]\s*([A-Za-z0-9._:-]+)/i
|
||||
)?.[1];
|
||||
if (explicitRequestId) {
|
||||
return explicitRequestId;
|
||||
}
|
||||
|
||||
const bracketedRequestId = line.match(/^\[[^\]]+\]\s+\[([^\]]+)\]/)?.[1]?.trim();
|
||||
return bracketedRequestId && bracketedRequestId !== '--------' ? bracketedRequestId : undefined;
|
||||
}
|
||||
|
||||
function parseOAuthSelection(line: string): PendingOAuthRequest | null {
|
||||
const match = line.match(
|
||||
/Use OAuth\s+provider=([^\s]+)\s+auth_file=("[^"]+"|'[^']+'|[^\s]+)\s+for model\s+("[^"]+"|'[^']+'|[^\s]+)/i
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: extractTimestamp(line),
|
||||
provider: unquote(match[1] ?? 'unknown'),
|
||||
authFile: unquote(match[2] ?? 'unknown'),
|
||||
model: unquote(match[3] ?? 'unknown'),
|
||||
requestId: extractRequestId(line),
|
||||
};
|
||||
}
|
||||
|
||||
function parseProviderCompletion(line: string): ProviderCompletion | null {
|
||||
const match = line.match(/\b(?:POST|GET)\s+"?\/api\/provider\/([^/"\s]+)\//i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: extractTimestamp(line),
|
||||
provider: unquote(match[1] ?? 'unknown'),
|
||||
requestId: extractRequestId(line),
|
||||
failed:
|
||||
/\b(?:failed|failure|error)\b/i.test(line) ||
|
||||
/\bstatus[=:]\s*[45]\d\d\b/i.test(line) ||
|
||||
/\s[45]\d\d(?:\s|$)/.test(line),
|
||||
};
|
||||
}
|
||||
|
||||
function addPendingRequest(
|
||||
pending: PendingOAuthRequest,
|
||||
byRequestId: Map<string, PendingOAuthRequest>,
|
||||
byProvider: Map<string, PendingOAuthRequest[]>
|
||||
): void {
|
||||
if (pending.requestId) {
|
||||
const previous = byRequestId.get(pending.requestId);
|
||||
if (previous) {
|
||||
const previousProviderQueue = byProvider.get(previous.provider) ?? [];
|
||||
byProvider.set(
|
||||
previous.provider,
|
||||
previousProviderQueue.filter((entry) => entry !== previous)
|
||||
);
|
||||
}
|
||||
byRequestId.set(pending.requestId, pending);
|
||||
}
|
||||
|
||||
const providerQueue = byProvider.get(pending.provider) ?? [];
|
||||
providerQueue.push(pending);
|
||||
byProvider.set(pending.provider, providerQueue);
|
||||
}
|
||||
|
||||
function takePendingRequest(
|
||||
completion: ProviderCompletion,
|
||||
byRequestId: Map<string, PendingOAuthRequest>,
|
||||
byProvider: Map<string, PendingOAuthRequest[]>
|
||||
): PendingOAuthRequest | null {
|
||||
const byId = completion.requestId ? byRequestId.get(completion.requestId) : undefined;
|
||||
if (byId) {
|
||||
byRequestId.delete(completion.requestId ?? '');
|
||||
const providerQueue = byProvider.get(byId.provider) ?? [];
|
||||
byProvider.set(
|
||||
byId.provider,
|
||||
providerQueue.filter((entry) => entry !== byId)
|
||||
);
|
||||
return byId;
|
||||
}
|
||||
|
||||
const providerQueue = byProvider.get(completion.provider) ?? [];
|
||||
const next = providerQueue.shift();
|
||||
byProvider.set(completion.provider, providerQueue);
|
||||
if (next?.requestId) {
|
||||
byRequestId.delete(next.requestId);
|
||||
}
|
||||
return next ?? null;
|
||||
}
|
||||
|
||||
export function buildUsageResponseFromCliproxyLogLines(lines: string[]): CliproxyUsageApiResponse {
|
||||
const pendingByRequestId = new Map<string, PendingOAuthRequest>();
|
||||
const pendingByProvider = new Map<string, PendingOAuthRequest[]>();
|
||||
const records: unknown[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const selection = parseOAuthSelection(line);
|
||||
if (selection) {
|
||||
addPendingRequest(selection, pendingByRequestId, pendingByProvider);
|
||||
continue;
|
||||
}
|
||||
|
||||
const completion = parseProviderCompletion(line);
|
||||
if (!completion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pending = takePendingRequest(completion, pendingByRequestId, pendingByProvider);
|
||||
if (!pending) {
|
||||
continue;
|
||||
}
|
||||
|
||||
records.push({
|
||||
timestamp: completion.timestamp ?? pending.timestamp ?? '1970-01-01T00:00:00.000Z',
|
||||
provider: pending.provider,
|
||||
model: pending.model,
|
||||
source: `provider=${pending.provider} auth_file=${pending.authFile}`,
|
||||
auth_index: pending.authFile,
|
||||
request_id: completion.requestId ?? pending.requestId,
|
||||
failed: completion.failed,
|
||||
tokens: {},
|
||||
});
|
||||
}
|
||||
|
||||
return buildUsageResponseFromQueueRecords(records);
|
||||
}
|
||||
|
||||
function readLogFile(filePath: string): string | null {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
export function buildUsageResponseFromCliproxyMainLog(): CliproxyUsageApiResponse | null {
|
||||
const logPath = path.join(getCliproxyWritablePath(), 'logs', 'main.log');
|
||||
const contents = readLogFile(logPath);
|
||||
if (!contents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = buildUsageResponseFromCliproxyLogLines(contents.split(/\r?\n/));
|
||||
return hasUsageDetails(response) ? response : null;
|
||||
}
|
||||
@@ -13,6 +13,22 @@ import {
|
||||
buildManagementHeaders,
|
||||
} from '../proxy/proxy-target-resolver';
|
||||
import { buildCliproxyStatsFromUsageResponse } from './stats-transformer';
|
||||
import { buildUsageResponseFromCliproxyMainLog } from './oauth-usage-log-transformer';
|
||||
import {
|
||||
buildUsageResponseFromApiKeyUsage,
|
||||
buildUsageResponseFromQueueRecords,
|
||||
hasUsageDetails,
|
||||
hasUsageTotals,
|
||||
mergeUsageResponseWithMissingDetails,
|
||||
mergeUsageResponses,
|
||||
} from './usage-compatibility-transformer';
|
||||
|
||||
interface ManagementJsonResult<T> {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: T | null;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
/** Per-account usage statistics */
|
||||
export interface AccountUsageStats {
|
||||
@@ -65,6 +81,7 @@ export interface CliproxyRequestDetail {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
auth_index: string | number;
|
||||
request_id?: string;
|
||||
tokens: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
@@ -112,6 +129,11 @@ export interface CliproxyManagementAuthFile {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const cachedUsageQueueResponses = new Map<string, CliproxyUsageApiResponse>();
|
||||
const USAGE_QUEUE_BATCH_SIZE = 1000;
|
||||
const USAGE_QUEUE_MAX_BATCHES = 100;
|
||||
const USAGE_QUEUE_DRAIN_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Fetch usage statistics from CLIProxyAPI management API
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
@@ -142,15 +164,159 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
|
||||
export async function fetchCliproxyUsageRaw(
|
||||
port?: number
|
||||
): Promise<CliproxyUsageApiResponse | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
let localLogUsage: CliproxyUsageApiResponse | null | undefined;
|
||||
const getLocalLogUsage = (): CliproxyUsageApiResponse | null => {
|
||||
if (localLogUsage !== undefined) {
|
||||
return localLogUsage;
|
||||
}
|
||||
|
||||
localLogUsage = getProxyTarget().isRemote ? null : buildUsageResponseFromCliproxyMainLog();
|
||||
return localLogUsage;
|
||||
};
|
||||
|
||||
const mergeLocalLogUsage = (response: CliproxyUsageApiResponse): CliproxyUsageApiResponse =>
|
||||
mergeUsageResponseWithMissingDetails(response, getLocalLogUsage());
|
||||
const mergeAggregateWithDetails = (
|
||||
aggregate: CliproxyUsageApiResponse,
|
||||
details: CliproxyUsageApiResponse
|
||||
): CliproxyUsageApiResponse =>
|
||||
mergeUsageResponseWithMissingDetails(aggregate, details, { appendExtraDetails: false });
|
||||
|
||||
let legacyAggregateUsage: CliproxyUsageApiResponse | null = null;
|
||||
const legacyUsage = await fetchManagementJson<CliproxyUsageApiResponse>(
|
||||
'/v0/management/usage',
|
||||
port
|
||||
);
|
||||
if (legacyUsage?.ok && legacyUsage.data) {
|
||||
if (hasUsageDetails(legacyUsage.data)) {
|
||||
return mergeLocalLogUsage(legacyUsage.data);
|
||||
}
|
||||
legacyAggregateUsage = legacyUsage.data;
|
||||
}
|
||||
|
||||
let cachedUsageQueueResponse: CliproxyUsageApiResponse | undefined;
|
||||
const usageQueue = await fetchUsageQueueRecords(port);
|
||||
if (usageQueue?.cacheKey) {
|
||||
cachedUsageQueueResponse = cachedUsageQueueResponses.get(usageQueue.cacheKey);
|
||||
}
|
||||
|
||||
if (usageQueue && Array.isArray(usageQueue.data)) {
|
||||
const queueResponse = buildUsageResponseFromQueueRecords(usageQueue.data);
|
||||
if (hasUsageDetails(queueResponse)) {
|
||||
const mergedUsageQueueResponse = cachedUsageQueueResponse
|
||||
? mergeUsageResponses(cachedUsageQueueResponse, queueResponse)
|
||||
: queueResponse;
|
||||
cachedUsageQueueResponses.set(usageQueue.cacheKey, mergedUsageQueueResponse);
|
||||
cachedUsageQueueResponse = mergedUsageQueueResponse;
|
||||
if (usageQueue.ok) {
|
||||
const response = legacyAggregateUsage
|
||||
? mergeAggregateWithDetails(legacyAggregateUsage, mergedUsageQueueResponse)
|
||||
: mergedUsageQueueResponse;
|
||||
return mergeLocalLogUsage(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (legacyAggregateUsage) {
|
||||
const response = cachedUsageQueueResponse
|
||||
? mergeAggregateWithDetails(legacyAggregateUsage, cachedUsageQueueResponse)
|
||||
: legacyAggregateUsage;
|
||||
return mergeLocalLogUsage(response);
|
||||
}
|
||||
|
||||
const apiKeyUsage = await fetchManagementJson<unknown>('/v0/management/api-key-usage', port);
|
||||
if (apiKeyUsage?.ok && apiKeyUsage.data) {
|
||||
const apiKeyResponseWithCachedDetails = cachedUsageQueueResponse
|
||||
? mergeAggregateWithDetails(
|
||||
buildUsageResponseFromApiKeyUsage(apiKeyUsage.data),
|
||||
cachedUsageQueueResponse
|
||||
)
|
||||
: buildUsageResponseFromApiKeyUsage(apiKeyUsage.data);
|
||||
const apiKeyResponse = mergeLocalLogUsage(apiKeyResponseWithCachedDetails);
|
||||
if (hasUsageTotals(apiKeyResponse)) {
|
||||
return apiKeyResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedUsageQueueResponse) {
|
||||
return mergeLocalLogUsage(cachedUsageQueueResponse);
|
||||
}
|
||||
|
||||
const logUsage = getLocalLogUsage();
|
||||
if (logUsage && hasUsageDetails(logUsage)) {
|
||||
return logUsage;
|
||||
}
|
||||
|
||||
if (usageQueue?.ok) {
|
||||
return buildUsageResponseFromQueueRecords([]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchUsageQueueRecords(
|
||||
port?: number
|
||||
): Promise<ManagementJsonResult<unknown[]> | null> {
|
||||
const records: unknown[] = [];
|
||||
const seenFullBatchSignatures = new Set<string>();
|
||||
let cacheKey = '';
|
||||
let status = 0;
|
||||
const drainStartedAt = Date.now();
|
||||
|
||||
for (let batchCount = 0; batchCount < USAGE_QUEUE_MAX_BATCHES; batchCount++) {
|
||||
const result = await fetchManagementJson<unknown[]>(
|
||||
`/v0/management/usage-queue?count=${USAGE_QUEUE_BATCH_SIZE}`,
|
||||
port
|
||||
);
|
||||
if (!result) {
|
||||
return records.length > 0 ? { ok: false, status, data: records, cacheKey } : null;
|
||||
}
|
||||
|
||||
cacheKey ||= result.cacheKey;
|
||||
status = result.status;
|
||||
if (!result.ok || !Array.isArray(result.data)) {
|
||||
return records.length > 0 ? { ok: false, status, data: records, cacheKey } : result;
|
||||
}
|
||||
|
||||
if (result.data.length === USAGE_QUEUE_BATCH_SIZE) {
|
||||
const batchSignature = createUsageQueueBatchSignature(result.data);
|
||||
if (seenFullBatchSignatures.has(batchSignature)) {
|
||||
return { ok: false, status, data: records, cacheKey };
|
||||
}
|
||||
seenFullBatchSignatures.add(batchSignature);
|
||||
}
|
||||
|
||||
records.push(...result.data);
|
||||
if (result.data.length < USAGE_QUEUE_BATCH_SIZE) {
|
||||
return { ok: true, status, data: records, cacheKey };
|
||||
}
|
||||
|
||||
if (Date.now() - drainStartedAt >= USAGE_QUEUE_DRAIN_TIMEOUT_MS) {
|
||||
return { ok: false, status, data: records, cacheKey };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, status, data: records, cacheKey };
|
||||
}
|
||||
|
||||
function createUsageQueueBatchSignature(records: unknown[]): string {
|
||||
return JSON.stringify(records);
|
||||
}
|
||||
|
||||
async function fetchManagementJson<T>(
|
||||
endpointPath: string,
|
||||
port?: number,
|
||||
timeoutMs = 5000
|
||||
): Promise<ManagementJsonResult<T> | null> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const target = getProxyTarget();
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v0/management/usage');
|
||||
const url = buildProxyUrl(target, endpointPath);
|
||||
|
||||
const headers = target.isRemote
|
||||
? buildManagementHeaders(target)
|
||||
@@ -161,51 +327,46 @@ export async function fetchCliproxyUsageRaw(
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
return { ok: false, status: response.status, data: null, cacheKey: url };
|
||||
}
|
||||
|
||||
return (await response.json()) as CliproxyUsageApiResponse;
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
data: (await response.json()) as T,
|
||||
cacheKey: url,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>(
|
||||
'/v0/management/auth-files',
|
||||
port
|
||||
);
|
||||
if (!result?.ok || !result.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { files?: CliproxyManagementAuthFile[] };
|
||||
const data = result.data;
|
||||
return Array.isArray(data.files) ? data.files : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
clearCachedUsageQueueResponse(): void {
|
||||
cachedUsageQueueResponses.clear();
|
||||
},
|
||||
};
|
||||
|
||||
/** OpenAI-compatible model object from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
|
||||
@@ -30,6 +30,44 @@ function normalizeProvider(provider: string): string {
|
||||
return mapExternalProviderName(normalized) ?? normalized;
|
||||
}
|
||||
|
||||
function extractAuthFilenameFromSource(source: string): string {
|
||||
const authFileMatch = source.match(/(?:^|\s)auth_file=("[^"]+"|'[^']+'|[^\s]+)/i);
|
||||
const rawValue = authFileMatch?.[1] ?? source;
|
||||
const value = rawValue.trim().replace(/^['"]|['"]$/g, '');
|
||||
const filenameCandidate = value.split('|').pop() ?? value;
|
||||
return filenameCandidate.split(/[\\/]/).pop() ?? filenameCandidate.trim();
|
||||
}
|
||||
|
||||
function stripKnownAuthPlanSuffix(value: string): string {
|
||||
return value.replace(/-(?:free|plus|pro|team|business|enterprise)$/i, '');
|
||||
}
|
||||
|
||||
function normalizeAuthFilenameSource(provider: string, source: string): string | null {
|
||||
const filename = extractAuthFilenameFromSource(source);
|
||||
const normalizedProvider = normalizeProvider(provider);
|
||||
const hadJsonExtension = /\.json$/i.test(filename);
|
||||
let candidate = filename.replace(/\.json$/i, '');
|
||||
const providerPrefix = `${normalizedProvider}-`;
|
||||
if (candidate.toLowerCase().startsWith(providerPrefix)) {
|
||||
candidate = candidate.slice(providerPrefix.length);
|
||||
}
|
||||
|
||||
candidate = candidate.replace(/^[a-f0-9]{8}[-_]/i, '');
|
||||
if (hadJsonExtension) {
|
||||
candidate = stripKnownAuthPlanSuffix(candidate);
|
||||
}
|
||||
|
||||
const parts = candidate.split('@');
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localPart = parts[0]?.trim();
|
||||
const domainPart = parts.slice(1).join('@').trim();
|
||||
const email = `${localPart}@${domainPart}`;
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) ? email.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function buildAuthIndexLookup(
|
||||
authFiles: CliproxyManagementAuthFile[] | undefined
|
||||
): ReadonlyMap<string, ResolvedAuthFile> {
|
||||
@@ -106,12 +144,38 @@ function resolveSourceForDetail(
|
||||
|
||||
const source = detail.source?.trim();
|
||||
if (source) {
|
||||
return source;
|
||||
return normalizeAuthFilenameSource(resolvedProvider, source) ?? source;
|
||||
}
|
||||
|
||||
return resolvedAuthFile?.source ?? 'unknown';
|
||||
}
|
||||
|
||||
function resolveCountWithDetails(aggregateCount: number | undefined, detailCount: number): number {
|
||||
if (aggregateCount === undefined) {
|
||||
return detailCount;
|
||||
}
|
||||
|
||||
return aggregateCount < detailCount ? detailCount : aggregateCount;
|
||||
}
|
||||
|
||||
function shouldReplaceLastUsedAt(current: string | undefined, next: string | undefined): boolean {
|
||||
if (!next) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextTime = Date.parse(next);
|
||||
if (!Number.isFinite(nextTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentTime = Date.parse(current);
|
||||
return !Number.isFinite(currentTime) || nextTime > currentTime;
|
||||
}
|
||||
|
||||
export function buildCliproxyStatsFromUsageResponse(
|
||||
data: CliproxyUsageApiResponse,
|
||||
options: BuildCliproxyStatsOptions = {}
|
||||
@@ -129,7 +193,7 @@ export function buildCliproxyStatsFromUsageResponse(
|
||||
|
||||
if (usage?.apis) {
|
||||
for (const [provider, providerData] of Object.entries(usage.apis)) {
|
||||
let sawProviderDetail = false;
|
||||
let providerDetailCount = 0;
|
||||
if (!providerData.models) {
|
||||
const normalizedProvider = normalizeProvider(provider);
|
||||
requestsByProvider[normalizedProvider] =
|
||||
@@ -138,14 +202,16 @@ export function buildCliproxyStatsFromUsageResponse(
|
||||
}
|
||||
|
||||
for (const [model, modelData] of Object.entries(providerData.models)) {
|
||||
requestsByModel[model] = modelData.total_requests ?? 0;
|
||||
let modelDetailCount = 0;
|
||||
if (!modelData.details) {
|
||||
requestsByModel[model] = (requestsByModel[model] ?? 0) + (modelData.total_requests ?? 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const detail of modelData.details) {
|
||||
sawAnyDetail = true;
|
||||
sawProviderDetail = true;
|
||||
providerDetailCount++;
|
||||
modelDetailCount++;
|
||||
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
|
||||
const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup);
|
||||
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
|
||||
@@ -172,26 +238,52 @@ export function buildCliproxyStatsFromUsageResponse(
|
||||
|
||||
const tokens = detail.tokens?.total_tokens ?? 0;
|
||||
accountStats[accountKey].totalTokens += tokens;
|
||||
accountStats[accountKey].lastUsedAt = detail.timestamp;
|
||||
if (shouldReplaceLastUsedAt(accountStats[accountKey].lastUsedAt, detail.timestamp)) {
|
||||
accountStats[accountKey].lastUsedAt = detail.timestamp;
|
||||
}
|
||||
totalInputTokens += detail.tokens?.input_tokens ?? 0;
|
||||
totalOutputTokens += detail.tokens?.output_tokens ?? 0;
|
||||
}
|
||||
|
||||
requestsByModel[model] =
|
||||
(requestsByModel[model] ?? 0) +
|
||||
resolveCountWithDetails(modelData.total_requests, modelDetailCount);
|
||||
}
|
||||
|
||||
if (!sawProviderDetail) {
|
||||
const providerTotal = providerData.total_requests ?? 0;
|
||||
if (providerDetailCount === 0) {
|
||||
const normalizedProvider = normalizeProvider(provider);
|
||||
requestsByProvider[normalizedProvider] =
|
||||
(requestsByProvider[normalizedProvider] ?? 0) + (providerData.total_requests ?? 0);
|
||||
(requestsByProvider[normalizedProvider] ?? 0) + providerTotal;
|
||||
} else if (providerTotal > providerDetailCount) {
|
||||
const normalizedProvider = normalizeProvider(provider);
|
||||
requestsByProvider[normalizedProvider] =
|
||||
(requestsByProvider[normalizedProvider] ?? 0) + (providerTotal - providerDetailCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateFailureCount = usage?.failure_count ?? data.failed_requests;
|
||||
const successCount = sawAnyDetail
|
||||
? resolveCountWithDetails(usage?.success_count, totalSuccessCount)
|
||||
: (usage?.success_count ?? 0);
|
||||
const failureCount = sawAnyDetail
|
||||
? resolveCountWithDetails(aggregateFailureCount, totalFailureCount)
|
||||
: (aggregateFailureCount ?? 0);
|
||||
const providerTotalRequests = Object.values(requestsByProvider).reduce(
|
||||
(total, count) => total + count,
|
||||
0
|
||||
);
|
||||
const totalRequests = Math.max(
|
||||
usage?.total_requests ?? 0,
|
||||
successCount + failureCount,
|
||||
providerTotalRequests
|
||||
);
|
||||
|
||||
return {
|
||||
totalRequests: usage?.total_requests ?? 0,
|
||||
successCount: sawAnyDetail ? totalSuccessCount : (usage?.success_count ?? 0),
|
||||
failureCount: sawAnyDetail
|
||||
? totalFailureCount
|
||||
: (usage?.failure_count ?? data.failed_requests ?? 0),
|
||||
totalRequests,
|
||||
successCount,
|
||||
failureCount,
|
||||
tokens: {
|
||||
input: totalInputTokens,
|
||||
output: totalOutputTokens,
|
||||
@@ -200,7 +292,7 @@ export function buildCliproxyStatsFromUsageResponse(
|
||||
requestsByModel,
|
||||
requestsByProvider,
|
||||
accountStats,
|
||||
quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0,
|
||||
quotaExceededCount: failureCount,
|
||||
retryCount: 0,
|
||||
collectedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
import type { CliproxyRequestDetail, CliproxyUsageApiResponse } from './stats-fetcher';
|
||||
|
||||
interface CliproxyUsageQueueRecord {
|
||||
timestamp?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
alias?: string;
|
||||
source?: string;
|
||||
auth_index?: string | number;
|
||||
request_id?: string;
|
||||
tokens?: Partial<CliproxyRequestDetail['tokens']>;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
interface ApiKeyUsageEntry {
|
||||
success?: number;
|
||||
failed?: number;
|
||||
}
|
||||
|
||||
type ApiKeyUsageResponse = Record<string, Record<string, ApiKeyUsageEntry>>;
|
||||
|
||||
interface MergeMissingDetailsOptions {
|
||||
appendExtraDetails?: boolean;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function normalizeTokens(rawTokens: unknown): CliproxyRequestDetail['tokens'] {
|
||||
const tokens = asRecord(rawTokens) ?? {};
|
||||
const input = asNumber(tokens.input_tokens);
|
||||
const output = asNumber(tokens.output_tokens);
|
||||
const reasoning = asNumber(tokens.reasoning_tokens);
|
||||
const cached = asNumber(tokens.cached_tokens);
|
||||
const explicitTotal = asNumber(tokens.total_tokens);
|
||||
const total = explicitTotal || input + output + reasoning + cached;
|
||||
|
||||
return {
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
reasoning_tokens: reasoning,
|
||||
cached_tokens: cached,
|
||||
total_tokens: total,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeQueueRecord(record: unknown): CliproxyUsageQueueRecord | null {
|
||||
const raw = asRecord(record);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = asString(raw.provider, 'unknown');
|
||||
const model = asString(raw.model, asString(raw.alias, 'unknown'));
|
||||
const source = asString(raw.source, 'unknown');
|
||||
const authIndex =
|
||||
typeof raw.auth_index === 'string' || typeof raw.auth_index === 'number'
|
||||
? raw.auth_index
|
||||
: source;
|
||||
|
||||
return {
|
||||
timestamp: asString(raw.timestamp, new Date().toISOString()),
|
||||
provider,
|
||||
model,
|
||||
alias: asString(raw.alias, model),
|
||||
source,
|
||||
auth_index: authIndex,
|
||||
request_id: asString(raw.request_id, ''),
|
||||
tokens: normalizeTokens(raw.tokens),
|
||||
failed: asBoolean(raw.failed),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureProviderBucket(
|
||||
response: CliproxyUsageApiResponse,
|
||||
provider: string
|
||||
): NonNullable<NonNullable<CliproxyUsageApiResponse['usage']>['apis']>[string] {
|
||||
const usage = (response.usage ??= { apis: {} });
|
||||
const apis = (usage.apis ??= {});
|
||||
return (apis[provider] ??= { total_requests: 0, total_tokens: 0, models: {} });
|
||||
}
|
||||
|
||||
function ensureModelBucket(
|
||||
providerBucket: NonNullable<NonNullable<CliproxyUsageApiResponse['usage']>['apis']>[string],
|
||||
model: string
|
||||
): NonNullable<typeof providerBucket.models>[string] {
|
||||
const models = (providerBucket.models ??= {});
|
||||
return (models[model] ??= { total_requests: 0, total_tokens: 0, details: [] });
|
||||
}
|
||||
|
||||
function addDetail(
|
||||
response: CliproxyUsageApiResponse,
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail
|
||||
): void {
|
||||
const usage = (response.usage ??= { apis: {} });
|
||||
const providerBucket = ensureProviderBucket(response, provider);
|
||||
const modelBucket = ensureModelBucket(providerBucket, model);
|
||||
const totalTokens = detail.tokens?.total_tokens ?? 0;
|
||||
|
||||
usage.total_requests = (usage.total_requests ?? 0) + 1;
|
||||
usage.total_tokens = (usage.total_tokens ?? 0) + totalTokens;
|
||||
if (detail.failed) {
|
||||
usage.failure_count = (usage.failure_count ?? 0) + 1;
|
||||
response.failed_requests = (response.failed_requests ?? 0) + 1;
|
||||
} else {
|
||||
usage.success_count = (usage.success_count ?? 0) + 1;
|
||||
}
|
||||
|
||||
providerBucket.total_requests = (providerBucket.total_requests ?? 0) + 1;
|
||||
providerBucket.total_tokens = (providerBucket.total_tokens ?? 0) + totalTokens;
|
||||
modelBucket.total_requests = (modelBucket.total_requests ?? 0) + 1;
|
||||
modelBucket.total_tokens = (modelBucket.total_tokens ?? 0) + totalTokens;
|
||||
(modelBucket.details ??= []).push(detail);
|
||||
}
|
||||
|
||||
function createDetailSignature(
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail
|
||||
): string {
|
||||
return [
|
||||
provider,
|
||||
model,
|
||||
detail.request_id?.trim() ?? '',
|
||||
detail.timestamp,
|
||||
detail.source,
|
||||
String(detail.auth_index),
|
||||
detail.tokens?.input_tokens ?? 0,
|
||||
detail.tokens?.output_tokens ?? 0,
|
||||
detail.tokens?.reasoning_tokens ?? 0,
|
||||
detail.tokens?.cached_tokens ?? 0,
|
||||
detail.tokens?.total_tokens ?? 0,
|
||||
detail.failed ? '1' : '0',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function collectResponseDetails(
|
||||
response: CliproxyUsageApiResponse
|
||||
): Array<{ provider: string; model: string; detail: CliproxyRequestDetail }> {
|
||||
const entries: Array<{ provider: string; model: string; detail: CliproxyRequestDetail }> = [];
|
||||
for (const [provider, providerData] of Object.entries(response.usage?.apis ?? {})) {
|
||||
for (const [model, modelData] of Object.entries(providerData.models ?? {})) {
|
||||
for (const detail of modelData.details ?? []) {
|
||||
entries.push({ provider, model, detail });
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildUsageResponseFromQueueRecords(records: unknown[]): CliproxyUsageApiResponse {
|
||||
const response: CliproxyUsageApiResponse = {
|
||||
failed_requests: 0,
|
||||
usage: {
|
||||
total_requests: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
total_tokens: 0,
|
||||
apis: {},
|
||||
},
|
||||
};
|
||||
|
||||
for (const rawRecord of records) {
|
||||
const record = normalizeQueueRecord(rawRecord);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
|
||||
addDetail(response, record.provider ?? 'unknown', record.model ?? 'unknown', {
|
||||
timestamp: record.timestamp ?? new Date().toISOString(),
|
||||
source: record.source ?? 'unknown',
|
||||
auth_index: record.auth_index ?? record.source ?? 'unknown',
|
||||
request_id: record.request_id || undefined,
|
||||
tokens: normalizeTokens(record.tokens),
|
||||
failed: record.failed === true,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function mergeUsageResponses(
|
||||
base: CliproxyUsageApiResponse,
|
||||
incoming: CliproxyUsageApiResponse
|
||||
): CliproxyUsageApiResponse {
|
||||
const merged = buildUsageResponseFromQueueRecords([]);
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of [...collectResponseDetails(base), ...collectResponseDetails(incoming)]) {
|
||||
const signature = createDetailSignature(entry.provider, entry.model, entry.detail);
|
||||
if (seen.has(signature)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(signature);
|
||||
addDetail(merged, entry.provider, entry.model, entry.detail);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function cloneUsageResponse(response: CliproxyUsageApiResponse): CliproxyUsageApiResponse {
|
||||
return {
|
||||
failed_requests: response.failed_requests ?? 0,
|
||||
usage: {
|
||||
total_requests: response.usage?.total_requests ?? 0,
|
||||
success_count: response.usage?.success_count ?? 0,
|
||||
failure_count: response.usage?.failure_count ?? 0,
|
||||
total_tokens: response.usage?.total_tokens ?? 0,
|
||||
apis: Object.fromEntries(
|
||||
Object.entries(response.usage?.apis ?? {}).map(([provider, providerData]) => [
|
||||
provider,
|
||||
{
|
||||
total_requests: providerData.total_requests ?? 0,
|
||||
total_tokens: providerData.total_tokens ?? 0,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(providerData.models ?? {}).map(([model, modelData]) => [
|
||||
model,
|
||||
{
|
||||
total_requests: modelData.total_requests ?? 0,
|
||||
total_tokens: modelData.total_tokens ?? 0,
|
||||
details: (modelData.details ?? []).map((detail) => ({
|
||||
...detail,
|
||||
tokens: { ...detail.tokens },
|
||||
})),
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloneDetail(detail: CliproxyRequestDetail): CliproxyRequestDetail {
|
||||
return {
|
||||
...detail,
|
||||
tokens: { ...detail.tokens },
|
||||
};
|
||||
}
|
||||
|
||||
function createMissingDetailMergeKey(
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail
|
||||
): string {
|
||||
return [
|
||||
provider,
|
||||
model,
|
||||
detail.request_id?.trim() ?? '',
|
||||
detail.timestamp,
|
||||
detail.source?.trim() ?? '',
|
||||
String(detail.auth_index ?? '').trim(),
|
||||
detail.tokens?.input_tokens ?? 0,
|
||||
detail.tokens?.output_tokens ?? 0,
|
||||
detail.tokens?.reasoning_tokens ?? 0,
|
||||
detail.tokens?.cached_tokens ?? 0,
|
||||
detail.tokens?.total_tokens ?? 0,
|
||||
detail.failed ? '1' : '0',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function extractDuplicateIdentityValue(value: string): string {
|
||||
const authFileMatch = value.match(/(?:^|\s)auth_file=("[^"]+"|'[^']+'|[^\s]+)/i);
|
||||
const rawValue = authFileMatch?.[1] ?? value;
|
||||
const unquotedValue = rawValue.trim().replace(/^['"]|['"]$/g, '');
|
||||
const pipeCandidate = unquotedValue.split('|').pop() ?? unquotedValue;
|
||||
return pipeCandidate.split(/[\\/]/).pop() ?? pipeCandidate.trim();
|
||||
}
|
||||
|
||||
function stripKnownAuthPlanSuffix(value: string): string {
|
||||
return value.replace(/-(?:free|plus|pro|team|business|enterprise)$/i, '');
|
||||
}
|
||||
|
||||
function normalizeDuplicateIdentity(provider: string, value: string | number | undefined): string {
|
||||
const rawValue = String(value ?? '').trim();
|
||||
if (!rawValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedProvider = provider.trim().toLowerCase();
|
||||
const identityValue = extractDuplicateIdentityValue(rawValue);
|
||||
const hadJsonExtension = /\.json$/i.test(identityValue);
|
||||
let candidate = identityValue.replace(/\.json$/i, '');
|
||||
const providerPrefix = `${normalizedProvider}-`;
|
||||
if (candidate.toLowerCase().startsWith(providerPrefix)) {
|
||||
candidate = candidate.slice(providerPrefix.length);
|
||||
}
|
||||
|
||||
candidate = candidate.replace(/^[a-f0-9]{8}[-_]/i, '').trim();
|
||||
if (hadJsonExtension) {
|
||||
candidate = stripKnownAuthPlanSuffix(candidate);
|
||||
}
|
||||
|
||||
return candidate ? candidate.toLowerCase() : rawValue.toLowerCase();
|
||||
}
|
||||
|
||||
function resolveCompleteDetailDuplicateIdentity(
|
||||
provider: string,
|
||||
detail: CliproxyRequestDetail
|
||||
): string {
|
||||
return (
|
||||
normalizeDuplicateIdentity(provider, detail.source) ||
|
||||
normalizeDuplicateIdentity(provider, detail.auth_index) ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
function createLikelyLogDuplicateKey(
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail
|
||||
): string {
|
||||
return [
|
||||
provider,
|
||||
model,
|
||||
resolveCompleteDetailDuplicateIdentity(provider, detail),
|
||||
detail.failed ? '1' : '0',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function createRequestIdDuplicateKey(
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail
|
||||
): string {
|
||||
const requestId = detail.request_id?.trim();
|
||||
return requestId ? [provider, model, requestId, detail.failed ? '1' : '0'].join('|') : '';
|
||||
}
|
||||
|
||||
function parseDetailTimestamp(detail: CliproxyRequestDetail): number | null {
|
||||
const timestampMs = Date.parse(detail.timestamp);
|
||||
return Number.isFinite(timestampMs) ? timestampMs : null;
|
||||
}
|
||||
|
||||
const TOKENLESS_LOG_DUPLICATE_WINDOW_MS = 5_000;
|
||||
|
||||
interface LikelyLogDuplicateCandidates {
|
||||
byRequestId: Map<string, number>;
|
||||
byIdentity: Map<string, number[]>;
|
||||
}
|
||||
|
||||
function detailHasTokenUsage(detail: CliproxyRequestDetail): boolean {
|
||||
return (
|
||||
(detail.tokens?.input_tokens ?? 0) > 0 ||
|
||||
(detail.tokens?.output_tokens ?? 0) > 0 ||
|
||||
(detail.tokens?.reasoning_tokens ?? 0) > 0 ||
|
||||
(detail.tokens?.cached_tokens ?? 0) > 0 ||
|
||||
(detail.tokens?.total_tokens ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function collectDetailMergeKeyCounts(response: CliproxyUsageApiResponse): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of collectResponseDetails(response)) {
|
||||
const key = createMissingDetailMergeKey(entry.provider, entry.model, entry.detail);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function collectLikelyLogDuplicateCandidates(
|
||||
response: CliproxyUsageApiResponse
|
||||
): LikelyLogDuplicateCandidates {
|
||||
const candidates: LikelyLogDuplicateCandidates = {
|
||||
byRequestId: new Map<string, number>(),
|
||||
byIdentity: new Map<string, number[]>(),
|
||||
};
|
||||
for (const [provider, providerData] of Object.entries(response.usage?.apis ?? {})) {
|
||||
for (const [model, modelData] of Object.entries(providerData.models ?? {})) {
|
||||
const details = modelData.details ?? [];
|
||||
const canUseIdentityWindow = (modelData.total_requests ?? 0) <= details.length;
|
||||
for (const detail of details) {
|
||||
const requestIdKey = createRequestIdDuplicateKey(provider, model, detail);
|
||||
if (requestIdKey) {
|
||||
candidates.byRequestId.set(
|
||||
requestIdKey,
|
||||
(candidates.byRequestId.get(requestIdKey) ?? 0) + 1
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUseIdentityWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestampMs = parseDetailTimestamp(detail);
|
||||
if (timestampMs === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = createLikelyLogDuplicateKey(provider, model, detail);
|
||||
const timestamps = candidates.byIdentity.get(key) ?? [];
|
||||
timestamps.push(timestampMs);
|
||||
candidates.byIdentity.set(key, timestamps);
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function consumeDetailKey(counts: Map<string, number>, key: string): boolean {
|
||||
const count = counts.get(key) ?? 0;
|
||||
if (count <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
counts.delete(key);
|
||||
} else {
|
||||
counts.set(key, count - 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function consumeCount(counts: Map<string, number>, key: string): boolean {
|
||||
const count = counts.get(key) ?? 0;
|
||||
if (count <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
counts.delete(key);
|
||||
} else {
|
||||
counts.set(key, count - 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function consumeLikelyLogDuplicateCandidate(
|
||||
candidates: LikelyLogDuplicateCandidates,
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail
|
||||
): boolean {
|
||||
if (detailHasTokenUsage(detail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestIdKey = createRequestIdDuplicateKey(provider, model, detail);
|
||||
if (requestIdKey && consumeCount(candidates.byRequestId, requestIdKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const timestampMs = parseDetailTimestamp(detail);
|
||||
if (timestampMs === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = createLikelyLogDuplicateKey(provider, model, detail);
|
||||
const timestamps = candidates.byIdentity.get(key) ?? [];
|
||||
const index = timestamps.findIndex(
|
||||
(candidateTimestampMs) =>
|
||||
Math.abs(candidateTimestampMs - timestampMs) <= TOKENLESS_LOG_DUPLICATE_WINDOW_MS
|
||||
);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
timestamps.splice(index, 1);
|
||||
if (timestamps.length === 0) {
|
||||
candidates.byIdentity.delete(key);
|
||||
} else {
|
||||
candidates.byIdentity.set(key, timestamps);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function countProviderDetails(
|
||||
providerBucket: NonNullable<NonNullable<CliproxyUsageApiResponse['usage']>['apis']>[string]
|
||||
): number {
|
||||
return Object.values(providerBucket.models ?? {}).reduce(
|
||||
(total, modelData) => total + (modelData.details ?? []).length,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function appendDetailToExistingProvider(
|
||||
merged: CliproxyUsageApiResponse,
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail,
|
||||
options: Required<MergeMissingDetailsOptions>
|
||||
): void {
|
||||
const providerBucket = ensureProviderBucket(merged, provider);
|
||||
const shouldFillAggregateOnly =
|
||||
(providerBucket.total_requests ?? 0) > countProviderDetails(providerBucket);
|
||||
if (!shouldFillAggregateOnly) {
|
||||
if (!options.appendExtraDetails) {
|
||||
return;
|
||||
}
|
||||
addDetail(merged, provider, model, cloneDetail(detail));
|
||||
return;
|
||||
}
|
||||
|
||||
const modelBucket = ensureModelBucket(providerBucket, model);
|
||||
const shouldFillModelAggregateOnly =
|
||||
(modelBucket.total_requests ?? 0) > (modelBucket.details ?? []).length;
|
||||
|
||||
if (!shouldFillModelAggregateOnly) {
|
||||
modelBucket.total_requests = (modelBucket.total_requests ?? 0) + 1;
|
||||
}
|
||||
(modelBucket.details ??= []).push(cloneDetail(detail));
|
||||
}
|
||||
|
||||
export function mergeUsageResponseWithMissingDetails(
|
||||
base: CliproxyUsageApiResponse,
|
||||
incoming: CliproxyUsageApiResponse | null | undefined,
|
||||
options: MergeMissingDetailsOptions = {}
|
||||
): CliproxyUsageApiResponse {
|
||||
if (!incoming || !hasUsageDetails(incoming)) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const normalizedOptions: Required<MergeMissingDetailsOptions> = {
|
||||
appendExtraDetails: options.appendExtraDetails ?? true,
|
||||
};
|
||||
const merged = cloneUsageResponse(base);
|
||||
const existingDetailCounts = collectDetailMergeKeyCounts(base);
|
||||
const likelyLogDuplicateCandidates = collectLikelyLogDuplicateCandidates(base);
|
||||
for (const entry of collectResponseDetails(incoming)) {
|
||||
const mergeKey = createMissingDetailMergeKey(entry.provider, entry.model, entry.detail);
|
||||
if (consumeDetailKey(existingDetailCounts, mergeKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
consumeLikelyLogDuplicateCandidate(
|
||||
likelyLogDuplicateCandidates,
|
||||
entry.provider,
|
||||
entry.model,
|
||||
entry.detail
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (base.usage?.apis?.[entry.provider]) {
|
||||
appendDetailToExistingProvider(
|
||||
merged,
|
||||
entry.provider,
|
||||
entry.model,
|
||||
entry.detail,
|
||||
normalizedOptions
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
addDetail(merged, entry.provider, entry.model, cloneDetail(entry.detail));
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function buildUsageResponseFromApiKeyUsage(rawResponse: unknown): CliproxyUsageApiResponse {
|
||||
const response = buildUsageResponseFromQueueRecords([]);
|
||||
const usage = response.usage ?? {
|
||||
total_requests: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
total_tokens: 0,
|
||||
apis: {},
|
||||
};
|
||||
response.usage = usage;
|
||||
const byProvider = asRecord(rawResponse) as ApiKeyUsageResponse | null;
|
||||
if (!byProvider) {
|
||||
return response;
|
||||
}
|
||||
|
||||
for (const [provider, sources] of Object.entries(byProvider)) {
|
||||
const sourceRecords = asRecord(sources);
|
||||
if (!sourceRecords) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let providerRequests = 0;
|
||||
for (const entry of Object.values(sourceRecords)) {
|
||||
const usageEntry = asRecord(entry);
|
||||
if (!usageEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const success = asNumber(usageEntry.success);
|
||||
const failed = asNumber(usageEntry.failed);
|
||||
providerRequests += success + failed;
|
||||
usage.success_count = (usage.success_count ?? 0) + success;
|
||||
usage.failure_count = (usage.failure_count ?? 0) + failed;
|
||||
response.failed_requests = (response.failed_requests ?? 0) + failed;
|
||||
}
|
||||
|
||||
if (providerRequests > 0) {
|
||||
const providerBucket = ensureProviderBucket(response, provider);
|
||||
providerBucket.total_requests = (providerBucket.total_requests ?? 0) + providerRequests;
|
||||
usage.total_requests = (usage.total_requests ?? 0) + providerRequests;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function hasUsageDetails(response: CliproxyUsageApiResponse): boolean {
|
||||
return collectResponseDetails(response).length > 0;
|
||||
}
|
||||
|
||||
export function hasUsageTotals(response: CliproxyUsageApiResponse): boolean {
|
||||
return (response.usage?.total_requests ?? 0) > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user