蜂鸟Pro v2.0.1 - 基础框架版本 (待完善)

## 当前状态
- 插件界面已完成重命名 (cursorpro → hummingbird)
- 双账号池 UI 已实现 (Auto/Pro 卡片)
- 后端已切换到 MySQL 数据库
- 添加了 Cursor 官方用量 API 文档

## 已知问题 (待修复)
1. 激活时检查账号导致无账号时激活失败
2. 未启用无感换号时不应获取账号
3. 账号用量模块不显示 (seamless 未启用时应隐藏)
4. 积分显示为 0 (后端未正确返回)
5. Auto/Pro 双密钥逻辑混乱,状态不同步
6. 账号添加后无自动分析功能

## 下一版本计划
- 重构数据模型,优化账号状态管理
- 实现 Cursor API 自动分析账号
- 修复激活流程,不依赖账号
- 启用无感时才分配账号
- 完善账号用量实时显示

## 文件说明
- docs/系统设计文档.md - 完整架构设计
- cursor 官方用量接口.md - Cursor API 文档
- 参考计费/ - Vibeviewer 开源项目参考

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ccdojox-crypto
2025-12-18 11:21:52 +08:00
parent f310ca7b97
commit 73a71f198f
202 changed files with 19142 additions and 252 deletions

View File

@@ -0,0 +1,42 @@
{
"originHash" : "92fad12ce0ee54ec200016721b4c688ff3af7c525ef00f048094fd209751300c",
"pins" : [
{
"identity" : "alamofire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
"revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5",
"version" : "5.10.2"
}
},
{
"identity" : "moya",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Moya/Moya.git",
"state" : {
"revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26",
"version" : "15.0.3"
}
},
{
"identity" : "reactiveswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git",
"state" : {
"revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c",
"version" : "6.7.0"
}
},
{
"identity" : "rxswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ReactiveX/RxSwift.git",
"state" : {
"revision" : "5dd1907d64f0d36f158f61a466bab75067224893",
"version" : "6.9.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,19 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "VibeviewerModel",
platforms: [
.macOS(.v14)
],
products: [
.library(name: "VibeviewerModel", targets: ["VibeviewerModel"]),
],
dependencies: [
.package(path: "../VibeviewerCore")
],
targets: [
.target(name: "VibeviewerModel", dependencies: ["VibeviewerCore"]),
.testTarget(name: "VibeviewerModelTests", dependencies: ["VibeviewerModel"])
]
)

View File

@@ -0,0 +1,20 @@
import Foundation
public enum AIModelBrands: String, CaseIterable {
case gpt
case claude
case deepseek
case gemini
case grok
case kimi
case `default`
public static func brand(for modelName: String) -> AIModelBrands {
for brand in AIModelBrands.allCases {
if modelName.hasPrefix(brand.rawValue) {
return brand
}
}
return .default
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
/// 使
public struct AggregatedUsageEvents: Sendable, Equatable, Codable {
/// 使
public let aggregations: [ModelAggregation]
/// token
public let totalInputTokens: Int
/// token
public let totalOutputTokens: Int
/// token
public let totalCacheWriteTokens: Int
/// token
public let totalCacheReadTokens: Int
///
public let totalCostCents: Double
public init(
aggregations: [ModelAggregation],
totalInputTokens: Int,
totalOutputTokens: Int,
totalCacheWriteTokens: Int,
totalCacheReadTokens: Int,
totalCostCents: Double
) {
self.aggregations = aggregations
self.totalInputTokens = totalInputTokens
self.totalOutputTokens = totalOutputTokens
self.totalCacheWriteTokens = totalCacheWriteTokens
self.totalCacheReadTokens = totalCacheReadTokens
self.totalCostCents = totalCostCents
}
}
/// 使
public struct ModelAggregation: Sendable, Equatable, Codable {
/// / "claude-4.5-sonnet-thinking"
public let modelIntent: String
/// token
public let inputTokens: Int
/// token
public let outputTokens: Int
/// token
public let cacheWriteTokens: Int
/// token
public let cacheReadTokens: Int
///
public let totalCents: Double
public init(
modelIntent: String,
inputTokens: Int,
outputTokens: Int,
cacheWriteTokens: Int,
cacheReadTokens: Int,
totalCents: Double
) {
self.modelIntent = modelIntent
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheWriteTokens = cacheWriteTokens
self.cacheReadTokens = cacheReadTokens
self.totalCents = totalCents
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
public enum AppAppearance: String, Codable, Sendable, Equatable, CaseIterable, Hashable {
case system
case light
case dark
}

View File

@@ -0,0 +1,14 @@
import Foundation
import Observation
@MainActor
@Observable
public final class AppSession {
public var credentials: Credentials?
public var snapshot: DashboardSnapshot?
public init(credentials: Credentials? = nil, snapshot: DashboardSnapshot? = nil) {
self.credentials = credentials
self.snapshot = snapshot
}
}

View File

@@ -0,0 +1,98 @@
import Foundation
import Observation
@Observable
public final class AppSettings: Codable, Sendable, Equatable {
public var launchAtLogin: Bool
public var usageHistory: AppSettings.UsageHistory
public var overview: AppSettings.Overview
public var pauseOnScreenSleep: Bool
public var appearance: AppAppearance
public var analyticsDataDays: Int
public init(
launchAtLogin: Bool = false,
usageHistory: AppSettings.UsageHistory = AppSettings.UsageHistory(limit: 5),
overview: AppSettings.Overview = AppSettings.Overview(refreshInterval: 5),
pauseOnScreenSleep: Bool = false,
appearance: AppAppearance = .system,
analyticsDataDays: Int = 7
) {
self.launchAtLogin = launchAtLogin
self.usageHistory = usageHistory
self.overview = overview
self.pauseOnScreenSleep = pauseOnScreenSleep
self.appearance = appearance
self.analyticsDataDays = analyticsDataDays
}
public static func == (lhs: AppSettings, rhs: AppSettings) -> Bool {
lhs.launchAtLogin == rhs.launchAtLogin &&
lhs.usageHistory == rhs.usageHistory &&
lhs.overview == rhs.overview &&
lhs.pauseOnScreenSleep == rhs.pauseOnScreenSleep &&
lhs.appearance == rhs.appearance &&
lhs.analyticsDataDays == rhs.analyticsDataDays
}
// MARK: - Codable (backward compatible)
private enum CodingKeys: String, CodingKey {
case launchAtLogin
case usageHistory
case overview
case pauseOnScreenSleep
case appearance
case analyticsDataDays
}
public required convenience init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let launchAtLogin = try container.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? false
let usageHistory = try container.decodeIfPresent(AppSettings.UsageHistory.self, forKey: .usageHistory) ?? AppSettings.UsageHistory(limit: 5)
let overview = try container.decodeIfPresent(AppSettings.Overview.self, forKey: .overview) ?? AppSettings.Overview(refreshInterval: 5)
let pauseOnScreenSleep = try container.decodeIfPresent(Bool.self, forKey: .pauseOnScreenSleep) ?? false
let appearance = try container.decodeIfPresent(AppAppearance.self, forKey: .appearance) ?? .system
let analyticsDataDays = try container.decodeIfPresent(Int.self, forKey: .analyticsDataDays) ?? 7
self.init(
launchAtLogin: launchAtLogin,
usageHistory: usageHistory,
overview: overview,
pauseOnScreenSleep: pauseOnScreenSleep,
appearance: appearance,
analyticsDataDays: analyticsDataDays
)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.launchAtLogin, forKey: .launchAtLogin)
try container.encode(self.usageHistory, forKey: .usageHistory)
try container.encode(self.overview, forKey: .overview)
try container.encode(self.pauseOnScreenSleep, forKey: .pauseOnScreenSleep)
try container.encode(self.appearance, forKey: .appearance)
try container.encode(self.analyticsDataDays, forKey: .analyticsDataDays)
}
public struct Overview: Codable, Sendable, Equatable {
public var refreshInterval: Int
public init(
refreshInterval: Int = 5
) {
self.refreshInterval = refreshInterval
}
}
public struct UsageHistory: Codable, Sendable, Equatable {
public var limit: Int
public init(
limit: Int = 5
) {
self.limit = limit
}
}
// moved to its own file: AppAppearance
}

View File

@@ -0,0 +1,18 @@
import Foundation
///
public struct BillingCycle: Sendable, Equatable, Codable {
///
public let startDate: Date
///
public let endDate: Date
public init(
startDate: Date,
endDate: Date
) {
self.startDate = startDate
self.endDate = endDate
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
@Observable
public class Credentials: Codable, Equatable {
public let userId: Int
public let workosId: String
public let email: String
public let teamId: Int
public let cookieHeader: String
public let isEnterpriseUser: Bool
public init(userId: Int, workosId: String, email: String, teamId: Int, cookieHeader: String, isEnterpriseUser: Bool) {
self.userId = userId
self.workosId = workosId
self.email = email
self.teamId = teamId
self.cookieHeader = cookieHeader
self.isEnterpriseUser = isEnterpriseUser
}
public static func == (lhs: Credentials, rhs: Credentials) -> Bool {
lhs.userId == rhs.userId &&
lhs.workosId == rhs.workosId &&
lhs.email == rhs.email &&
lhs.teamId == rhs.teamId &&
lhs.cookieHeader == rhs.cookieHeader &&
lhs.isEnterpriseUser == rhs.isEnterpriseUser
}
}

View File

@@ -0,0 +1,165 @@
import Foundation
@Observable
public class DashboardSnapshot: Codable, Equatable {
//
public let email: String
/// ( + (Billing))
public let totalRequestsAllModels: Int
///
public let spendingCents: Int
///
public let hardLimitDollars: Int
///
public let usageEvents: [UsageEvent]
/// usageEvents
public let requestToday: Int
/// usageEvents
public let requestYestoday: Int
/// 使
public let usageSummary: UsageSummary?
/// Team Plan
public let freeUsageCents: Int
/// 使
public let modelsUsageChart: ModelsUsageChartData?
/// Pro Team
public let modelsUsageSummary: ModelsUsageSummary?
///
public let billingCycleStartMs: String?
///
public let billingCycleEndMs: String?
public init(
email: String,
totalRequestsAllModels: Int,
spendingCents: Int,
hardLimitDollars: Int,
usageEvents: [UsageEvent] = [],
requestToday: Int = 0,
requestYestoday: Int = 0,
usageSummary: UsageSummary? = nil,
freeUsageCents: Int = 0,
modelsUsageChart: ModelsUsageChartData? = nil,
modelsUsageSummary: ModelsUsageSummary? = nil,
billingCycleStartMs: String? = nil,
billingCycleEndMs: String? = nil
) {
self.email = email
self.totalRequestsAllModels = totalRequestsAllModels
self.spendingCents = spendingCents
self.hardLimitDollars = hardLimitDollars
self.usageEvents = usageEvents
self.requestToday = requestToday
self.requestYestoday = requestYestoday
self.usageSummary = usageSummary
self.freeUsageCents = freeUsageCents
self.modelsUsageChart = modelsUsageChart
self.modelsUsageSummary = modelsUsageSummary
self.billingCycleStartMs = billingCycleStartMs
self.billingCycleEndMs = billingCycleEndMs
}
private enum CodingKeys: String, CodingKey {
case email
case totalRequestsAllModels
case spendingCents
case hardLimitDollars
case usageEvents
case requestToday
case requestYestoday
case usageSummary
case freeUsageCents
case modelsUsageChart
case modelsUsageSummary
case billingCycleStartMs
case billingCycleEndMs
}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.email = try container.decode(String.self, forKey: .email)
self.totalRequestsAllModels = try container.decode(Int.self, forKey: .totalRequestsAllModels)
self.spendingCents = try container.decode(Int.self, forKey: .spendingCents)
self.hardLimitDollars = try container.decode(Int.self, forKey: .hardLimitDollars)
self.requestToday = try container.decode(Int.self, forKey: .requestToday)
self.requestYestoday = try container.decode(Int.self, forKey: .requestYestoday)
self.usageEvents = try container.decode([UsageEvent].self, forKey: .usageEvents)
self.usageSummary = try? container.decode(UsageSummary.self, forKey: .usageSummary)
self.freeUsageCents = (try? container.decode(Int.self, forKey: .freeUsageCents)) ?? 0
self.modelsUsageChart = try? container.decode(ModelsUsageChartData.self, forKey: .modelsUsageChart)
self.modelsUsageSummary = try? container.decode(ModelsUsageSummary.self, forKey: .modelsUsageSummary)
self.billingCycleStartMs = try? container.decode(String.self, forKey: .billingCycleStartMs)
self.billingCycleEndMs = try? container.decode(String.self, forKey: .billingCycleEndMs)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.email, forKey: .email)
try container.encode(self.totalRequestsAllModels, forKey: .totalRequestsAllModels)
try container.encode(self.spendingCents, forKey: .spendingCents)
try container.encode(self.hardLimitDollars, forKey: .hardLimitDollars)
try container.encode(self.usageEvents, forKey: .usageEvents)
try container.encode(self.requestToday, forKey: .requestToday)
try container.encode(self.requestYestoday, forKey: .requestYestoday)
if let usageSummary = self.usageSummary {
try container.encode(usageSummary, forKey: .usageSummary)
}
if self.freeUsageCents > 0 {
try container.encode(self.freeUsageCents, forKey: .freeUsageCents)
}
if let modelsUsageChart = self.modelsUsageChart {
try container.encode(modelsUsageChart, forKey: .modelsUsageChart)
}
if let modelsUsageSummary = self.modelsUsageSummary {
try container.encode(modelsUsageSummary, forKey: .modelsUsageSummary)
}
if let billingCycleStartMs = self.billingCycleStartMs {
try container.encode(billingCycleStartMs, forKey: .billingCycleStartMs)
}
if let billingCycleEndMs = self.billingCycleEndMs {
try container.encode(billingCycleEndMs, forKey: .billingCycleEndMs)
}
}
/// plan + onDemand
public var totalUsageCents: Int {
guard let usageSummary = usageSummary else {
return spendingCents
}
let planUsed = usageSummary.individualUsage.plan.used
let onDemandUsed = usageSummary.individualUsage.onDemand?.used ?? 0
let freeUsage = freeUsageCents
return planUsed + onDemandUsed + freeUsage
}
/// UI
/// - Pro pro / proPlus / ultra `modelsUsageSummary`
/// 使 `ModelUsageInfo`
/// - 退 `totalUsageCents`
public var displayTotalUsageCents: Int {
if
let usageSummary,
let modelsUsageSummary,
usageSummary.membershipType.isProSeries
{
return Int(modelsUsageSummary.totalCostCents.rounded())
}
return totalUsageCents
}
public static func == (lhs: DashboardSnapshot, rhs: DashboardSnapshot) -> Bool {
lhs.email == rhs.email &&
lhs.totalRequestsAllModels == rhs.totalRequestsAllModels &&
lhs.spendingCents == rhs.spendingCents &&
lhs.hardLimitDollars == rhs.hardLimitDollars &&
lhs.usageSummary == rhs.usageSummary &&
lhs.freeUsageCents == rhs.freeUsageCents &&
lhs.modelsUsageChart == rhs.modelsUsageChart &&
lhs.modelsUsageSummary == rhs.modelsUsageSummary &&
lhs.billingCycleStartMs == rhs.billingCycleStartMs &&
lhs.billingCycleEndMs == rhs.billingCycleEndMs
}
}

View File

@@ -0,0 +1,11 @@
import Foundation
public struct FilteredUsageHistory: Sendable, Equatable {
public let totalCount: Int
public let events: [UsageEvent]
public init(totalCount: Int, events: [UsageEvent]) {
self.totalCount = totalCount
self.events = events
}
}

View File

@@ -0,0 +1,56 @@
import Foundation
///
public enum MembershipType: String, Sendable, Equatable, Codable {
case enterprise = "enterprise"
case freeTrial = "free_trial"
case pro = "pro"
case proPlus = "pro_plus"
case ultra = "ultra"
case free = "free"
/// Pro Pro / Pro+ / Ultra
public var isProSeries: Bool {
switch self {
case .pro, .proPlus, .ultra:
return true
default:
return false
}
}
///
/// - Parameters:
/// - subscriptionStatus:
/// - isEnterprise: Enterprise Team Plan
/// - Returns:
public func displayName(
subscriptionStatus: SubscriptionStatus? = nil,
isEnterprise: Bool = false
) -> String {
switch self {
case .enterprise:
return isEnterprise ? "Enterprise" : "Team Plan"
case .freeTrial:
return "Pro Trial"
case .pro:
return subscriptionStatus == .trialing ? "Pro Trial" : "Pro Plan"
case .proPlus:
return subscriptionStatus == .trialing ? "Pro+ Trial" : "Pro+ Plan"
case .ultra:
return "Ultra Plan"
case .free:
return "Free Plan"
}
}
}
///
public enum SubscriptionStatus: String, Sendable, Equatable, Codable {
case trialing = "trialing"
case active = "active"
case canceled = "canceled"
case pastDue = "past_due"
case unpaid = "unpaid"
}

View File

@@ -0,0 +1,126 @@
import Foundation
/// - 使
public struct ModelUsageInfo: Sendable, Equatable, Codable {
///
public let modelName: String
/// token
public let inputTokens: Int
/// token
public let outputTokens: Int
/// token
public let cacheWriteTokens: Int
/// token
public let cacheReadTokens: Int
///
public let costCents: Double
public init(
modelName: String,
inputTokens: Int,
outputTokens: Int,
cacheWriteTokens: Int,
cacheReadTokens: Int,
costCents: Double
) {
self.modelName = modelName
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheWriteTokens = cacheWriteTokens
self.cacheReadTokens = cacheReadTokens
self.costCents = costCents
}
/// ModelAggregation
public init(from aggregation: ModelAggregation) {
self.modelName = aggregation.modelIntent
self.inputTokens = aggregation.inputTokens
self.outputTokens = aggregation.outputTokens
self.cacheWriteTokens = aggregation.cacheWriteTokens
self.cacheReadTokens = aggregation.cacheReadTokens
self.costCents = aggregation.totalCents
}
/// token
public var totalTokens: Int {
inputTokens + outputTokens
}
/// token
public var totalTokensWithCache: Int {
inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens
}
/// "$1.23"
public var formattedCost: String {
String(format: "$%.2f", costCents / 100.0)
}
}
/// -
public struct ModelsUsageSummary: Sendable, Equatable, Codable {
///
public let models: [ModelUsageInfo]
/// token
public let totalInputTokens: Int
/// token
public let totalOutputTokens: Int
/// token
public let totalCacheWriteTokens: Int
/// token
public let totalCacheReadTokens: Int
///
public let totalCostCents: Double
public init(
models: [ModelUsageInfo],
totalInputTokens: Int,
totalOutputTokens: Int,
totalCacheWriteTokens: Int,
totalCacheReadTokens: Int,
totalCostCents: Double
) {
self.models = models
self.totalInputTokens = totalInputTokens
self.totalOutputTokens = totalOutputTokens
self.totalCacheWriteTokens = totalCacheWriteTokens
self.totalCacheReadTokens = totalCacheReadTokens
self.totalCostCents = totalCostCents
}
/// AggregatedUsageEvents
public init(from aggregated: AggregatedUsageEvents) {
self.models = aggregated.aggregations.map { ModelUsageInfo(from: $0) }
self.totalInputTokens = aggregated.totalInputTokens
self.totalOutputTokens = aggregated.totalOutputTokens
self.totalCacheWriteTokens = aggregated.totalCacheWriteTokens
self.totalCacheReadTokens = aggregated.totalCacheReadTokens
self.totalCostCents = aggregated.totalCostCents
}
/// token
public var totalTokens: Int {
totalInputTokens + totalOutputTokens
}
/// token
public var totalTokensWithCache: Int {
totalInputTokens + totalOutputTokens + totalCacheWriteTokens + totalCacheReadTokens
}
/// "$1.23"
public var formattedTotalCost: String {
String(format: "$%.2f", totalCostCents / 100.0)
}
///
public var modelsSortedByCost: [ModelUsageInfo] {
models.sorted { $0.costCents > $1.costCents }
}
/// token 使
public var modelsSortedByTokens: [ModelUsageInfo] {
models.sorted { $0.totalTokens > $1.totalTokens }
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
/// 使
public struct ModelsUsageChartData: Codable, Sendable, Equatable {
///
public let dataPoints: [DataPoint]
public init(dataPoints: [DataPoint]) {
self.dataPoints = dataPoints
}
///
public struct DataPoint: Codable, Sendable, Equatable {
/// YYYY-MM-DD
public let date: String
/// MM/dd
public let dateLabel: String
/// 使
public let modelUsages: [ModelUsage]
/// 使
public var totalValue: Int {
modelUsages.reduce(0) { $0 + $1.requests }
}
public init(date: String, dateLabel: String, modelUsages: [ModelUsage]) {
self.date = date
self.dateLabel = dateLabel
self.modelUsages = modelUsages
}
}
/// 使
public struct ModelUsage: Codable, Sendable, Equatable {
///
public let modelName: String
///
public let requests: Int
public init(modelName: String, requests: Int) {
self.modelName = modelName
self.requests = requests
}
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
public struct TokenUsage: Codable, Sendable, Equatable {
public let outputTokens: Int?
public let inputTokens: Int?
public let totalCents: Double
public let cacheWriteTokens: Int?
public let cacheReadTokens: Int?
public var totalTokens: Int {
return (outputTokens ?? 0) + (inputTokens ?? 0) + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0)
}
public init(
outputTokens: Int?,
inputTokens: Int?,
totalCents: Double,
cacheWriteTokens: Int?,
cacheReadTokens: Int?
) {
self.outputTokens = outputTokens
self.inputTokens = inputTokens
self.totalCents = totalCents
self.cacheWriteTokens = cacheWriteTokens
self.cacheReadTokens = cacheReadTokens
}
}

View File

@@ -0,0 +1,92 @@
import Foundation
public struct UsageEvent: Codable, Sendable, Equatable {
public let occurredAtMs: String
public let modelName: String
public let kind: String
public let requestCostCount: Int
public let usageCostDisplay: String
///
public let usageCostCents: Int
public let isTokenBased: Bool
public let userDisplayName: String
public let cursorTokenFee: Double
public let tokenUsage: TokenUsage?
public var brand: AIModelBrands {
AIModelBrands.brand(for: self.modelName)
}
///
public var calculatedCostDisplay: String {
let totalCents = (tokenUsage?.totalCents ?? 0.0) + cursorTokenFee
let dollars = totalCents / 100.0
return String(format: "$%.2f", dollars)
}
public init(
occurredAtMs: String,
modelName: String,
kind: String,
requestCostCount: Int,
usageCostDisplay: String,
usageCostCents: Int = 0,
isTokenBased: Bool,
userDisplayName: String,
cursorTokenFee: Double = 0.0,
tokenUsage: TokenUsage? = nil
) {
self.occurredAtMs = occurredAtMs
self.modelName = modelName
self.kind = kind
self.requestCostCount = requestCostCount
self.usageCostDisplay = usageCostDisplay
self.usageCostCents = usageCostCents
self.isTokenBased = isTokenBased
self.userDisplayName = userDisplayName
self.cursorTokenFee = cursorTokenFee
self.tokenUsage = tokenUsage
}
private enum CodingKeys: String, CodingKey {
case occurredAtMs
case modelName
case kind
case requestCostCount
case usageCostDisplay
case usageCostCents
case isTokenBased
case userDisplayName
case teamDisplayName
case cursorTokenFee
case tokenUsage
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.occurredAtMs = try container.decode(String.self, forKey: .occurredAtMs)
self.modelName = try container.decode(String.self, forKey: .modelName)
self.kind = try container.decode(String.self, forKey: .kind)
self.requestCostCount = try container.decode(Int.self, forKey: .requestCostCount)
self.usageCostDisplay = try container.decode(String.self, forKey: .usageCostDisplay)
self.usageCostCents = (try? container.decode(Int.self, forKey: .usageCostCents)) ?? 0
self.isTokenBased = try container.decode(Bool.self, forKey: .isTokenBased)
self.userDisplayName = try container.decode(String.self, forKey: .userDisplayName)
self.cursorTokenFee = (try? container.decode(Double.self, forKey: .cursorTokenFee)) ?? 0.0
self.tokenUsage = try container.decodeIfPresent(TokenUsage.self, forKey: .tokenUsage)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.occurredAtMs, forKey: .occurredAtMs)
try container.encode(self.modelName, forKey: .modelName)
try container.encode(self.kind, forKey: .kind)
try container.encode(self.requestCostCount, forKey: .requestCostCount)
try container.encode(self.usageCostDisplay, forKey: .usageCostDisplay)
try container.encode(self.usageCostCents, forKey: .usageCostCents)
try container.encode(self.isTokenBased, forKey: .isTokenBased)
try container.encode(self.userDisplayName, forKey: .userDisplayName)
try container.encode(self.cursorTokenFee, forKey: .cursorTokenFee)
try container.encodeIfPresent(self.tokenUsage, forKey: .tokenUsage)
}
}

View File

@@ -0,0 +1,68 @@
import Foundation
import VibeviewerCore
public struct UsageEventHourGroup: Identifiable, Sendable, Equatable {
public let id: Date
public let hourStart: Date
public let title: String
public let events: [UsageEvent]
public var totalRequests: Int { events.map(\.requestCostCount).reduce(0, +) }
public var totalCostCents: Int { events.map(\.usageCostCents).reduce(0, +) }
///
public var calculatedTotalCostDisplay: String {
let totalCents = events.reduce(0.0) { sum, event in
sum + (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee
}
let dollars = totalCents / 100.0
return String(format: "$%.2f", dollars)
}
public init(id: Date, hourStart: Date, title: String, events: [UsageEvent]) {
self.id = id
self.hourStart = hourStart
self.title = title
self.events = events
}
}
public extension Array where Element == UsageEvent {
func groupedByHour(calendar: Calendar = .current) -> [UsageEventHourGroup] {
var buckets: [Date: [UsageEvent]] = [:]
for event in self {
guard let date = DateUtils.date(fromMillisecondsString: event.occurredAtMs),
let hourStart = calendar.dateInterval(of: .hour, for: date)?.start else { continue }
buckets[hourStart, default: []].append(event)
}
let sortedStarts = buckets.keys.sorted(by: >)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd HH:00"
return sortedStarts.map { start in
UsageEventHourGroup(
id: start,
hourStart: start,
title: formatter.string(from: start),
events: buckets[start] ?? []
)
}
}
}
public enum UsageEventHourGrouper {
public static func groupByHour(_ events: [UsageEvent], calendar: Calendar = .current) -> [UsageEventHourGroup] {
events.groupedByHour(calendar: calendar)
}
}
public extension UsageEventHourGroup {
static func group(_ events: [UsageEvent], calendar: Calendar = .current) -> [UsageEventHourGroup] {
events.groupedByHour(calendar: calendar)
}
}

View File

@@ -0,0 +1,22 @@
import Foundation
public struct UsageOverview: Sendable, Equatable {
public struct ModelUsage: Sendable, Equatable {
public let modelName: String
/// token
public let tokensUsed: Int?
public init(modelName: String, tokensUsed: Int? = nil) {
self.modelName = modelName
self.tokensUsed = tokensUsed
}
}
public let startOfMonthMs: Date
public let models: [ModelUsage]
public init(startOfMonthMs: Date, models: [ModelUsage]) {
self.startOfMonthMs = startOfMonthMs
self.models = models
}
}

View File

@@ -0,0 +1,84 @@
import Foundation
public struct UsageSummary: Sendable, Equatable, Codable {
public let billingCycleStart: Date
public let billingCycleEnd: Date
public let membershipType: MembershipType
public let limitType: String
public let individualUsage: IndividualUsage
public let teamUsage: TeamUsage?
public init(
billingCycleStart: Date,
billingCycleEnd: Date,
membershipType: MembershipType,
limitType: String,
individualUsage: IndividualUsage,
teamUsage: TeamUsage? = nil
) {
self.billingCycleStart = billingCycleStart
self.billingCycleEnd = billingCycleEnd
self.membershipType = membershipType
self.limitType = limitType
self.individualUsage = individualUsage
self.teamUsage = teamUsage
}
}
public struct IndividualUsage: Sendable, Equatable, Codable {
public let plan: PlanUsage
public let onDemand: OnDemandUsage?
public init(plan: PlanUsage, onDemand: OnDemandUsage? = nil) {
self.plan = plan
self.onDemand = onDemand
}
}
public struct PlanUsage: Sendable, Equatable, Codable {
public let used: Int
public let limit: Int
public let remaining: Int
public let breakdown: PlanBreakdown
public init(used: Int, limit: Int, remaining: Int, breakdown: PlanBreakdown) {
self.used = used
self.limit = limit
self.remaining = remaining
self.breakdown = breakdown
}
}
public struct PlanBreakdown: Sendable, Equatable, Codable {
public let included: Int
public let bonus: Int
public let total: Int
public init(included: Int, bonus: Int, total: Int) {
self.included = included
self.bonus = bonus
self.total = total
}
}
public struct OnDemandUsage: Sendable, Equatable, Codable {
public let used: Int
public let limit: Int?
public let remaining: Int?
public let enabled: Bool
public init(used: Int, limit: Int?, remaining: Int?, enabled: Bool) {
self.used = used
self.limit = limit
self.remaining = remaining
self.enabled = enabled
}
}
public struct TeamUsage: Sendable, Equatable, Codable {
public let onDemand: OnDemandUsage
public init(onDemand: OnDemandUsage) {
self.onDemand = onDemand
}
}

View File

@@ -0,0 +1,8 @@
@testable import VibeviewerModel
import XCTest
final class VibeviewerModelTests: XCTestCase {
func testExample() {
XCTAssertTrue(true)
}
}