diff --git a/src/cliproxy/services/stats-fetcher.ts b/src/cliproxy/services/stats-fetcher.ts index 272e8ffd..fcad1e33 100644 --- a/src/cliproxy/services/stats-fetcher.ts +++ b/src/cliproxy/services/stats-fetcher.ts @@ -344,7 +344,9 @@ async function fetchManagementJson( } } -async function fetchCliproxyAuthFiles(port?: number): Promise { +export async function fetchCliproxyAuthFiles( + port?: number +): Promise { 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 { + const map = new Map(); + 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; diff --git a/src/web-server/usage/cliproxy-snapshot-reader.ts b/src/web-server/usage/cliproxy-snapshot-reader.ts new file mode 100644 index 00000000..9ec76213 --- /dev/null +++ b/src/web-server/usage/cliproxy-snapshot-reader.ts @@ -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 { + const snapshotPath = getLatestSnapshotPath(); + + try { + if (!fs.existsSync(snapshotPath)) { + return []; + } + + const raw = fs.readFileSync(snapshotPath, 'utf-8'); + const snapshot = JSON.parse(raw) as Record; + + 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 []; + } +} diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index f72adce4..822cea54 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -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 { 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 | 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}`); } diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index 472601b7..d1982501 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -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 ): 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 ): 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, diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts index db2f5109..09d51522 100644 --- a/src/web-server/usage/data-aggregator.ts +++ b/src/web-server/usage/data-aggregator.ts @@ -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 { + const dateKey = today ?? new Date().toISOString().slice(0, 10); + const result: Record = {}; + + 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) // ============================================================================ diff --git a/src/web-server/usage/types.ts b/src/web-server/usage/types.ts index 0cd234a7..baf83ff5 100644 --- a/src/web-server/usage/types.ts +++ b/src/web-server/usage/types.ts @@ -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; diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index 32f1195f..a53e41fd 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/web-server/usage/account-attribution.test.ts b/tests/unit/web-server/usage/account-attribution.test.ts new file mode 100644 index 00000000..f82f27a0 --- /dev/null +++ b/tests/unit/web-server/usage/account-attribution.test.ts @@ -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 = 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 = 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).source).toBeUndefined(); + expect((detail as Record).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'); + }); +});