feat(bar): honest quota state and merged multi-source analytics

Derive a tri-state quotaStatus (ok|unsupported|error) per account so a
provider with no quota API renders as 'no quota' with a healthy dot instead
of an alarming bare dash, and treat unsupported as healthy in deriveHealth.

Switch the analytics endpoint off the CLIProxy snapshot, which freezes
whenever the proxy restarts (usage is in-memory only), and onto the merged
daily/hourly usage the dashboard uses. Recent activity from Claude Code,
Codex, and Droid now shows, and a per-surface breakdown answers where usage
goes. Add lastActivityAt, daysSinceLastActivity, hasRecentData and a 30-day
series; per-account today_cost reads null (unknown) rather than a misleading 0.
This commit is contained in:
Tam Nhu Tran
2026-06-09 11:49:36 -04:00
parent 9b64514a57
commit 5f850c4899
5 changed files with 436 additions and 26 deletions
+4
View File
@@ -835,11 +835,15 @@ export async function fetchAccountQuota(
if (provider !== 'agy') {
const error = `Quota not supported for provider: ${provider}`;
if (verbose) console.error(`[!] Error: ${error}`);
// Stable machine code so callers branch on a code, not the human string.
// This is "no quota API for this provider", which is healthy — distinct
// from a transient fetch failure or an expired token.
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
errorCode: 'quota_not_supported',
};
}
+84 -15
View File
@@ -21,7 +21,8 @@ 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';
import { computeBarAnalyticsFromDaily } from '../usage/bar-analytics';
import type { DailyUsage, HourlyUsage } from '../usage/types';
// ============================================================================
// Types
@@ -41,8 +42,21 @@ export interface BarSummaryRow {
paused: boolean;
/** Best-guess quota remaining percentage (0-100), null on error */
quota_percentage: number | null;
/**
* Tri-state quota availability for this account:
* 'ok' — provider has a quota API and the fetch succeeded
* 'unsupported' — provider has no quota API at all (e.g. ghcp, kiro)
* 'error' — provider should report quota but the fetch failed/timed out/needs reauth
* The UI uses this to render "no quota" (unsupported) vs "quota ?" (error)
* instead of a bare "--" that conflates the two.
*/
quotaStatus: 'ok' | 'unsupported' | 'error';
/** ISO timestamp of next quota reset, null if unknown */
next_reset: string | null;
/** Whether this is the provider's default account (drives the active/default badge) */
is_default: boolean;
/** ISO timestamp this account was last used, null if never/unknown */
last_activity_at: string | null;
/** Today's attributed cost in USD, null if unavailable */
today_cost: number | null;
/** Health status derived from overall system health */
@@ -77,6 +91,13 @@ export interface BarRouterDeps {
getTodayCostByAccount: (details: CliproxyUsageHistoryDetail[]) => Record<string, number>;
/** Load persisted CLIProxy usage details (from snapshot cache) */
loadCliproxyDetails: () => Promise<CliproxyUsageHistoryDetail[]>;
/**
* Load merged, multi-source daily usage (Claude Code, Codex, Droid, CLIProxy).
* Fresh, stale-while-revalidate; carries cost + per-model + per-surface spend.
*/
loadDailyUsage: () => Promise<DailyUsage[]>;
/** Load merged hourly usage — the source of request counts (daily lacks them). */
loadHourlyUsage: () => Promise<HourlyUsage[]>;
/**
* 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
@@ -146,6 +167,25 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
// Per-account health derivation
// ============================================================================
/**
* Tri-state quota availability derived from the QuotaResult.
*
* We branch ONLY on the result's stable errorCode, never on a provider
* registry: ghcp is listed in MANAGED_QUOTA_PROVIDERS, yet fetchAccountQuota
* returns unsupported for every provider !== 'agy'. The result's
* 'quota_not_supported' code is the only honest signal that a provider has no
* quota API, so we use it here.
*
* success → 'ok'
* errorCode 'quota_not_supported' → 'unsupported' (no quota API; healthy)
* anything else (null/timeout/reauth/other failure) → 'error'
*/
function deriveQuotaStatus(quota: QuotaResult | null): 'ok' | 'unsupported' | 'error' {
if (quota?.success === true) return 'ok';
if (quota && quota.errorCode === 'quota_not_supported') return 'unsupported';
return 'error';
}
/**
* Health for a single account, derived from its own quota-fetch result.
*
@@ -153,12 +193,21 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
* 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'
* A provider with no quota API (quotaStatus 'unsupported', e.g. ghcp/kiro) is
* healthy — it must not show a permanent warning dot. 'warning' is reserved for
* genuine transient fetch failures, 'error' for accounts needing reauth.
*
* needsReauth → 'error' (token expired; user action required)
* quota unsupported → 'ok' (no quota API is not a fault)
* fetch failed → 'warning' (transient/unknown; row degrades but isn't fatal)
* success → 'ok'
*/
function deriveHealth(quota: QuotaResult | null): 'ok' | 'warning' | 'error' {
function deriveHealth(
quota: QuotaResult | null,
quotaStatus: 'ok' | 'unsupported' | 'error'
): 'ok' | 'warning' | 'error' {
if (quota?.needsReauth) return 'error';
if (quotaStatus === 'unsupported') return 'ok';
if (!quota || !quota.success) return 'warning';
return 'ok';
}
@@ -267,12 +316,17 @@ function buildRow(
): BarSummaryRow {
const { quota, cached, fetchedAt } = fetchResult;
const costKey = resolveCostKey(account);
const health = deriveHealth(quota);
const quotaStatus = deriveQuotaStatus(quota);
const health = deriveHealth(quota, quotaStatus);
const isDefault = account.isDefault ?? false;
const lastActivityAt = account.lastUsedAt ?? null;
// 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.
// Report null (unknowable) rather than the inflated shared total.
const todayCost = sharedCostKeys.has(costKey) ? null : (costByAccount[costKey] ?? 0);
// 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, so it is null=unknowable. A missing key on a single-owner account is
// ALSO null=unknown (no usage record on a possibly-stale snapshot), distinct from
// a genuine 0 spend — the UI renders "no data" vs "$0.00" honestly.
const todayCost = sharedCostKeys.has(costKey) ? null : (costByAccount[costKey] ?? null);
if (!quota || !quota.success) {
// Degraded row: preserve identity fields, null out quota data
@@ -283,7 +337,10 @@ function buildRow(
tier: account.tier ?? null,
paused: account.paused ?? false,
quota_percentage: null,
quotaStatus,
next_reset: null,
is_default: isDefault,
last_activity_at: lastActivityAt,
today_cost: todayCost,
health,
cached,
@@ -299,7 +356,10 @@ function buildRow(
tier: quota.tier ?? account.tier ?? null,
paused: account.paused ?? false,
quota_percentage: extractQuotaPercentage(quota),
quotaStatus,
next_reset: extractNextReset(quota),
is_default: isDefault,
last_activity_at: lastActivityAt,
today_cost: todayCost,
health,
cached,
@@ -425,14 +485,20 @@ 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.
* Rolls up the merged, multi-source usage (Claude Code, Codex, Droid, CLIProxy)
* into today / 7-day / 30-day spend, a 30-day sparkline, top models, and a
* per-surface breakdown. Reads the dashboard's stale-while-revalidate caches so
* recent activity shows even when the CLIProxy snapshot is frozen by a restart.
* Both loads are bounded so a slow read can't stall the menu; on miss the
* windows degrade to empty rather than failing the payload.
*/
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());
const [daily, hourly] = await Promise.all([
withTimeout(deps.loadDailyUsage(), SIDELOAD_TIMEOUT_MS).catch(() => [] as DailyUsage[]),
withTimeout(deps.loadHourlyUsage(), SIDELOAD_TIMEOUT_MS).catch(() => [] as HourlyUsage[]),
]);
const analytics = computeBarAnalyticsFromDaily(daily ?? [], hourly ?? [], new Date());
res.json(analytics);
} catch (err) {
console.error('[bar-routes] /analytics error:', (err as Error).message);
@@ -456,6 +522,7 @@ import {
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
import { getTodayCostByAccount } from '../usage/data-aggregator';
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator';
/** Production bar router — wired to real dependencies */
const barRouter: Router = createBarRouter({
@@ -466,6 +533,8 @@ const barRouter: Router = createBarRouter({
fetchAccountQuota,
getTodayCostByAccount,
loadCliproxyDetails: loadCliproxySnapshotDetails,
loadDailyUsage: () => getCachedDailyData(),
loadHourlyUsage: () => getCachedHourlyData(),
});
export default barRouter;
+220 -2
View File
@@ -11,6 +11,7 @@
*/
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
import type { DailyUsage, HourlyUsage } from './types';
/** A single day's roll-up (local-day granularity). */
export interface BarAnalyticsDay {
@@ -20,6 +21,21 @@ export interface BarAnalyticsDay {
requests: number;
}
/**
* One usage surface's contribution to spend over the active window.
* A "surface" is the tool/origin a request came from (Claude Code, Codex, the
* CLIProxy router, Droid, …) — the dimension the menu bar uses to answer
* "where is my usage actually going".
*/
export interface BarAnalyticsSurface {
/** Raw pipeline source key (custom-parser | codex-native | cliproxy | droid-native | …). */
source: string;
/** Human label shown in the bar (Claude Code, Codex, CLIProxy, Droid, …). */
surface: string;
cost: number;
requests: number;
}
/** Aggregate spend over a rolling window. */
export interface BarAnalyticsWindow {
cost: number;
@@ -40,17 +56,34 @@ export interface BarAnalytics {
last30d: BarAnalyticsWindow;
/** Lifetime totals across every record in the snapshot. */
allTime: BarAnalyticsWindow;
/** Oldest → newest, exactly 7 entries (zero-filled), for the sparkline. */
/** Oldest → newest, exactly 30 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';
/**
* Spend/requests broken down by usage surface (tool/origin), for the same
* window as `topModels`. Descending by cost. Empty when no source is known
* (e.g. the legacy snapshot-only path that carries no surface dimension).
*/
bySurface: BarAnalyticsSurface[];
/** ISO timestamp of the most recent non-failed usage record, null if none. */
lastActivityAt: string | null;
/** Whole local-days since `lastActivityAt`, null if no usable records. */
daysSinceLastActivity: number | null;
/**
* True when the trailing 30 days carry any spend or requests. The UI pivots
* its empty/stale presentation on this without re-deriving it.
*/
hasRecentData: boolean;
/** ISO timestamp the payload was generated. */
generatedAt: string;
}
const SPARKLINE_DAYS = 7;
// 30-day trailing window: gives a non-empty sparkline shape even when the last
// 7 days are zero, so a stale-but-real history doesn't read as a broken chart.
const SPARKLINE_DAYS = 30;
const TOP_MODELS_LIMIT = 5;
/** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */
@@ -108,6 +141,10 @@ export function computeBarAnalytics(
}
};
// Epoch ms of the most recent non-failed record, tracked inside the single
// loop the function already runs (O(1) extra per iteration, no new I/O).
let lastActivityMs = -Infinity;
for (const detail of details) {
if (detail.failed) continue;
const ts = new Date(detail.timestamp);
@@ -116,6 +153,8 @@ export function computeBarAnalytics(
const delta = dayDelta(now, ts); // 0 = today, 1 = yesterday, …
if (delta < 0) continue; // ignore future-dated noise
if (ts.getTime() > lastActivityMs) lastActivityMs = ts.getTime();
const cost = Number.isFinite(detail.cost) ? detail.cost : 0;
const requests = Number.isFinite(detail.requestCount) ? detail.requestCount : 0;
@@ -127,9 +166,12 @@ export function computeBarAnalytics(
today.cost += cost;
today.requests += requests;
}
// Window math stays 7-day; only the sparkline bucket fill widens to 30.
if (delta < 7) {
last7d.cost += cost;
last7d.requests += requests;
}
if (delta < SPARKLINE_DAYS) {
const bucket = dayBuckets.get(localDayKey(ts));
if (bucket) {
bucket.cost += cost;
@@ -143,6 +185,11 @@ export function computeBarAnalytics(
}
}
const lastActivityAt =
lastActivityMs === -Infinity ? null : new Date(lastActivityMs).toISOString();
const daysSinceLastActivity =
lastActivityAt === null ? null : dayDelta(now, new Date(lastActivityAt));
// 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;
@@ -159,6 +206,177 @@ export function computeBarAnalytics(
byDay: Array.from(dayBuckets.values()),
topModels,
topModelsWindow: recentHasData ? '30d' : 'all',
// The snapshot-detail path has no surface attribution; the daily path does.
bySurface: [],
lastActivityAt,
daysSinceLastActivity,
hasRecentData: recentHasData,
generatedAt: now.toISOString(),
};
}
// Maps a raw usage-pipeline `source` to the label shown in the bar. Unknown
// sources fall through to their raw key so a new collector is never silently
// dropped — it just shows un-prettified until added here.
const SURFACE_LABELS: Record<string, string> = {
'custom-parser': 'Claude Code',
'codex-native': 'Codex',
'droid-native': 'Droid',
cliproxy: 'CLIProxy',
};
/** Human-friendly surface name for a raw usage `source` key. */
function surfaceLabel(source: string): string {
if (SURFACE_LABELS[source]) return SURFACE_LABELS[source];
return source || 'Other';
}
/** Local-midnight Date from a YYYY-MM-DD day key (calendar-day anchored). */
function dateFromDayKey(key: string): Date {
const [y, m, d] = key.split('-').map((n) => parseInt(n, 10));
return new Date(y, (m || 1) - 1, d || 1);
}
/**
* Roll the merged, multi-source usage aggregates into the bar analytics payload.
*
* Unlike `computeBarAnalytics` (which reads only the CLIProxy snapshot — frozen
* whenever the proxy restarts), this consumes the same merged daily/hourly data
* the dashboard uses, so recent activity from Claude Code, Codex, Droid, and the
* CLIProxy router all show up. `daily` carries cost+models+source; `hourly`
* carries the request counts (daily aggregates don't), so the two are combined:
* cost/models/surface-cost from daily, request counts from hourly.
*/
export function computeBarAnalyticsFromDaily(
daily: DailyUsage[],
hourly: HourlyUsage[],
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 };
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 });
}
const model30d = new Map<string, BarAnalyticsModel>();
const modelAll = new Map<string, BarAnalyticsModel>();
const bumpModel = (map: Map<string, BarAnalyticsModel>, model: string, cost: number): void => {
const existing = map.get(model);
if (existing) existing.cost += cost;
else map.set(model, { model, cost, requests: 0 });
};
const surface30d = new Map<string, BarAnalyticsSurface>();
const surfaceAll = new Map<string, BarAnalyticsSurface>();
const bumpSurface = (
map: Map<string, BarAnalyticsSurface>,
source: string,
cost: number,
requests: number
): void => {
const existing = map.get(source);
if (existing) {
existing.cost += cost;
existing.requests += requests;
} else {
map.set(source, { source, surface: surfaceLabel(source), cost, requests });
}
};
// Latest local-day with real activity (cost or requests), across both passes.
let lastActivityKey: string | null = null;
const touchActivity = (dayKey: string): void => {
if (lastActivityKey === null || dayKey > lastActivityKey) lastActivityKey = dayKey;
};
// Pass 1 — daily: cost, per-model spend, per-surface spend, sparkline cost.
for (const d of daily) {
if (!d || !d.date) continue;
const delta = dayDelta(now, dateFromDayKey(d.date));
if (delta < 0) continue; // ignore future-dated noise
const cost = Number.isFinite(d.totalCost) ? d.totalCost : Number.isFinite(d.cost) ? d.cost : 0;
const source = d.source || '';
allTime.cost += cost;
bumpSurface(surfaceAll, source, cost, 0);
for (const mb of d.modelBreakdowns || []) {
bumpModel(modelAll, mb.modelName, Number.isFinite(mb.cost) ? mb.cost : 0);
}
if (cost > 0) touchActivity(d.date);
if (delta === 0) today.cost += cost;
if (delta < 7) last7d.cost += cost;
if (delta < 30) {
last30d.cost += cost;
bumpSurface(surface30d, source, cost, 0);
for (const mb of d.modelBreakdowns || []) {
bumpModel(model30d, mb.modelName, Number.isFinite(mb.cost) ? mb.cost : 0);
}
}
if (delta < SPARKLINE_DAYS) {
const bucket = dayBuckets.get(d.date);
if (bucket) bucket.cost += cost;
}
}
// Pass 2 — hourly: request counts (daily aggregates don't carry them).
for (const h of hourly) {
if (!h || !h.hour) continue;
const dayKey = h.hour.slice(0, 10);
const delta = dayDelta(now, dateFromDayKey(dayKey));
if (delta < 0) continue;
const requests = Number.isFinite(h.requestCount) ? (h.requestCount as number) : 0;
if (requests <= 0) continue;
const source = h.source || '';
allTime.requests += requests;
bumpSurface(surfaceAll, source, 0, requests);
touchActivity(dayKey);
if (delta === 0) today.requests += requests;
if (delta < 7) last7d.requests += requests;
if (delta < 30) {
last30d.requests += requests;
bumpSurface(surface30d, source, 0, requests);
}
if (delta < SPARKLINE_DAYS) {
const bucket = dayBuckets.get(dayKey);
if (bucket) bucket.requests += requests;
}
}
// Prefer recent leaders; fall back to lifetime when the last 30 days are idle.
const recentHasData = last30d.cost > 0 || last30d.requests > 0;
const topModels = Array.from((recentHasData ? model30d : modelAll).values())
.filter((m) => m.cost > 0 || m.requests > 0)
.sort((a, b) => b.cost - a.cost)
.slice(0, TOP_MODELS_LIMIT);
const bySurface = Array.from((recentHasData ? surface30d : surfaceAll).values())
.filter((s) => s.cost > 0 || s.requests > 0)
.sort((a, b) => b.cost - a.cost);
const lastActivityAt = lastActivityKey ? dateFromDayKey(lastActivityKey).toISOString() : null;
const daysSinceLastActivity = lastActivityKey
? dayDelta(now, dateFromDayKey(lastActivityKey))
: null;
return {
today,
last7d,
last30d,
allTime,
byDay: Array.from(dayBuckets.values()),
topModels,
topModelsWindow: recentHasData ? '30d' : 'all',
bySurface,
lastActivityAt,
daysSinceLastActivity,
hasRecentData: recentHasData,
generatedAt: now.toISOString(),
};
}
+41 -4
View File
@@ -29,9 +29,13 @@ describe('computeBarAnalytics', () => {
const a = computeBarAnalytics([], NOW);
expect(a.today.cost).toBe(0);
expect(a.allTime.cost).toBe(0);
expect(a.byDay).toHaveLength(7);
expect(a.byDay).toHaveLength(30);
expect(a.topModels).toHaveLength(0);
expect(a.topModelsWindow).toBe('all');
// No usable records → no last-activity signal, not stale-but-present.
expect(a.lastActivityAt).toBeNull();
expect(a.daysSinceLastActivity).toBeNull();
expect(a.hasRecentData).toBe(false);
});
it('rolls today / 7d / 30d / allTime into the right windows', () => {
@@ -60,15 +64,48 @@ describe('computeBarAnalytics', () => {
expect(a.allTime.cost).toBe(1);
});
it('zero-fills the 7-day sparkline in chronological order', () => {
it('zero-fills the 30-day sparkline in chronological order', () => {
const a = computeBarAnalytics([detail({ timestamp: daysAgo(2), cost: 4 })], NOW);
expect(a.byDay).toHaveLength(7);
expect(a.byDay).toHaveLength(30);
// oldest first, newest last
expect(a.byDay[0].date < a.byDay[6].date).toBe(true);
expect(a.byDay[0].date < a.byDay[29].date).toBe(true);
const hit = a.byDay.find((d) => d.cost > 0);
expect(hit?.cost).toBe(4);
});
it('populates sparkline days 8..30 from records older than the 7-day window', () => {
// A record 20 days ago is outside last7d but inside the 30-day sparkline:
// the bucket must fill so the chart isn't flat when only old data exists.
const a = computeBarAnalytics([detail({ timestamp: daysAgo(20), cost: 6 })], NOW);
expect(a.last7d.cost).toBe(0); // 7-day window math unchanged
const hit = a.byDay.find((d) => d.cost > 0);
expect(hit?.cost).toBe(6);
});
it('reports last-activity and hasRecentData from the freshest non-failed record', () => {
const recent = daysAgo(1);
const a = computeBarAnalytics(
[
detail({ timestamp: daysAgo(5), cost: 1 }),
detail({ timestamp: recent, cost: 2 }),
// failed record must NOT count as activity even though it's newer
detail({ timestamp: daysAgo(0), cost: 9, failed: true }),
],
NOW
);
expect(a.lastActivityAt).toBe(recent);
expect(a.daysSinceLastActivity).toBe(1);
expect(a.hasRecentData).toBe(true);
});
it('reports hasRecentData false and last-activity from old data when the 30-day window is idle', () => {
const old = daysAgo(45);
const a = computeBarAnalytics([detail({ timestamp: old, cost: 5 })], NOW);
expect(a.hasRecentData).toBe(false);
expect(a.lastActivityAt).toBe(old);
expect(a.daysSinceLastActivity).toBe(45);
});
it('ranks top models by spend and labels the window 30d when recent data exists', () => {
const a = computeBarAnalytics(
[
+87 -5
View File
@@ -21,7 +21,10 @@ interface BarSummaryRow {
tier: string | null;
paused: boolean;
quota_percentage: number | null;
quotaStatus: 'ok' | 'unsupported' | 'error';
next_reset: string | null;
is_default: boolean;
last_activity_at: string | null;
today_cost: number | null;
health: 'ok' | 'warning' | 'error';
cached: boolean;
@@ -71,6 +74,7 @@ function makeQuotaResult(
models: Array<{ name: string; percentage: number; resetTime: string | null }>;
needsReauth: boolean;
error: string;
errorCode: string;
lastUpdated: number;
}> = {}
) {
@@ -82,6 +86,7 @@ function makeQuotaResult(
lastUpdated: overrides.lastUpdated ?? Date.now(),
needsReauth: overrides.needsReauth ?? false,
...(overrides.error !== undefined && { error: overrides.error }),
...(overrides.errorCode !== undefined && { errorCode: overrides.errorCode }),
};
}
@@ -166,6 +171,8 @@ describe('GET /api/bar/summary', () => {
},
getTodayCostByAccount: (_details: unknown[]) => mockCostByAccount,
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
runHealthChecks: async () => mockHealthReport,
});
@@ -221,6 +228,10 @@ describe('GET /api/bar/summary', () => {
expect(typeof row.health).toBe('string');
expect(['ok', 'warning', 'error']).toContain(row.health);
expect(typeof row.needsReauth).toBe('boolean');
// New contract fields
expect(['ok', 'unsupported', 'error']).toContain(row.quotaStatus);
expect(typeof row.is_default).toBe('boolean');
expect(row.last_activity_at === null || typeof row.last_activity_at === 'string').toBe(true);
});
it('maps account metadata into the row correctly', async () => {
@@ -246,6 +257,10 @@ describe('GET /api/bar/summary', () => {
expect(row.paused).toBe(false);
expect(row.quota_percentage).toBe(60);
expect(row.next_reset).toBe('2026-06-08T00:00:00Z');
// is_default mirrors account.isDefault (factory default true); successful
// quota fetch → quotaStatus 'ok'.
expect(row.is_default).toBe(true);
expect(row.quotaStatus).toBe('ok');
});
// --------------------------------------------------------------------------
@@ -388,15 +403,16 @@ describe('GET /api/bar/summary', () => {
expect(row.today_cost).toBeCloseTo(1.23);
});
it('today_cost is 0 or null for accounts with no cost record', async () => {
it('today_cost is null (unknown) for accounts with no cost record', async () => {
mockAccounts = [makeAccountInfo({ id: 'nocost@example.com', provider: 'agy' })];
mockCostByAccount = {}; // no entry for this account
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
const row = body[0];
// Should be 0 or null — either is acceptable, not a hard error
expect(row.today_cost === 0 || row.today_cost === null).toBe(true);
// A missing cost-key means "no usage data" (possibly stale snapshot), which is
// null=unknown — distinct from a genuine 0 spend. The UI renders "no data".
expect(row.today_cost).toBeNull();
});
// --------------------------------------------------------------------------
@@ -435,6 +451,61 @@ describe('GET /api/bar/summary', () => {
expect(body[0].health).toBe('error');
});
// --------------------------------------------------------------------------
// quotaStatus tri-state (unsupported vs error vs ok) and its health mapping
// --------------------------------------------------------------------------
it('quotaStatus is "ok" and health "ok" on a successful quota fetch', async () => {
mockQuotaResult = makeQuotaResult({ success: true });
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].quotaStatus).toBe('ok');
expect(body[0].health).toBe('ok');
});
it('provider without a quota API (errorCode quota_not_supported) → quotaStatus "unsupported" and health "ok"', async () => {
// Mirrors fetchAccountQuota for any provider !== "agy" (e.g. ghcp/kiro):
// success false with the stable quota_not_supported code. This must read as
// healthy (no permanent orange dot), not a transient warning.
mockQuotaResult = makeQuotaResult({
success: false,
models: [],
error: 'Quota not supported for provider: ghcp',
errorCode: 'quota_not_supported',
});
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].quotaStatus).toBe('unsupported');
expect(body[0].health).toBe('ok');
expect(body[0].quota_percentage).toBeNull();
});
it('transient fetch failure (no errorCode, no reauth) → quotaStatus "error" and health "warning"', async () => {
mockQuotaResult = makeQuotaResult({
success: false,
needsReauth: false,
models: [],
error: 'temporary network blip',
});
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].quotaStatus).toBe('error');
expect(body[0].health).toBe('warning');
});
it('needsReauth → quotaStatus "error" and health "error"', async () => {
mockQuotaResult = makeQuotaResult({
success: false,
needsReauth: true,
models: [],
error: 'token expired',
});
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].quotaStatus).toBe('error');
expect(body[0].health).toBe('error');
});
// --------------------------------------------------------------------------
// displayName
// --------------------------------------------------------------------------
@@ -526,6 +597,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
fetchAccountQuota: async () => makeQuotaResult(),
getTodayCostByAccount: () => costMap,
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
@@ -553,7 +626,7 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
expect(row.today_cost).toBeCloseTo(2.5);
});
it('attributes cost correctly for account with no email (kiro/ghcp type): cost remains 0', async () => {
it('attributes cost correctly for account with no email (kiro/ghcp type): cost is null (no record)', async () => {
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
@@ -591,6 +664,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
fetchAccountQuota: async () => makeQuotaResult(),
getTodayCostByAccount: () => costMap2,
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
@@ -610,7 +685,8 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
const { body } = await getJson<BarSummaryRow[]>(baseUrl2, '/api/bar/summary');
expect(body.length).toBe(1);
expect(body[0].today_cost).toBe(0);
// No matching cost-key → null=unknown (not a misleading $0.00).
expect(body[0].today_cost).toBeNull();
await new Promise<void>((resolve) => server2.close(() => resolve()));
});
@@ -659,6 +735,8 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => {
},
getTodayCostByAccount: () => ({}),
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
@@ -746,6 +824,8 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () =>
},
getTodayCostByAccount: () => ({}),
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
runHealthChecks: async () => makeHealthReport(),
});
@@ -817,6 +897,8 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
fetchAccountQuota: async () => makeQuotaResult(),
getTodayCostByAccount: () => costMap,
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
runHealthChecks: async () => makeHealthReport(),
});