mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
fix(analytics): correct token accounting and usage aggregation
Align snapshot handling and UI contracts with the current analytics payloads.
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -244,6 +244,7 @@ export function aggregateHourlyUsage(
|
||||
totalCost,
|
||||
modelsUsed: Array.from(modelMap.keys()),
|
||||
modelBreakdowns,
|
||||
requestCount: hourEntries.length,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user