Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Sources/CursorUsageMenuBar/Localization/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,8 @@ enum L10n {
.clear: "Clear",
.autoRefresh: "Auto refresh",
.requestInterval: "Request interval",
.requestIntervalValue: "10–20s random",
.pacerHint: "Each API call and refresh cycle waits a random interval to avoid fixed traffic patterns.",
.requestIntervalValue: "panel 3s / cycle 5–8s",
.pacerHint: "While the panel is open, event details refresh every ~3s and metrics every ~8s. Background sync waits 5–8s between cycles; requests inside a cycle are not delayed.",
.status: "Status",
.configured: "Configured",
.notConfigured: "Not configured",
Expand Down Expand Up @@ -637,8 +637,8 @@ enum L10n {
.clear: "清除",
.autoRefresh: "自动刷新",
.requestInterval: "请求间隔",
.requestIntervalValue: "10–20 秒随机",
.pacerHint: "每次 API 请求与自动刷新轮次之间都会随机等待,降低固定访问节奏。",
.requestIntervalValue: "面板 3 秒 / 后台 5–8 秒",
.pacerHint: "面板打开时:明细约每 3 秒刷新,指标约每 8 秒。后台轮次间隔 5–8 秒;同一轮内请求不再逐个等待。",
.status: "状态",
.configured: "已配置",
.notConfigured: "未配置",
Expand Down
6 changes: 4 additions & 2 deletions Sources/CursorUsageMenuBar/Models/UsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,19 +241,21 @@ struct UsageEvent: Codable, Sendable, Identifiable {
let totalCents: Double?
}

var id: String {
/// 稳定身份:不含金额/Token。进行中请求的 chargedCents 会变,若写入 id 会导致每次轮询都当成新事件。
var identityKey: String {
[
timestamp,
model,
kind,
cloudAgentId,
conversationId,
chargedCents.map { String($0) },
]
.compactMap { $0 }
.joined(separator: "|")
}

var id: String { identityKey }

var eventDate: Date? {
guard let timestamp, let milliseconds = Double(timestamp) else { return nil }
return Date(timeIntervalSince1970: milliseconds / 1000)
Expand Down
46 changes: 36 additions & 10 deletions Sources/CursorUsageMenuBar/Services/RequestPacer.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import Foundation

/// 在每次 API 请求前随机等待 10–20 秒,降低固定节奏触发风控的概率。
/// 同一操作内的连续分页(如图表全量拉取)可进入 burst 模式,页与页之间不再等待。
/// API 节奏控制:轮次之间拉开间隔;**同一次同步波次内**不再逐请求等待。
///
/// 历史问题:并发 `async let` 会串行排队在 actor 上,每个请求各睡 10–20s,
/// 导致一轮同步拖到一分钟以上。现改为「波次前等一次,波次内 burst」。
actor RequestPacer {
static let shared = RequestPacer()

private let minSeconds = 10.0
private let maxSeconds = 20.0
/// 后台整轮指标同步的间隔(秒)
private let cycleMinSeconds = 5.0
private let cycleMaxSeconds = 8.0

private var skipNextWait = true
private var burstCount = 0
private var waveActive = false

func beginBurst() {
burstCount += 1
Expand All @@ -24,23 +29,44 @@ actor RequestPacer {
return try await operation()
}

/// 一整轮 dashboard 同步:先按轮次间隔等待(或跳过首次),波次内所有请求免等待。
func withSyncWave<T>(_ operation: () async throws -> T) async rethrows -> T {
await waitForNextWaveGate()
waveActive = true
defer { waveActive = false }
return try await operation()
}

func waitBeforeRequest() async {
if burstCount > 0 { return }
if burstCount > 0 || waveActive { return }
if skipNextWait {
skipNextWait = false
return
}
let seconds = nextDelaySeconds()
let nanoseconds = UInt64(seconds * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
await sleep(seconds: nextCycleDelaySeconds())
}

func nextDelaySeconds() -> Double {
Double.random(in: minSeconds...maxSeconds)
nextCycleDelaySeconds()
}

func sleepUntilNextCycle() async {
let seconds = nextDelaySeconds()
await sleep(seconds: nextCycleDelaySeconds())
}

private func waitForNextWaveGate() async {
if skipNextWait {
skipNextWait = false
return
}
await sleep(seconds: nextCycleDelaySeconds())
}

private func nextCycleDelaySeconds() -> Double {
Double.random(in: cycleMinSeconds...cycleMaxSeconds)
}

private func sleep(seconds: Double) async {
let nanoseconds = UInt64(seconds * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
Expand Down
113 changes: 95 additions & 18 deletions Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ enum UsageAnalytics {
return explicitSpend
}

/// Auto 池花费:接口常不再返回 bonusSpend;优先 aggregated 第一方汇总,其次 total − API 已用
/// Auto 池花费:优先 aggregated 第一方模型汇总。
/// 注意:`bonusSpend` 常是 period.totalSpend − includedSpend(超额/bonus 口径),
/// **不是** Auto+Composer 池花费,不能优先采用(否则会显示成 ~$253 这种偏小值)。
static func resolveAutoSpendCents(
summary: UsageSummary?,
period: PeriodUsageResponse?,
Expand All @@ -87,13 +89,6 @@ enum UsageAnalytics {
) -> Double? {
let periodPlan = period?.planUsage

if let bonusSpend = periodPlan?.bonusSpend, bonusSpend > 0 {
return bonusSpend
}
if let autoSpend = periodPlan?.autoSpend, autoSpend > 0 {
return autoSpend
}

if let fromAggregated = poolSpendCents(
from: aggregated,
autoBucketModels: autoBucketModels,
Expand All @@ -102,14 +97,23 @@ enum UsageAnalytics {
return fromAggregated
}

let totalSpend = periodPlan?.totalSpend
?? aggregated?.totalCostCents
if let autoSpend = periodPlan?.autoSpend, autoSpend > 0 {
return autoSpend
}

let totalSpend = aggregated?.totalCostCents
?? periodPlan?.totalSpend
?? summary?.billingPlan?.used
if let totalSpend, let apiUsedCents, totalSpend >= apiUsedCents {
let derived = totalSpend - apiUsedCents
if derived > 0 { return derived }
}

// 仅作最后兜底;多数账号此字段含义已不是 Auto 池
if let bonusSpend = periodPlan?.bonusSpend, bonusSpend > 0 {
return bonusSpend
}

return nil
}

Expand Down Expand Up @@ -844,24 +848,97 @@ enum UsageAnalytics {
)
}

/// 将最新一页事件合并进缓存:同 id 以 fresh 为准,按时间新→旧排序。
/// 将最新一页合并进缓存。
/// - 新鲜第 1 页覆盖其时间窗(避免轮询把已从服务端消失的中间快照堆成「飞涨」)
/// - 同 identity 保留 Token/金额更大的版本
/// - 折叠同对话+模型下被更大快照取代的进行中中间态
static func mergeEvents(fresh: [UsageEvent], into cached: [UsageEvent]) -> [UsageEvent] {
guard !fresh.isEmpty else { return cached }
guard !cached.isEmpty else { return sortEventsByNewest(fresh) }
guard !fresh.isEmpty else {
return sortEventsByNewest(collapseSupersededSnapshots(cached))
}

var byID: [String: UsageEvent] = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) })
for event in fresh {
byID[event.id] = event
let freshDeduped = dedupeByIdentity(fresh)
guard !cached.isEmpty else {
return sortEventsByNewest(collapseSupersededSnapshots(freshDeduped))
}
return sortEventsByNewest(Array(byID.values))

let freshOldest = freshDeduped.compactMap(\.eventDate).min() ?? .distantPast
// 第 1 页是该时间窗内的权威数据:丢掉窗内旧缓存,再写入 fresh
let retainedOlder = cached.filter { ($0.eventDate ?? .distantPast) < freshOldest }
let merged = dedupeByIdentity(retainedOlder + freshDeduped)
return sortEventsByNewest(collapseSupersededSnapshots(merged))
}

private static func dedupeByIdentity(_ events: [UsageEvent]) -> [UsageEvent] {
var byKey: [String: UsageEvent] = [:]
for event in events {
let key = event.identityKey
if let existing = byKey[key] {
byKey[key] = preferredEvent(event, existing)
} else {
byKey[key] = event
}
}
return Array(byKey.values)
}

private static func preferredEvent(_ a: UsageEvent, _ b: UsageEvent) -> UsageEvent {
if a.totalTokens != b.totalTokens {
return a.totalTokens > b.totalTokens ? a : b
}
let aCents = a.chargedCents ?? -1
let bCents = b.chargedCents ?? -1
if aCents != bCents {
return aCents > bCents ? a : b
}
return a
}

/// 同一 conversation + model 下,短时间内 Token 明显增长的前序事件视为进行中快照并丢弃。
/// 正常连续 tool 调用(Token 接近)不会被折叠。
static func collapseSupersededSnapshots(_ events: [UsageEvent]) -> [UsageEvent] {
guard events.count > 1 else { return events }

let grouped = Dictionary(grouping: events) { event in
[
event.conversationId ?? "",
event.model ?? "",
event.cloudAgentId ?? "",
].joined(separator: "|")
}

var survivors: [UsageEvent] = []
survivors.reserveCapacity(events.count)

for (_, group) in grouped {
let ordered = group.sorted {
($0.eventDate ?? .distantPast) < ($1.eventDate ?? .distantPast)
}
var kept: [UsageEvent] = []
for event in ordered {
kept.removeAll { isSupersededSnapshot($0, by: event) }
kept.append(event)
}
survivors.append(contentsOf: kept)
}
return survivors
}

private static func isSupersededSnapshot(_ previous: UsageEvent, by next: UsageEvent) -> Bool {
guard let previousDate = previous.eventDate, let nextDate = next.eventDate else { return false }
let delta = nextDate.timeIntervalSince(previousDate)
guard delta > 0, delta <= 120 else { return false }
guard previous.totalTokens > 0, next.totalTokens > previous.totalTokens else { return false }
let growth = Double(next.totalTokens) / Double(previous.totalTokens)
return growth >= 1.15
}

private static func sortEventsByNewest(_ events: [UsageEvent]) -> [UsageEvent] {
events.sorted { lhs, rhs in
let left = lhs.eventDate ?? .distantPast
let right = rhs.eventDate ?? .distantPast
if left != right { return left > right }
return lhs.id > rhs.id
return lhs.identityKey > rhs.identityKey
}
}

Expand Down
97 changes: 97 additions & 0 deletions Sources/CursorUsageMenuBar/Services/UsageEventsMemoryCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Foundation

/// 用量明细的内存 + 磁盘缓存读写,供 ViewModel 调用。
@MainActor
final class UsageEventsMemoryCache {
private var memory: (key: String, store: UsageEventsCache.Store)?

func clearAll() {
memory = nil
UsageEventsCache.clearAll()
}

func clear(startMs: String, endMs: String) {
UsageEventsCache.clear(startMs: startMs, endMs: endMs)
if memory?.key == key(startMs: startMs, endMs: endMs) {
memory = nil
}
}

func load(startMs: String, endMs: String) -> UsageEventsCache.Store? {
let cacheKey = key(startMs: startMs, endMs: endMs)
if let memory, memory.key == cacheKey {
return memory.store
}
guard let store = UsageEventsCache.load(startMs: startMs, endMs: endMs) else { return nil }

// 清理历史 live 轮询堆出来的进行中中间快照
let normalizedEvents = UsageAnalytics.mergeEvents(fresh: store.events, into: [])
let removed = store.events.count - normalizedEvents.count
let normalized = UsageEventsCache.Store(
billingCycleStartMs: store.billingCycleStartMs,
billingCycleEndMs: store.billingCycleEndMs,
totalCount: max(store.totalCount - max(0, removed), normalizedEvents.count),
fetchedAt: store.fetchedAt,
events: normalizedEvents
)
if removed > 0 {
UsageEventsCache.save(
events: normalized.events,
totalCount: normalized.totalCount,
startMs: startMs,
endMs: endMs
)
}
memory = (cacheKey, normalized)
return normalized
}

func save(events: [UsageEvent], totalCount: Int, startMs: String, endMs: String) {
UsageEventsCache.save(events: events, totalCount: totalCount, startMs: startMs, endMs: endMs)
memory = (
key(startMs: startMs, endMs: endMs),
UsageEventsCache.Store(
billingCycleStartMs: startMs,
billingCycleEndMs: endMs,
totalCount: totalCount,
fetchedAt: Date(),
events: events
)
)
}

func page(startMs: String, endMs: String, page: Int, pageSize: Int) -> UsageEventsPage? {
guard UsageEventsCache.canServePage(
startMs: startMs,
endMs: endMs,
page: page,
pageSize: pageSize
) else { return nil }

if let store = load(startMs: startMs, endMs: endMs) {
return UsageEventsPage.slice(
from: store.events,
totalCount: store.totalCount,
page: page,
pageSize: pageSize
)
}
return UsageEventsCache.page(
startMs: startMs,
endMs: endMs,
page: page,
pageSize: pageSize
)
}

func isStale(startMs: String?, endMs: String?, expectedTotal: Int) -> Bool {
guard let startMs, let endMs, let store = load(startMs: startMs, endMs: endMs) else {
return true
}
return store.events.count < expectedTotal
}

private func key(startMs: String, endMs: String) -> String {
"\(startMs)|\(endMs)"
}
}
Loading