Merge pull request #1578 from kaitranntt/kai/feat/ccs-bar-codex-sync-spend-chart

feat(bar): live Codex usage sync with force refresh and spend-chart periods
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-20 19:54:00 -04:00
commit 458f836a07
13 files changed
+1263 -53

No files matched your search

@@ -26,6 +26,11 @@ struct BarAnalyticsView: View {
/// Spend header (using the otherwise-blank space) so the user switches
/// bars/line in place rather than digging into Settings. nil for `.breakdown`.
var onToggleSpendStyle: (() -> Void)? = nil
/// Selected time window for the spend chart. Default .last7d so the rolling
/// 7-day view is the startup default, matching SpendPeriodStore's default.
var spendPeriod: SpendPeriod = .last7d
/// Callback when the user taps a period selector button. nil for .breakdown.
var onSelectPeriod: ((SpendPeriod) -> Void)? = nil
private var lastActive: String? {
BarFormatting.lastActiveLabel(
@@ -71,14 +76,20 @@ struct BarAnalyticsView: View {
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
}
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
/// line, and a thin inline 30-day sparkline when there is real spend.
/// The informational spend strip: a "SPEND" label, period selector,
/// bars/line toggle, a muted caption, a taller sparkline, and axis labels.
private var spendStrip: some View {
VStack(alignment: .leading, spacing: 5) {
// Header row: section label | period selector | bars/line toggle.
HStack(spacing: 6) {
SectionLabel("Spend")
Spacer()
// Inline bars/line toggle in the header's blank space — no Settings trip.
// Compact 3-segment period selector — only when there is data to chart,
// so the idle state shows no controls that would have no visible effect.
if let select = onSelectPeriod, analytics.hasRecentData {
periodSelector(onSelect: select)
}
// Inline bars/line toggle — only when there is data to render.
if let toggle = onToggleSpendStyle, analytics.hasRecentData, !sparklineIsEmpty {
Button(action: toggle) {
Image(
@@ -92,16 +103,16 @@ struct BarAnalyticsView: View {
.help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")")
}
}
if analytics.hasRecentData {
Text(spendCaption)
.font(.caption2)
.foregroundStyle(.secondary)
if !sparklineIsEmpty {
// height: 30 (up from 18) so daily spend gradations are clearly readable.
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent,
style: spendChartStyle)
.frame(height: 30)
}
// height: 56 — more room so per-hour or per-day gradations are readable.
Sparkline(values: activeSeries, accent: theme.accent, style: spendChartStyle)
.frame(height: 56)
// Axis labels below the chart, aligned to the same width.
axisLabelRow
} else {
Text(idleCaption)
.font(.caption2)
@@ -110,11 +121,129 @@ struct BarAnalyticsView: View {
}
}
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
/// Small 3-button period selector: "Today / 7d / 30d".
private func periodSelector(onSelect: @escaping (SpendPeriod) -> Void) -> some View {
HStack(spacing: 4) {
periodButton("Today", period: .today, onSelect: onSelect)
periodButton("7d", period: .last7d, onSelect: onSelect)
periodButton("30d", period: .last30d, onSelect: onSelect)
}
}
private func periodButton(
_ label: String, period: SpendPeriod, onSelect: @escaping (SpendPeriod) -> Void
) -> some View {
Button(label) { onSelect(period) }
.buttonStyle(.borderless)
.font(
spendPeriod == period
? .system(size: 10, weight: .semibold)
: .system(size: 10))
.foregroundStyle(spendPeriod == period ? theme.accent : Color.secondary)
}
/// The value series for the currently-selected period.
private var activeSeries: [Double] {
switch spendPeriod {
case .today:
return analytics.byHour.map(\.cost)
case .last7d:
return Array(analytics.byDay.suffix(7)).map(\.cost)
case .last30d:
return analytics.byDay.map(\.cost)
}
}
/// Caption showing the cost for the active period. Each period sums the SAME
/// series it charts so the caption total always matches the visible bars.
private var spendCaption: String {
"today \(BarFormatting.money(analytics.today.cost))"
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
switch spendPeriod {
case .today:
// Sum byHour (the charted series) so caption == bars. Fall back to the
// daily today total only when there is no hourly series to draw.
let cost =
analytics.byHour.isEmpty
? analytics.today.cost
: analytics.byHour.reduce(0) { $0 + $1.cost }
return "today \(BarFormatting.money(cost))"
case .last7d:
let cost = Array(analytics.byDay.suffix(7)).reduce(0) { $0 + $1.cost }
return "7d \(BarFormatting.money(cost))"
case .last30d:
return "30d \(BarFormatting.money(analytics.last30d.cost))"
}
}
/// Axis labels rendered below the sparkline. Each tick is placed at its data
/// point's horizontal fraction (bar-center) so the label sits under the hour /
/// day it names — not merely evenly distributed, which drifts on the Today
/// view where the tick indices aren't at even fractions of the width. Edge
/// labels are clamped inward by their estimated half-width so they don't clip.
@ViewBuilder private var axisLabelRow: some View {
let ticks = axisTicks(for: spendPeriod)
if !ticks.isEmpty {
GeometryReader { geo in
let width = Double(geo.size.width)
ForEach(Array(ticks.enumerated()), id: \.offset) { _, tick in
// ~2.75pt per char at 9pt monospaced is half a glyph; clamp keeps the
// first/last labels fully on-screen.
let halfW = max(8.0, Double(tick.label.count) * 2.75)
let x = min(max(tick.fraction * width, halfW), max(halfW, width - halfW))
Text(tick.label)
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.fixedSize()
.position(x: x, y: 6)
}
}
.frame(height: 12)
}
}
/// Tick (label, horizontal fraction 0...1) pairs for the current period.
/// Fraction is the bar CENTER ((i + 0.5) / count) so labels align under the
/// default bar chart; for the line style the end ticks differ by half a bar,
/// which is visually negligible.
private func axisTicks(for period: SpendPeriod) -> [(label: String, fraction: Double)] {
func center(_ i: Int, _ n: Int) -> Double { n > 0 ? (Double(i) + 0.5) / Double(n) : 0 }
switch period {
case .today:
// Hours at 0, 6, 12, 18, 23 — only those that exist.
let hours = analytics.byHour
guard !hours.isEmpty else { return [] }
let n = hours.count
return [0, 6, 12, 18, 23].compactMap { idx -> (String, Double)? in
guard idx < n, let label = BarCardFormatting.hourShort(fromHourKey: hours[idx].hour)
else { return nil }
return (label, center(idx, n))
}
case .last7d:
// All 7 days (or whatever suffix(7) yields): short weekday "Mon".
let days = Array(analytics.byDay.suffix(7))
guard !days.isEmpty else { return [] }
let n = days.count
return days.enumerated().compactMap { (i, d) -> (String, Double)? in
guard let label = BarCardFormatting.weekdayShort(fromDayKey: d.date) else { return nil }
return (label, center(i, n))
}
case .last30d:
// 5 evenly-spaced "MMM d" labels: first, ~1/4, mid, ~3/4, last.
let days = analytics.byDay
guard days.count >= 2 else { return [] }
let n = days.count
let last = n - 1
let indices = [0, last / 4, last / 2, last * 3 / 4, last].reduce(into: [Int]()) { acc, i in
if acc.last != i { acc.append(i) }
}
return indices.compactMap { i -> (String, Double)? in
guard i < n, let label = BarCardFormatting.monthDayShort(fromDayKey: days[i].date)
else { return nil }
return (label, center(i, n))
}
}
}
/// Honest idle caption when there's no recent spend, folding in last-active.
@@ -125,8 +254,10 @@ struct BarAnalyticsView: View {
return headline
}
/// True when the active period's series is all zero. Still shows the chart
/// frame + axis (do not hide them), but the bars/line toggle is suppressed.
private var sparklineIsEmpty: Bool {
analytics.byDay.allSatisfy { $0.cost <= 0 }
activeSeries.allSatisfy { $0 <= 0 }
}
}
@@ -51,4 +51,57 @@ enum BarCardFormatting {
fmt.dateFormat = "HH:mm"
return fmt.string(from: date)
}
// MARK: - Axis label formatters (used by BarAnalyticsView spend chart)
/// Short hour label from a byHour key "YYYY-MM-DD HH:00", e.g. "12a", "6p".
/// Extracts the HH part (characters at index 11-12) and converts to 12-hour
/// format with lowercase "a"/"p" suffix. Returns nil for unparseable keys.
static func hourShort(fromHourKey key: String) -> String? {
// key format: "YYYY-MM-DD HH:00" — HH is at offset 11, length 2.
guard key.count >= 13 else { return nil }
let start = key.index(key.startIndex, offsetBy: 11)
let end = key.index(start, offsetBy: 2)
guard let hour = Int(key[start..<end]) else { return nil }
switch hour {
case 0: return "12a"
case 1..<12: return "\(hour)a"
case 12: return "12p"
default: return "\(hour - 12)p"
}
}
/// Short weekday label from a byDay key "YYYY-MM-DD", e.g. "Mon".
/// Returns nil for unparseable keys. Formats in UTC to match how `dayDate`
/// parses the key, so the weekday names the calendar day the key represents
/// (formatting in local time would shift it a day for users west of UTC).
static func weekdayShort(fromDayKey key: String) -> String? {
guard let date = dayDate(fromKey: key) else { return nil }
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "EEE"
return fmt.string(from: date)
}
/// Short month+day label from a byDay key "YYYY-MM-DD", e.g. "Jun 5".
/// Returns nil for unparseable keys. Formats in UTC to match `dayDate`'s
/// parse zone so the label names the same calendar day as the key.
static func monthDayShort(fromDayKey key: String) -> String? {
guard let date = dayDate(fromKey: key) else { return nil }
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "MMM d"
return fmt.string(from: date)
}
/// Parse a "YYYY-MM-DD" key into a Date at midnight UTC.
private static func dayDate(fromKey key: String) -> Date? {
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "yyyy-MM-dd"
return fmt.date(from: key)
}
}
@@ -92,8 +92,8 @@ struct BarMenuView: View {
accountsSection
// (3) SPEND — demoted to a thin informational strip below the cockpit.
// spendChartStyle is threaded from the viewModel and toggled inline
// from the Spend header, so a change updates the chart immediately.
// spendChartStyle and spendPeriod are threaded from the viewModel and
// toggled/selected inline from the Spend header so changes are live.
if let analytics = viewModel.analytics {
Divider()
BarAnalyticsView(
@@ -102,7 +102,9 @@ struct BarMenuView: View {
onToggleSpendStyle: {
viewModel.spendChartStyle =
viewModel.spendChartStyle == .bars ? .line : .bars
})
},
spendPeriod: viewModel.spendPeriod,
onSelectPeriod: { viewModel.spendPeriod = $0 })
}
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
@@ -185,7 +187,7 @@ struct BarMenuView: View {
} else {
subscriptionsHeader(parts.subscriptions)
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
BarSubscriptionCard(row: row)
BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() })
}
}
}
@@ -341,7 +343,7 @@ struct BarMenuView: View {
.help("Settings — appearance/theme, menu-bar glance, and alerts")
Spacer()
Button {
viewModel.onOpen()
viewModel.forceRefresh()
} label: {
Image(systemName: "arrow.clockwise")
}
@@ -16,6 +16,9 @@ struct BarSubscriptionCard: View {
/// Injected clock — defaults to live Date() in production, pinned in previews
/// and tests so countdown math is deterministic.
var now: Date = Date()
/// Optional force-refresh action. When provided, the stale footnote appends a
/// compact inline refresh button so the user can reload without reopening menus.
var onRefresh: (() -> Void)? = nil
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
@@ -219,6 +222,8 @@ struct BarSubscriptionCard: View {
/// "as of HH:mm (older session)" caption when the Codex reading came from an
/// older session. The bar still renders — the data is real, just not live.
/// When `onRefresh` is provided, a compact inline refresh button trails the
/// caption so the user can force-reload without extra navigation.
@ViewBuilder private var staleFootnote: some View {
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
HStack(spacing: 4) {
@@ -228,6 +233,15 @@ struct BarSubscriptionCard: View {
Text("as of \(clock), older session")
.font(.caption2)
.foregroundStyle(.tertiary)
if let refresh = onRefresh {
Button(action: refresh) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 9))
}
.buttonStyle(.borderless)
.foregroundStyle(.tertiary)
.help("Force refresh to get the latest data")
}
}
}
}
@@ -33,6 +33,11 @@ final class BarViewModel: ObservableObject {
@Published var spendChartStyle: SpendChartStyle {
didSet { SpendChartStyleStore.save(spendChartStyle) }
}
/// Active time window for the spend sparkline (today/7d/30d). Persisted via
/// SpendPeriodStore; didSet mirrors the spendChartStyle pattern.
@Published var spendPeriod: SpendPeriod {
didSet { SpendPeriodStore.save(spendPeriod) }
}
/// The alerts the most recent evaluation wanted delivered, surfaced in the
/// dropdown so users who deny notifications still see the conditions.
@Published var activeAlerts: [BarNotification] = []
@@ -71,6 +76,7 @@ final class BarViewModel: ObservableObject {
self.appearance = BarAppearanceStore.load()
self.glanceMode = prefs.load().glanceMode
self.spendChartStyle = SpendChartStyleStore.load()
self.spendPeriod = SpendPeriodStore.load()
reconnect()
startBackgroundPolling()
}
@@ -219,6 +225,16 @@ final class BarViewModel: ObservableObject {
reconnectAndLoad(force: force)
}
// MARK: - Force refresh (bypasses the 15s debounce)
/// Unconditional force-refresh: skips the debouncer and calls
/// `reconnectAndLoad(force: true)` directly. Used by the footer Refresh button
/// and by the Codex stale-footnote inline action so those never silently no-op
/// inside the debounce window.
func forceRefresh() {
reconnectAndLoad(force: true)
}
// MARK: - Start CCS (called from offline UI button)
/// Trigger a full connect sequence (probe → launch → poll). Used by the
@@ -57,6 +57,20 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
}
}
/// Spend and request count for one hour of today. 24 entries, zero-filled,
/// oldest (00:00) to newest (23:00). The `hour` key is "YYYY-MM-DD HH:00".
public struct Hour: Codable, Sendable, Equatable, Identifiable {
public let hour: String
public let cost: Double
public let requests: Int
public var id: String { hour }
public init(hour: String, cost: Double, requests: Int) {
self.hour = hour
self.cost = cost
self.requests = requests
}
}
public let today: Window
public let last7d: Window
public let last30d: Window
@@ -67,6 +81,9 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
public let allTime: Window
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
public let byDay: [Day]
/// Today's hourly breakdown: 24 entries, oldest (00:00) → newest (23:00),
/// zero-filled. Absent from older payloads; defaults to [].
public let byHour: [Hour]
public let topModels: [Model]
/// "30d" when recent data exists, else "all".
public let topModelsWindow: String
@@ -90,6 +107,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
monthToDate: Window = Window(cost: 0, requests: 0),
allTime: Window,
byDay: [Day],
byHour: [Hour] = [],
topModels: [Model],
topModelsWindow: String,
lastActivityAt: String? = nil,
@@ -104,6 +122,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
self.monthToDate = monthToDate
self.allTime = allTime
self.byDay = byDay
self.byHour = byHour
self.topModels = topModels
self.topModelsWindow = topModelsWindow
self.lastActivityAt = lastActivityAt
@@ -127,6 +146,9 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
(try c.decodeIfPresent(Window.self, forKey: .monthToDate)) ?? Window(cost: 0, requests: 0)
allTime = try c.decode(Window.self, forKey: .allTime)
byDay = try c.decode([Day].self, forKey: .byDay)
// `byHour` is a new field; older payloads omit it. Default to [] so cached
// or older-backend payloads decode cleanly without throwing.
byHour = (try c.decodeIfPresent([Hour].self, forKey: .byHour)) ?? []
topModels = try c.decode([Model].self, forKey: .topModels)
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
@@ -33,3 +33,32 @@ public enum SpendChartStyleStore {
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
}
}
// MARK: - SpendPeriod
/// Time window for the spend sparkline selector: today (hourly), last 7 days,
/// or last 30 days. Mirrors the SpendChartStyle pattern: CaseIterable + Sendable.
public enum SpendPeriod: String, CaseIterable, Sendable {
case today
case last7d
case last30d
}
// MARK: - SpendPeriodStore
/// Persists the chosen spend period. Mirrors SpendChartStyleStore exactly:
/// a UserDefaults key, a static load, and a static save. The `?? .last7d`
/// fallback is the sole source of the default.
public enum SpendPeriodStore {
public static let defaultsKey = "ccsbar.spendPeriod"
public static func load() -> SpendPeriod {
let raw = UserDefaults.standard.string(forKey: defaultsKey)
?? SpendPeriod.last7d.rawValue
return SpendPeriod(rawValue: raw) ?? .last7d
}
public static func save(_ period: SpendPeriod) {
UserDefaults.standard.set(period.rawValue, forKey: defaultsKey)
}
}
+23 -6
View File
@@ -150,8 +150,17 @@ export interface BarRouterDeps {
* async so older tests that build deps without it keep passing. The native
* collector owns its own long-TTL cache + safety controls, so this is cheap
* to call per request.
*
* `opts.force` is forwarded when a debounce-passing refresh=true is in flight,
* so the native rows are re-pulled live alongside the CLIProxy rows.
*/
getNativeAccountRows?: () => Promise<BarSummaryRow[]>;
getNativeAccountRows?: (opts?: { force?: boolean }) => Promise<BarSummaryRow[]>;
/**
* Last-known native rows from cache, with NO fetch. Used as an instant
* fallback when a forced live native re-pull overruns the side-load budget,
* so the Claude/Codex cards never vanish mid-refresh. Defaults to [].
*/
getCachedNativeRows?: () => BarSummaryRow[];
}
// ============================================================================
@@ -547,10 +556,17 @@ export function createBarRouter(deps: BarRouterDeps): Router {
const rows = await Promise.race([gather, deadline]);
// Native subscription rows (Claude Code + Codex) are side-loaded AFTER the
// CLIProxy rows resolve, bounded so a slow/failed native fetch degrades to
// [] rather than blocking or erroring the response.
// CLIProxy rows resolve, bounded so a slow/failed native fetch degrades
// rather than blocking or erroring the response. Pass force so a
// debounce-passing refresh also re-pulls native rows live. On timeout fall
// back to the last-known cached native rows (NOT []) so a slow forced
// re-pull never momentarily drops the Claude/Codex cards; the in-flight
// fetch keeps warming the cache for the next poll.
const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]);
const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? [];
const getCachedNative = deps.getCachedNativeRows ?? (() => [] as BarSummaryRow[]);
const nativeRows =
(await withTimeout(getNative({ force: doForceRefresh }), NATIVE_SIDELOAD_TIMEOUT_MS)) ??
getCachedNative();
res.json([...rows, ...nativeRows].map(serializeBarRow));
} catch (err) {
@@ -607,7 +623,7 @@ 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';
import { getNativeAccountRows } from '../usage/native-quota-collector';
import { getNativeAccountRows, getCachedNativeAccountRows } from '../usage/native-quota-collector';
/** Production bar router — wired to real dependencies */
const barRouter: Router = createBarRouter({
@@ -620,7 +636,8 @@ const barRouter: Router = createBarRouter({
loadCliproxyDetails: loadCliproxySnapshotDetails,
loadDailyUsage: () => getCachedDailyData(),
loadHourlyUsage: () => getCachedHourlyData(),
getNativeAccountRows: () => getNativeAccountRows(),
getNativeAccountRows: (opts) => getNativeAccountRows(undefined, opts),
getCachedNativeRows: getCachedNativeAccountRows,
});
export default barRouter;
+85 -1
View File
@@ -21,6 +21,20 @@ export interface BarAnalyticsDay {
requests: number;
}
/**
* One hour's roll-up for today's spend chart.
* `hour` is the user's LOCAL "YYYY-MM-DD HH:00" clock hour (the UTC keys from
* HourlyUsage are converted to local before bucketing). Covers exactly 24
* buckets (00:00..23:00) for today only; zero-filled when no activity was
* recorded in that hour.
*/
export interface BarAnalyticsHour {
/** Local "YYYY-MM-DD HH:00" clock-hour bucket key. */
hour: string;
cost: number;
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
@@ -64,6 +78,14 @@ export interface BarAnalytics {
allTime: BarAnalyticsWindow;
/** Oldest → newest, exactly 30 entries (zero-filled), for the sparkline. */
byDay: BarAnalyticsDay[];
/**
* Hourly spend + request counts for today only. Exactly 24 entries ordered
* 00:00 → 23:00 (local), zero-filled for hours with no activity. Used by the
* bar's intra-day spend chart. `hour` keys are "YYYY-MM-DD HH:00" local time
* matching HourlyUsage.hour. Empty array from the snapshot-only path
* (computeBarAnalytics) which carries no hourly dimension.
*/
byHour: BarAnalyticsHour[];
/** Highest-spend models (descending, capped) for the window in `topModelsWindow`. */
topModels: BarAnalyticsModel[];
/** Which window `topModels` covers — the most recent one that has data. */
@@ -231,6 +253,9 @@ export function computeBarAnalytics(
monthToDate,
allTime,
byDay: Array.from(dayBuckets.values()),
// The snapshot-detail path has no hourly dimension; return empty so the wire
// shape is stable and callers can always iterate byHour safely.
byHour: [],
topModels,
topModelsWindow: recentHasData ? '30d' : 'all',
// The snapshot-detail path has no surface attribution; the daily path does.
@@ -275,6 +300,25 @@ function dateFromDayKey(key: string): Date | null {
return date;
}
/**
* Convert a "YYYY-MM-DD HH:00" hour key into the user's LOCAL Date.
*
* The usage pipeline builds hour keys by slicing the raw ISO timestamp, which is
* UTC for the CLIProxy/Codex/Claude sources, so the key's HH is a UTC hour. The
* dashboard's 24H chart treats these keys as UTC and renders them in local time;
* we do the same here so the bar's intra-day chart shows the user's own clock
* hours (not UTC) and so a late-local-evening record — which is the next day in
* UTC — still lands in today's local chart. Returns null for unparseable keys.
*/
function localDateFromHourKey(key: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):/.exec(key);
if (!match) return null;
const date = new Date(
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), 0, 0)
);
return Number.isNaN(date.getTime()) ? null : date;
}
/**
* Roll the merged, multi-source usage aggregates into the bar analytics payload.
*
@@ -300,6 +344,21 @@ export function computeBarAnalyticsFromDaily(
// so slice(0,7) yields the local YYYY-MM to compare against the current month.
const currentMonth = localMonthKey(now);
// Today's local day key — used to seed the 24 intra-day buckets for the
// spend chart and to filter which hourly records belong to today (local).
const todayKey = localDayKey(now);
// Seed 24 zero-filled hour buckets for today, ordered 00 → 23, keyed by the
// user's LOCAL clock hour ("YYYY-MM-DD HH:00" in local time). The UTC hour
// keys from the usage pipeline are converted to local before being bucketed
// here (see localDateFromHourKey), so the chart shows the user's own hours.
const hourBuckets = new Map<string, BarAnalyticsHour>();
for (let h = 0; h < 24; h++) {
const hh = String(h).padStart(2, '0');
const key = `${todayKey} ${hh}:00`;
hourBuckets.set(key, { hour: key, 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);
@@ -372,17 +431,40 @@ export function computeBarAnalyticsFromDaily(
}
// Pass 2 — hourly: request counts (daily aggregates don't carry them).
// Also fills the 24-bucket intra-day spend chart for today (local time).
for (const h of hourly) {
if (!h || !h.hour) continue;
// Intra-day hourly buckets (LOCAL): convert the UTC hour key to the user's
// local time and bucket today's local hours. Done BEFORE the UTC `delta`
// gate below so a late-local-evening record — next day in UTC — still lands
// in today's chart. Cost precedence mirrors the daily pass (totalCost, then
// cost). Independent of the UTC day-key math the rest of this loop uses.
const localHourDate = localDateFromHourKey(h.hour);
if (localHourDate && localDayKey(localHourDate) === todayKey) {
const hCost = Number.isFinite(h.totalCost)
? h.totalCost
: Number.isFinite(h.cost)
? h.cost
: 0;
const localHH = String(localHourDate.getHours()).padStart(2, '0');
const hBucket = hourBuckets.get(`${todayKey} ${localHH}:00`);
if (hBucket) {
hBucket.cost += hCost;
hBucket.requests += Number.isFinite(h.requestCount) ? (h.requestCount as number) : 0;
}
}
const dayKey = h.hour.slice(0, 10);
const activityDate = dateFromDayKey(dayKey);
if (!activityDate) continue;
const delta = dayDelta(now, activityDate);
if (delta < 0) continue;
const requests = Number.isFinite(h.requestCount) ? (h.requestCount as number) : 0;
if (requests <= 0) continue;
const source = h.source || '';
if (requests <= 0) continue;
allTime.requests += requests;
bumpSurface(surfaceAll, source, 0, requests);
touchActivity(dayKey);
@@ -422,6 +504,8 @@ export function computeBarAnalyticsFromDaily(
monthToDate,
allTime,
byDay: Array.from(dayBuckets.values()),
// 24 hour buckets for today (00:00 → 23:00 local), zero-filled.
byHour: Array.from(hourBuckets.values()),
topModels,
topModelsWindow: recentHasData ? '30d' : 'all',
bySurface,
+258 -24
View File
@@ -2,11 +2,11 @@
* Native subscription quota collector — the ONLY server-side fetch surface for
* the user's own Claude Code + Codex subscription quota.
*
* The macOS bar reads localhost /api/bar/summary and NEVER calls Anthropic. All
* Anthropic traffic originates here, under strict safety controls, because the
* OAuth usage endpoint is undocumented and hostile to polling (persistent 429s,
* no Retry-After, first-party-only policy). The controls below exist to protect
* the user's account:
* The macOS bar reads localhost /api/bar/summary and NEVER calls Anthropic or
* ChatGPT. All upstream traffic originates here, under strict safety controls,
* because these endpoints are undocumented and hostile to polling (persistent
* 429s, no Retry-After, first-party-only policy). The controls below exist to
* protect the user's accounts:
*
* - long TTL (10 min) on-demand cache, never a tight timer loop
* - in-flight coalescing so concurrent /summary calls share one fetch
@@ -14,7 +14,10 @@
* - circuit breaker stops calling after repeated 429s for a cooldown
* - serve-stale-on-failure; only omit a row when there is genuinely no data
*
* Codex is a pure local file read (no network), so it skips the network guards.
* Claude path: reads native credentials + polls api.anthropic.com/api/oauth/usage.
* Codex path: PRIMARY = live network (chatgpt.com/backend-api/wham/usage, via
* fetchCodexQuota), FALLBACK = local session logs (getCodexLocalQuota), mirroring
* the same safety pattern as the Claude path.
*/
import {
@@ -25,8 +28,10 @@ import {
type ClaudeNativeCredentials,
} from './claude-native-credentials';
import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude';
import { fetchCodexQuota } from '../../cliproxy/quota/quota-fetcher-codex';
import { getDefaultAccount } from '../../cliproxy/accounts/query';
import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector';
import type { ClaudeQuotaResult } from '../../cliproxy/quota/quota-types';
import type { ClaudeQuotaResult, CodexQuotaResult } from '../../cliproxy/quota/quota-types';
import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes';
// ============================================================================
@@ -56,7 +61,7 @@ const CLAUDE_PROVIDER = 'claude-code';
const CODEX_PROVIDER = 'codex';
// ============================================================================
// Injectable dependencies (tests inject mocks; never live Anthropic in CI)
// Injectable dependencies (tests inject mocks; never live endpoints in CI)
// ============================================================================
export interface NativeQuotaDeps {
@@ -64,7 +69,17 @@ export interface NativeQuotaDeps {
readCredentials?: () => ClaudeNativeCredentials | null;
/** Fetch Claude quota with a directly-supplied native token. */
fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise<ClaudeQuotaResult>;
/** Read Codex quota from local session logs (zero network). */
/**
* Resolve the default Codex account ID for network quota fetch.
* Returns null when no Codex account is configured (bar omits the live path).
*/
getDefaultCodexAccountId?: () => string | null;
/**
* Fetch Codex quota live from the network.
* Injected so tests never hit chatgpt.com.
*/
fetchCodexNetworkQuota?: (accountId: string) => Promise<CodexQuotaResult>;
/** Read Codex quota from local session logs (zero network, fallback). */
getCodexQuota?: () => Promise<CodexLocalQuota | null>;
/** Clock seam for deterministic backoff/TTL/breaker tests. */
now?: () => number;
@@ -106,10 +121,12 @@ function freshProviderState(): ProviderState {
}
const claudeState = freshProviderState();
const codexState = freshProviderState();
/** Reset all module state. Tests call this to avoid cross-test pollution. */
export function resetNativeQuotaState(): void {
Object.assign(claudeState, freshProviderState());
Object.assign(codexState, freshProviderState());
}
// ============================================================================
@@ -297,6 +314,75 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow {
};
}
/**
* Build the Codex row from a LIVE network quota result.
*
* Uses coreUsage (5h/weekly) to produce QuotaWindowDetail entries with the same
* stable keys as the Claude path. quota_percentage = min remaining across present
* core windows. next_reset = soonest core resetAt. No staleAsOf on a live result.
*/
function buildCodexNetworkRow(quota: CodexQuotaResult, now: number): BarSummaryRow {
const windows: QuotaWindowDetail[] = [];
const fiveHour = quota.coreUsage?.fiveHour;
if (fiveHour) {
windows.push({
key: 'five_hour',
label: '5h',
usedPercent: 100 - fiveHour.remainingPercent,
remainingPercent: fiveHour.remainingPercent,
resetAt: fiveHour.resetAt,
windowMinutes: FIVE_HOUR_MINUTES,
});
}
const weekly = quota.coreUsage?.weekly;
if (weekly) {
windows.push({
key: 'seven_day',
label: 'week',
usedPercent: 100 - weekly.remainingPercent,
remainingPercent: weekly.remainingPercent,
resetAt: weekly.resetAt,
windowMinutes: SEVEN_DAY_MINUTES,
});
}
// quota_percentage = min remaining across the windows present (mirrors Claude derivation)
const coreWindows = [fiveHour, weekly].filter((w): w is NonNullable<typeof w> => !!w);
const quotaPercentage =
coreWindows.length > 0 ? Math.min(...coreWindows.map((w) => w.remainingPercent)) : null;
// next_reset = soonest resetAt across present core windows
const resets = coreWindows
.map((w) => w.resetAt)
.filter((r): r is string => typeof r === 'string')
.map((r) => ({ iso: r, ms: new Date(r).getTime() }))
.filter((r) => Number.isFinite(r.ms))
.sort((a, b) => a.ms - b.ms);
const nextReset = resets.length > 0 ? resets[0].iso : null;
return {
account_id: CODEX_PROVIDER,
provider: CODEX_PROVIDER,
displayName: 'Codex',
tier: quota.planType ?? null,
paused: false,
quota_percentage: quotaPercentage,
quotaStatus: 'ok',
next_reset: nextReset,
is_default: false,
last_activity_at: null,
today_cost: null,
health: 'ok',
cached: false,
fetchedAt: new Date(now).toISOString(),
needsReauth: false,
// No staleAsOf — live data is always fresh.
...(windows.length > 0 ? { quotaWindows: windows } : {}),
};
}
/** Return the cached row marked cached=true (used for TTL + stale serving). */
function serveCached(state: ProviderState): BarSummaryRow | null {
if (!state.cachedRow) return null;
@@ -307,16 +393,20 @@ function serveCached(state: ProviderState): BarSummaryRow | null {
// Claude path with full safety controls
// ============================================================================
async function collectClaudeRow(deps: NativeQuotaDeps): Promise<BarSummaryRow | null> {
async function collectClaudeRow(
deps: NativeQuotaDeps,
force = false
): Promise<BarSummaryRow | null> {
const now = (deps.now ?? Date.now)();
const state = claudeState;
// Serve from cache while within TTL — on-demand only, NO network.
if (state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) {
// Serve from cache while within TTL — force bypasses TTL short-circuit.
if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) {
return serveCached(state);
}
// Breaker open or cooldown active -> zero network, serve stale (may be null).
// Force does NOT bypass the breaker — it protects the account.
if (now < state.breakerOpenUntil || now < state.cooldownUntil) {
return serveCached(state);
}
@@ -419,19 +509,140 @@ async function collectClaudeRow(deps: NativeQuotaDeps): Promise<BarSummaryRow |
}
// ============================================================================
// Codex path (local read, no network guards needed)
// Codex path with live network as PRIMARY, local logs as FALLBACK
// ============================================================================
async function collectCodexRow(deps: NativeQuotaDeps): Promise<BarSummaryRow | null> {
async function collectCodexRow(
deps: NativeQuotaDeps,
force = false
): Promise<BarSummaryRow | null> {
const now = (deps.now ?? Date.now)();
const getCodex = deps.getCodexQuota ?? getCodexLocalQuota;
try {
const quota = await getCodex();
if (!quota) return null; // exec-mode / no rate_limits -> omit the row
return buildCodexRow(quota, now);
} catch {
return null;
const state = codexState;
// Serve from cache while within TTL — force bypasses TTL short-circuit.
if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) {
return serveCached(state);
}
// Breaker open or cooldown active -> skip network, go to LOCAL fallback.
// Force does NOT bypass the breaker — it protects the account.
const breakerOrCooldownActive = now < state.breakerOpenUntil || now < state.cooldownUntil;
// Coalesce: concurrent callers past TTL share one in-flight resolution.
if (state.pending) {
return state.pending;
}
const getDefaultAccountId =
deps.getDefaultCodexAccountId ?? (() => getDefaultAccount('codex')?.id ?? null);
const fetchNetwork =
deps.fetchCodexNetworkQuota ?? ((accountId: string) => fetchCodexQuota(accountId));
const getCodex = deps.getCodexQuota ?? getCodexLocalQuota;
const sleep = deps.sleep ?? defaultSleep;
state.pending = (async (): Promise<BarSummaryRow | null> => {
try {
// ----------------------------------------------------------------
// PRIMARY: live network fetch (skipped when breaker/cooldown active)
// ----------------------------------------------------------------
if (!breakerOrCooldownActive) {
const accountId = getDefaultAccountId();
if (accountId) {
const quota = await fetchNetwork(accountId);
if (quota.success) {
// A healthy response closes the breaker and clears backoff,
// regardless of content.
state.consecutive429 = 0;
state.breakerOpenUntil = 0;
state.cooldownUntil = 0;
state.backoffAttempt = 0;
// Only usable when at least one core window (5h/weekly) resolved. A
// success with empty coreUsage (only code-review/additional windows,
// or a changed payload) carries no glanceable signal — do NOT cache
// a contentless "ok" row or clobber a good cache; fall through to
// the local fallback so the bar shows real data instead.
if (quota.coreUsage?.fiveHour || quota.coreUsage?.weekly) {
const row = buildCodexNetworkRow(quota, now);
state.cachedRow = row;
state.cachedAt = now;
return { ...row, cached: false };
}
// else: fall through to LOCAL fallback below.
} else if (quota.needsReauth) {
// Token expired -> reauth row; do NOT cache as a good value.
return {
account_id: CODEX_PROVIDER,
provider: CODEX_PROVIDER,
displayName: 'Codex',
tier: null,
paused: false,
quota_percentage: null,
quotaStatus: 'error',
next_reset: null,
is_default: false,
last_activity_at: null,
today_cost: null,
health: 'error',
cached: false,
fetchedAt: new Date(now).toISOString(),
needsReauth: true,
};
} else if (quota.httpStatus === 429) {
// 429: apply breaker + backoff, then fall through to local.
state.consecutive429 += 1;
if (state.consecutive429 >= CB_TRIP_THRESHOLD) {
state.breakerOpenUntil = now + CB_COOLDOWN_MS;
}
const retryAfter = parseRetryAfterMs(quota.errorDetail, now);
const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt);
state.cooldownUntil = now + backoff;
state.backoffAttempt += 1;
void sleep; // retained as an injectable seam for future inline retry
} else if (quota.retryable) {
// Other transient failure: set cooldown, fall through to local.
const backoff = computeBackoffMs(state.backoffAttempt);
state.cooldownUntil = now + backoff;
state.backoffAttempt += 1;
} else {
// Terminal non-retryable failure (e.g. 403/404): back off so we
// don't re-hit a dead endpoint every poll when no local data caches
// a row to engage the TTL short-circuit. Then fall through to local.
const backoff = computeBackoffMs(state.backoffAttempt);
state.cooldownUntil = now + backoff;
state.backoffAttempt += 1;
}
// Fall through to LOCAL fallback below.
}
// No configured accountId -> fall through to local fallback.
}
// ----------------------------------------------------------------
// LOCAL FALLBACK: session log read (zero network, always attempted
// when network is unavailable / no accountId / breaker active)
// ----------------------------------------------------------------
const localQuota = await getCodex();
if (localQuota) {
const row = buildCodexRow(localQuota, now);
state.cachedRow = row;
state.cachedAt = now;
return { ...row, cached: false };
}
// No local data either: serve stale (may be null).
return serveCached(state);
} catch {
// Network/parse rejection -> treat as transient, serve stale.
const backoff = computeBackoffMs(state.backoffAttempt);
state.cooldownUntil = now + backoff;
state.backoffAttempt += 1;
return serveCached(state);
} finally {
state.pending = null;
}
})();
return state.pending;
}
// ============================================================================
@@ -443,11 +654,19 @@ async function collectCodexRow(deps: NativeQuotaDeps): Promise<BarSummaryRow | n
*
* Each path is independently try/caught so one failing source never blocks the
* other or the response. Returns only rows that represent real data.
*
* `opts.force` bypasses the TTL short-circuit on both paths so a debounce-
* passing refresh re-pulls native rows live. The circuit breaker is always
* respected regardless of force (account protection).
*/
export async function getNativeAccountRows(deps: NativeQuotaDeps = {}): Promise<BarSummaryRow[]> {
export async function getNativeAccountRows(
deps: NativeQuotaDeps = {},
opts?: { force?: boolean }
): Promise<BarSummaryRow[]> {
const force = opts?.force ?? false;
const [claude, codex] = await Promise.all([
collectClaudeRow(deps).catch(() => null),
collectCodexRow(deps).catch(() => null),
collectClaudeRow(deps, force).catch(() => null),
collectCodexRow(deps, force).catch(() => null),
]);
const rows: BarSummaryRow[] = [];
@@ -455,3 +674,18 @@ export async function getNativeAccountRows(deps: NativeQuotaDeps = {}): Promise<
if (codex) rows.push(codex);
return rows;
}
/**
* Last-known native rows from cache, WITHOUT any fetch (instant, no network).
*
* Used as a non-blocking fallback by /summary: when a forced live re-pull
* overruns the native side-load budget, the response serves these cached rows
* instead of dropping the Claude/Codex cards entirely. The in-flight fetch keeps
* warming the cache, so the next poll shows the fresh values.
*/
export function getCachedNativeAccountRows(): BarSummaryRow[] {
const rows: BarSummaryRow[] = [];
if (claudeState.cachedRow) rows.push({ ...claudeState.cachedRow, cached: true });
if (codexState.cachedRow) rows.push({ ...codexState.cachedRow, cached: true });
return rows;
}
+128
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'bun:test';
import {
computeBarAnalytics,
computeBarAnalyticsFromDaily,
localDayKey,
} from '../../../src/web-server/usage/bar-analytics';
import type { CliproxyUsageHistoryDetail } from '../../../src/web-server/usage/cliproxy-usage-transformer';
import type { DailyUsage, HourlyUsage } from '../../../src/web-server/usage/types';
@@ -251,3 +252,130 @@ describe('computeBarAnalyticsFromDaily — monthToDate', () => {
expect(a.monthToDate).toEqual({ cost: 0, requests: 0 });
});
});
// ============================================================================
// byHour — 24-bucket intra-day spend chart for today
// ============================================================================
describe('byHour — intra-day spend chart', () => {
// NOW = 2026-06-08T12:00:00-04:00. byHour buckets are the user's LOCAL clock
// hours; the pipeline's hour keys are UTC and get converted to local. These
// tests build UTC keys FROM the desired local time so they pass regardless of
// the test runner's timezone (the round-trip is machine-TZ-independent).
const todayKey = localDayKey(NOW);
const pad = (n: number): string => String(n).padStart(2, '0');
/** A machine-local Date at `hour` o'clock on NOW's local calendar day (+dayOffset). */
function localDayAt(hour: number, dayOffset = 0): Date {
return new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() + dayOffset, hour, 0, 0);
}
/** The UTC "YYYY-MM-DD HH:00" key that converts back to `local`'s clock hour. */
function utcHourKeyForLocal(local: Date): string {
return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(
local.getUTCDate()
)} ${pad(local.getUTCHours())}:00`;
}
it('computeBarAnalytics returns byHour as empty array (snapshot path has no hourly)', () => {
const a = computeBarAnalytics([], NOW);
expect(Array.isArray(a.byHour)).toBe(true);
expect(a.byHour).toHaveLength(0);
});
it('computeBarAnalyticsFromDaily returns exactly 24 hour buckets', () => {
const a = computeBarAnalyticsFromDaily([], [], NOW);
expect(a.byHour).toHaveLength(24);
});
it('hour buckets are LOCAL, ordered 00:00 → 23:00', () => {
const a = computeBarAnalyticsFromDaily([], [], NOW);
expect(a.byHour[0].hour).toBe(`${todayKey} 00:00`);
expect(a.byHour[23].hour).toBe(`${todayKey} 23:00`);
for (let i = 1; i < 24; i++) {
expect(a.byHour[i].hour > a.byHour[i - 1].hour).toBe(true);
}
});
it('hours with no data are zero-filled', () => {
const a = computeBarAnalyticsFromDaily([], [], NOW);
for (const h of a.byHour) {
expect(h.cost).toBe(0);
expect(h.requests).toBe(0);
}
});
it('a UTC hour key lands in the matching LOCAL hour bucket (not the raw UTC hour)', () => {
// Activity at local 10:00 today, encoded as its UTC key.
const a = computeBarAnalyticsFromDaily(
[],
[
hourly({ hour: utcHourKeyForLocal(localDayAt(10)), totalCost: 3.5, requestCount: 2 }),
hourly({ hour: utcHourKeyForLocal(localDayAt(14)), totalCost: 1.0, requestCount: 1 }),
],
NOW
);
expect(a.byHour[10].hour).toBe(`${todayKey} 10:00`);
expect(a.byHour[10].cost).toBeCloseTo(3.5);
expect(a.byHour[10].requests).toBe(2);
expect(a.byHour[14].cost).toBeCloseTo(1.0);
expect(a.byHour[14].requests).toBe(1);
});
it('late local-evening activity (next day in UTC) still lands in TODAY', () => {
// local 23:00 today is the next calendar day in any UTC-negative zone; it
// must NOT be dropped from today's chart (regression guard for the TZ bug).
const a = computeBarAnalyticsFromDaily(
[],
[hourly({ hour: utcHourKeyForLocal(localDayAt(23)), totalCost: 7, requestCount: 4 })],
NOW
);
expect(a.byHour[23].cost).toBeCloseTo(7);
expect(a.byHour[23].requests).toBe(4);
// Every other hour stays zero.
for (let i = 0; i < 23; i++) expect(a.byHour[i].cost).toBe(0);
});
it('yesterday-local activity does NOT land in today buckets', () => {
const a = computeBarAnalyticsFromDaily(
[],
[hourly({ hour: utcHourKeyForLocal(localDayAt(10, -1)), totalCost: 99, requestCount: 10 })],
NOW
);
for (const h of a.byHour) {
expect(h.cost).toBe(0);
expect(h.requests).toBe(0);
}
});
it('accumulates multiple sources into the same local hour bucket', () => {
const key = utcHourKeyForLocal(localDayAt(9));
const a = computeBarAnalyticsFromDaily(
[],
[
hourly({ hour: key, source: 'custom-parser', totalCost: 2.0, requestCount: 3 }),
hourly({ hour: key, source: 'codex-native', totalCost: 1.5, requestCount: 1 }),
],
NOW
);
expect(a.byHour[9].cost).toBeCloseTo(3.5);
expect(a.byHour[9].requests).toBe(4);
});
it('prefers finite totalCost over cost (0 is a valid total)', () => {
const a = computeBarAnalyticsFromDaily(
[],
[
hourly({
hour: utcHourKeyForLocal(localDayAt(11)),
cost: 2.2,
totalCost: 0,
requestCount: 1,
}),
],
NOW
);
// totalCost = 0 is finite so it wins — the bucket stays 0.
expect(a.byHour[11].cost).toBeCloseTo(0);
});
});
+131
View File
@@ -987,6 +987,137 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
});
});
// ============================================================================
// force flag threading: refresh=true passes {force:true} to getNativeAccountRows
// ============================================================================
describe('/summary force flag passed to getNativeAccountRows', () => {
async function buildForceFlagRouter(
onNativeCall: (opts: { force?: boolean } | undefined) => void
) {
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
);
const app = express();
app.use(express.json());
const router = createBarRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any,
getCachedQuota: () => makeQuotaResult(),
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
fetchAccountQuota: async () => makeQuotaResult(),
getTodayCostByAccount: () => ({}),
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
runHealthChecks: async () => makeHealthReport(),
getNativeAccountRows: async (opts) => {
onNativeCall(opts);
return [];
},
});
app.use('/api/bar', router);
const srv = await new Promise<Server>((resolve, reject) => {
const instance = app.listen(0, '127.0.0.1');
instance.once('error', reject);
instance.once('listening', () => resolve(instance));
});
const addr = srv.address();
if (!addr || typeof addr === 'string') throw new Error('No server address');
resetDebounce();
return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` };
}
it('refresh=true (after debounce reset) passes { force: true } to getNativeAccountRows', async () => {
const calls: Array<{ force?: boolean } | undefined> = [];
const { srv, url } = await buildForceFlagRouter((opts) => calls.push(opts));
await getJson<BarSummaryRow[]>(url, '/api/bar/summary?refresh=true');
await new Promise<void>((resolve) => srv.close(() => resolve()));
expect(calls.length).toBeGreaterThan(0);
// The first (and only) call must carry force: true
expect(calls[0]?.force).toBe(true);
});
it('refresh=false (no query param) passes { force: false } to getNativeAccountRows', async () => {
const calls: Array<{ force?: boolean } | undefined> = [];
const { srv, url } = await buildForceFlagRouter((opts) => calls.push(opts));
await getJson<BarSummaryRow[]>(url, '/api/bar/summary');
await new Promise<void>((resolve) => srv.close(() => resolve()));
expect(calls.length).toBeGreaterThan(0);
// No refresh param → doForceRefresh is false
expect(calls[0]?.force).toBe(false);
});
it('falls back to cached native rows when the live native side-load fails (cards do not vanish)', async () => {
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
);
const cachedCodex: BarSummaryRow = {
account_id: 'codex',
provider: 'codex',
displayName: 'Codex',
tier: 'pro',
paused: false,
quota_percentage: 42,
quotaStatus: 'ok',
next_reset: null,
is_default: false,
last_activity_at: null,
today_cost: null,
health: 'ok',
cached: true,
fetchedAt: '2026-06-09T14:00:00.000Z',
needsReauth: false,
};
const app = express();
app.use(express.json());
const router = createBarRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any,
getCachedQuota: () => makeQuotaResult(),
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
fetchAccountQuota: async () => makeQuotaResult(),
getTodayCostByAccount: () => ({}),
loadCliproxyDetails: async () => [],
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
runHealthChecks: async () => makeHealthReport(),
// Live native fetch unavailable → withTimeout resolves null → fallback.
getNativeAccountRows: async () => {
throw new Error('native unavailable');
},
getCachedNativeRows: () => [cachedCodex],
});
app.use('/api/bar', router);
const srv = await new Promise<Server>((resolve, reject) => {
const instance = app.listen(0, '127.0.0.1');
instance.once('error', reject);
instance.once('listening', () => resolve(instance));
});
const addr = srv.address();
if (!addr || typeof addr === 'string') throw new Error('No server address');
resetDebounce();
const url = `http://127.0.0.1:${(addr as { port: number }).port}`;
const { status, body } = await getJson<BarSummaryRow[]>(url, '/api/bar/summary?refresh=true');
await new Promise<void>((resolve) => srv.close(() => resolve()));
// Response is healthy and the cached Codex row is served, not dropped.
expect(status).toBe(200);
expect(body.some((r) => r.provider === 'codex')).toBe(true);
});
});
// ============================================================================
// Native subscription rows side-load into /summary
// ============================================================================
@@ -1,18 +1,21 @@
/**
* Tests for the native subscription quota collector.
*
* The Anthropic fetch is ALWAYS mocked — these tests NEVER hit the live usage
* endpoint. A controllable clock drives TTL / backoff / breaker assertions.
* Neither the Anthropic nor the ChatGPT endpoint is ever hit — all fetches are
* injected via NativeQuotaDeps. A controllable clock drives TTL / backoff /
* breaker assertions for both the Claude and Codex paths.
*/
import { beforeEach, describe, expect, it } from 'bun:test';
import {
getNativeAccountRows,
getCachedNativeAccountRows,
resetNativeQuotaState,
type NativeQuotaDeps,
} from '../../../src/web-server/usage/native-quota-collector';
import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types';
import type { ClaudeQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota/quota-types';
import type { ClaudeNativeCredentials } from '../../../src/web-server/usage/claude-native-credentials';
import type { CodexLocalQuota } from '../../../src/web-server/usage/codex-local-quota-collector';
// A jump comfortably past any single-call backoff cooldown (<= 60s), used so a
// breaker-test fetch is not blocked by the prior 429's per-call cooldown.
@@ -351,11 +354,331 @@ describe('stale-on-fail', () => {
});
});
// ============================================================================
// Codex network helpers
// ============================================================================
function codexSuccessQuota(): CodexQuotaResult {
return {
success: true,
windows: [],
coreUsage: {
fiveHour: {
label: 'Primary',
remainingPercent: 60,
resetAfterSeconds: 3600,
resetAt: '2026-06-09T19:00:00.000Z',
},
weekly: {
label: 'Secondary',
remainingPercent: 80,
resetAfterSeconds: 86400 * 4,
resetAt: '2026-06-13T00:00:00.000Z',
},
},
planType: 'pro',
lastUpdated: Date.now(),
accountId: 'codex-user@example.com',
};
}
function codexRateLimitedQuota(retryAfter?: string): CodexQuotaResult {
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
accountId: 'codex-user@example.com',
httpStatus: 429,
retryable: true,
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
error: 'rate limited',
};
}
function codexReatuhQuota(): CodexQuotaResult {
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
accountId: 'codex-user@example.com',
needsReauth: true,
error: 'Token expired',
};
}
function codexLocalQuota(): CodexLocalQuota {
return {
quotaPercentage: 30,
nextReset: '2026-06-09T19:00:00.000Z',
tier: 'pro',
stale: true,
staleAsOf: '2026-06-09T13:30:00.000Z',
windows: [
{
key: 'five_hour',
label: '5h',
usedPercent: 70,
remainingPercent: 30,
resetAt: '2026-06-09T19:00:00.000Z',
windowMinutes: 300,
},
],
};
}
/**
* Build a NativeQuotaDeps that completely stubs the Codex path.
* Claude path is disabled (no credentials) so only the Codex row is produced.
*/
function makeCodexDeps(
overrides: Partial<NativeQuotaDeps> & { clock: { now: number } }
): NativeQuotaDeps & { networkCount: () => number; localCount: () => number } {
let networkCalls = 0;
let localCalls = 0;
const { clock, ...rest } = overrides;
return {
// Disable Claude path
readCredentials: () => null,
// Default network quota: success
fetchCodexNetworkQuota: async (_id: string) => {
networkCalls += 1;
return codexSuccessQuota();
},
// Default local fallback: stale data
getCodexQuota: async () => {
localCalls += 1;
return codexLocalQuota();
},
// Default account id resolved
getDefaultCodexAccountId: () => 'codex-user@example.com',
now: () => clock.now,
sleep: async () => {},
// Allow overriding any dep
...rest,
networkCount: () => networkCalls,
localCount: () => localCalls,
};
}
// ============================================================================
// Codex network path tests
// ============================================================================
describe('Codex network path', () => {
it('network success builds a fresh row from coreUsage — no staleAsOf, health ok, correct windows', async () => {
const clock = { now: 1_000_000 };
const deps = makeCodexDeps({ clock });
const rows = await getNativeAccountRows(deps);
const row = rows.find((r) => r.provider === 'codex');
expect(row).toBeDefined();
expect(row?.quotaStatus).toBe('ok');
expect(row?.health).toBe('ok');
expect(row?.tier).toBe('pro');
expect(row?.needsReauth).toBe(false);
// No staleAsOf on a live result
expect(row?.staleAsOf).toBeUndefined();
// quota_percentage = min(60, 80) = 60
expect(row?.quota_percentage).toBe(60);
// next_reset = soonest = fiveHour resetAt
expect(row?.next_reset).toBe('2026-06-09T19:00:00.000Z');
// quotaWindows: five_hour + seven_day
expect(row?.quotaWindows).toHaveLength(2);
const fiveHr = row?.quotaWindows?.find((w) => w.key === 'five_hour');
expect(fiveHr?.label).toBe('5h');
expect(fiveHr?.remainingPercent).toBe(60);
expect(fiveHr?.usedPercent).toBe(40);
expect(fiveHr?.windowMinutes).toBe(300);
expect(fiveHr?.resetAt).toBe('2026-06-09T19:00:00.000Z');
const week = row?.quotaWindows?.find((w) => w.key === 'seven_day');
expect(week?.label).toBe('week');
expect(week?.remainingPercent).toBe(80);
expect(week?.usedPercent).toBe(20);
expect(week?.windowMinutes).toBe(10080);
// Network was called; local was NOT (network succeeded)
expect(deps.networkCount()).toBe(1);
expect(deps.localCount()).toBe(0);
});
it('network failure falls back to local stale row — staleAsOf set, health warning', async () => {
const clock = { now: 1_000_000 };
const deps = makeCodexDeps({
clock,
fetchCodexNetworkQuota: async () => ({
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'network error',
retryable: true,
}),
});
const rows = await getNativeAccountRows(deps);
const row = rows.find((r) => r.provider === 'codex');
expect(row).toBeDefined();
expect(row?.health).toBe('warning');
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
expect(deps.localCount()).toBe(1);
});
it('force bypasses TTL and re-fetches from network', async () => {
const clock = { now: 1_000_000 };
const deps = makeCodexDeps({ clock });
// Prime the cache
await getNativeAccountRows(deps);
expect(deps.networkCount()).toBe(1);
// Normal call within TTL: served from cache
clock.now += 5 * 60 * 1000;
await getNativeAccountRows(deps);
expect(deps.networkCount()).toBe(1);
// Force call: bypasses TTL, re-fetches
await getNativeAccountRows(deps, { force: true });
expect(deps.networkCount()).toBe(2);
});
it('token_expired (needsReauth) returns a reauth row — not cached as good', async () => {
const clock = { now: 1_000_000 };
const deps = makeCodexDeps({
clock,
fetchCodexNetworkQuota: async () => codexReatuhQuota(),
});
const rows = await getNativeAccountRows(deps);
const row = rows.find((r) => r.provider === 'codex');
expect(row?.quotaStatus).toBe('error');
expect(row?.health).toBe('error');
expect(row?.needsReauth).toBe(true);
// Local fallback was NOT called (reauth is a distinct path, not a transient error)
expect(deps.localCount()).toBe(0);
});
it('breaker-open path: skips network and serves local fallback', async () => {
const clock = { now: 1_000_000 };
let networkCalls = 0;
let localCalls = 0;
const deps: NativeQuotaDeps & { networkCount: () => number; localCount: () => number } = {
readCredentials: () => null,
getDefaultCodexAccountId: () => 'codex-user@example.com',
fetchCodexNetworkQuota: async () => {
networkCalls += 1;
return codexRateLimitedQuota();
},
getCodexQuota: async () => {
localCalls += 1;
return codexLocalQuota();
},
now: () => clock.now,
sleep: async () => {},
networkCount: () => networkCalls,
localCount: () => localCalls,
};
// Trip the breaker with 3 consecutive 429s
for (let i = 0; i < 3; i++) {
await getNativeAccountRows(deps);
// advance past per-call cooldown between each
clock.now += 62_000;
}
const callsAfterTrip = networkCalls;
// Breaker is now open: next call must skip network
await getNativeAccountRows(deps);
expect(networkCalls).toBe(callsAfterTrip); // no new network call
// Local fallback is called instead
expect(localCalls).toBeGreaterThan(0);
});
it('no-account path: skips network and serves local fallback', async () => {
const clock = { now: 1_000_000 };
let networkCalls = 0;
let localCalls = 0;
const deps: NativeQuotaDeps = {
readCredentials: () => null,
getDefaultCodexAccountId: () => null, // no account configured
fetchCodexNetworkQuota: async () => {
networkCalls += 1;
return codexSuccessQuota();
},
getCodexQuota: async () => {
localCalls += 1;
return codexLocalQuota();
},
now: () => clock.now,
sleep: async () => {},
};
const rows = await getNativeAccountRows(deps);
const row = rows.find((r) => r.provider === 'codex');
// No network call since no account
expect(networkCalls).toBe(0);
// Local fallback was used
expect(localCalls).toBe(1);
// Row is present from local data
expect(row).toBeDefined();
expect(row?.health).toBe('warning'); // local stale
});
it('network success with EMPTY coreUsage falls back to local (no contentless ok row)', async () => {
const clock = { now: 1_000_000 };
// Track our own network counter — an override passed to makeCodexDeps
// replaces the helper's default counting fetcher, so deps.networkCount()
// would not see this override's calls.
let networkCalls = 0;
const deps = makeCodexDeps({
clock,
// Healthy response but no resolved core windows (e.g. only code-review
// windows or a changed payload) — carries no glanceable 5h/weekly signal.
fetchCodexNetworkQuota: async () => {
networkCalls += 1;
return {
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
planType: 'pro',
lastUpdated: clock.now,
accountId: 'codex-user@example.com',
};
},
});
const rows = await getNativeAccountRows(deps);
const row = rows.find((r) => r.provider === 'codex');
// Network was attempted, but the empty result must NOT be cached as an "ok"
// row — the local fallback supplies real (stale) data instead.
expect(networkCalls).toBe(1);
expect(deps.localCount()).toBe(1);
expect(row).toBeDefined();
expect(row?.health).toBe('warning'); // came from local stale path
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
expect(row?.quotaWindows).toHaveLength(1); // local five_hour window
});
});
// ============================================================================
// Codex path (original local-only tests, preserved)
// These tests explicitly set getDefaultCodexAccountId: () => null so the
// network path is skipped and only the local fallback is exercised.
// ============================================================================
describe('Codex path', () => {
it('maps a local Codex quota into a codex ok row', async () => {
const clock = { now: 1_000_000 };
const deps: NativeQuotaDeps = {
readCredentials: () => null, // no claude row
getDefaultCodexAccountId: () => null, // disable network path
getCodexQuota: async () => ({
quotaPercentage: 52,
nextReset: '2026-06-09T19:00:00.000Z',
@@ -398,6 +721,7 @@ describe('Codex path', () => {
const clock = { now: 1_000_000 };
const deps: NativeQuotaDeps = {
readCredentials: () => null,
getDefaultCodexAccountId: () => null, // disable network path
getCodexQuota: async () => ({
quotaPercentage: 10,
nextReset: null,
@@ -419,6 +743,7 @@ describe('Codex path', () => {
const clock = { now: 1_000_000 };
const deps: NativeQuotaDeps = {
readCredentials: () => null,
getDefaultCodexAccountId: () => null, // disable network path
getCodexQuota: async () => null,
now: () => clock.now,
};
@@ -426,3 +751,27 @@ describe('Codex path', () => {
expect(rows.find((r) => r.provider === 'codex')).toBeUndefined();
});
});
describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => {
it('returns [] before any successful collect', () => {
expect(getCachedNativeAccountRows()).toEqual([]);
});
it('returns the last cached codex row (cached=true) after a successful collect, [] after reset', async () => {
const clock = { now: 1_000_000 };
const deps = makeCodexDeps({ clock });
// Prime the cache via a successful network collect.
await getNativeAccountRows(deps);
const cached = getCachedNativeAccountRows();
const codex = cached.find((r) => r.provider === 'codex');
expect(codex).toBeDefined();
expect(codex?.cached).toBe(true);
// No additional network call — the accessor reads cache only.
expect(deps.networkCount()).toBe(1);
resetNativeQuotaState();
expect(getCachedNativeAccountRows()).toEqual([]);
});
});