mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
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:
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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user