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
24 changes: 24 additions & 0 deletions Sources/CursorUsageMenuBar/Localization/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ enum L10n {
case cumulative
case spendAmount
case usedSpend
case yourUsage
case yourUsageSubtitle
case groupByModelSpend
case cumulativeSpendAxis
case todayMarker
case emptyCumulativeUsage
case includedSpend
case onDemandSpend

// Spending tab
case cycleSpending
Expand Down Expand Up @@ -301,6 +309,14 @@ enum L10n {
.cumulative: "Cumulative %@",
.spendAmount: "Spend $%.2f",
.usedSpend: "Used $%.2f",
.yourUsage: "Your Usage",
.yourUsageSubtitle: "Cumulative spend per day across this billing period",
.groupByModelSpend: "Model · Spend",
.cumulativeSpendAxis: "Cumulative Spend ($)",
.todayMarker: "Today",
.emptyCumulativeUsage: "No cumulative usage data yet",
.includedSpend: "Included",
.onDemandSpend: "On-demand",

.cycleSpending: "Cycle spending",
.spendShare: "Spend breakdown",
Expand Down Expand Up @@ -511,6 +527,14 @@ enum L10n {
.cumulative: "累计 %@",
.spendAmount: "花费 $%.2f",
.usedSpend: "已用 $%.2f",
.yourUsage: "Your Usage",
.yourUsageSubtitle: "本账单周期内按日累计花费(按模型)",
.groupByModelSpend: "模型 · 花费",
.cumulativeSpendAxis: "累计花费 ($)",
.todayMarker: "今天",
.emptyCumulativeUsage: "暂无累计用量图数据",
.includedSpend: "包含用量",
.onDemandSpend: "按需用量",

.cycleSpending: "本周期花费 (Spending)",
.spendShare: "花费占比",
Expand Down
43 changes: 43 additions & 0 deletions Sources/CursorUsageMenuBar/Models/UsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,34 @@ struct DailyModelShareChartPoint: Identifiable, Sendable {
}
}

struct CumulativeModelSpendPoint: Identifiable, Sendable {
let id: String
let date: Date
let model: String
let cumulativeCents: Double

init(date: Date, model: String, cumulativeCents: Double) {
self.date = date
self.model = model
self.cumulativeCents = cumulativeCents
id = "\(ISO8601DateFormatter().string(from: date))-\(model)"
}

var cumulativeDollars: Double { cumulativeCents / 100 }
}

struct SpendSummaryMetrics: Sendable {
let totalSpendCents: Double?
let includedSpendCents: Double?
let onDemandSpendCents: Double?

static let empty = SpendSummaryMetrics(
totalSpendCents: nil,
includedSpendCents: nil,
onDemandSpendCents: nil
)
}

struct ModelTokenUsageSummary: Sendable {
let rows: [ModelTokenUsageRow]
let totalInputTokens: Int
Expand Down Expand Up @@ -591,6 +619,8 @@ struct DashboardSnapshot: Sendable {
let quotaCurves: [QuotaCurvePoint]
let modelBreakdown: [ModelSpendSlice]
let dailyModelShare: [DailyModelShareDay]
let cumulativeModelSpend: [CumulativeModelSpendPoint]
let spendSummary: SpendSummaryMetrics
let spendingBreakdown: [SpendingBreakdownItem]
let todayStats: TodayUsageStats
let partialErrors: [String]
Expand All @@ -613,6 +643,8 @@ struct DashboardSnapshot: Sendable {
quotaCurves: [],
modelBreakdown: [],
dailyModelShare: [],
cumulativeModelSpend: [],
spendSummary: .empty,
spendingBreakdown: [],
todayStats: .empty,
partialErrors: [],
Expand Down Expand Up @@ -677,6 +709,8 @@ struct DashboardSnapshot: Sendable {
quotaCurves: quotaCurves,
modelBreakdown: modelBreakdown,
dailyModelShare: dailyModelShare,
cumulativeModelSpend: cumulativeModelSpend,
spendSummary: spendSummary,
spendingBreakdown: spendingBreakdown,
todayStats: todayStats,
partialErrors: partialErrors,
Expand Down Expand Up @@ -707,6 +741,8 @@ struct DashboardSnapshot: Sendable {
),
modelBreakdown: UsageAnalytics.modelBreakdown(from: aggregatedUsage, events: allEvents),
dailyModelShare: UsageAnalytics.dailyModelShare(from: allEvents),
cumulativeModelSpend: UsageAnalytics.cumulativeModelSpend(from: allEvents),
spendSummary: UsageAnalytics.spendSummary(from: periodUsage, aggregated: aggregatedUsage),
spendingBreakdown: spendingBreakdown,
todayStats: UsageAnalytics.todayStats(from: allEvents, billing: usageLimit),
partialErrors: partialErrors,
Expand All @@ -733,6 +769,8 @@ struct DashboardSnapshot: Sendable {
quotaCurves: quotaCurves,
modelBreakdown: modelBreakdown,
dailyModelShare: dailyModelShare,
cumulativeModelSpend: cumulativeModelSpend,
spendSummary: incoming.spendSummary,
spendingBreakdown: incoming.spendingBreakdown,
todayStats: todayStats,
partialErrors: incoming.partialErrors,
Expand Down Expand Up @@ -791,6 +829,11 @@ struct DashboardSnapshot: Sendable {
hasher.combine(day.id)
hasher.combine(day.totalTokens)
}
hasher.combine(cumulativeModelSpend.count)
if let last = cumulativeModelSpend.last {
hasher.combine(last.id)
hasher.combine(last.cumulativeCents)
}
hasher.combine(todayStats.eventCount)
hasher.combine(todayStats.totalChargedCents)
hasher.combine(todayStats.dailyPercent)
Expand Down
2 changes: 2 additions & 0 deletions Sources/CursorUsageMenuBar/Services/CursorAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ struct CursorAPIClient: Sendable {
quotaCurves: [],
modelBreakdown: modelBreakdown,
dailyModelShare: [],
cumulativeModelSpend: [],
spendSummary: UsageAnalytics.spendSummary(from: periodUsage, aggregated: aggregatedUsage),
spendingBreakdown: UsageAnalytics.spendingBreakdown(from: periodUsage, summary: summary),
todayStats: .empty,
partialErrors: partialErrors,
Expand Down
101 changes: 92 additions & 9 deletions Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,26 @@ enum UsageAnalytics {
]
}

/// Billing API 已用 = apiPercentUsed × 包含额度(plan.limit)
/// API 已用:优先 apiPercentUsed × API 额度(与仪表盘 % 一致)。
/// 勿盲信 period.apiSpend / includedSpend——后者常等于额度上限(如 $70),不是已用。
static func resolveAPIUsedCents(
percent: Double?,
limitCents: Double?,
explicitSpend: Double?
) -> Double? {
if let explicitSpend { return explicitSpend }
guard let percent, let limitCents else { return nil }
return limitCents * (percent / 100)
if let percent, let limitCents {
let fromPercent = limitCents * (percent / 100)
// explicitSpend 若接近额度本身且与百分比明显不符,视为脏数据,忽略
if let explicitSpend, limitCents > 0 {
let looksLikeLimit = abs(explicitSpend - limitCents) / limitCents < 0.02
let disagreesWithPercent = percent < 95 && abs(explicitSpend - fromPercent) / limitCents > 0.05
if looksLikeLimit && disagreesWithPercent {
return fromPercent
}
}
return fromPercent
}
return explicitSpend
}

/// Billing 总口径:totalPercentUsed + 全周期花费
Expand Down Expand Up @@ -788,13 +799,85 @@ enum UsageAnalytics {

static func formatPercent(_ value: Double?) -> String {
guard let value else { return "—" }
if value < 1 {
return String(format: "%.2f%%", value)
// 对齐 Cursor 新版 Usage 表:固定一位小数(含 0.0%)
return String(format: "%.1f%%", value)
}

/// 按日累计、按模型堆叠的花费序列(对齐 Cursor「Your Usage」累计面积图)
static func cumulativeModelSpend(
from events: [UsageEvent],
topLimit: Int = 9
) -> [CumulativeModelSpendPoint] {
let calendar = Calendar.current
let language = LocalizationManager.resolvedLanguage()
let othersLabel = L10n.string(.othersModel, language: language)

var dailyModelCents: [Date: [String: Double]] = [:]
var modelTotals: [String: Double] = [:]

for event in events {
guard let date = event.eventDate else { continue }
let day = calendar.startOfDay(for: date)
let cents = event.chargedCents ?? 0
guard cents > 0 else { continue }
let model = displayModelName(event.model ?? L10n.string(.unknownModel, language: language))
dailyModelCents[day, default: [:]][model, default: 0] += cents
modelTotals[model, default: 0] += cents
}
if value < 10 {
return String(format: "%.1f%%", value)

guard !dailyModelCents.isEmpty else { return [] }

let ranked = modelTotals.sorted { $0.value > $1.value }
let topModels = ranked.prefix(topLimit).map(\.key)
let topSet = Set(topModels)
let hasOthers = ranked.count > topLimit
let seriesModels = topModels + (hasOthers ? [othersLabel] : [])

let sortedDays = dailyModelCents.keys.sorted()
var cumulative: [String: Double] = Dictionary(uniqueKeysWithValues: seriesModels.map { ($0, 0.0) })
var points: [CumulativeModelSpendPoint] = []

for day in sortedDays {
let dayMap = dailyModelCents[day] ?? [:]
var otherDay = 0.0
for (model, cents) in dayMap {
if topSet.contains(model) {
cumulative[model, default: 0] += cents
} else {
otherDay += cents
}
}
if hasOthers {
cumulative[othersLabel, default: 0] += otherDay
}

for model in seriesModels {
points.append(
CumulativeModelSpendPoint(
date: day,
model: model,
cumulativeCents: cumulative[model] ?? 0
)
)
}
}
return String(format: "%.0f%%", value.rounded())

return points
}

static func spendSummary(
from period: PeriodUsageResponse?,
aggregated: AggregatedUsageResponse?
) -> SpendSummaryMetrics {
let plan = period?.planUsage
let total = aggregated?.totalCostCents ?? plan?.totalSpend
let included = plan?.includedSpend ?? total
let onDemand = period?.onDemandUsage?.used ?? 0
return SpendSummaryMetrics(
totalSpendCents: total,
includedSpendCents: included,
onDemandSpendCents: onDemand
)
}

static func formatBillingPeriod(start: Date?, end: Date?, language: ResolvedLanguage) -> String? {
Expand Down
10 changes: 10 additions & 0 deletions Sources/CursorUsageMenuBar/ViewModels/UsageViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ final class UsageViewModel: ObservableObject {
var summary: UsageSummary? { dashboard.summary }
var todayStats: TodayUsageStats { dashboard.todayStats }

/// API 池实际已用(美分)= apiPercent × API 额度;勿用 period.includedSpend(常是额度上限)
var apiPoolUsedCents: Double? {
dashboard.usagePools.first(where: { $0.id == "api" })?.usedCents
}

var apiPoolLimitCents: Double? {
dashboard.usagePools.first(where: { $0.id == "api" })?.limitCents
?? summary?.planLimit
}

var menuBarTitle: String {
menuBarTitle(language: LocalizationManager.shared.resolved)
}
Expand Down
Loading