fix(analytics): correct token accounting and usage aggregation

Align snapshot handling and UI contracts with the current analytics payloads.
This commit is contained in:
Tam Nhu Tran
2026-04-26 15:21:52 -04:00
parent 10cf27f63e
commit e2f1363708
25 changed files with 1318 additions and 420 deletions
+25 -4
View File
@@ -82,6 +82,18 @@ function toNonNegativeNumber(value: unknown): number {
return numeric;
}
function parseRequiredNonNegativeNumber(value: unknown): number | null {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return null;
}
return numeric;
}
function isValidTimestamp(value: unknown): value is string {
return typeof value === 'string' && Number.isFinite(Date.parse(value));
}
export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null {
// Strip UTF-8 BOM if present (can occur on first line of some files)
const cleanLine = line.replace(/^\uFEFF/, '').trim();
@@ -97,16 +109,25 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr
const usage = entry.message.usage;
const assistant = entry as JsonlAssistantEntry;
const inputTokens = parseRequiredNonNegativeNumber(usage.input_tokens);
const outputTokens = parseRequiredNonNegativeNumber(usage.output_tokens);
if (inputTokens === null || outputTokens === null || !isValidTimestamp(assistant.timestamp)) {
return null;
}
const normalizedProjectPath =
typeof assistant.cwd === 'string' && assistant.cwd.trim().length > 0
? assistant.cwd
: projectPath;
return {
inputTokens: toNonNegativeNumber(usage.input_tokens),
outputTokens: toNonNegativeNumber(usage.output_tokens),
inputTokens,
outputTokens,
cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens),
cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens),
model: assistant.message.model,
sessionId: assistant.sessionId || '',
timestamp: assistant.timestamp || new Date().toISOString(),
projectPath,
timestamp: assistant.timestamp,
projectPath: normalizedProjectPath,
version: assistant.version,
target:
typeof (entry as { target?: unknown }).target === 'string'
+5 -13
View File
@@ -828,9 +828,9 @@ function getDirectOrAliasPricing(model: string): ModelPricing | undefined {
}
/**
* Get pricing for a model with fuzzy matching fallback
* @param model - Model name (exact or with provider prefix)
* @returns ModelPricing for the model or fallback pricing
* Get pricing for a model with narrow fuzzy matching fallback.
* Unknown future model families should fall back instead of inheriting the
* first known family tier that happens to share a prefix.
*/
export function getModelPricing(model: string): ModelPricing {
const directOrAliasPricing = getDirectOrAliasPricing(model);
@@ -839,17 +839,9 @@ export function getModelPricing(model: string): ModelPricing {
}
for (const candidate of getLookupCandidates(model)) {
// Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5")
// Allow provider/routing wrappers to suffix a canonical model ID.
for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) {
if (candidate.endsWith(key) || key.endsWith(candidate)) {
return pricing;
}
}
// Try partial matching for model families
for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) {
// Match by model family prefix
if (candidate.startsWith(key.split('-').slice(0, 2).join('-'))) {
if (candidate.endsWith(key)) {
return pricing;
}
}
+43 -3
View File
@@ -19,6 +19,7 @@ import {
} from './disk-cache';
import { ok, info, fail } from '../../utils/ui';
import { getCcsDir } from '../../utils/config-manager';
import { getClaudeConfigDir, getDefaultClaudeConfigDir } from '../../utils/claude-config-path';
import {
loadCachedCliproxyData,
startCliproxySync,
@@ -35,6 +36,21 @@ function getCcsInstancesDir() {
return path.join(getCcsDir(), 'instances');
}
function isPathWithinDir(childPath: string, parentPath: string): boolean {
const relative = path.relative(parentPath, childPath);
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
}
function getDefaultProjectsDirForAnalytics(): string {
const activeClaudeConfigDir = getClaudeConfigDir();
const instancesDir = getCcsInstancesDir();
const claudeConfigDir = isPathWithinDir(activeClaudeConfigDir, instancesDir)
? getDefaultClaudeConfigDir()
: activeClaudeConfigDir;
return path.join(claudeConfigDir, 'projects');
}
/**
* Get list of CCS instance paths that have usage data
* Only returns instances with existing projects/ directory
@@ -149,8 +165,26 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
existing.totalCost += month.totalCost;
const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]);
existing.modelsUsed = Array.from(modelSet);
for (const breakdown of month.modelBreakdowns) {
const existingBreakdown = existing.modelBreakdowns.find(
(item) => item.modelName === breakdown.modelName
);
if (existingBreakdown) {
existingBreakdown.inputTokens += breakdown.inputTokens;
existingBreakdown.outputTokens += breakdown.outputTokens;
existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens;
existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens;
existingBreakdown.cost += breakdown.cost;
} else {
existing.modelBreakdowns.push({ ...breakdown });
}
}
} else {
monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] });
monthMap.set(month.month, {
...month,
modelsUsed: [...month.modelsUsed],
modelBreakdowns: month.modelBreakdowns.map((breakdown) => ({ ...breakdown })),
});
}
}
}
@@ -174,6 +208,7 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
existing.cacheCreationTokens += hour.cacheCreationTokens;
existing.cacheReadTokens += hour.cacheReadTokens;
existing.totalCost += hour.totalCost;
existing.requestCount = (existing.requestCount ?? 0) + (hour.requestCount ?? 0);
const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]);
existing.modelsUsed = Array.from(modelSet);
// Merge model breakdowns
@@ -196,6 +231,7 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
...hour,
modelsUsed: [...hour.modelsUsed],
modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })),
requestCount: hour.requestCount ?? 0,
});
}
}
@@ -251,6 +287,10 @@ export function getLastFetchTimestamp(): number | null {
return lastFetchTimestamp;
}
export function getUsageCacheSize(): number {
return cache.size;
}
// In-memory cache
const cache = new Map<string, CacheEntry<unknown>>();
@@ -300,8 +340,8 @@ async function refreshFromSource(): Promise<{
// Non-fatal: syncer handles unavailability and stale fallback.
await syncCliproxyUsage();
// Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR)
const defaultData = await loadAllUsageData();
// Load canonical default data and avoid counting the active instance twice
const defaultData = await loadAllUsageData({ projectsDir: getDefaultProjectsDirForAnalytics() });
// Load data from all CCS instances sequentially
const instancePaths = getInstancePaths();
+66 -21
View File
@@ -34,7 +34,8 @@ interface CliproxyUsageSnapshot {
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
const SNAPSHOT_VERSION = 1;
const SNAPSHOT_VERSION = 2;
const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */
const SYNC_INTERVAL_MS = Math.max(
@@ -44,6 +45,7 @@ const SYNC_INTERVAL_MS = Math.max(
// Module-level interval ID
let syncIntervalId: ReturnType<typeof setInterval> | null = null;
let snapshotTimestampOrdinal = 0;
// ---------------------------------------------------------------------------
// Cache directory helpers
@@ -64,6 +66,44 @@ function ensureCliproxyCacheDir(): void {
}
}
function getSnapshotTimestamp(): number {
snapshotTimestampOrdinal = (snapshotTimestampOrdinal + 1) % 1000;
return Date.now() + snapshotTimestampOrdinal / 1000;
}
function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
try {
const snapshotPath = getLatestSnapshotPath();
if (!fs.existsSync(snapshotPath)) {
return null;
}
const raw = fs.readFileSync(snapshotPath, 'utf-8');
const snapshot = JSON.parse(raw) as CliproxyUsageSnapshot;
if (snapshot.version !== SNAPSHOT_VERSION) {
if (emitWarnings) {
console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync'));
}
return null;
}
if (!Number.isFinite(snapshot.timestamp)) {
if (emitWarnings) {
console.log(info('CLIProxy snapshot timestamp invalid, will refresh on next sync'));
}
return null;
}
return snapshot;
} catch (err) {
if (emitWarnings) {
console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`);
}
return null;
}
}
// ---------------------------------------------------------------------------
// Load cached data
// ---------------------------------------------------------------------------
@@ -79,25 +119,17 @@ export async function loadCachedCliproxyData(): Promise<{
}> {
const empty = { daily: [], hourly: [], monthly: [] };
try {
const snapshotPath = getLatestSnapshotPath();
if (!fs.existsSync(snapshotPath)) {
return empty;
}
const raw = fs.readFileSync(snapshotPath, 'utf-8');
const snapshot: CliproxyUsageSnapshot = JSON.parse(raw);
if (snapshot.version !== SNAPSHOT_VERSION) {
console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync'));
return empty;
}
return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly };
} catch (err) {
console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`);
const snapshot = readSnapshot();
if (!snapshot) {
return empty;
}
const age = Date.now() - snapshot.timestamp;
if (age > SNAPSHOT_MAX_AGE_MS) {
console.log(info('Using stale CLIProxy snapshot while proxy sync is unavailable'));
}
return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly };
}
// ---------------------------------------------------------------------------
@@ -111,6 +143,7 @@ export async function loadCachedCliproxyData(): Promise<{
export async function syncCliproxyUsage(
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
): Promise<void> {
const syncStartedAt = getSnapshotTimestamp();
const raw = await fetchRaw();
if (raw === null) {
@@ -127,16 +160,28 @@ export async function syncCliproxyUsage(
const snapshot: CliproxyUsageSnapshot = {
version: SNAPSHOT_VERSION,
timestamp: Date.now(),
timestamp: syncStartedAt,
daily,
hourly,
monthly,
};
// Atomic write: temp file + rename
const snapshotPath = getLatestSnapshotPath();
const tempFile = snapshotPath + '.tmp';
const tempFile = `${snapshotPath}.${process.pid}.${syncStartedAt}.tmp`;
const currentSnapshot = readSnapshot(false);
if (currentSnapshot && currentSnapshot.timestamp > snapshot.timestamp) {
console.log(info('Skipping stale CLIProxy snapshot write'));
return;
}
fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8');
const latestSnapshot = readSnapshot(false);
if (latestSnapshot && latestSnapshot.timestamp > snapshot.timestamp) {
fs.rmSync(tempFile, { force: true });
console.log(info('Skipping stale CLIProxy snapshot write'));
return;
}
fs.renameSync(tempFile, snapshotPath);
console.log(ok('CLIProxy usage snapshot updated'));
@@ -40,9 +40,19 @@ function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBre
// FLATTEN
// ============================================================================
function hasTrackedUsage(detail: CliproxyRequestDetail): boolean {
const tokens = detail.tokens;
return (
(tokens?.input_tokens ?? 0) > 0 ||
(tokens?.output_tokens ?? 0) > 0 ||
(tokens?.cached_tokens ?? 0) > 0
);
}
/**
* Flatten the nested response.usage.apis[provider].models[model].details[]
* structure into a flat array. Failed requests are skipped.
* structure into a flat array. Failed requests are retained only when they
* still report tracked token usage that analytics can account for.
*/
export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): FlatDetail[] {
const apis = response?.usage?.apis;
@@ -56,7 +66,7 @@ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): Flat
const details = modelData?.details;
if (!details) continue;
for (const detail of details) {
if (detail.failed) continue;
if (detail.failed && !hasTrackedUsage(detail)) continue;
results.push({ model, detail });
}
}
@@ -72,15 +82,17 @@ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): Flat
function aggregateByKey<T>(
flat: FlatDetail[],
keyFn: (timestamp: string) => string,
buildRecord: (key: string, breakdowns: ModelBreakdown[]) => T,
buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T,
sortFn: (a: T, b: T) => number
): T[] {
// bucket: timeKey -> modelName -> accumulator
const buckets = new Map<string, Map<string, ModelAccumulator>>();
const requestCounts = new Map<string, number>();
for (const { model, detail } of flat) {
const key = keyFn(detail.timestamp);
if (!buckets.has(key)) buckets.set(key, new Map());
requestCounts.set(key, (requestCounts.get(key) ?? 0) + 1);
const modelMap = buckets.get(key) as Map<string, ModelAccumulator>;
if (!modelMap.has(model)) {
modelMap.set(model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 });
@@ -96,7 +108,7 @@ function aggregateByKey<T>(
const breakdowns = Array.from(modelMap.entries()).map(([name, acc]) =>
buildModelBreakdown(name, acc)
);
records.push(buildRecord(key, breakdowns));
records.push(buildRecord(key, breakdowns, requestCounts.get(key) ?? 0));
});
return records.sort(sortFn);
@@ -146,7 +158,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons
const hour = ts.slice(11, 13) || '00';
return `${date} ${hour}:00`;
},
(hour, breakdowns) => {
(hour, breakdowns, requestCount) => {
const totalCost = sumField(breakdowns, 'cost');
return {
hour,
@@ -159,6 +171,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons
totalCost,
modelsUsed: breakdowns.map((b) => b.modelName),
modelBreakdowns: breakdowns,
requestCount,
};
},
(a, b) => b.hour.localeCompare(a.hour)
+1
View File
@@ -244,6 +244,7 @@ export function aggregateHourlyUsage(
totalCost,
modelsUsed: Array.from(modelMap.keys()),
modelBreakdowns,
requestCount: hourEntries.length,
});
}
+210 -41
View File
@@ -13,6 +13,7 @@ import {
getCachedMonthlyData,
getCachedSessionData,
getCachedHourlyData,
getUsageCacheSize,
getLastFetchTimestamp,
refreshUsageCache,
} from './aggregator';
@@ -87,6 +88,13 @@ export function validateOffset(offset?: string): number {
return num;
}
export function validateDateRangeOrder(since?: string, until?: string): void {
if (!since || !until) return;
if (since > until) {
throw new Error('The "since" date must be earlier than or equal to "until"');
}
}
export function filterByDateRange<
T extends { date?: string; month?: string; lastActivity?: string },
>(data: T[] | undefined, since?: string, until?: string): T[] {
@@ -120,6 +128,66 @@ export function errorResponse(res: Response, error: unknown, defaultMessage: str
});
}
function roundToCurrency(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function calculateUsageTotalTokens(
input: number,
output: number,
cacheCreation: number,
cacheRead: number
): number {
return input + output + cacheCreation + cacheRead;
}
function parseDateKey(dateString: string): Date {
return new Date(
Date.UTC(
Number(dateString.slice(0, 4)),
Number(dateString.slice(4, 6)) - 1,
Number(dateString.slice(6, 8))
)
);
}
function getInclusiveDayCount(start: Date, end: Date): number {
const dayMs = 24 * 60 * 60 * 1000;
return Math.floor((end.getTime() - start.getTime()) / dayMs) + 1;
}
function countCalendarDays(data: DailyUsage[], since?: string, until?: string): number {
const sortedDates = [...data]
.map((item) => item.date.replace(/-/g, ''))
.sort((a, b) => a.localeCompare(b));
const earliestDate = sortedDates[0];
const latestDate = sortedDates[sortedDates.length - 1];
if (since && until) {
return getInclusiveDayCount(parseDateKey(since), parseDateKey(until));
}
if (since) {
const end = until
? parseDateKey(until)
: latestDate
? parseDateKey(latestDate)
: parseDateKey(since);
return getInclusiveDayCount(parseDateKey(since), end);
}
if (until) {
const start = earliestDate ? parseDateKey(earliestDate) : parseDateKey(until);
return getInclusiveDayCount(start, parseDateKey(until));
}
if (data.length === 0) {
return 0;
}
return getInclusiveDayCount(parseDateKey(earliestDate), parseDateKey(latestDate));
}
// ============================================================================
// Cost Calculation Helpers
// ============================================================================
@@ -150,10 +218,10 @@ export function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBrea
}
return {
input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 },
output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 },
cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 },
cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 },
input: { tokens: inputTokens, cost: roundToCurrency(inputCost) },
output: { tokens: outputTokens, cost: roundToCurrency(outputCost) },
cacheCreation: { tokens: cacheCreationTokens, cost: roundToCurrency(cacheCreationCost) },
cacheRead: { tokens: cacheReadTokens, cost: roundToCurrency(cacheReadCost) },
};
}
@@ -334,6 +402,7 @@ export async function handleSummary(
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
validateDateRangeOrder(since, until);
const dailyData = await getCachedDailyData();
const filtered = filterByDateRange(dailyData, since, until);
@@ -351,8 +420,15 @@ export async function handleSummary(
totalCost += day.totalCost;
}
const totalTokens = totalInputTokens + totalOutputTokens;
const totalTokens = calculateUsageTotalTokens(
totalInputTokens,
totalOutputTokens,
totalCacheCreationTokens,
totalCacheReadTokens
);
const tokenBreakdown = calculateTokenBreakdownCosts(filtered);
const totalDays = countCalendarDays(filtered, since, until);
const activeDays = filtered.length;
res.json({
success: true,
@@ -363,12 +439,14 @@ export async function handleSummary(
totalCacheTokens: totalCacheCreationTokens + totalCacheReadTokens,
totalCacheCreationTokens,
totalCacheReadTokens,
totalCost: Math.round(totalCost * 100) / 100,
totalCost: roundToCurrency(totalCost),
tokenBreakdown,
totalDays: filtered.length,
averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0,
averageCostPerDay:
filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0,
totalDays,
activeDays,
averageTokensPerDay: totalDays > 0 ? Math.round(totalTokens / totalDays) : 0,
averageTokensPerActiveDay: activeDays > 0 ? Math.round(totalTokens / activeDays) : 0,
averageCostPerDay: totalDays > 0 ? roundToCurrency(totalCost / totalDays) : 0,
averageCostPerActiveDay: activeDays > 0 ? roundToCurrency(totalCost / activeDays) : 0,
},
});
} catch (error) {
@@ -383,16 +461,22 @@ export async function handleDaily(
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
validateDateRangeOrder(since, until);
const dailyData = await getCachedDailyData();
const filtered = filterByDateRange(dailyData, since, until);
const trends = filtered.map((day) => ({
date: day.date,
tokens: day.inputTokens + day.outputTokens,
tokens: calculateUsageTotalTokens(
day.inputTokens,
day.outputTokens,
day.cacheCreationTokens,
day.cacheReadTokens
),
inputTokens: day.inputTokens,
outputTokens: day.outputTokens,
cacheTokens: day.cacheCreationTokens + day.cacheReadTokens,
cost: Math.round(day.totalCost * 100) / 100,
cost: roundToCurrency(day.totalCost),
modelsUsed: day.modelsUsed.length,
}));
@@ -409,6 +493,7 @@ export async function handleHourly(
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
validateDateRangeOrder(since, until);
const hourlyData = await getCachedHourlyData();
const filtered = (hourlyData || []).filter((h) => {
@@ -420,13 +505,18 @@ export async function handleHourly(
const trends = filtered.map((hour) => ({
hour: hour.hour,
tokens: hour.inputTokens + hour.outputTokens,
tokens: calculateUsageTotalTokens(
hour.inputTokens,
hour.outputTokens,
hour.cacheCreationTokens,
hour.cacheReadTokens
),
inputTokens: hour.inputTokens,
outputTokens: hour.outputTokens,
cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens,
cost: Math.round(hour.totalCost * 100) / 100,
cost: roundToCurrency(hour.totalCost),
modelsUsed: hour.modelsUsed.length,
requests: hour.modelBreakdowns.length,
requests: hour.requestCount ?? hour.modelBreakdowns.length,
}));
const filledTrends = fillHourlyGaps(trends, since, until);
@@ -443,6 +533,7 @@ export async function handleModels(
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
validateDateRangeOrder(since, until);
const dailyData = await getCachedDailyData();
const filtered = filterByDateRange(dailyData, since, until);
@@ -478,7 +569,17 @@ export async function handleModels(
}
const models = Array.from(modelMap.values());
const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0);
const totalTokens = models.reduce(
(sum, model) =>
sum +
calculateUsageTotalTokens(
model.inputTokens,
model.outputTokens,
model.cacheCreationTokens,
model.cacheReadTokens
),
0
);
const result = models
.map((m) => {
@@ -489,28 +590,32 @@ export async function handleModels(
(m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion;
const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion;
const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0;
const totalModelTokens = calculateUsageTotalTokens(
m.inputTokens,
m.outputTokens,
m.cacheCreationTokens,
m.cacheReadTokens
);
return {
model: m.model,
tokens: m.inputTokens + m.outputTokens,
tokens: totalModelTokens,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
cacheCreationTokens: m.cacheCreationTokens,
cacheReadTokens: m.cacheReadTokens,
cacheTokens: m.cacheCreationTokens + m.cacheReadTokens,
cost: Math.round(m.cost * 100) / 100,
cost: roundToCurrency(m.cost),
percentage:
totalTokens > 0
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
: 0,
totalTokens > 0 ? Math.round((totalModelTokens / totalTokens) * 1000) / 10 : 0,
costBreakdown: {
input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 },
output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 },
input: { tokens: m.inputTokens, cost: roundToCurrency(inputCost) },
output: { tokens: m.outputTokens, cost: roundToCurrency(outputCost) },
cacheCreation: {
tokens: m.cacheCreationTokens,
cost: Math.round(cacheCreationCost * 100) / 100,
cost: roundToCurrency(cacheCreationCost),
},
cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 },
cacheRead: { tokens: m.cacheReadTokens, cost: roundToCurrency(cacheReadCost) },
},
ioRatio: Math.round(ioRatio * 10) / 10,
};
@@ -530,6 +635,7 @@ export async function handleSessions(
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
validateDateRangeOrder(since, until);
const limit = validateLimit(req.query.limit);
const offset = validateOffset(req.query.offset);
@@ -543,10 +649,15 @@ export async function handleSessions(
const sessions = paginated.map((s) => ({
sessionId: s.sessionId,
projectPath: s.projectPath,
tokens: s.inputTokens + s.outputTokens,
tokens: calculateUsageTotalTokens(
s.inputTokens,
s.outputTokens,
s.cacheCreationTokens,
s.cacheReadTokens
),
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
cost: Math.round(s.totalCost * 100) / 100,
cost: roundToCurrency(s.totalCost),
lastActivity: s.lastActivity,
modelsUsed: s.modelsUsed,
target: s.target || 'claude',
@@ -574,25 +685,83 @@ export async function handleMonthly(
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
const monthlyData = await getCachedMonthlyData();
validateDateRangeOrder(since, until);
let filtered: Array<{
month: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalCost: number;
modelsUsed: string[];
}>;
const filtered =
since || until
? monthlyData.filter((m) => {
const monthDate = m.month.replace('-', '') + '01';
if (since && monthDate < since) return false;
if (until && monthDate > until) return false;
return true;
})
: monthlyData;
if (since || until) {
const dailyData = filterByDateRange(await getCachedDailyData(), since, until);
const monthMap = new Map<
string,
{
month: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalCost: number;
modelsUsed: Set<string>;
}
>();
for (const day of dailyData) {
const month = day.date.slice(0, 7);
const existing = monthMap.get(month) ?? {
month,
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalCost: 0,
modelsUsed: new Set<string>(),
};
existing.inputTokens += day.inputTokens;
existing.outputTokens += day.outputTokens;
existing.cacheCreationTokens += day.cacheCreationTokens;
existing.cacheReadTokens += day.cacheReadTokens;
existing.totalCost += day.totalCost;
for (const model of day.modelsUsed) {
existing.modelsUsed.add(model);
}
monthMap.set(month, existing);
}
filtered = Array.from(monthMap.values())
.map((month) => ({
month: month.month,
inputTokens: month.inputTokens,
outputTokens: month.outputTokens,
cacheCreationTokens: month.cacheCreationTokens,
cacheReadTokens: month.cacheReadTokens,
totalCost: month.totalCost,
modelsUsed: Array.from(month.modelsUsed),
}))
.sort((a, b) => a.month.localeCompare(b.month));
} else {
filtered = await getCachedMonthlyData();
}
const result = filtered.map((m) => ({
month: m.month,
tokens: m.inputTokens + m.outputTokens,
tokens: calculateUsageTotalTokens(
m.inputTokens,
m.outputTokens,
m.cacheCreationTokens,
m.cacheReadTokens
),
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
cacheTokens: m.cacheCreationTokens + m.cacheReadTokens,
cost: Math.round(m.totalCost * 100) / 100,
cost: roundToCurrency(m.totalCost),
modelsUsed: m.modelsUsed.length,
}));
@@ -612,10 +781,9 @@ export async function handleRefresh(_req: Request, res: Response): Promise<void>
}
export function handleStatus(_req: Request, res: Response): void {
const cache = new Map(); // Note: this is a placeholder, actual cache is in aggregator
res.json({
success: true,
data: { lastFetch: getLastFetchTimestamp(), cacheSize: cache.size },
data: { lastFetch: getLastFetchTimestamp(), cacheSize: getUsageCacheSize() },
});
}
@@ -626,6 +794,7 @@ export async function handleInsights(
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
validateDateRangeOrder(since, until);
const dailyData = await getCachedDailyData();
const filtered = filterByDateRange(dailyData, since, until);
const anomalies = detectAnomalies(filtered);
+5
View File
@@ -49,6 +49,11 @@ export interface HourlyUsage {
totalCost: number;
modelsUsed: string[];
modelBreakdowns: ModelBreakdown[];
/**
* Raw request count for this hour bucket.
* Optional for backward compatibility with previously persisted snapshots.
*/
requestCount?: number;
}
/** Monthly usage aggregation (YYYY-MM) */
+26 -7
View File
@@ -146,6 +146,16 @@ describe('parseUsageEntry', () => {
test('includes project path in result', () => {
const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path');
expect(result!.projectPath).toBe('/home/user/project');
});
test('falls back to the decoded project path when cwd is missing', () => {
const withoutCwd = JSON.stringify({
...JSON.parse(VALID_ASSISTANT_ENTRY),
cwd: undefined,
});
const result = parseUsageEntry(withoutCwd, '/custom/project/path');
expect(result!.projectPath).toBe('/custom/project/path');
});
@@ -169,7 +179,7 @@ describe('parseUsageEntry', () => {
expect(result!.target).toBeUndefined();
});
test('coerces token fields to non-negative numbers', () => {
test('returns null when required token fields are invalid', () => {
const withInvalidUsage = JSON.stringify({
...JSON.parse(VALID_ASSISTANT_ENTRY),
message: {
@@ -184,11 +194,16 @@ describe('parseUsageEntry', () => {
});
const result = parseUsageEntry(withInvalidUsage, '/test');
expect(result).not.toBeNull();
expect(result!.inputTokens).toBe(1500);
expect(result!.outputTokens).toBe(0);
expect(result!.cacheCreationTokens).toBe(0);
expect(result!.cacheReadTokens).toBe(0);
expect(result).toBeNull();
});
test('returns null when timestamp is missing', () => {
const withoutTimestamp = JSON.stringify({
...JSON.parse(VALID_ASSISTANT_ENTRY),
timestamp: undefined,
});
expect(parseUsageEntry(withoutTimestamp, '/test')).toBeNull();
});
});
@@ -314,8 +329,12 @@ describe('parseProjectDirectory', () => {
test('sanitizes derived projectPath from dashed directory names', async () => {
const projectDir = path.join(tempDir, '-..-etc-passwd');
const withoutCwd = JSON.stringify({
...JSON.parse(VALID_ASSISTANT_ENTRY),
cwd: undefined,
});
fs.mkdirSync(projectDir);
fs.writeFileSync(path.join(projectDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY);
fs.writeFileSync(path.join(projectDir, 'session.jsonl'), withoutCwd);
const entries = await parseProjectDirectory(projectDir);
+7
View File
@@ -160,6 +160,13 @@ describe('model-pricing', () => {
expect(opus47dated.inputPerMillion).toBe(5.0);
expect(opus47dated.outputPerMillion).toBe(25.0);
});
it('should not map unknown future model families onto known family pricing', () => {
const fallback = getModelPricing('unknown-model-xyz');
expect(getModelPricing('claude-opus-5-20270101')).toEqual(fallback);
expect(getModelPricing('gemini-3.2-pro')).toEqual(fallback);
});
});
describe('calculateCost', () => {
@@ -20,6 +20,48 @@ function fetchRawResponse(): Promise<CliproxyUsageApiResponse | null> {
return Promise.resolve(rawResponse);
}
function buildResponse(inputTokens: number, timestamp = '2026-03-02T12:00:00.000Z'): CliproxyUsageApiResponse {
return {
usage: {
apis: {
gemini: {
models: {
'gemini-2.5-pro': {
details: [
{
timestamp,
source: 'account-a',
auth_index: 0,
tokens: {
input_tokens: inputTokens,
output_tokens: 20,
reasoning_tokens: 0,
cached_tokens: 10,
total_tokens: inputTokens + 30,
},
failed: false,
},
],
},
},
},
},
},
};
}
function createDeferredFetch(response: CliproxyUsageApiResponse | null) {
let resolveFetch: ((value: CliproxyUsageApiResponse | null) => void) | undefined;
return {
fetch: () =>
new Promise<CliproxyUsageApiResponse | null>((resolve) => {
fetchCalls++;
resolveFetch = resolve;
}),
resolve: () => resolveFetch?.(response),
};
}
beforeEach(() => {
ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-'));
fetchCalls = 0;
@@ -92,4 +134,43 @@ describe('cliproxy usage syncer', () => {
stopCliproxySync();
intervalSpy.mockRestore();
});
it('keeps stale cached snapshots available for historical analytics reads', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
await syncCliproxyUsage(fetchRawResponse);
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
timestamp: number;
};
snapshot.timestamp = Date.now() - 8 * 24 * 60 * 60 * 1000;
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot), 'utf-8');
const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(1);
expect(cached.hourly).toHaveLength(1);
expect(cached.monthly).toHaveLength(1);
});
});
it('keeps the newer overlapping snapshot when an older sync finishes last', async () => {
const olderSync = createDeferredFetch(buildResponse(100, '2026-03-02T10:00:00.000Z'));
const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z'));
await runWithScopedConfigDir(ccsDir, async () => {
const olderWrite = syncCliproxyUsage(olderSync.fetch);
const newerWrite = syncCliproxyUsage(newerSync.fetch);
newerSync.resolve();
await Promise.resolve();
olderSync.resolve();
await Promise.all([olderWrite, newerWrite]);
const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(1);
expect(cached.daily[0].inputTokens).toBe(200);
});
});
});
@@ -40,6 +40,19 @@ const sampleResponse: CliproxyUsageApiResponse = {
},
failed: true,
},
{
timestamp: '2026-03-01T12:15:00.000Z',
source: 'account-a',
auth_index: 0,
tokens: {
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: 0,
cached_tokens: 0,
total_tokens: 0,
},
failed: true,
},
{
timestamp: '2026-03-01T10:45:00.000Z',
source: 'account-a',
@@ -83,10 +96,25 @@ const sampleResponse: CliproxyUsageApiResponse = {
};
describe('cliproxy usage transformer', () => {
it('flattens nested API details and skips failed requests', () => {
it('retains failed requests when they carry usage and skips zero-usage failures', () => {
const flat = flattenCliproxyDetails(sampleResponse);
expect(flat).toHaveLength(3);
expect(flat.every((entry) => entry.detail.failed === false)).toBe(true);
expect(flat).toHaveLength(4);
expect(
flat.some(
(entry) =>
entry.detail.failed === true &&
entry.detail.tokens.input_tokens === 40 &&
entry.detail.tokens.output_tokens === 10
)
).toBe(true);
expect(
flat.some(
(entry) =>
entry.detail.failed === true &&
entry.detail.tokens.input_tokens === 0 &&
entry.detail.tokens.output_tokens === 0
)
).toBe(false);
});
it('transforms daily usage with aggregated model totals', () => {
@@ -98,21 +126,25 @@ describe('cliproxy usage transformer', () => {
expect(daily[1].date).toBe('2026-03-01');
const marchFirst = daily.find((d) => d.date === '2026-03-01');
expect(marchFirst?.inputTokens).toBe(130);
expect(marchFirst?.outputTokens).toBe(70);
expect(marchFirst?.cacheReadTokens).toBe(30);
expect(marchFirst?.inputTokens).toBe(170);
expect(marchFirst?.outputTokens).toBe(80);
expect(marchFirst?.cacheReadTokens).toBe(35);
expect(marchFirst?.modelsUsed).toContain('gemini-2.5-pro');
});
it('transforms hourly usage with hour buckets', () => {
const hourly = transformCliproxyToHourlyUsage(sampleResponse);
expect(hourly).toHaveLength(2);
expect(hourly).toHaveLength(3);
expect(hourly[0].hour).toBe('2026-03-02 01:00');
const tenAm = hourly.find((h) => h.hour === '2026-03-01 10:00');
expect(tenAm?.inputTokens).toBe(130);
expect(tenAm?.outputTokens).toBe(70);
const elevenAm = hourly.find((h) => h.hour === '2026-03-01 11:00');
expect(elevenAm?.inputTokens).toBe(40);
expect(elevenAm?.outputTokens).toBe(10);
});
it('transforms monthly usage with cliproxy source', () => {
@@ -121,8 +153,8 @@ describe('cliproxy usage transformer', () => {
expect(monthly).toHaveLength(1);
expect(monthly[0].month).toBe('2026-03');
expect(monthly[0].source).toBe('cliproxy');
expect(monthly[0].inputTokens).toBe(200);
expect(monthly[0].outputTokens).toBe(100);
expect(monthly[0].cacheReadTokens).toBe(30);
expect(monthly[0].inputTokens).toBe(240);
expect(monthly[0].outputTokens).toBe(110);
expect(monthly[0].cacheReadTokens).toBe(35);
});
});
@@ -9,6 +9,7 @@ let claudeDir = '';
let aggregator: typeof import('../../../src/web-server/usage/aggregator');
let originalCcsDir: string | undefined;
let originalClaudeConfigDir: string | undefined;
let originalCcsHome: string | undefined;
function writeClaudeJsonlFixture(): void {
const projectDir = path.join(claudeDir, 'projects', 'project-one');
@@ -37,7 +38,7 @@ function writeCliproxySnapshotFixture(): void {
fs.mkdirSync(snapshotDir, { recursive: true });
const snapshot = {
version: 1,
version: 2,
timestamp: Date.now(),
daily: [
{
@@ -143,7 +144,9 @@ beforeEach(async () => {
originalCcsDir = process.env.CCS_DIR;
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_DIR = ccsDir;
process.env.CCS_HOME = tempRoot;
process.env.CLAUDE_CONFIG_DIR = claudeDir;
writeUnifiedConfigFixture();
@@ -170,6 +173,12 @@ afterEach(() => {
delete process.env.CLAUDE_CONFIG_DIR;
}
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
});
@@ -193,4 +202,108 @@ describe('usage aggregator cliproxy integration', () => {
aggregator.clearUsageCache();
expect(aggregator.getLastFetchTimestamp()).toBeNull();
});
it('avoids double counting when CLAUDE_CONFIG_DIR points at a CCS instance', async () => {
const instancePath = path.join(ccsDir, 'instances', 'work-profile');
const instanceProjectDir = path.join(instancePath, 'projects', 'profile-one');
fs.mkdirSync(instanceProjectDir, { recursive: true });
const globalLine = JSON.stringify({
type: 'assistant',
sessionId: 'session-global',
timestamp: '2026-03-02T10:00:00.000Z',
cwd: '/tmp/global',
message: {
model: 'claude-sonnet-4-5',
usage: {
input_tokens: 10,
output_tokens: 1,
},
},
});
fs.writeFileSync(path.join(claudeDir, 'projects', 'project-one', 'global.jsonl'), `${globalLine}\n`);
const instanceLine = JSON.stringify({
type: 'assistant',
sessionId: 'session-instance',
timestamp: '2026-03-02T11:00:00.000Z',
cwd: '/tmp/instance',
message: {
model: 'gemini-2.5-pro',
usage: {
input_tokens: 100,
output_tokens: 10,
},
},
});
fs.writeFileSync(path.join(instanceProjectDir, 'usage.jsonl'), `${instanceLine}\n`);
process.env.CLAUDE_CONFIG_DIR = instancePath;
aggregator.clearUsageCache();
const daily = await aggregator.getCachedDailyData();
expect(daily).toHaveLength(1);
expect(daily[0].inputTokens).toBe(260);
expect(daily[0].outputTokens).toBe(61);
expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5');
expect(daily[0].modelsUsed).toContain('gemini-2.5-pro');
});
it('merges monthly model breakdowns when sources collide', () => {
const result = aggregator.mergeMonthlyData([
[
{
month: '2026-03',
source: 'default',
inputTokens: 10,
outputTokens: 1,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalCost: 1,
modelsUsed: ['claude-sonnet-4-5'],
modelBreakdowns: [
{
modelName: 'claude-sonnet-4-5',
inputTokens: 10,
outputTokens: 1,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 1,
},
],
},
],
[
{
month: '2026-03',
source: 'instance',
inputTokens: 20,
outputTokens: 2,
cacheCreationTokens: 5,
cacheReadTokens: 3,
totalCost: 2,
modelsUsed: ['gemini-2.5-pro'],
modelBreakdowns: [
{
modelName: 'gemini-2.5-pro',
inputTokens: 20,
outputTokens: 2,
cacheCreationTokens: 5,
cacheReadTokens: 3,
cost: 2,
},
],
},
],
]);
expect(result).toHaveLength(1);
expect(result[0].inputTokens).toBe(30);
expect(result[0].outputTokens).toBe(3);
expect(result[0].cacheCreationTokens).toBe(5);
expect(result[0].cacheReadTokens).toBe(3);
expect(result[0].totalCost).toBe(3);
expect(result[0].modelBreakdowns).toHaveLength(2);
});
});
@@ -0,0 +1,328 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
type HandlersModule = typeof import('../../../src/web-server/usage/handlers');
type AggregatorModule = typeof import('../../../src/web-server/usage/aggregator');
interface AssistantFixture {
project: string;
sessionId: string;
timestamp: string;
model: string;
inputTokens?: number;
outputTokens?: number;
cacheCreationTokens?: number;
cacheReadTokens?: number;
}
interface MockResponse {
payload: unknown;
statusCode: number;
status: (code: number) => MockResponse;
json: (body: unknown) => MockResponse;
}
let tempHome = '';
let claudeDir = '';
let handlers: HandlersModule;
let aggregator: AggregatorModule;
let originalCcsHome: string | undefined;
let originalClaudeConfigDir: string | undefined;
function writeUnifiedConfigFixture(): void {
const yaml = `version: 2
accounts: {}
profiles: {}
preferences:
theme: system
telemetry: false
auto_update: true
cliproxy:
oauth_accounts: {}
providers:
- gemini
- codex
- agy
variants: {}
cliproxy_server:
local:
port: 65534
`;
fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
fs.writeFileSync(path.join(tempHome, '.ccs', 'config.yaml'), yaml, 'utf-8');
}
function writeAssistantEntries(entries: AssistantFixture[]): void {
for (const entry of entries) {
const projectDir = path.join(claudeDir, 'projects', entry.project);
fs.mkdirSync(projectDir, { recursive: true });
const line = JSON.stringify({
type: 'assistant',
sessionId: entry.sessionId,
timestamp: entry.timestamp,
version: '1.0.0',
cwd: `/tmp/${entry.project}`,
message: {
model: entry.model,
usage: {
input_tokens: entry.inputTokens ?? 0,
output_tokens: entry.outputTokens ?? 0,
cache_creation_input_tokens: entry.cacheCreationTokens ?? 0,
cache_read_input_tokens: entry.cacheReadTokens ?? 0,
},
},
});
fs.writeFileSync(
path.join(projectDir, `${entry.sessionId}.jsonl`),
`${line}\n`,
'utf-8'
);
}
}
function createMockResponse(): MockResponse {
return {
payload: undefined,
statusCode: 200,
status(code: number) {
this.statusCode = code;
return this;
},
json(body: unknown) {
this.payload = body;
return this;
},
};
}
beforeEach(async () => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-handlers-'));
claudeDir = path.join(tempHome, '.claude');
originalCcsHome = process.env.CCS_HOME;
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CCS_HOME = tempHome;
process.env.CLAUDE_CONFIG_DIR = claudeDir;
writeUnifiedConfigFixture();
handlers = await import('../../../src/web-server/usage/handlers');
aggregator = await import('../../../src/web-server/usage/aggregator');
aggregator.shutdownUsageAggregator();
aggregator.clearUsageCache();
});
afterEach(() => {
aggregator.shutdownUsageAggregator();
aggregator.clearUsageCache();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('usage handlers semantics', () => {
it('includes cache tokens in summary totals and uses calendar-day averages', async () => {
writeAssistantEntries([
{
project: 'project-one',
sessionId: 'session-a',
timestamp: '2026-03-02T10:00:00.000Z',
model: 'claude-sonnet-4-5',
inputTokens: 1_000_000,
outputTokens: 100_000,
cacheCreationTokens: 100_000,
cacheReadTokens: 200_000,
},
]);
const res = createMockResponse();
await handlers.handleSummary(
{ query: { since: '20260301', until: '20260303' } } as never,
res as never
);
expect(res.statusCode).toBe(200);
expect(res.payload).toMatchObject({
success: true,
data: {
totalTokens: 1_400_000,
totalCacheTokens: 300_000,
totalDays: 3,
activeDays: 1,
averageTokensPerDay: 466_667,
averageTokensPerActiveDay: 1_400_000,
averageCostPerDay: 1.65,
averageCostPerActiveDay: 4.93,
},
});
});
it('counts hourly requests from raw entries instead of distinct models', async () => {
writeAssistantEntries([
{
project: 'project-one',
sessionId: 'session-a',
timestamp: '2026-03-02T10:05:00.000Z',
model: 'claude-sonnet-4-5',
inputTokens: 100,
outputTokens: 10,
},
{
project: 'project-two',
sessionId: 'session-b',
timestamp: '2026-03-02T10:15:00.000Z',
model: 'claude-sonnet-4-5',
inputTokens: 120,
outputTokens: 15,
},
{
project: 'project-three',
sessionId: 'session-c',
timestamp: '2026-03-02T10:30:00.000Z',
model: 'gemini-2.5-pro',
inputTokens: 80,
outputTokens: 20,
},
]);
const res = createMockResponse();
await handlers.handleHourly(
{ query: { since: '20260302', until: '20260302' } } as never,
res as never
);
const payload = res.payload as { success: boolean; data: Array<{ hour: string; requests: number }> };
const targetHour = payload.data.find((row) => row.hour === '2026-03-02 10:00');
expect(targetHour?.requests).toBe(3);
});
it('uses overlapping months for monthly filtering', async () => {
writeAssistantEntries([
{
project: 'march-project',
sessionId: 'session-march',
timestamp: '2026-03-20T10:00:00.000Z',
model: 'claude-sonnet-4-5',
inputTokens: 100,
outputTokens: 10,
},
{
project: 'april-project',
sessionId: 'session-april',
timestamp: '2026-04-05T10:00:00.000Z',
model: 'claude-sonnet-4-5',
inputTokens: 200,
outputTokens: 20,
},
]);
const res = createMockResponse();
await handlers.handleMonthly(
{ query: { since: '20260315', until: '20260410' } } as never,
res as never
);
expect(res.payload).toMatchObject({
success: true,
data: [
expect.objectContaining({ month: '2026-03' }),
expect.objectContaining({ month: '2026-04' }),
],
});
});
it('reports actual cache size after warming the usage cache', async () => {
writeAssistantEntries([
{
project: 'project-one',
sessionId: 'session-a',
timestamp: '2026-03-02T10:00:00.000Z',
model: 'claude-sonnet-4-5',
inputTokens: 100,
outputTokens: 10,
},
]);
await aggregator.getCachedDailyData();
const res = createMockResponse();
handlers.handleStatus({} as never, res as never);
const payload = res.payload as {
success: boolean;
data: { lastFetch: number | null; cacheSize: unknown };
};
expect(payload.success).toBe(true);
expect(payload.data.lastFetch).not.toBeNull();
expect(payload.data.cacheSize).toBe(aggregator.getUsageCacheSize());
expect(aggregator.getUsageCacheSize()).toBeGreaterThan(0);
});
it('includes cache-only model activity in model token percentages', async () => {
writeAssistantEntries([
{
project: 'project-one',
sessionId: 'session-a',
timestamp: '2026-03-02T10:00:00.000Z',
model: 'claude-sonnet-4-5',
inputTokens: 100,
outputTokens: 0,
},
{
project: 'project-two',
sessionId: 'session-b',
timestamp: '2026-03-02T11:00:00.000Z',
model: 'gemini-2.5-pro',
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 100,
},
]);
const res = createMockResponse();
await handlers.handleModels(
{ query: { since: '20260302', until: '20260302' } } as never,
res as never
);
expect(res.payload).toMatchObject({
success: true,
data: expect.arrayContaining([
expect.objectContaining({ model: 'claude-sonnet-4-5', tokens: 100, percentage: 50 }),
expect.objectContaining({ model: 'gemini-2.5-pro', tokens: 100, percentage: 50 }),
]),
});
});
it('rejects reversed date ranges before computing summary totals', async () => {
const res = createMockResponse();
await handlers.handleSummary(
{ query: { since: '20260410', until: '20260401' } } as never,
res as never
);
expect(res.statusCode).toBe(400);
expect(res.payload).toMatchObject({
success: false,
error: 'The "since" date must be earlier than or equal to "until"',
});
});
});
@@ -13,6 +13,8 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const ioRatioStatus = getIoRatioStatus(model.ioRatio);
const cacheTokens = model.cacheCreationTokens + model.cacheReadTokens;
const totalTokensLabel = cacheTokens > 0 ? 'All Tokens' : t('analyticsCards.totalTokens');
return (
<div className="space-y-4">
@@ -49,7 +51,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
{formatCompactNumber(model.tokens)}
</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
{t('analyticsCards.totalTokens')}
{totalTokensLabel}
</p>
</div>
</div>
@@ -31,26 +31,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
const sessions = data.sessions;
const totalSessions = data.total;
// Calculate average tokens per session
const totalTokens = sessions.reduce((sum, s) => sum + (s.inputTokens + s.outputTokens), 0);
const avgTokens = Math.round(totalTokens / sessions.length);
const hasPartialSample = data.hasMore || data.offset > 0;
// Calculate total cost for visible sessions
const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
const avgCost = totalCost / sessions.length;
// Most recent session
const lastSession = sessions[0];
const lastActive = lastSession
? formatDistanceToNow(new Date(lastSession.lastActivity), { addSuffix: true })
: 'N/A';
return {
totalSessions,
avgTokens,
avgCost,
lastActive,
hasPartialSample,
recentSessions: sessions.slice(0, 3),
};
}, [data]);
@@ -122,8 +112,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
</span>
</div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
{/* TODO i18n: missing key for "Avg Cost/Session" */}
Avg Cost/Session
{stats.hasPartialSample ? 'Recent Avg Cost' : 'Avg Cost/Session'}
</p>
</div>
</div>
@@ -159,7 +148,8 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
<div className={cn('text-right shrink-0 ml-2', privacyMode && PRIVACY_BLUR_CLASS)}>
<div className="font-mono">${session.cost.toFixed(2)}</div>
<div className="text-[10px] text-muted-foreground">
{formatCompact(session.inputTokens + session.outputTokens)} toks
{formatCompact(session.tokens ?? session.inputTokens + session.outputTokens)}{' '}
toks
</div>
</div>
</div>
@@ -22,6 +22,11 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const totalTokens = data?.totalTokens ?? 0;
const totalInputTokens = data?.totalInputTokens ?? 0;
const totalOutputTokens = data?.totalOutputTokens ?? 0;
const totalCacheTokens = data?.totalCacheTokens ?? 0;
const totalIoTokens = totalInputTokens + totalOutputTokens;
if (isLoading) {
return (
@@ -47,19 +52,27 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
const cacheCost =
(data?.tokenBreakdown?.cacheCreation?.cost ?? 0) + (data?.tokenBreakdown?.cacheRead?.cost ?? 0);
const cacheCostPercent = data?.totalCost ? Math.round((cacheCost / data.totalCost) * 100) : 0;
const totalTokensIncludesCache = totalCacheTokens > 0 && totalTokens > totalIoTokens;
const totalTokensTitle =
totalCacheTokens > 0 && !totalTokensIncludesCache
? 'Total Tokens (I/O)'
: t('analyticsSummary.totalTokens');
const totalTokensSubtitle = totalTokensIncludesCache
? `${formatNumber(totalInputTokens)} in / ${formatNumber(totalOutputTokens)} out / ${formatNumber(totalCacheTokens)} cache`
: t('analyticsSummary.totalTokensSubtitle', {
input: formatNumber(totalInputTokens),
output: formatNumber(totalOutputTokens),
});
const cards = [
{
title: t('analyticsSummary.totalTokens'),
value: data?.totalTokens ?? 0,
title: totalTokensTitle,
value: totalTokens,
icon: FileText,
format: (v: number) => formatNumber(v),
color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
subtitle: t('analyticsSummary.totalTokensSubtitle', {
input: formatNumber(data?.totalInputTokens ?? 0),
output: formatNumber(data?.totalOutputTokens ?? 0),
}),
subtitle: totalTokensSubtitle,
},
{
title: t('analyticsSummary.totalCost'),
@@ -146,7 +146,6 @@ export function UsageTrendChart({
content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const tooltipData = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{label}</p>
@@ -162,16 +161,6 @@ export function UsageTrendChart({
: `$${entry.value}`}
</p>
))}
{'requests' in tooltipData && (
<p
className={cn(
'text-sm text-muted-foreground mt-1',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
Requests: {tooltipData.requests}
</p>
)}
</div>
);
}}
+16 -15
View File
@@ -95,6 +95,7 @@ export interface UsageInsights {
export interface Session {
sessionId: string;
projectPath: string;
tokens?: number;
inputTokens: number;
outputTokens: number;
cost: number;
@@ -145,48 +146,49 @@ function formatDateForApi(date: Date): string {
return `${year}${month}${day}`;
}
function buildUsageUrl(path: string, params: URLSearchParams): string {
const query = params.toString();
return query ? `${path}?${query}` : path;
}
export const usageApi = {
summary: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<UsageSummary>(`/usage/summary?${params}`);
return request<UsageSummary>(buildUsageUrl('/usage/summary', params));
},
trends: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<DailyUsage[]>(`/usage/daily?${params}`);
return request<DailyUsage[]>(buildUsageUrl('/usage/daily', params));
},
hourly: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
return request<HourlyUsage[]>(`/usage/hourly?${params}`);
return request<HourlyUsage[]>(buildUsageUrl('/usage/hourly', params));
},
models: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<ModelUsage[]>(`/usage/models?${params}`);
return request<ModelUsage[]>(buildUsageUrl('/usage/models', params));
},
sessions: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
return request<PaginatedSessions>(`/usage/sessions?${params}`);
return request<PaginatedSessions>(buildUsageUrl('/usage/sessions', params));
},
monthly: (months?: number, profile?: string) => {
monthly: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (months) params.append('months', months.toString());
if (profile) params.append('profile', profile);
return request<MonthlyUsage[]>(`/usage/monthly?${params}`);
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
return request<MonthlyUsage[]>(buildUsageUrl('/usage/monthly', params));
},
/** Clear server-side usage cache and force fresh data fetch */
refresh: async (): Promise<void> => {
@@ -205,8 +207,7 @@ export const usageApi = {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<UsageInsights>(`/usage/insights?${params}`);
return request<UsageInsights>(buildUsageUrl('/usage/insights', params));
},
};
+6 -1
View File
@@ -18,6 +18,8 @@ import {
type ModelUsage,
} from '@/hooks/use-usage';
const RECENT_SESSION_SAMPLE_LIMIT = 50;
export function useAnalyticsPage() {
// Default to last 30 days
const [dateRange, setDateRange] = useState<DateRange | undefined>({
@@ -55,7 +57,10 @@ export function useAnalyticsPage() {
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
const { data: sessions, isLoading: isSessionsLoading } = useSessions({
...apiOptions,
limit: RECENT_SESSION_SAMPLE_LIMIT,
});
const { data: status } = useUsageStatus();
// Handle "24H" preset click
@@ -1,15 +1,9 @@
/**
* Session Stats Card Tests
* Unit tests for SessionStatsCard component with project name formatting
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SessionStatsCard } from '../../../../src/components/analytics/session-stats-card';
import { AllProviders } from '../../../setup/test-utils';
import type { PaginatedSessions } from '../../../../src/hooks/use-usage';
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
import type { PaginatedSessions, Session } from '@/hooks/use-usage';
import { AllProviders } from '@tests/setup/test-utils';
// Mock date-fns to return consistent dates
vi.mock('date-fns', async () => {
const actual = await vi.importActual('date-fns');
return {
@@ -18,288 +12,132 @@ vi.mock('date-fns', async () => {
};
});
function buildSession(overrides: Partial<Session> = {}): Session {
return {
sessionId: 'session-1',
projectPath: '/Users/kaitran/CloudPersonal/worktrees/ccs-cli/feature-branch',
inputTokens: 1_500,
outputTokens: 2_500,
cost: 0.08,
lastActivity: '2026-04-26T14:00:00.000Z',
modelsUsed: ['claude-sonnet-4'],
...overrides,
};
}
function buildPaginatedSessions(overrides: Partial<PaginatedSessions> = {}): PaginatedSessions {
return {
sessions: [buildSession()],
total: 1,
limit: 50,
offset: 0,
hasMore: false,
...overrides,
};
}
describe('SessionStatsCard', () => {
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
});
describe('Loading and Empty States', () => {
it('renders loading skeleton when isLoading is true', () => {
const { container } = render(<SessionStatsCard data={undefined} isLoading={true} />, {
wrapper: AllProviders,
});
// Should have skeleton loading elements
expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument();
it('renders a loading skeleton', () => {
const { container } = render(<SessionStatsCard data={undefined} isLoading />, {
wrapper: AllProviders,
});
it('shows empty state when no data available', () => {
render(<SessionStatsCard data={undefined} />, { wrapper: AllProviders });
expect(screen.getByText('Session Stats')).toBeInTheDocument();
expect(screen.getByText('No session data available')).toBeInTheDocument();
});
it('shows empty state when sessions array is empty', () => {
const emptyData: PaginatedSessions = {
sessions: [],
total: 0,
page: 1,
pageSize: 10,
};
render(<SessionStatsCard data={emptyData} />, { wrapper: AllProviders });
expect(screen.getByText('Session Stats')).toBeInTheDocument();
expect(screen.getByText('No session data available')).toBeInTheDocument();
});
expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument();
});
describe('Session Stats Display', () => {
const createMockSession = (
projectPath: string,
inputTokens: number,
outputTokens: number,
cost: number
) => ({
sessionId: `session-${Math.random()}`,
projectPath,
inputTokens,
outputTokens,
cost,
lastActivity: new Date().toISOString(),
it('renders the empty state with the live paginated contract', () => {
render(<SessionStatsCard data={buildPaginatedSessions({ sessions: [], total: 0 })} />, {
wrapper: AllProviders,
});
const mockData: PaginatedSessions = {
expect(screen.getByText('Session Stats')).toBeInTheDocument();
expect(screen.getByText('No session data available')).toBeInTheDocument();
});
it('uses the API total for overall session count while labeling subset averages as recent', () => {
const data = buildPaginatedSessions({
sessions: [
createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08),
createMockSession(
'/home/user/workspaces/repo-name/worktrees/feature-branch',
2000,
3000,
0.12
),
createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05),
buildSession({ sessionId: 'session-1', cost: 0.08 }),
buildSession({
sessionId: 'session-2',
projectPath: '/Users/kaitran/projects/platform/worktrees/kai-fix',
inputTokens: 2_000,
outputTokens: 3_000,
cost: 0.12,
target: 'codex',
}),
buildSession({
sessionId: 'session-3',
projectPath: '/Users/kaitran/projects/share-pi',
inputTokens: 1_000,
outputTokens: 2_000,
cost: 0.05,
}),
],
total: 3,
page: 1,
pageSize: 10,
};
beforeEach(() => {
mockData.sessions = [
createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08),
createMockSession(
'/home/user/workspaces/repo-name/worktrees/feature-branch',
2000,
3000,
0.12
),
createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05),
];
total: 9,
hasMore: true,
});
it('displays session stats header', () => {
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
expect(screen.getByText('Session Stats')).toBeInTheDocument();
});
it('shows total sessions count', () => {
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('Total Sessions')).toBeInTheDocument();
});
it('calculates and displays average cost per session', () => {
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
// Average cost: (0.08 + 0.12 + 0.05) / 3 = 0.0833 → $0.08
// Use getAllByText since cost may appear multiple times (per session + average)
const costElements = screen.getAllByText('$0.08');
expect(costElements.length).toBeGreaterThan(0);
expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument();
});
it('shows recent activity section', () => {
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
});
expect(screen.getByText('Total Sessions')).toBeInTheDocument();
expect(screen.getByText('9')).toBeInTheDocument();
expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument();
expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0);
expect(screen.getByText('codex')).toBeInTheDocument();
});
describe('Project Name Formatting', () => {
it('displays correct project name for simple path', () => {
const mockData: PaginatedSessions = {
sessions: [
{
sessionId: '1',
projectPath: '/home/user/projects/my-app',
inputTokens: 1000,
outputTokens: 2000,
cost: 0.05,
lastActivity: new Date().toISOString(),
},
],
total: 1,
page: 1,
pageSize: 10,
};
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
// Should show "my-app" instead of just "app"
expect(screen.getByTitle('/home/user/projects/my-app')).toHaveTextContent('my-app');
it('keeps the overall average label when the full result set is loaded', () => {
const data = buildPaginatedSessions({
sessions: [
buildSession({ sessionId: 'session-1', cost: 0.04 }),
buildSession({ sessionId: 'session-2', cost: 0.06 }),
],
total: 2,
hasMore: false,
});
it('displays correct project name for worktree path', () => {
const mockData: PaginatedSessions = {
sessions: [
{
sessionId: '1',
projectPath:
'/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08',
inputTokens: 1000,
outputTokens: 2000,
cost: 0.05,
lastActivity: new Date().toISOString(),
},
],
total: 1,
page: 1,
pageSize: 10,
};
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
// Should show "2026-01-08" instead of just "08"
expect(
screen.getByTitle(
'/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08'
)
).toHaveTextContent('2026-01-08');
});
it('displays correct project name for shared project', () => {
const mockData: PaginatedSessions = {
sessions: [
{
sessionId: '1',
projectPath: '/Users/joe/Developer/share-pi',
inputTokens: 1000,
outputTokens: 2000,
cost: 0.05,
lastActivity: new Date().toISOString(),
},
],
total: 1,
page: 1,
pageSize: 10,
};
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
// Should show "share-pi" instead of just "pi"
expect(screen.getByTitle('/Users/joe/Developer/share-pi')).toHaveTextContent('share-pi');
});
it('handles empty project path gracefully', () => {
const mockData: PaginatedSessions = {
sessions: [
{
sessionId: '1',
projectPath: '',
inputTokens: 1000,
outputTokens: 2000,
cost: 0.05,
lastActivity: new Date().toISOString(),
},
],
total: 1,
page: 1,
pageSize: 10,
};
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
// Should not crash and show empty string
expect(screen.getByTitle('')).toHaveTextContent('');
});
it('handles project path with only slashes', () => {
const mockData: PaginatedSessions = {
sessions: [
{
sessionId: '1',
projectPath: '///',
inputTokens: 1000,
outputTokens: 2000,
cost: 0.05,
lastActivity: new Date().toISOString(),
},
],
total: 1,
page: 1,
pageSize: 10,
};
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
// Should handle gracefully and show empty string
expect(screen.getByTitle('///')).toHaveTextContent('');
});
expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument();
expect(screen.queryByText('Recent Avg Cost')).not.toBeInTheDocument();
});
describe('Token Count Display', () => {
it('displays token counts in compact format', () => {
const mockData: PaginatedSessions = {
sessions: [
{
sessionId: '1',
projectPath: '/project/test',
inputTokens: 1500000, // 1.5M
outputTokens: 500000, // 500K
cost: 0.1,
lastActivity: new Date().toISOString(),
},
],
total: 1,
page: 1,
pageSize: 10,
};
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
expect(screen.getByText('2.0M toks')).toBeInTheDocument();
it('formats project names from worktree paths without regressing the live fixture shape', () => {
const data = buildPaginatedSessions({
sessions: [
buildSession({
projectPath:
'/Users/kaitran/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08',
}),
],
});
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
expect(
screen.getByTitle(
'/Users/kaitran/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08'
)
).toHaveTextContent('2026-01-08');
});
describe('Privacy Mode', () => {
it('blurs cost information when privacy mode is enabled', () => {
// This would require mocking the privacy context
// For now, just ensure the component renders with privacy mode
const testData: PaginatedSessions = {
sessions: [
{
sessionId: '1',
projectPath: '/home/user/project',
inputTokens: 1000,
outputTokens: 2000,
cost: 0.05,
lastActivity: new Date().toISOString(),
},
],
total: 1,
page: 1,
pageSize: 10,
};
const { container } = render(<SessionStatsCard data={testData} />, { wrapper: AllProviders });
// Component should render without errors
expect(container).toBeInTheDocument();
it('formats visible token counts from input plus output only', () => {
const data = buildPaginatedSessions({
sessions: [
buildSession({
inputTokens: 1_500_000,
outputTokens: 500_000,
}),
],
});
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
expect(screen.getByText('2.0M toks')).toBeInTheDocument();
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
import type { UsageSummary } from '@/hooks/use-usage';
import { AllProviders } from '@tests/setup/test-utils';
function buildSummary(overrides: Partial<UsageSummary> = {}): UsageSummary {
return {
totalTokens: 3_000,
totalInputTokens: 2_000,
totalOutputTokens: 1_000,
totalCacheTokens: 500,
totalCacheCreationTokens: 300,
totalCacheReadTokens: 200,
totalCost: 2,
tokenBreakdown: {
input: { tokens: 2_000, cost: 0.8 },
output: { tokens: 1_000, cost: 0.7 },
cacheCreation: { tokens: 300, cost: 0.3 },
cacheRead: { tokens: 200, cost: 0.2 },
},
totalDays: 2,
averageTokensPerDay: 1_500,
averageCostPerDay: 1,
...overrides,
};
}
describe('UsageSummaryCards', () => {
it('labels total tokens as I/O-only when cache is excluded from the backend total', () => {
render(<UsageSummaryCards data={buildSummary()} />, { wrapper: AllProviders });
expect(screen.getByText('Total Tokens (I/O)')).toBeInTheDocument();
expect(screen.getByText('2.0K in / 1.0K out')).toBeInTheDocument();
expect(screen.getByText('Cache Tokens')).toBeInTheDocument();
expect(screen.getByText('$0.50 (25% of cost)')).toBeInTheDocument();
});
it('falls back to an inclusive total label when total tokens already include cache', () => {
render(
<UsageSummaryCards
data={buildSummary({
totalTokens: 3_500,
})}
/>,
{ wrapper: AllProviders }
);
expect(screen.getByText('Total Tokens')).toBeInTheDocument();
expect(screen.getByText('2.0K in / 1.0K out / 500 cache')).toBeInTheDocument();
expect(screen.queryByText('Total Tokens (I/O)')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { AllProviders } from '@tests/setup/test-utils';
vi.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AreaChart: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Area: () => null,
CartesianGrid: () => null,
XAxis: () => null,
YAxis: () => null,
Tooltip: ({ content }: { content: (args: unknown) => ReactNode }) => (
<div data-testid="chart-tooltip">
{content({
active: true,
label: '14:00',
payload: [
{
name: 'Tokens',
value: 1_200,
color: '#0080FF',
payload: { requests: 4 },
},
{
name: 'Cost',
value: 1.5,
color: '#00C49F',
payload: { requests: 4 },
},
],
})}
</div>
),
defs: ({ children }: { children: ReactNode }) => <>{children}</>,
linearGradient: ({ children }: { children: ReactNode }) => <>{children}</>,
stop: () => null,
}));
describe('UsageTrendChart', () => {
it('does not overclaim hourly request semantics in the tooltip', () => {
render(
<UsageTrendChart
granularity="hourly"
data={[
{
hour: '2026-04-26 14:00',
tokens: 1_200,
inputTokens: 700,
outputTokens: 500,
cacheTokens: 200,
cost: 1.5,
modelsUsed: 2,
requests: 4,
},
]}
/>,
{ wrapper: AllProviders }
);
expect(screen.getByText('Tokens: 1.2K')).toBeInTheDocument();
expect(screen.getByText('Cost: $1.5')).toBeInTheDocument();
expect(screen.queryByText(/Requests:/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAnalyticsPage } from '@/pages/analytics/hooks';
import { AllProviders } from '@tests/setup/test-utils';
const usageMocks = vi.hoisted(() => ({
useUsageSummary: vi.fn(() => ({ data: undefined, isLoading: false })),
useUsageTrends: vi.fn(() => ({ data: undefined, isLoading: false })),
useHourlyUsage: vi.fn(() => ({ data: undefined, isLoading: false })),
useModelUsage: vi.fn(() => ({ data: undefined, isLoading: false })),
useRefreshUsage: vi.fn(() => vi.fn()),
useUsageStatus: vi.fn(() => ({ data: { lastFetch: null } })),
useSessions: vi.fn(() => ({ data: undefined, isLoading: false })),
}));
vi.mock('@/hooks/use-usage', () => usageMocks);
describe('useAnalyticsPage', () => {
it('requests a broader recent session sample instead of the old 3-session slice', () => {
renderHook(() => useAnalyticsPage(), {
wrapper: AllProviders,
});
expect(usageMocks.useSessions).toHaveBeenCalledWith(
expect.objectContaining({
limit: 50,
})
);
expect(usageMocks.useSessions).not.toHaveBeenCalledWith(
expect.objectContaining({
limit: 3,
})
);
});
});
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { usageApi } from '@/hooks/use-usage';
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('analytics usage API contract', () => {
beforeEach(() => {
global.fetch = vi
.fn()
.mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof fetch;
});
it('omits unsupported profile and months query params while keeping supported filters', async () => {
const startDate = new Date(2026, 3, 1);
const endDate = new Date(2026, 3, 30);
await usageApi.summary({ startDate, endDate, profile: 'work' });
await usageApi.trends({ startDate, endDate, profile: 'work' });
await usageApi.models({ startDate, endDate, profile: 'work' });
await usageApi.sessions({ startDate, endDate, profile: 'work', limit: 50, offset: 10 });
await usageApi.insights({ startDate, endDate, profile: 'work' });
await usageApi.monthly({ startDate, endDate, profile: 'work' });
const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url));
expect(urls).toContain('/api/usage/summary?since=20260401&until=20260430');
expect(urls).toContain('/api/usage/daily?since=20260401&until=20260430');
expect(urls).toContain('/api/usage/models?since=20260401&until=20260430');
expect(urls).toContain('/api/usage/sessions?since=20260401&until=20260430&limit=50&offset=10');
expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430');
expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430');
expect(urls.every((url) => !url.includes('profile='))).toBe(true);
expect(urls.every((url) => !url.includes('months='))).toBe(true);
});
});