mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +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,
|
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,
|
buildManagementHeaders,
|
||||||
} from '../proxy/proxy-target-resolver';
|
} from '../proxy/proxy-target-resolver';
|
||||||
import { buildCliproxyStatsFromUsageResponse } from './stats-transformer';
|
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 */
|
/** Per-account usage statistics */
|
||||||
export interface AccountUsageStats {
|
export interface AccountUsageStats {
|
||||||
@@ -65,6 +81,7 @@ export interface CliproxyRequestDetail {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
source: string;
|
source: string;
|
||||||
auth_index: string | number;
|
auth_index: string | number;
|
||||||
|
request_id?: string;
|
||||||
tokens: {
|
tokens: {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
@@ -112,6 +129,11 @@ export interface CliproxyManagementAuthFile {
|
|||||||
name?: string;
|
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
|
* Fetch usage statistics from CLIProxyAPI management API
|
||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
@@ -142,15 +164,159 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
|
|||||||
export async function fetchCliproxyUsageRaw(
|
export async function fetchCliproxyUsageRaw(
|
||||||
port?: number
|
port?: number
|
||||||
): Promise<CliproxyUsageApiResponse | null> {
|
): Promise<CliproxyUsageApiResponse | null> {
|
||||||
try {
|
let localLogUsage: CliproxyUsageApiResponse | null | undefined;
|
||||||
const controller = new AbortController();
|
const getLocalLogUsage = (): CliproxyUsageApiResponse | null => {
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
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();
|
const target = getProxyTarget();
|
||||||
if (port !== undefined && !target.isRemote) {
|
if (port !== undefined && !target.isRemote) {
|
||||||
target.port = port;
|
target.port = port;
|
||||||
}
|
}
|
||||||
const url = buildProxyUrl(target, '/v0/management/usage');
|
const url = buildProxyUrl(target, endpointPath);
|
||||||
|
|
||||||
const headers = target.isRemote
|
const headers = target.isRemote
|
||||||
? buildManagementHeaders(target)
|
? buildManagementHeaders(target)
|
||||||
@@ -161,51 +327,46 @@ export async function fetchCliproxyUsageRaw(
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCliproxyAuthFiles(port?: number): Promise<CliproxyManagementAuthFile[] | null> {
|
async function fetchCliproxyAuthFiles(port?: number): Promise<CliproxyManagementAuthFile[] | null> {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>(
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
'/v0/management/auth-files',
|
||||||
|
port
|
||||||
const target = getProxyTarget();
|
);
|
||||||
if (port !== undefined && !target.isRemote) {
|
if (!result?.ok || !result.data) {
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as { files?: CliproxyManagementAuthFile[] };
|
const data = result.data;
|
||||||
return Array.isArray(data.files) ? data.files : null;
|
return Array.isArray(data.files) ? data.files : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __testExports = {
|
||||||
|
clearCachedUsageQueueResponse(): void {
|
||||||
|
cachedUsageQueueResponses.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** 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;
|
||||||
|
|||||||
@@ -30,6 +30,44 @@ function normalizeProvider(provider: string): string {
|
|||||||
return mapExternalProviderName(normalized) ?? normalized;
|
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(
|
function buildAuthIndexLookup(
|
||||||
authFiles: CliproxyManagementAuthFile[] | undefined
|
authFiles: CliproxyManagementAuthFile[] | undefined
|
||||||
): ReadonlyMap<string, ResolvedAuthFile> {
|
): ReadonlyMap<string, ResolvedAuthFile> {
|
||||||
@@ -106,12 +144,38 @@ function resolveSourceForDetail(
|
|||||||
|
|
||||||
const source = detail.source?.trim();
|
const source = detail.source?.trim();
|
||||||
if (source) {
|
if (source) {
|
||||||
return source;
|
return normalizeAuthFilenameSource(resolvedProvider, source) ?? source;
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolvedAuthFile?.source ?? 'unknown';
|
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(
|
export function buildCliproxyStatsFromUsageResponse(
|
||||||
data: CliproxyUsageApiResponse,
|
data: CliproxyUsageApiResponse,
|
||||||
options: BuildCliproxyStatsOptions = {}
|
options: BuildCliproxyStatsOptions = {}
|
||||||
@@ -129,7 +193,7 @@ export function buildCliproxyStatsFromUsageResponse(
|
|||||||
|
|
||||||
if (usage?.apis) {
|
if (usage?.apis) {
|
||||||
for (const [provider, providerData] of Object.entries(usage.apis)) {
|
for (const [provider, providerData] of Object.entries(usage.apis)) {
|
||||||
let sawProviderDetail = false;
|
let providerDetailCount = 0;
|
||||||
if (!providerData.models) {
|
if (!providerData.models) {
|
||||||
const normalizedProvider = normalizeProvider(provider);
|
const normalizedProvider = normalizeProvider(provider);
|
||||||
requestsByProvider[normalizedProvider] =
|
requestsByProvider[normalizedProvider] =
|
||||||
@@ -138,14 +202,16 @@ export function buildCliproxyStatsFromUsageResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [model, modelData] of Object.entries(providerData.models)) {
|
for (const [model, modelData] of Object.entries(providerData.models)) {
|
||||||
requestsByModel[model] = modelData.total_requests ?? 0;
|
let modelDetailCount = 0;
|
||||||
if (!modelData.details) {
|
if (!modelData.details) {
|
||||||
|
requestsByModel[model] = (requestsByModel[model] ?? 0) + (modelData.total_requests ?? 0);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const detail of modelData.details) {
|
for (const detail of modelData.details) {
|
||||||
sawAnyDetail = true;
|
sawAnyDetail = true;
|
||||||
sawProviderDetail = true;
|
providerDetailCount++;
|
||||||
|
modelDetailCount++;
|
||||||
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
|
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
|
||||||
const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup);
|
const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup);
|
||||||
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
|
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
|
||||||
@@ -172,26 +238,52 @@ export function buildCliproxyStatsFromUsageResponse(
|
|||||||
|
|
||||||
const tokens = detail.tokens?.total_tokens ?? 0;
|
const tokens = detail.tokens?.total_tokens ?? 0;
|
||||||
accountStats[accountKey].totalTokens += tokens;
|
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;
|
totalInputTokens += detail.tokens?.input_tokens ?? 0;
|
||||||
totalOutputTokens += detail.tokens?.output_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);
|
const normalizedProvider = normalizeProvider(provider);
|
||||||
requestsByProvider[normalizedProvider] =
|
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 {
|
return {
|
||||||
totalRequests: usage?.total_requests ?? 0,
|
totalRequests,
|
||||||
successCount: sawAnyDetail ? totalSuccessCount : (usage?.success_count ?? 0),
|
successCount,
|
||||||
failureCount: sawAnyDetail
|
failureCount,
|
||||||
? totalFailureCount
|
|
||||||
: (usage?.failure_count ?? data.failed_requests ?? 0),
|
|
||||||
tokens: {
|
tokens: {
|
||||||
input: totalInputTokens,
|
input: totalInputTokens,
|
||||||
output: totalOutputTokens,
|
output: totalOutputTokens,
|
||||||
@@ -200,7 +292,7 @@ export function buildCliproxyStatsFromUsageResponse(
|
|||||||
requestsByModel,
|
requestsByModel,
|
||||||
requestsByProvider,
|
requestsByProvider,
|
||||||
accountStats,
|
accountStats,
|
||||||
quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0,
|
quotaExceededCount: failureCount,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
collectedAt: new Date().toISOString(),
|
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