mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
feat(usage): attribute CLIProxy usage to accounts for per-account cost
Carry the CLIProxy auth_index through the usage transformer and aggregator, build an auth_index->account map from the auth files, and wire it into the usage syncer so persisted snapshots stamp accountId. Adds getTodayCostByAccount and a snapshot detail reader. Backward-compatible: accountId is optional and profile-based aggregation is unchanged.
This commit is contained in:
@@ -344,7 +344,9 @@ async function fetchManagementJson<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCliproxyAuthFiles(port?: number): Promise<CliproxyManagementAuthFile[] | null> {
|
||||
export async function fetchCliproxyAuthFiles(
|
||||
port?: number
|
||||
): Promise<CliproxyManagementAuthFile[] | null> {
|
||||
try {
|
||||
const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>(
|
||||
'/v0/management/auth-files',
|
||||
@@ -367,6 +369,29 @@ export const __testExports = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an auth_index → account email/id map from CLIProxy auth file metadata.
|
||||
*
|
||||
* Keys are stored as strings (String(auth_index)) so both numeric and string
|
||||
* auth_index values resolve consistently via `map.get(String(auth_index))`.
|
||||
*
|
||||
* Entries missing either `auth_index` or `email` are silently skipped.
|
||||
*
|
||||
* @param authFiles Auth file records from /v0/management/auth-files
|
||||
* @returns Map from String(auth_index) → email
|
||||
*/
|
||||
export function buildAuthIndexToAccountMap(
|
||||
authFiles: CliproxyManagementAuthFile[]
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const file of authFiles) {
|
||||
if (file.auth_index === undefined || file.auth_index === null) continue;
|
||||
if (!file.email || file.email.trim().length === 0) continue;
|
||||
map.set(String(file.auth_index), file.email.trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** OpenAI-compatible model object from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* CLIProxy Snapshot Reader
|
||||
*
|
||||
* Lightweight reader that extracts the flat CliproxyUsageHistoryDetail array
|
||||
* from the persisted snapshot at ~/.ccs/cache/cliproxy-usage/latest.json.
|
||||
*
|
||||
* This is a read-only helper — it never writes or syncs. The syncer owns
|
||||
* the write path; this module owns the "give me the raw details" path
|
||||
* needed by the bar-routes aggregator for per-account cost mapping.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import {
|
||||
normalizeCliproxyUsageHistoryDetail,
|
||||
type CliproxyUsageHistoryDetail,
|
||||
} from './cliproxy-usage-transformer';
|
||||
|
||||
const SUPPORTED_SNAPSHOT_VERSION = 3;
|
||||
|
||||
function getLatestSnapshotPath(): string {
|
||||
return path.join(getCcsDir(), 'cache', 'cliproxy-usage', 'latest.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted CLIProxy usage snapshot and return its raw detail records.
|
||||
*
|
||||
* Returns an empty array when:
|
||||
* - The snapshot file does not exist
|
||||
* - The file cannot be parsed as JSON
|
||||
* - The snapshot version is unrecognised
|
||||
* - The details array is absent or malformed
|
||||
*
|
||||
* Individual malformed detail records are silently dropped (normalizer returns null).
|
||||
*/
|
||||
export async function loadCliproxySnapshotDetails(): Promise<CliproxyUsageHistoryDetail[]> {
|
||||
const snapshotPath = getLatestSnapshotPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(snapshotPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(snapshotPath, 'utf-8');
|
||||
const snapshot = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
if (snapshot.version !== SUPPORTED_SNAPSHOT_VERSION) {
|
||||
// Legacy / future snapshot — skip rather than mis-interpret
|
||||
return [];
|
||||
}
|
||||
|
||||
const details = snapshot.details;
|
||||
if (!Array.isArray(details)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return details
|
||||
.map((item) => normalizeCliproxyUsageHistoryDetail(item))
|
||||
.filter((item): item is CliproxyUsageHistoryDetail => item !== null);
|
||||
} catch {
|
||||
// IO / parse errors are non-fatal — bar glance degrades gracefully
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fetchCliproxyUsageRaw } from '../../cliproxy/services/stats-fetcher';
|
||||
import {
|
||||
fetchCliproxyUsageRaw,
|
||||
fetchCliproxyAuthFiles,
|
||||
buildAuthIndexToAccountMap,
|
||||
} from '../../cliproxy/services/stats-fetcher';
|
||||
import {
|
||||
buildCliproxyUsageHistoryAggregates,
|
||||
extractCliproxyUsageHistoryDetails,
|
||||
@@ -42,6 +46,7 @@ type LegacyCliproxyUsageSnapshot = {
|
||||
};
|
||||
|
||||
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
|
||||
type FetchCliproxyAuthFiles = typeof fetchCliproxyAuthFiles;
|
||||
|
||||
const SNAPSHOT_VERSION = 3;
|
||||
const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -318,7 +323,8 @@ export async function loadCachedCliproxyData(): Promise<{
|
||||
}
|
||||
|
||||
export async function syncCliproxyUsage(
|
||||
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
|
||||
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw,
|
||||
fetchAuthFiles: FetchCliproxyAuthFiles = fetchCliproxyAuthFiles
|
||||
): Promise<void> {
|
||||
const raw = await fetchRaw();
|
||||
|
||||
@@ -327,8 +333,20 @@ export async function syncCliproxyUsage(
|
||||
return;
|
||||
}
|
||||
|
||||
// Build auth_index → account email map for attribution.
|
||||
// Auth file fetch failure is non-fatal: fall back to undefined map (cost = 0).
|
||||
let accountMap: Map<string, string> | undefined;
|
||||
try {
|
||||
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw));
|
||||
const authFiles = await fetchAuthFiles();
|
||||
if (authFiles !== null) {
|
||||
accountMap = buildAuthIndexToAccountMap(authFiles);
|
||||
}
|
||||
} catch {
|
||||
// Auth files unavailable — proceed without account attribution
|
||||
}
|
||||
|
||||
try {
|
||||
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw, accountMap));
|
||||
} catch (err) {
|
||||
console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { getModelsUsed, normalizeUsageProvider } from './model-identity';
|
||||
export interface CliproxyUsageHistoryDetail {
|
||||
model: string;
|
||||
provider?: string;
|
||||
/** CLIProxy account email/id derived from auth_index lookup. Populated when an accountMap is supplied. */
|
||||
accountId?: string;
|
||||
timestamp: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -59,16 +61,25 @@ function buildModelBreakdown(
|
||||
function createHistoryDetail(
|
||||
provider: string,
|
||||
model: string,
|
||||
detail: CliproxyRequestDetail
|
||||
detail: CliproxyRequestDetail,
|
||||
accountMap?: Map<number | string, string>
|
||||
): CliproxyUsageHistoryDetail {
|
||||
const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase();
|
||||
const inputTokens = detail.tokens?.input_tokens ?? 0;
|
||||
const outputTokens = detail.tokens?.output_tokens ?? 0;
|
||||
const cacheReadTokens = detail.tokens?.cached_tokens ?? 0;
|
||||
|
||||
// Resolve accountId from auth_index → account map, falling back to detail.source
|
||||
let accountId: string | undefined;
|
||||
if (accountMap !== undefined) {
|
||||
const key = String(detail.auth_index);
|
||||
accountId = accountMap.get(key) ?? accountMap.get(detail.auth_index) ?? detail.source;
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
provider: pricingProvider,
|
||||
...(accountId !== undefined && { accountId }),
|
||||
timestamp: detail.timestamp,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -147,9 +158,15 @@ export function normalizeCliproxyUsageHistoryDetail(
|
||||
)
|
||||
);
|
||||
|
||||
const accountId =
|
||||
typeof candidate.accountId === 'string' && candidate.accountId.length > 0
|
||||
? candidate.accountId
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
model: candidate.model,
|
||||
...(provider && { provider }),
|
||||
...(accountId !== undefined && { accountId }),
|
||||
timestamp: candidate.timestamp,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -177,9 +194,14 @@ function hasTrackedUsage(detail: CliproxyRequestDetail): boolean {
|
||||
* Flatten the nested response.usage.apis[provider].models[model].details[]
|
||||
* structure into normalized history details. Failed requests are retained only
|
||||
* when they still report tracked token usage that analytics can account for.
|
||||
*
|
||||
* @param accountMap Optional auth_index → account email/id map. When provided,
|
||||
* each detail's `accountId` is resolved from auth_index; falls back to
|
||||
* `detail.source` when the index is not in the map.
|
||||
*/
|
||||
export function extractCliproxyUsageHistoryDetails(
|
||||
response: CliproxyUsageApiResponse
|
||||
response: CliproxyUsageApiResponse,
|
||||
accountMap?: Map<number | string, string>
|
||||
): CliproxyUsageHistoryDetail[] {
|
||||
const apis = response?.usage?.apis;
|
||||
if (!apis) return [];
|
||||
@@ -193,7 +215,7 @@ export function extractCliproxyUsageHistoryDetails(
|
||||
if (!details) continue;
|
||||
for (const detail of details) {
|
||||
if (detail.failed && !hasTrackedUsage(detail)) continue;
|
||||
results.push(createHistoryDetail(provider, model, detail));
|
||||
results.push(createHistoryDetail(provider, model, detail, accountMap));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +226,7 @@ function sanitizeHistoryDetail(detail: CliproxyUsageHistoryDetail): CliproxyUsag
|
||||
return {
|
||||
model: detail.model,
|
||||
...(detail.provider && { provider: detail.provider }),
|
||||
...(detail.accountId !== undefined && { accountId: detail.accountId }),
|
||||
timestamp: detail.timestamp,
|
||||
inputTokens: detail.inputTokens,
|
||||
outputTokens: detail.outputTokens,
|
||||
|
||||
@@ -479,6 +479,42 @@ export function aggregateSessionUsage(
|
||||
return sessionUsage;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLIPROXY ACCOUNT-LEVEL COST (Phase 1A: CCS Bar)
|
||||
// ============================================================================
|
||||
|
||||
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
|
||||
|
||||
/**
|
||||
* Compute per-account cost totals for a given calendar day.
|
||||
*
|
||||
* @param details Flat history details produced by extractCliproxyUsageHistoryDetails.
|
||||
* Details with `accountId` are grouped by that value; details without are grouped
|
||||
* under the key `'unknown'`.
|
||||
* @param today YYYY-MM-DD date string (defaults to local date if omitted).
|
||||
* @returns Record mapping accountId (or 'unknown') → total cost in USD for that day.
|
||||
*/
|
||||
export function getTodayCostByAccount(
|
||||
details: CliproxyUsageHistoryDetail[],
|
||||
today?: string
|
||||
): Record<string, number> {
|
||||
const dateKey = today ?? new Date().toISOString().slice(0, 10);
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
for (const detail of details) {
|
||||
// Filter to the requested day only
|
||||
if (!detail.timestamp.startsWith(dateKey)) continue;
|
||||
|
||||
// Skip zero-cost records to avoid polluting result with no-op entries
|
||||
if (detail.cost <= 0) continue;
|
||||
|
||||
const accountKey = detail.accountId ?? 'unknown';
|
||||
result[accountKey] = (result[accountKey] ?? 0) + detail.cost;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN DATA LOADER (drop-in replacement for better-ccusage)
|
||||
// ============================================================================
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface DailyUsage {
|
||||
date: string;
|
||||
/** Stable CCS profile name when the source can be attributed to one. */
|
||||
profile?: string;
|
||||
/** Account email/id when the source can be attributed to a specific CLIProxy account. */
|
||||
accountId?: string;
|
||||
source: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/services/stats-fetcher';
|
||||
import type {
|
||||
CliproxyUsageApiResponse,
|
||||
CliproxyManagementAuthFile,
|
||||
} from '../../../src/cliproxy/services/stats-fetcher';
|
||||
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
||||
import {
|
||||
loadCachedCliproxyData,
|
||||
@@ -394,3 +397,108 @@ describe('cliproxy usage syncer', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Finding #3/#5: account attribution wired into syncer (accountMap passed to extractor)
|
||||
// ============================================================================
|
||||
|
||||
describe('syncCliproxyUsage — account attribution (finding #3/#5)', () => {
|
||||
let ccsDir2 = '';
|
||||
|
||||
beforeEach(() => {
|
||||
ccsDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-attr-'));
|
||||
stopCliproxySync();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopCliproxySync();
|
||||
fs.rmSync(ccsDir2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10);
|
||||
|
||||
function buildResponseWithAuthIndex(authIndex: number): CliproxyUsageApiResponse {
|
||||
return {
|
||||
usage: {
|
||||
apis: {
|
||||
anthropic: {
|
||||
models: {
|
||||
'claude-sonnet-4-5': {
|
||||
details: [
|
||||
{
|
||||
timestamp: `${TODAY}T10:00:00.000Z`,
|
||||
source: 'raw-source',
|
||||
auth_index: authIndex,
|
||||
tokens: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 1500,
|
||||
},
|
||||
failed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('persists accountId into snapshot when auth files are provided', async () => {
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
|
||||
];
|
||||
|
||||
await runWithScopedConfigDir(ccsDir2, async () => {
|
||||
await syncCliproxyUsage(
|
||||
() => Promise.resolve(buildResponseWithAuthIndex(0)),
|
||||
() => Promise.resolve(authFiles)
|
||||
);
|
||||
});
|
||||
|
||||
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
|
||||
details: Array<{ accountId?: string }>;
|
||||
};
|
||||
|
||||
expect(snapshot.details[0].accountId).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('falls back gracefully when auth-files fetch returns null (no throw, no accountId)', async () => {
|
||||
await runWithScopedConfigDir(ccsDir2, async () => {
|
||||
await syncCliproxyUsage(
|
||||
() => Promise.resolve(buildResponseWithAuthIndex(0)),
|
||||
() => Promise.resolve(null)
|
||||
);
|
||||
});
|
||||
|
||||
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
expect(fs.existsSync(snapshotPath)).toBe(true);
|
||||
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
|
||||
details: Array<{ accountId?: string }>;
|
||||
};
|
||||
// accountId must be absent (not populated) when auth files unavailable
|
||||
expect(snapshot.details[0].accountId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back gracefully when auth-files fetch throws (no throw, no accountId)', async () => {
|
||||
await runWithScopedConfigDir(ccsDir2, async () => {
|
||||
await syncCliproxyUsage(
|
||||
() => Promise.resolve(buildResponseWithAuthIndex(0)),
|
||||
() => Promise.reject(new Error('network error'))
|
||||
);
|
||||
});
|
||||
|
||||
const snapshotPath = path.join(ccsDir2, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
expect(fs.existsSync(snapshotPath)).toBe(true);
|
||||
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
|
||||
details: Array<{ accountId?: string }>;
|
||||
};
|
||||
expect(snapshot.details[0].accountId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Phase 1A: Account Attribution Tests
|
||||
*
|
||||
* TDD tests for auth_index → accountId mapping through the usage pipeline.
|
||||
* Covers:
|
||||
* - auth_index maps to account email via accountMap in transformer
|
||||
* - extractCliproxyUsageHistoryDetails carries accountId
|
||||
* - getTodayCostByAccount returns correct per-account totals
|
||||
* - Profile-based aggregation unaffected (backward compat)
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'bun:test';
|
||||
import type { CliproxyUsageApiResponse, CliproxyManagementAuthFile } from '../../../../src/cliproxy/services/stats-fetcher';
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS & FIXTURES
|
||||
// ============================================================================
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
function makeResponse(entries: Array<{
|
||||
provider: string;
|
||||
model: string;
|
||||
auth_index: number;
|
||||
source: string;
|
||||
timestamp: string;
|
||||
input: number;
|
||||
output: number;
|
||||
failed?: boolean;
|
||||
}>): CliproxyUsageApiResponse {
|
||||
const apis: CliproxyUsageApiResponse['usage'] = { apis: {} };
|
||||
for (const e of entries) {
|
||||
if (!apis.apis![e.provider]) {
|
||||
apis.apis![e.provider] = { models: {} };
|
||||
}
|
||||
const models = apis.apis![e.provider].models!;
|
||||
if (!models[e.model]) {
|
||||
models[e.model] = { details: [] };
|
||||
}
|
||||
models[e.model].details!.push({
|
||||
timestamp: e.timestamp,
|
||||
source: e.source,
|
||||
auth_index: e.auth_index,
|
||||
tokens: {
|
||||
input_tokens: e.input,
|
||||
output_tokens: e.output,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: e.input + e.output,
|
||||
},
|
||||
failed: e.failed ?? false,
|
||||
});
|
||||
}
|
||||
return { usage: apis };
|
||||
}
|
||||
|
||||
const twoAccountResponse = makeResponse([
|
||||
{
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5',
|
||||
auth_index: 0,
|
||||
source: 'old-source-a',
|
||||
timestamp: `${TODAY}T10:00:00.000Z`,
|
||||
input: 1000,
|
||||
output: 500,
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5',
|
||||
auth_index: 1,
|
||||
source: 'old-source-b',
|
||||
timestamp: `${TODAY}T11:00:00.000Z`,
|
||||
input: 2000,
|
||||
output: 800,
|
||||
},
|
||||
// auth_index 0 again — same account, second request.
|
||||
// output=201 (not 200) ensures alice's two-request total ($0.018025) strictly
|
||||
// exceeds bob's single-request total ($0.018), avoiding a floating-point tie.
|
||||
{
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-5',
|
||||
auth_index: 0,
|
||||
source: 'old-source-a',
|
||||
timestamp: `${TODAY}T12:00:00.000Z`,
|
||||
input: 500,
|
||||
output: 201,
|
||||
},
|
||||
]);
|
||||
|
||||
const authFileMap: Map<number | string, string> = new Map([
|
||||
[0, 'alice@example.com'],
|
||||
[1, 'bob@example.com'],
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// TRANSFORMER: extractCliproxyUsageHistoryDetails with accountMap
|
||||
// ============================================================================
|
||||
|
||||
describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
|
||||
it('populates accountId from accountMap when auth_index is present', async () => {
|
||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||
|
||||
const aliceDetails = details.filter((d) => d.accountId === 'alice@example.com');
|
||||
const bobDetails = details.filter((d) => d.accountId === 'bob@example.com');
|
||||
|
||||
expect(aliceDetails).toHaveLength(2); // auth_index 0 appears twice
|
||||
expect(bobDetails).toHaveLength(1); // auth_index 1 appears once
|
||||
});
|
||||
|
||||
it('falls back to detail.source when auth_index not in accountMap', async () => {
|
||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const partialMap: Map<number | string, string> = new Map([[0, 'alice@example.com']]);
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, partialMap);
|
||||
|
||||
const unknownAccount = details.find((d) => d.accountId === 'old-source-b');
|
||||
expect(unknownAccount).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not include accountId when no accountMap is provided (backward compat)', async () => {
|
||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
||||
|
||||
for (const detail of details) {
|
||||
expect(detail.accountId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not expose source or auth_index on returned history details', async () => {
|
||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||
|
||||
for (const detail of details) {
|
||||
expect((detail as Record<string, unknown>).source).toBeUndefined();
|
||||
expect((detail as Record<string, unknown>).auth_index).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TRANSFORMER: CliproxyUsageHistoryDetail type has optional accountId
|
||||
// ============================================================================
|
||||
|
||||
describe('CliproxyUsageHistoryDetail type', () => {
|
||||
it('allows accountId as optional string field', async () => {
|
||||
const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const withAccount = normalizeCliproxyUsageHistoryDetail({
|
||||
model: 'claude-sonnet-4-5',
|
||||
timestamp: `${TODAY}T10:00:00.000Z`,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0,
|
||||
requestCount: 1,
|
||||
cost: 0.01,
|
||||
failed: false,
|
||||
accountId: 'alice@example.com',
|
||||
});
|
||||
|
||||
expect(withAccount).not.toBeNull();
|
||||
expect(withAccount?.accountId).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('normalizes detail without accountId (remains undefined)', async () => {
|
||||
const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const noAccount = normalizeCliproxyUsageHistoryDetail({
|
||||
model: 'claude-sonnet-4-5',
|
||||
timestamp: `${TODAY}T10:00:00.000Z`,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0,
|
||||
requestCount: 1,
|
||||
cost: 0.01,
|
||||
failed: false,
|
||||
});
|
||||
|
||||
expect(noAccount).not.toBeNull();
|
||||
expect(noAccount?.accountId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DATA-AGGREGATOR: getTodayCostByAccount
|
||||
// ============================================================================
|
||||
|
||||
describe('getTodayCostByAccount', () => {
|
||||
it('returns per-account cost totals for today', async () => {
|
||||
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
|
||||
|
||||
const details = [];
|
||||
|
||||
// Simulate alice's two requests today
|
||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
const todayDetails = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||
|
||||
const result = getTodayCostByAccount(todayDetails, TODAY);
|
||||
|
||||
// Alice has auth_index 0: two requests (claude-sonnet + claude-opus)
|
||||
// Bob has auth_index 1: one request (claude-sonnet)
|
||||
expect(typeof result['alice@example.com']).toBe('number');
|
||||
expect(typeof result['bob@example.com']).toBe('number');
|
||||
expect(result['alice@example.com']).toBeGreaterThan(0);
|
||||
expect(result['bob@example.com']).toBeGreaterThan(0);
|
||||
// Alice has two requests across two models; Bob has one request with more tokens.
|
||||
// Alice's two-request total ($0.018025) exceeds Bob's single-request total ($0.018).
|
||||
expect(result['alice@example.com']).toBeGreaterThan(result['bob@example.com']);
|
||||
});
|
||||
|
||||
it('returns empty object when no details exist for today', async () => {
|
||||
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
|
||||
|
||||
const result = getTodayCostByAccount([], TODAY);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('filters out details from days other than today', async () => {
|
||||
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
|
||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const yesterdayResponse = makeResponse([
|
||||
{
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5',
|
||||
auth_index: 0,
|
||||
source: 'old-source-a',
|
||||
timestamp: '2020-01-01T10:00:00.000Z', // definitely not today
|
||||
input: 9999,
|
||||
output: 9999,
|
||||
},
|
||||
]);
|
||||
const details = extractCliproxyUsageHistoryDetails(yesterdayResponse, authFileMap);
|
||||
const result = getTodayCostByAccount(details, TODAY);
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accumulates costs across multiple details for the same account', async () => {
|
||||
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
|
||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
|
||||
|
||||
// alice appears for two models — verify aggregated correctly
|
||||
const result = getTodayCostByAccount(details, TODAY);
|
||||
const aliceCostFromDetails = details
|
||||
.filter((d) => d.accountId === 'alice@example.com')
|
||||
.reduce((acc, d) => acc + d.cost, 0);
|
||||
|
||||
expect(result['alice@example.com']).toBeCloseTo(aliceCostFromDetails, 10);
|
||||
});
|
||||
|
||||
it('details without accountId are grouped under fallback source key', async () => {
|
||||
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
|
||||
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
// no accountMap — accountId will be undefined on all details
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
||||
const result = getTodayCostByAccount(details, TODAY);
|
||||
|
||||
// With no accountId, details with cost > 0 should still contribute
|
||||
// grouped under some non-empty key derived from the detail
|
||||
expect(Object.values(result).some((v) => v > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BACKWARD COMPATIBILITY: existing profile aggregation still works
|
||||
// ============================================================================
|
||||
|
||||
describe('backward compatibility: profile-based aggregation unaffected', () => {
|
||||
it('transformCliproxyToDailyUsage works without accountMap', async () => {
|
||||
const { transformCliproxyToDailyUsage } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const daily = transformCliproxyToDailyUsage(twoAccountResponse);
|
||||
|
||||
expect(daily.length).toBeGreaterThan(0);
|
||||
expect(daily[0].source).toBe('cliproxy');
|
||||
expect(daily[0].modelBreakdowns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('buildCliproxyUsageHistoryAggregates preserves existing shape', async () => {
|
||||
const { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
|
||||
|
||||
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
|
||||
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details);
|
||||
|
||||
expect(Array.isArray(daily)).toBe(true);
|
||||
expect(Array.isArray(hourly)).toBe(true);
|
||||
expect(Array.isArray(monthly)).toBe(true);
|
||||
if (daily.length > 0) {
|
||||
expect(typeof daily[0].date).toBe('string');
|
||||
expect(typeof daily[0].totalCost).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('DailyUsage shape includes optional accountId field', async () => {
|
||||
// Type-level test: verify DailyUsage can carry accountId without breaking shape
|
||||
type DailyUsageShape = { date: string; source: string; totalCost: number; accountId?: string };
|
||||
const sample: DailyUsageShape = {
|
||||
date: TODAY,
|
||||
source: 'cliproxy',
|
||||
totalCost: 1.5,
|
||||
accountId: 'alice@example.com',
|
||||
};
|
||||
const noAccount: DailyUsageShape = { date: TODAY, source: 'cliproxy', totalCost: 0.5 };
|
||||
expect(sample.accountId).toBe('alice@example.com');
|
||||
expect(noAccount.accountId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// STATS-FETCHER: buildAuthIndexToAccountMap
|
||||
// ============================================================================
|
||||
|
||||
describe('buildAuthIndexToAccountMap', () => {
|
||||
it('builds map from auth files with auth_index and email', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
|
||||
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
|
||||
{ auth_index: 1, provider: 'anthropic', email: 'bob@example.com' },
|
||||
{ auth_index: 2, provider: 'gemini', email: 'carol@example.com' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
|
||||
expect(map.get('0')).toBe('alice@example.com');
|
||||
expect(map.get('1')).toBe('bob@example.com');
|
||||
expect(map.get('2')).toBe('carol@example.com');
|
||||
});
|
||||
|
||||
it('skips entries missing auth_index', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
|
||||
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ provider: 'anthropic', email: 'nobody@example.com' }, // no auth_index
|
||||
{ auth_index: 3, provider: 'anthropic', email: 'alice@example.com' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get('3')).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('skips entries missing email', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
|
||||
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 4, provider: 'anthropic' }, // no email
|
||||
{ auth_index: 5, provider: 'anthropic', email: 'dave@example.com' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get('5')).toBe('dave@example.com');
|
||||
});
|
||||
|
||||
it('returns empty map for empty auth files array', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
|
||||
|
||||
const map = buildAuthIndexToAccountMap([]);
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles numeric and string auth_index keys consistently', async () => {
|
||||
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
|
||||
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{ auth_index: 7, provider: 'anthropic', email: 'alice@example.com' },
|
||||
{ auth_index: '8', provider: 'anthropic', email: 'bob@example.com' },
|
||||
];
|
||||
|
||||
const map = buildAuthIndexToAccountMap(authFiles);
|
||||
|
||||
expect(map.get('7')).toBe('alice@example.com');
|
||||
expect(map.get('8')).toBe('bob@example.com');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user