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:
Tam Nhu Tran
2026-06-07 15:32:19 -04:00
parent 89414972be
commit 1867116472
8 changed files with 672 additions and 8 deletions
+26 -1
View File
@@ -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 [];
}
}
+21 -3
View File
@@ -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,
+36
View File
@@ -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)
// ============================================================================
+2
View File
@@ -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');
});
});