蜂鸟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:
42
参考计费/Packages/VibeviewerModel/Package.resolved
Normal file
42
参考计费/Packages/VibeviewerModel/Package.resolved
Normal 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
|
||||
}
|
||||
19
参考计费/Packages/VibeviewerModel/Package.swift
Normal file
19
参考计费/Packages/VibeviewerModel/Package.swift
Normal 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"])
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
public enum AppAppearance: String, Codable, Sendable, Equatable, CaseIterable, Hashable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@testable import VibeviewerModel
|
||||
import XCTest
|
||||
|
||||
final class VibeviewerModelTests: XCTestCase {
|
||||
func testExample() {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user