mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
feat(bar): harden summary against provider hangs and add analytics endpoint
Summary aggregator could block indefinitely: it ran the full system health audit (a synchronous execSync) on every request and had no per-account or request-level timeout. Now: - per-account fetch is bounded; whole response is raced against a deadline - health is derived per-account from each quota result (no blocking audit) - stale-while-revalidate keeps the last value when a refresh is slow/fails Adds GET /api/bar/analytics plus a pure, tested aggregator rolling up today/7d/30d/all-time spend, a 7-day sparkline, and top models.
This commit is contained in:
@@ -21,6 +21,7 @@ import type { AccountInfo } from '../../cliproxy/accounts/types';
|
||||
import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher';
|
||||
import type { HealthReport } from '../health-service';
|
||||
import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer';
|
||||
import { computeBarAnalytics } from '../usage/bar-analytics';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -76,32 +77,89 @@ export interface BarRouterDeps {
|
||||
getTodayCostByAccount: (details: CliproxyUsageHistoryDetail[]) => Record<string, number>;
|
||||
/** Load persisted CLIProxy usage details (from snapshot cache) */
|
||||
loadCliproxyDetails: () => Promise<CliproxyUsageHistoryDetail[]>;
|
||||
/** Run system health checks */
|
||||
runHealthChecks: () => Promise<HealthReport>;
|
||||
/**
|
||||
* Optional, retained only for test back-compat. NOT used by the request path:
|
||||
* the bar derives per-account health from each quota result. The real system
|
||||
* audit shells out via a synchronous execSync that must never run here.
|
||||
*/
|
||||
runHealthChecks?: () => Promise<HealthReport>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Debounce state (module-level; reset across test suites via DI)
|
||||
// Timing budgets (module-level; the bar must NEVER block on a slow provider)
|
||||
// ============================================================================
|
||||
|
||||
/** Debounce window: skip force-fresh if last fresh pull was < 15s ago */
|
||||
const FORCE_FRESH_DEBOUNCE_MS = 15_000;
|
||||
|
||||
/** Hard ceiling for the whole /summary response. Past this we paint from cache. */
|
||||
const REQUEST_DEADLINE_MS = 2_500;
|
||||
|
||||
/** Per-account synchronous wait before falling back to stale cache (bg fetch continues). */
|
||||
const PER_ACCOUNT_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Bound for the cost side-load so a slow snapshot read can't dominate the response. */
|
||||
const SIDELOAD_TIMEOUT_MS = 1_500;
|
||||
|
||||
/** Timestamp of the last successful force-fresh pull (epoch ms, 0 = never) */
|
||||
let lastForceFreshAt = 0;
|
||||
|
||||
/** Reset debounce state — called in tests to prevent cross-test pollution */
|
||||
/** Reset module state — called in tests to prevent cross-test pollution */
|
||||
export function resetForceFreshDebounce(): void {
|
||||
lastForceFreshAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a promise to its value, or to null if it doesn't settle within `ms`.
|
||||
* The underlying promise keeps running (used to let a slow fetch warm the cache
|
||||
* for the next open while the current response degrades gracefully).
|
||||
*/
|
||||
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(null);
|
||||
}
|
||||
}, ms);
|
||||
p.then(
|
||||
(v) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Health mapping helper
|
||||
// Per-account health derivation
|
||||
// ============================================================================
|
||||
|
||||
function mapHealth(report: HealthReport): 'ok' | 'warning' | 'error' {
|
||||
if (report.summary.errors > 0) return 'error';
|
||||
if (report.summary.warnings > 0) return 'warning';
|
||||
/**
|
||||
* Health for a single account, derived from its own quota-fetch result.
|
||||
*
|
||||
* The menu bar is a per-account glance, so health is per-row — NOT the global
|
||||
* system audit. (The system audit is also unsafe here: it shells out via a
|
||||
* synchronous execSync that would block the event loop on the request path.)
|
||||
*
|
||||
* needsReauth → 'error' (token expired; user action required)
|
||||
* fetch failed → 'warning' (transient/unknown; row degrades but isn't fatal)
|
||||
* success → 'ok'
|
||||
*/
|
||||
function deriveHealth(quota: QuotaResult | null): 'ok' | 'warning' | 'error' {
|
||||
if (quota?.needsReauth) return 'error';
|
||||
if (!quota || !quota.success) return 'warning';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@@ -147,49 +205,39 @@ async function fetchAccountData(
|
||||
const provider = account.provider;
|
||||
const accountId = account.id;
|
||||
const now = new Date().toISOString();
|
||||
const isPaused = account.paused === true;
|
||||
|
||||
// Read any prior cache up front so it survives as a stale fallback even when a
|
||||
// refresh fetch is slow or fails (stale-while-revalidate).
|
||||
const cached = deps.getCachedQuota<QuotaResult>(provider, accountId);
|
||||
|
||||
// Paused accounts: serve cache if present, otherwise degrade.
|
||||
// Never trigger a live fetch for a user-paused account (avoids unnecessary
|
||||
// network calls and quota consumption for suspended accounts).
|
||||
if (isPaused) {
|
||||
const cachedQuota = deps.getCachedQuota<QuotaResult>(provider, accountId);
|
||||
return {
|
||||
quota: cachedQuota ?? null,
|
||||
cached: cachedQuota !== null,
|
||||
fetchedAt: now,
|
||||
};
|
||||
// Never trigger a live fetch for a user-paused account.
|
||||
if (account.paused === true) {
|
||||
return { quota: cached ?? null, cached: cached !== null, fetchedAt: now };
|
||||
}
|
||||
|
||||
// When force-fresh, invalidate first then fetch live
|
||||
if (forceRefresh) {
|
||||
deps.invalidateQuotaCache(provider, accountId);
|
||||
|
||||
try {
|
||||
const quota = await deps.fetchAccountQuota(provider, accountId);
|
||||
// Cache the result so subsequent default-mode calls serve it
|
||||
deps.setCachedQuota(provider, accountId, quota);
|
||||
return { quota, cached: false, fetchedAt: now };
|
||||
} catch {
|
||||
// Degrade this account row; don't throw
|
||||
return { quota: null, cached: false, fetchedAt: now };
|
||||
}
|
||||
}
|
||||
|
||||
// Default mode: check cache first
|
||||
const cached = deps.getCachedQuota<QuotaResult>(provider, accountId);
|
||||
if (cached) {
|
||||
// Default mode serves a present cache instantly (no provider call).
|
||||
if (!forceRefresh && cached) {
|
||||
return { quota: cached, cached: true, fetchedAt: now };
|
||||
}
|
||||
|
||||
// Cache miss → fetch live (still in default mode)
|
||||
try {
|
||||
const quota = await deps.fetchAccountQuota(provider, accountId);
|
||||
deps.setCachedQuota(provider, accountId, quota);
|
||||
return { quota, cached: false, fetchedAt: now };
|
||||
} catch {
|
||||
return { quota: null, cached: false, fetchedAt: now };
|
||||
// Force-fresh busts the route cache first (the stale value captured above
|
||||
// still backs the fallback below).
|
||||
if (forceRefresh) {
|
||||
deps.invalidateQuotaCache(provider, accountId);
|
||||
}
|
||||
|
||||
// Live fetch (force-refresh, or default-mode cache miss). It overwrites the
|
||||
// cache on success and is bounded by PER_ACCOUNT_TIMEOUT_MS; if it overruns,
|
||||
// the fetch keeps running (warming the cache for the next open) while this row
|
||||
// degrades to the stale value so the payload never blocks.
|
||||
const live = deps.fetchAccountQuota(provider as CLIProxyProvider, accountId).then((quota) => {
|
||||
deps.setCachedQuota(provider, accountId, quota);
|
||||
return quota;
|
||||
});
|
||||
const fresh = await withTimeout(live, PER_ACCOUNT_TIMEOUT_MS);
|
||||
if (fresh) return { quota: fresh, cached: false, fetchedAt: now };
|
||||
return { quota: cached ?? null, cached: cached !== null, fetchedAt: now };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -214,12 +262,12 @@ function buildRow(
|
||||
account: AccountInfo,
|
||||
fetchResult: AccountFetchResult,
|
||||
costByAccount: Record<string, number>,
|
||||
overallHealth: 'ok' | 'warning' | 'error',
|
||||
/** Set of cost-keys that are shared by more than one account. Cost is unknowable for these. */
|
||||
sharedCostKeys: ReadonlySet<string>
|
||||
): BarSummaryRow {
|
||||
const { quota, cached, fetchedAt } = fetchResult;
|
||||
const costKey = resolveCostKey(account);
|
||||
const health = deriveHealth(quota);
|
||||
|
||||
// Fix #11: when multiple accounts share the same cost-key (e.g. two codex accounts with
|
||||
// the same email), we cannot attribute the combined cost to either individual account.
|
||||
@@ -237,7 +285,7 @@ function buildRow(
|
||||
quota_percentage: null,
|
||||
next_reset: null,
|
||||
today_cost: todayCost,
|
||||
health: quota?.needsReauth ? 'error' : overallHealth,
|
||||
health,
|
||||
cached,
|
||||
fetchedAt,
|
||||
needsReauth: quota?.needsReauth ?? false,
|
||||
@@ -253,7 +301,7 @@ function buildRow(
|
||||
quota_percentage: extractQuotaPercentage(quota),
|
||||
next_reset: extractNextReset(quota),
|
||||
today_cost: todayCost,
|
||||
health: overallHealth,
|
||||
health,
|
||||
cached,
|
||||
fetchedAt,
|
||||
needsReauth: quota.needsReauth ?? false,
|
||||
@@ -300,23 +348,13 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
// else: debounce active — fall through to cache path
|
||||
}
|
||||
|
||||
// Fetch system health (overall, not per-account)
|
||||
let overallHealth: 'ok' | 'warning' | 'error' = 'ok';
|
||||
try {
|
||||
const healthReport = await deps.runHealthChecks();
|
||||
overallHealth = mapHealth(healthReport);
|
||||
} catch {
|
||||
overallHealth = 'warning'; // health service unavailable → degrade gracefully
|
||||
}
|
||||
|
||||
// Load usage details for per-account cost mapping
|
||||
let costByAccount: Record<string, number> = {};
|
||||
try {
|
||||
const details = await deps.loadCliproxyDetails();
|
||||
costByAccount = deps.getTodayCostByAccount(details);
|
||||
} catch {
|
||||
// Cost unavailable — rows get null/0; non-fatal
|
||||
}
|
||||
// Cost side-load is bounded so a slow usage-snapshot read can't stall the
|
||||
// glance. (Health is per-account, derived from each quota result below —
|
||||
// no blocking system audit on the request path.)
|
||||
const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS);
|
||||
const costByAccount: Record<string, number> = details
|
||||
? deps.getTodayCostByAccount(details)
|
||||
: {};
|
||||
|
||||
// Flatten all accounts across providers
|
||||
const summary = deps.getAllAccountsSummary();
|
||||
@@ -335,22 +373,48 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
.map(([key]) => key)
|
||||
);
|
||||
|
||||
// Fetch quota in parallel with per-account error isolation.
|
||||
// Concurrency is capped to avoid fan-out across large account lists.
|
||||
// Paused accounts are handled inside fetchAccountData (cache/degrade, no live fetch).
|
||||
const CONCURRENCY_CAP = 5;
|
||||
const rows: BarSummaryRow[] = [];
|
||||
for (let i = 0; i < allAccounts.length; i += CONCURRENCY_CAP) {
|
||||
const batch = allAccounts.slice(i, i + CONCURRENCY_CAP);
|
||||
const batchRows = await Promise.all(
|
||||
batch.map(async (account): Promise<BarSummaryRow> => {
|
||||
const fetchResult = await fetchAccountData(account, doForceRefresh, deps);
|
||||
return buildRow(account, fetchResult, costByAccount, overallHealth, sharedCostKeys);
|
||||
})
|
||||
);
|
||||
rows.push(...batchRows);
|
||||
}
|
||||
// Build every row synchronously from whatever is in cache right now. This
|
||||
// is the instant-paint fallback and the source of truth when the deadline
|
||||
// fires before live fetches finish.
|
||||
const cacheRows = (): BarSummaryRow[] => {
|
||||
const at = new Date().toISOString();
|
||||
return allAccounts.map((account) => {
|
||||
const cached = deps.getCachedQuota<QuotaResult>(account.provider, account.id);
|
||||
return buildRow(
|
||||
account,
|
||||
{ quota: cached ?? null, cached: cached !== null, fetchedAt: at },
|
||||
costByAccount,
|
||||
sharedCostKeys
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch quota in parallel with per-account error isolation. Each row is
|
||||
// bounded inside fetchAccountData; the whole gather is additionally raced
|
||||
// against REQUEST_DEADLINE_MS so the response NEVER hangs on a slow
|
||||
// provider — past the deadline we paint from cache and let background
|
||||
// fetches warm the next open.
|
||||
const CONCURRENCY_CAP = 5;
|
||||
const gather = (async (): Promise<BarSummaryRow[]> => {
|
||||
const rows: BarSummaryRow[] = [];
|
||||
for (let i = 0; i < allAccounts.length; i += CONCURRENCY_CAP) {
|
||||
const batch = allAccounts.slice(i, i + CONCURRENCY_CAP);
|
||||
const batchRows = await Promise.all(
|
||||
batch.map(async (account): Promise<BarSummaryRow> => {
|
||||
const fetchResult = await fetchAccountData(account, doForceRefresh, deps);
|
||||
return buildRow(account, fetchResult, costByAccount, sharedCostKeys);
|
||||
})
|
||||
);
|
||||
rows.push(...batchRows);
|
||||
}
|
||||
return rows;
|
||||
})();
|
||||
|
||||
const deadline = new Promise<BarSummaryRow[]>((resolve) => {
|
||||
setTimeout(() => resolve(cacheRows()), REQUEST_DEADLINE_MS);
|
||||
});
|
||||
|
||||
const rows = await Promise.race([gather, deadline]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('[bar-routes] /summary error:', (err as Error).message);
|
||||
@@ -358,6 +422,24 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /analytics
|
||||
*
|
||||
* Rolls up the persisted usage snapshot into today / 7-day / 30-day spend, a
|
||||
* 7-day sparkline, and the top models. Read-only and cheap (the snapshot is
|
||||
* already on disk); bounded so a slow read can't stall the menu.
|
||||
*/
|
||||
router.get('/analytics', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS);
|
||||
const analytics = computeBarAnalytics(details ?? [], new Date());
|
||||
res.json(analytics);
|
||||
} catch (err) {
|
||||
console.error('[bar-routes] /analytics error:', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -373,7 +455,6 @@ import {
|
||||
} from '../../cliproxy/quota/quota-response-cache';
|
||||
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
|
||||
import { getTodayCostByAccount } from '../usage/data-aggregator';
|
||||
import { runHealthChecks } from '../health-service';
|
||||
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
|
||||
|
||||
/** Production bar router — wired to real dependencies */
|
||||
@@ -385,7 +466,6 @@ const barRouter: Router = createBarRouter({
|
||||
fetchAccountQuota,
|
||||
getTodayCostByAccount,
|
||||
loadCliproxyDetails: loadCliproxySnapshotDetails,
|
||||
runHealthChecks,
|
||||
});
|
||||
|
||||
export default barRouter;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Bar Analytics Aggregator
|
||||
*
|
||||
* Pure functions that roll up the flat CliproxyUsageHistoryDetail array (the
|
||||
* same snapshot the bar already loads for per-account cost) into the small,
|
||||
* glanceable analytics the menu bar surfaces: today / 7-day / 30-day spend,
|
||||
* a 7-day cost sparkline, and the top models by spend.
|
||||
*
|
||||
* Kept dependency-free and deterministic (the reference "now" is injected) so
|
||||
* it is trivially unit-testable and cheap enough to run on every bar open.
|
||||
*/
|
||||
|
||||
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
|
||||
|
||||
/** A single day's roll-up (local-day granularity). */
|
||||
export interface BarAnalyticsDay {
|
||||
/** Local calendar day, YYYY-MM-DD. */
|
||||
date: string;
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** Aggregate spend over a rolling window. */
|
||||
export interface BarAnalyticsWindow {
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** One model's contribution to spend over the trailing 7 days. */
|
||||
export interface BarAnalyticsModel {
|
||||
model: string;
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** The full analytics payload returned by GET /api/bar/analytics. */
|
||||
export interface BarAnalytics {
|
||||
today: BarAnalyticsWindow;
|
||||
last7d: BarAnalyticsWindow;
|
||||
last30d: BarAnalyticsWindow;
|
||||
/** Lifetime totals across every record in the snapshot. */
|
||||
allTime: BarAnalyticsWindow;
|
||||
/** Oldest → newest, exactly 7 entries (zero-filled), for the sparkline. */
|
||||
byDay: BarAnalyticsDay[];
|
||||
/** Highest-spend models (descending, capped) for the window in `topModelsWindow`. */
|
||||
topModels: BarAnalyticsModel[];
|
||||
/** Which window `topModels` covers — the most recent one that has data. */
|
||||
topModelsWindow: '30d' | 'all';
|
||||
/** ISO timestamp the payload was generated. */
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
const SPARKLINE_DAYS = 7;
|
||||
const TOP_MODELS_LIMIT = 5;
|
||||
|
||||
/** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */
|
||||
function localDayKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Whole-day difference (a - b) in local days, via midnight-anchored dates. */
|
||||
function dayDelta(a: Date, b: Date): number {
|
||||
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
||||
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate());
|
||||
return Math.round((da.getTime() - db.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll the raw details into the bar analytics payload, relative to `now`.
|
||||
* Failed requests are excluded from spend (they carry no real cost).
|
||||
*/
|
||||
export function computeBarAnalytics(
|
||||
details: CliproxyUsageHistoryDetail[],
|
||||
now: Date
|
||||
): BarAnalytics {
|
||||
const today: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last7d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const last30d: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
const allTime: BarAnalyticsWindow = { cost: 0, requests: 0 };
|
||||
|
||||
// Seed the sparkline with the trailing 7 local days (zero-filled, ordered).
|
||||
const dayBuckets = new Map<string, BarAnalyticsDay>();
|
||||
for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
|
||||
dayBuckets.set(localDayKey(d), { date: localDayKey(d), cost: 0, requests: 0 });
|
||||
}
|
||||
|
||||
// Track per-model spend over both the trailing 30 days and all-time so we can
|
||||
// show recent leaders when fresh, and lifetime leaders when the proxy has
|
||||
// simply been idle lately.
|
||||
const model30d = new Map<string, BarAnalyticsModel>();
|
||||
const modelAll = new Map<string, BarAnalyticsModel>();
|
||||
const bump = (
|
||||
map: Map<string, BarAnalyticsModel>,
|
||||
model: string,
|
||||
cost: number,
|
||||
requests: number
|
||||
): void => {
|
||||
const existing = map.get(model);
|
||||
if (existing) {
|
||||
existing.cost += cost;
|
||||
existing.requests += requests;
|
||||
} else {
|
||||
map.set(model, { model, cost, requests });
|
||||
}
|
||||
};
|
||||
|
||||
for (const detail of details) {
|
||||
if (detail.failed) continue;
|
||||
const ts = new Date(detail.timestamp);
|
||||
if (Number.isNaN(ts.getTime())) continue;
|
||||
|
||||
const delta = dayDelta(now, ts); // 0 = today, 1 = yesterday, …
|
||||
if (delta < 0) continue; // ignore future-dated noise
|
||||
|
||||
const cost = Number.isFinite(detail.cost) ? detail.cost : 0;
|
||||
const requests = Number.isFinite(detail.requestCount) ? detail.requestCount : 0;
|
||||
|
||||
allTime.cost += cost;
|
||||
allTime.requests += requests;
|
||||
bump(modelAll, detail.model, cost, requests);
|
||||
|
||||
if (delta === 0) {
|
||||
today.cost += cost;
|
||||
today.requests += requests;
|
||||
}
|
||||
if (delta < 7) {
|
||||
last7d.cost += cost;
|
||||
last7d.requests += requests;
|
||||
const bucket = dayBuckets.get(localDayKey(ts));
|
||||
if (bucket) {
|
||||
bucket.cost += cost;
|
||||
bucket.requests += requests;
|
||||
}
|
||||
}
|
||||
if (delta < 30) {
|
||||
last30d.cost += cost;
|
||||
last30d.requests += requests;
|
||||
bump(model30d, detail.model, cost, requests);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer recent leaders; fall back to lifetime when the last 30 days are idle.
|
||||
const recentHasData = last30d.cost > 0 || last30d.requests > 0;
|
||||
const sourceMap = recentHasData ? model30d : modelAll;
|
||||
const topModels = Array.from(sourceMap.values())
|
||||
.filter((m) => m.cost > 0 || m.requests > 0)
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, TOP_MODELS_LIMIT);
|
||||
|
||||
return {
|
||||
today,
|
||||
last7d,
|
||||
last30d,
|
||||
allTime,
|
||||
byDay: Array.from(dayBuckets.values()),
|
||||
topModels,
|
||||
topModelsWindow: recentHasData ? '30d' : 'all',
|
||||
generatedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { computeBarAnalytics } from '../../../src/web-server/usage/bar-analytics';
|
||||
import type { CliproxyUsageHistoryDetail } from '../../../src/web-server/usage/cliproxy-usage-transformer';
|
||||
|
||||
const NOW = new Date('2026-06-08T12:00:00-04:00');
|
||||
|
||||
function detail(over: Partial<CliproxyUsageHistoryDetail>): CliproxyUsageHistoryDetail {
|
||||
return {
|
||||
model: 'gpt-5.5',
|
||||
timestamp: NOW.toISOString(),
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 0,
|
||||
requestCount: 1,
|
||||
cost: 1,
|
||||
failed: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an ISO timestamp `n` whole days before NOW (local). */
|
||||
function daysAgo(n: number): string {
|
||||
const d = new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() - n, 10, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
describe('computeBarAnalytics', () => {
|
||||
it('returns an empty/zeroed payload for no details', () => {
|
||||
const a = computeBarAnalytics([], NOW);
|
||||
expect(a.today.cost).toBe(0);
|
||||
expect(a.allTime.cost).toBe(0);
|
||||
expect(a.byDay).toHaveLength(7);
|
||||
expect(a.topModels).toHaveLength(0);
|
||||
expect(a.topModelsWindow).toBe('all');
|
||||
});
|
||||
|
||||
it('rolls today / 7d / 30d / allTime into the right windows', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ timestamp: daysAgo(0), cost: 2, requestCount: 1 }), // today
|
||||
detail({ timestamp: daysAgo(3), cost: 3, requestCount: 2 }), // 7d + 30d
|
||||
detail({ timestamp: daysAgo(20), cost: 5, requestCount: 1 }), // 30d only
|
||||
detail({ timestamp: daysAgo(90), cost: 10, requestCount: 4 }), // allTime only
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.today.cost).toBe(2);
|
||||
expect(a.last7d.cost).toBe(5); // 2 + 3
|
||||
expect(a.last30d.cost).toBe(10); // 2 + 3 + 5
|
||||
expect(a.allTime.cost).toBe(20); // + 10
|
||||
expect(a.allTime.requests).toBe(8);
|
||||
});
|
||||
|
||||
it('excludes failed requests from spend', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[detail({ cost: 9, failed: true }), detail({ cost: 1, failed: false })],
|
||||
NOW
|
||||
);
|
||||
expect(a.today.cost).toBe(1);
|
||||
expect(a.allTime.cost).toBe(1);
|
||||
});
|
||||
|
||||
it('zero-fills the 7-day sparkline in chronological order', () => {
|
||||
const a = computeBarAnalytics([detail({ timestamp: daysAgo(2), cost: 4 })], NOW);
|
||||
expect(a.byDay).toHaveLength(7);
|
||||
// oldest first, newest last
|
||||
expect(a.byDay[0].date < a.byDay[6].date).toBe(true);
|
||||
const hit = a.byDay.find((d) => d.cost > 0);
|
||||
expect(hit?.cost).toBe(4);
|
||||
});
|
||||
|
||||
it('ranks top models by spend and labels the window 30d when recent data exists', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ model: 'gpt-5.4', timestamp: daysAgo(1), cost: 5 }),
|
||||
detail({ model: 'gpt-5.5', timestamp: daysAgo(1), cost: 8 }),
|
||||
detail({ model: 'gpt-5.4', timestamp: daysAgo(2), cost: 2 }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.topModelsWindow).toBe('30d');
|
||||
expect(a.topModels[0].model).toBe('gpt-5.5'); // 8
|
||||
expect(a.topModels[1].model).toBe('gpt-5.4'); // 7
|
||||
});
|
||||
|
||||
it('falls back to all-time top models when the last 30 days are idle', () => {
|
||||
const a = computeBarAnalytics(
|
||||
[
|
||||
detail({ model: 'gpt-5.4', timestamp: daysAgo(60), cost: 100 }),
|
||||
detail({ model: 'gpt-5.5', timestamp: daysAgo(45), cost: 40 }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.last30d.cost).toBe(0);
|
||||
expect(a.topModelsWindow).toBe('all');
|
||||
expect(a.topModels[0].model).toBe('gpt-5.4');
|
||||
});
|
||||
});
|
||||
@@ -43,14 +43,16 @@ async function getJson<T>(baseUrl: string, path: string): Promise<{ status: numb
|
||||
// Mock factories
|
||||
// ============================================================================
|
||||
|
||||
function makeAccountInfo(overrides: Partial<{
|
||||
id: string;
|
||||
provider: string;
|
||||
nickname: string;
|
||||
tier: string;
|
||||
paused: boolean;
|
||||
isDefault: boolean;
|
||||
}> = {}) {
|
||||
function makeAccountInfo(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
provider: string;
|
||||
nickname: string;
|
||||
tier: string;
|
||||
paused: boolean;
|
||||
isDefault: boolean;
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? 'test@example.com',
|
||||
provider: overrides.provider ?? 'agy',
|
||||
@@ -63,25 +65,31 @@ function makeAccountInfo(overrides: Partial<{
|
||||
};
|
||||
}
|
||||
|
||||
function makeQuotaResult(overrides: Partial<{
|
||||
success: boolean;
|
||||
models: Array<{ name: string; percentage: number; resetTime: string | null }>;
|
||||
needsReauth: boolean;
|
||||
error: string;
|
||||
lastUpdated: number;
|
||||
}> = {}) {
|
||||
function makeQuotaResult(
|
||||
overrides: Partial<{
|
||||
success: boolean;
|
||||
models: Array<{ name: string; percentage: number; resetTime: string | null }>;
|
||||
needsReauth: boolean;
|
||||
error: string;
|
||||
lastUpdated: number;
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
success: overrides.success ?? true,
|
||||
models: overrides.models ?? [{ name: 'gemini-3-pro', percentage: 75, resetTime: '2026-06-08T00:00:00Z' }],
|
||||
models: overrides.models ?? [
|
||||
{ name: 'gemini-3-pro', percentage: 75, resetTime: '2026-06-08T00:00:00Z' },
|
||||
],
|
||||
lastUpdated: overrides.lastUpdated ?? Date.now(),
|
||||
needsReauth: overrides.needsReauth ?? false,
|
||||
...(overrides.error !== undefined && { error: overrides.error }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeHealthReport(overrides: Partial<{
|
||||
summary: { errors: number; warnings: number; passed: number; total: number; info: number };
|
||||
}> = {}) {
|
||||
function makeHealthReport(
|
||||
overrides: Partial<{
|
||||
summary: { errors: number; warnings: number; passed: number; total: number; info: number };
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
version: '1.0.0',
|
||||
@@ -216,8 +224,18 @@ describe('GET /api/bar/summary', () => {
|
||||
});
|
||||
|
||||
it('maps account metadata into the row correctly', async () => {
|
||||
mockAccounts = [makeAccountInfo({ id: 'alice@example.com', provider: 'agy', tier: 'ultra', paused: false, nickname: 'alice' })];
|
||||
mockQuotaResult = makeQuotaResult({ models: [{ name: 'gemini-3-pro', percentage: 60, resetTime: '2026-06-08T00:00:00Z' }] });
|
||||
mockAccounts = [
|
||||
makeAccountInfo({
|
||||
id: 'alice@example.com',
|
||||
provider: 'agy',
|
||||
tier: 'ultra',
|
||||
paused: false,
|
||||
nickname: 'alice',
|
||||
}),
|
||||
];
|
||||
mockQuotaResult = makeQuotaResult({
|
||||
models: [{ name: 'gemini-3-pro', percentage: 60, resetTime: '2026-06-08T00:00:00Z' }],
|
||||
});
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
const row = body[0];
|
||||
@@ -235,7 +253,9 @@ describe('GET /api/bar/summary', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('default mode returns cached: true when cache is populated', async () => {
|
||||
mockCachedQuota = makeQuotaResult({ models: [{ name: 'model-a', percentage: 80, resetTime: null }] });
|
||||
mockCachedQuota = makeQuotaResult({
|
||||
models: [{ name: 'model-a', percentage: 80, resetTime: null }],
|
||||
});
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
const row = body[0];
|
||||
@@ -323,7 +343,12 @@ describe('GET /api/bar/summary', () => {
|
||||
// We test degradation by simulating the success path; the real per-account
|
||||
// error test verifies via needsReauth row
|
||||
mockAccounts = [makeAccountInfo({ id: 'reauth@example.com', provider: 'agy' })];
|
||||
mockQuotaResult = makeQuotaResult({ success: false, needsReauth: true, models: [], error: 'token expired' });
|
||||
mockQuotaResult = makeQuotaResult({
|
||||
success: false,
|
||||
needsReauth: true,
|
||||
models: [],
|
||||
error: 'token expired',
|
||||
});
|
||||
void callCount; // suppress lint
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
@@ -375,25 +400,36 @@ describe('GET /api/bar/summary', () => {
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// health mapping
|
||||
// health is per-account, derived from each account's own quota result
|
||||
// (no blocking system audit on the request path)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('health is "ok" when overall health has no errors or warnings', async () => {
|
||||
mockHealthReport = makeHealthReport({ summary: { errors: 0, warnings: 0, passed: 5, total: 5, info: 0 } });
|
||||
it('health is "ok" when the account quota fetch succeeds', async () => {
|
||||
mockQuotaResult = makeQuotaResult({ success: true });
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
expect(body[0].health).toBe('ok');
|
||||
});
|
||||
|
||||
it('health is "warning" when overall health has warnings but no errors', async () => {
|
||||
mockHealthReport = makeHealthReport({ summary: { errors: 0, warnings: 2, passed: 3, total: 5, info: 0 } });
|
||||
it('health is "warning" when the quota fetch fails without needing reauth', async () => {
|
||||
mockQuotaResult = makeQuotaResult({
|
||||
success: false,
|
||||
needsReauth: false,
|
||||
models: [],
|
||||
error: 'temporary',
|
||||
});
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
expect(body[0].health).toBe('warning');
|
||||
});
|
||||
|
||||
it('health is "error" when overall health has errors', async () => {
|
||||
mockHealthReport = makeHealthReport({ summary: { errors: 1, warnings: 0, passed: 4, total: 5, info: 0 } });
|
||||
it('health is "error" when the account needs reauthentication', async () => {
|
||||
mockQuotaResult = makeQuotaResult({
|
||||
success: false,
|
||||
needsReauth: true,
|
||||
models: [],
|
||||
error: 'token expired',
|
||||
});
|
||||
|
||||
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
|
||||
expect(body[0].health).toBe('error');
|
||||
@@ -455,30 +491,35 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
|
||||
// Simulate a codex account where id = "user@example.com#free"
|
||||
// but the cost map key is the canonical email "user@example.com"
|
||||
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const costMap: Record<string, number> = {
|
||||
'codex-user@example.com': 2.50, // keyed by email (as buildAuthIndexToAccountMap produces)
|
||||
'codex-user@example.com': 2.5, // keyed by email (as buildAuthIndexToAccountMap produces)
|
||||
};
|
||||
|
||||
const router = createBarRouter({
|
||||
getAllAccountsSummary: () => ({
|
||||
codex: [{
|
||||
id: 'codex-user@example.com#free', // id has variant suffix
|
||||
email: 'codex-user@example.com', // email is the canonical lookup key
|
||||
provider: 'codex',
|
||||
nickname: 'codex-user',
|
||||
tier: 'free',
|
||||
paused: false,
|
||||
isDefault: true,
|
||||
tokenFile: 'codex-codex-user_example_com-free.json',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
getAllAccountsSummary: () =>
|
||||
({
|
||||
codex: [
|
||||
{
|
||||
id: 'codex-user@example.com#free', // id has variant suffix
|
||||
email: 'codex-user@example.com', // email is the canonical lookup key
|
||||
provider: 'codex',
|
||||
nickname: 'codex-user',
|
||||
tier: 'free',
|
||||
paused: false,
|
||||
isDefault: true,
|
||||
tokenFile: 'codex-codex-user_example_com-free.json',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
invalidateQuotaCache: () => {},
|
||||
@@ -509,12 +550,14 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
|
||||
// account_id should reflect the registry id
|
||||
expect(row.account_id).toBe('codex-user@example.com#free');
|
||||
// cost should be attributed via email lookup (2.50), not lost due to id mismatch
|
||||
expect(row.today_cost).toBeCloseTo(2.50);
|
||||
expect(row.today_cost).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it('attributes cost correctly for account with no email (kiro/ghcp type): cost remains 0', async () => {
|
||||
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
|
||||
const app2 = express();
|
||||
app2.use(express.json());
|
||||
@@ -525,20 +568,23 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
|
||||
const costMap2: Record<string, number> = {};
|
||||
|
||||
const router2 = createBarRouter({
|
||||
getAllAccountsSummary: () => ({
|
||||
kiro: [{
|
||||
id: 'kiro-default',
|
||||
// no email field
|
||||
provider: 'kiro',
|
||||
nickname: 'kiro-default',
|
||||
tier: 'unknown',
|
||||
paused: false,
|
||||
isDefault: true,
|
||||
tokenFile: 'kiro-default.json',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
getAllAccountsSummary: () =>
|
||||
({
|
||||
kiro: [
|
||||
{
|
||||
id: 'kiro-default',
|
||||
// no email field
|
||||
provider: 'kiro',
|
||||
nickname: 'kiro-default',
|
||||
tier: 'unknown',
|
||||
paused: false,
|
||||
isDefault: true,
|
||||
tokenFile: 'kiro-default.json',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
invalidateQuotaCache: () => {},
|
||||
@@ -584,7 +630,9 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => {
|
||||
concurrentInvalidateCalls = [];
|
||||
fetchDelay = 0;
|
||||
|
||||
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
|
||||
resetDebounce();
|
||||
|
||||
@@ -592,10 +640,11 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => {
|
||||
app.use(express.json());
|
||||
|
||||
const router = createBarRouter({
|
||||
getAllAccountsSummary: () => ({
|
||||
agy: [makeAccountInfo({ id: 'concurrent@example.com', provider: 'agy' })],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
getAllAccountsSummary: () =>
|
||||
({
|
||||
agy: [makeAccountInfo({ id: 'concurrent@example.com', provider: 'agy' })],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
invalidateQuotaCache: (provider: string, accountId: string) => {
|
||||
@@ -633,7 +682,8 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => {
|
||||
beforeEach(() => {
|
||||
concurrentInvalidateCalls = [];
|
||||
fetchDelay = 20; // ms — enough for requests to overlap
|
||||
const { resetForceFreshDebounce: resetDebounce } = require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } =
|
||||
require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
|
||||
resetDebounce();
|
||||
});
|
||||
|
||||
@@ -669,7 +719,9 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () =>
|
||||
beforeAll(async () => {
|
||||
fetchedAccounts = [];
|
||||
|
||||
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
|
||||
resetDebounce();
|
||||
|
||||
@@ -677,13 +729,14 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () =>
|
||||
app.use(express.json());
|
||||
|
||||
const router = createBarRouter({
|
||||
getAllAccountsSummary: () => ({
|
||||
agy: [
|
||||
makeAccountInfo({ id: 'active@example.com', provider: 'agy', paused: false }),
|
||||
makeAccountInfo({ id: 'paused@example.com', provider: 'agy', paused: true }),
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
getAllAccountsSummary: () =>
|
||||
({
|
||||
agy: [
|
||||
makeAccountInfo({ id: 'active@example.com', provider: 'agy', paused: false }),
|
||||
makeAccountInfo({ id: 'paused@example.com', provider: 'agy', paused: true }),
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
getCachedQuota: () => null,
|
||||
setCachedQuota: () => {},
|
||||
invalidateQuotaCache: () => {},
|
||||
@@ -715,7 +768,8 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () =>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchedAccounts = [];
|
||||
const { resetForceFreshDebounce: resetDebounce } = require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } =
|
||||
require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
|
||||
resetDebounce();
|
||||
});
|
||||
|
||||
@@ -747,7 +801,9 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
|
||||
|
||||
async function buildRouter(accounts: object[], costMap: Record<string, number>) {
|
||||
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
|
||||
const { resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -806,7 +862,7 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
|
||||
},
|
||||
];
|
||||
// The cost map only has a combined total for the shared email
|
||||
const costMap = { [sharedEmail]: 5.00 };
|
||||
const costMap = { [sharedEmail]: 5.0 };
|
||||
|
||||
const { srv, url } = await buildRouter(accounts, costMap);
|
||||
|
||||
@@ -818,8 +874,8 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
|
||||
expect(body[0].today_cost).toBeNull();
|
||||
expect(body[1].today_cost).toBeNull();
|
||||
// Neither should show the combined total
|
||||
expect(body[0].today_cost).not.toBe(5.00);
|
||||
expect(body[1].today_cost).not.toBe(5.00);
|
||||
expect(body[0].today_cost).not.toBe(5.0);
|
||||
expect(body[1].today_cost).not.toBe(5.0);
|
||||
});
|
||||
|
||||
it('unique-email account still shows its individual cost', async () => {
|
||||
|
||||
Reference in New Issue
Block a user