蜂鸟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/VibeviewerAPI/Package.resolved
Normal file
42
参考计费/Packages/VibeviewerAPI/Package.resolved
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"originHash" : "1c8e9c91c686aa90c1a15c428e52c1d8c1ad02100fe3069d87feb1d4fafef7d1",
|
||||
"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
|
||||
}
|
||||
34
参考计费/Packages/VibeviewerAPI/Package.swift
Normal file
34
参考计费/Packages/VibeviewerAPI/Package.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
// swift-tools-version:5.10
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "VibeviewerAPI",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.library(name: "VibeviewerAPI", targets: ["VibeviewerAPI"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../VibeviewerCore"),
|
||||
.package(path: "../VibeviewerModel"),
|
||||
.package(url: "https://github.com/Moya/Moya.git", .upToNextMajor(from: "15.0.0")),
|
||||
.package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.8.0")),
|
||||
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "VibeviewerAPI",
|
||||
dependencies: [
|
||||
"VibeviewerCore",
|
||||
"VibeviewerModel",
|
||||
.product(name: "Moya", package: "Moya"),
|
||||
.product(name: "Alamofire", package: "Alamofire"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "VibeviewerAPITests",
|
||||
dependencies: ["VibeviewerAPI"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Cursor API 返回的聚合使用事件响应 DTO
|
||||
struct CursorAggregatedUsageEventsResponse: Decodable, Sendable, Equatable {
|
||||
let aggregations: [CursorModelAggregation]
|
||||
let totalInputTokens: String
|
||||
let totalOutputTokens: String
|
||||
let totalCacheWriteTokens: String
|
||||
let totalCacheReadTokens: String
|
||||
let totalCostCents: Double
|
||||
|
||||
init(
|
||||
aggregations: [CursorModelAggregation],
|
||||
totalInputTokens: String,
|
||||
totalOutputTokens: String,
|
||||
totalCacheWriteTokens: String,
|
||||
totalCacheReadTokens: String,
|
||||
totalCostCents: Double
|
||||
) {
|
||||
self.aggregations = aggregations
|
||||
self.totalInputTokens = totalInputTokens
|
||||
self.totalOutputTokens = totalOutputTokens
|
||||
self.totalCacheWriteTokens = totalCacheWriteTokens
|
||||
self.totalCacheReadTokens = totalCacheReadTokens
|
||||
self.totalCostCents = totalCostCents
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个模型的聚合数据 DTO
|
||||
struct CursorModelAggregation: Decodable, Sendable, Equatable {
|
||||
let modelIntent: String
|
||||
let inputTokens: String?
|
||||
let outputTokens: String?
|
||||
let cacheWriteTokens: String?
|
||||
let cacheReadTokens: String?
|
||||
let totalCents: Double
|
||||
|
||||
init(
|
||||
modelIntent: String,
|
||||
inputTokens: String?,
|
||||
outputTokens: String?,
|
||||
cacheWriteTokens: String?,
|
||||
cacheReadTokens: String?,
|
||||
totalCents: Double
|
||||
) {
|
||||
self.modelIntent = modelIntent
|
||||
self.inputTokens = inputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.cacheWriteTokens = cacheWriteTokens
|
||||
self.cacheReadTokens = cacheReadTokens
|
||||
self.totalCents = totalCents
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
/// Cursor API 返回的当前计费周期响应 DTO
|
||||
struct CursorCurrentBillingCycleResponse: Decodable, Sendable, Equatable {
|
||||
let startDateEpochMillis: String
|
||||
let endDateEpochMillis: String
|
||||
|
||||
init(
|
||||
startDateEpochMillis: String,
|
||||
endDateEpochMillis: String
|
||||
) {
|
||||
self.startDateEpochMillis = startDateEpochMillis
|
||||
self.endDateEpochMillis = endDateEpochMillis
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
struct CursorTokenUsage: Decodable, Sendable, Equatable {
|
||||
let outputTokens: Int?
|
||||
let inputTokens: Int?
|
||||
let totalCents: Double?
|
||||
let cacheWriteTokens: Int?
|
||||
let cacheReadTokens: Int?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
struct CursorFilteredUsageEvent: Decodable, Sendable, Equatable {
|
||||
let timestamp: String
|
||||
let model: String
|
||||
let kind: String
|
||||
let requestsCosts: Double?
|
||||
let usageBasedCosts: String
|
||||
let isTokenBasedCall: Bool
|
||||
let owningUser: String
|
||||
let cursorTokenFee: Double
|
||||
let tokenUsage: CursorTokenUsage
|
||||
|
||||
init(
|
||||
timestamp: String,
|
||||
model: String,
|
||||
kind: String,
|
||||
requestsCosts: Double?,
|
||||
usageBasedCosts: String,
|
||||
isTokenBasedCall: Bool,
|
||||
owningUser: String,
|
||||
cursorTokenFee: Double,
|
||||
tokenUsage: CursorTokenUsage
|
||||
) {
|
||||
self.timestamp = timestamp
|
||||
self.model = model
|
||||
self.kind = kind
|
||||
self.requestsCosts = requestsCosts
|
||||
self.usageBasedCosts = usageBasedCosts
|
||||
self.isTokenBasedCall = isTokenBasedCall
|
||||
self.owningUser = owningUser
|
||||
self.cursorTokenFee = cursorTokenFee
|
||||
self.tokenUsage = tokenUsage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
struct CursorFilteredUsageResponse: Decodable, Sendable, Equatable {
|
||||
let totalUsageEventsCount: Int?
|
||||
let usageEventsDisplay: [CursorFilteredUsageEvent]?
|
||||
|
||||
init(totalUsageEventsCount: Int? = nil, usageEventsDisplay: [CursorFilteredUsageEvent]? = nil) {
|
||||
self.totalUsageEventsCount = totalUsageEventsCount
|
||||
self.usageEventsDisplay = usageEventsDisplay
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
struct CursorMeResponse: Decodable, Sendable {
|
||||
let authId: String
|
||||
let userId: Int
|
||||
let email: String
|
||||
let workosId: String
|
||||
let teamId: Int?
|
||||
let isEnterpriseUser: Bool
|
||||
|
||||
init(authId: String, userId: Int, email: String, workosId: String, teamId: Int?, isEnterpriseUser: Bool) {
|
||||
self.authId = authId
|
||||
self.userId = userId
|
||||
self.email = email
|
||||
self.workosId = workosId
|
||||
self.teamId = teamId
|
||||
self.isEnterpriseUser = isEnterpriseUser
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
struct CursorModelUsage: Decodable, Sendable {
|
||||
let numTokens: Int
|
||||
let maxTokenUsage: Int?
|
||||
|
||||
init(numTokens: Int, maxTokenUsage: Int?) {
|
||||
self.numTokens = numTokens
|
||||
self.maxTokenUsage = maxTokenUsage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// Cursor API 团队模型分析响应 DTO
|
||||
public struct CursorTeamModelsAnalyticsResponse: Codable, Sendable, Equatable {
|
||||
public let meta: [Meta]
|
||||
public let data: [DataItem]
|
||||
|
||||
public init(meta: [Meta], data: [DataItem]) {
|
||||
self.meta = meta
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
|
||||
/// 元数据信息
|
||||
public struct Meta: Codable, Sendable, Equatable {
|
||||
public let name: String
|
||||
public let type: String
|
||||
|
||||
public init(name: String, type: String) {
|
||||
self.name = name
|
||||
self.type = type
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据项
|
||||
public struct DataItem: Codable, Sendable, Equatable {
|
||||
public let date: String
|
||||
public let modelBreakdown: [String: ModelStats]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case modelBreakdown = "model_breakdown"
|
||||
}
|
||||
|
||||
public init(date: String, modelBreakdown: [String: ModelStats]) {
|
||||
self.date = date
|
||||
self.modelBreakdown = modelBreakdown
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型统计信息
|
||||
public struct ModelStats: Codable, Sendable, Equatable {
|
||||
public let requests: UInt64
|
||||
public let users: UInt64?
|
||||
|
||||
public init(requests: UInt64, users: UInt64) {
|
||||
self.requests = requests
|
||||
self.users = users
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
struct CursorTeamSpendResponse: Decodable, Sendable, Equatable {
|
||||
let teamMemberSpend: [CursorTeamMemberSpend]
|
||||
let subscriptionCycleStart: String
|
||||
let totalMembers: Int
|
||||
let totalPages: Int
|
||||
let totalByRole: [CursorRoleCount]
|
||||
let nextCycleStart: String
|
||||
let limitedUserCount: Int
|
||||
let maxUserSpendCents: Int?
|
||||
let subscriptionLimitedUsers: Int
|
||||
}
|
||||
|
||||
struct CursorTeamMemberSpend: Decodable, Sendable, Equatable {
|
||||
let userId: Int
|
||||
let email: String
|
||||
let role: String
|
||||
let hardLimitOverrideDollars: Int?
|
||||
let includedSpendCents: Int?
|
||||
let spendCents: Int?
|
||||
let fastPremiumRequests: Int?
|
||||
}
|
||||
|
||||
struct CursorRoleCount: Decodable, Sendable, Equatable {
|
||||
let role: String
|
||||
let count: Int
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
struct CursorUsageSummaryResponse: Decodable, Sendable, Equatable {
|
||||
let billingCycleStart: String
|
||||
let billingCycleEnd: String
|
||||
let membershipType: String
|
||||
let limitType: String
|
||||
let individualUsage: CursorIndividualUsage
|
||||
let teamUsage: CursorTeamUsage?
|
||||
|
||||
init(
|
||||
billingCycleStart: String,
|
||||
billingCycleEnd: String,
|
||||
membershipType: String,
|
||||
limitType: String,
|
||||
individualUsage: CursorIndividualUsage,
|
||||
teamUsage: CursorTeamUsage? = nil
|
||||
) {
|
||||
self.billingCycleStart = billingCycleStart
|
||||
self.billingCycleEnd = billingCycleEnd
|
||||
self.membershipType = membershipType
|
||||
self.limitType = limitType
|
||||
self.individualUsage = individualUsage
|
||||
self.teamUsage = teamUsage
|
||||
}
|
||||
}
|
||||
|
||||
struct CursorIndividualUsage: Decodable, Sendable, Equatable {
|
||||
let plan: CursorPlanUsage
|
||||
let onDemand: CursorOnDemandUsage?
|
||||
|
||||
init(plan: CursorPlanUsage, onDemand: CursorOnDemandUsage? = nil) {
|
||||
self.plan = plan
|
||||
self.onDemand = onDemand
|
||||
}
|
||||
}
|
||||
|
||||
struct CursorPlanUsage: Decodable, Sendable, Equatable {
|
||||
let used: Int
|
||||
let limit: Int
|
||||
let remaining: Int
|
||||
let breakdown: CursorPlanBreakdown
|
||||
|
||||
init(used: Int, limit: Int, remaining: Int, breakdown: CursorPlanBreakdown) {
|
||||
self.used = used
|
||||
self.limit = limit
|
||||
self.remaining = remaining
|
||||
self.breakdown = breakdown
|
||||
}
|
||||
}
|
||||
|
||||
struct CursorPlanBreakdown: Decodable, Sendable, Equatable {
|
||||
let included: Int
|
||||
let bonus: Int
|
||||
let total: Int
|
||||
|
||||
init(included: Int, bonus: Int, total: Int) {
|
||||
self.included = included
|
||||
self.bonus = bonus
|
||||
self.total = total
|
||||
}
|
||||
}
|
||||
|
||||
struct CursorOnDemandUsage: Decodable, Sendable, Equatable {
|
||||
let used: Int
|
||||
let limit: Int?
|
||||
let remaining: Int?
|
||||
let enabled: Bool
|
||||
|
||||
init(used: Int, limit: Int?, remaining: Int?, enabled: Bool) {
|
||||
self.used = used
|
||||
self.limit = limit
|
||||
self.remaining = remaining
|
||||
self.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
struct CursorTeamUsage: Decodable, Sendable, Equatable {
|
||||
let onDemand: CursorOnDemandUsage?
|
||||
|
||||
init(onDemand: CursorOnDemandUsage? = nil) {
|
||||
self.onDemand = onDemand
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Alamofire
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
struct RequestErrorWrapper {
|
||||
let moyaError: MoyaError
|
||||
|
||||
var afError: AFError? {
|
||||
if case let .underlying(error as AFError, _) = moyaError {
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var nsError: NSError? {
|
||||
if case let .underlying(error as NSError, _) = moyaError {
|
||||
return error
|
||||
} else if let afError {
|
||||
return afError.underlyingError as? NSError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var isRequestCancelled: Bool {
|
||||
if case .explicitlyCancelled = self.afError {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var defaultErrorMessage: String? {
|
||||
if self.nsError?.code == NSURLErrorTimedOut {
|
||||
"加载数据失败,请稍后重试"
|
||||
} else if self.nsError?.code == NSURLErrorNotConnectedToInternet {
|
||||
"无网络连接,请检查网络"
|
||||
} else {
|
||||
"加载数据失败,请稍后重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol RequestErrorHandlable {
|
||||
var errorHandlingType: RequestErrorHandlingPlugin.RequestErrorHandlingType { get }
|
||||
}
|
||||
|
||||
extension RequestErrorHandlable {
|
||||
var errorHandlingType: RequestErrorHandlingPlugin.RequestErrorHandlingType {
|
||||
.all
|
||||
}
|
||||
}
|
||||
|
||||
class RequestErrorHandlingPlugin {
|
||||
enum RequestErrorHandlingType {
|
||||
enum FilterResult {
|
||||
case handledByPlugin(message: String?)
|
||||
case shouldNotHandledByPlugin
|
||||
}
|
||||
|
||||
case connectionError // 现在包括超时和断网错误
|
||||
case all
|
||||
case allWithFilter(filter: (RequestErrorWrapper) -> FilterResult)
|
||||
|
||||
func handleError(_ error: RequestErrorWrapper, handler: (_ shouldHandle: Bool, _ message: String?) -> Void) {
|
||||
switch self {
|
||||
case .connectionError:
|
||||
if error.nsError?.code == NSURLErrorTimedOut {
|
||||
handler(true, error.defaultErrorMessage)
|
||||
} else if error.nsError?.code == NSURLErrorNotConnectedToInternet {
|
||||
handler(true, error.defaultErrorMessage)
|
||||
}
|
||||
case .all:
|
||||
handler(true, error.defaultErrorMessage)
|
||||
case let .allWithFilter(filter):
|
||||
switch filter(error) {
|
||||
case let .handledByPlugin(messsage):
|
||||
handler(true, messsage ?? error.defaultErrorMessage)
|
||||
case .shouldNotHandledByPlugin:
|
||||
handler(false, nil)
|
||||
}
|
||||
}
|
||||
handler(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RequestErrorHandlingPlugin: PluginType {
|
||||
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
|
||||
var request = request
|
||||
request.timeoutInterval = 30
|
||||
return request
|
||||
}
|
||||
|
||||
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
|
||||
let requestErrorHandleSubject: RequestErrorHandlable? =
|
||||
((target as? MultiTarget)?.target as? RequestErrorHandlable)
|
||||
?? (target as? RequestErrorHandlable)
|
||||
|
||||
guard let requestErrorHandleSubject, case let .failure(moyaError) = result else { return }
|
||||
|
||||
let errorWrapper = RequestErrorWrapper(moyaError: moyaError)
|
||||
if errorWrapper.isRequestCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
requestErrorHandleSubject.errorHandlingType.handleError(errorWrapper) { shouldHandle, message in
|
||||
if shouldHandle, let message, !message.isEmpty {
|
||||
// show error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
final class RequestHeaderConfigurationPlugin: PluginType {
|
||||
static let shared: RequestHeaderConfigurationPlugin = .init()
|
||||
|
||||
var header: [String: String] = [:]
|
||||
|
||||
// MARK: Plugin
|
||||
|
||||
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
|
||||
var request = request
|
||||
request.allHTTPHeaderFields?.merge(self.header) { _, new in new }
|
||||
return request
|
||||
}
|
||||
|
||||
func setAuthorization(_ token: String) {
|
||||
self.header["Authorization"] = "Bearer "
|
||||
}
|
||||
|
||||
func clearAuthorization() {
|
||||
self.header["Authorization"] = ""
|
||||
}
|
||||
|
||||
init() {
|
||||
self.header = [
|
||||
"Authorization": "Bearer "
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
import VibeviewerCore
|
||||
|
||||
final class SimpleNetworkLoggerPlugin {}
|
||||
|
||||
// MARK: - PluginType
|
||||
|
||||
extension SimpleNetworkLoggerPlugin: PluginType {
|
||||
func didReceive(_ result: Result<Moya.Response, MoyaError>, target: TargetType) {
|
||||
var loggings: [String] = []
|
||||
|
||||
let targetType: TargetType.Type = if let multiTarget = target as? MultiTarget {
|
||||
type(of: multiTarget.target)
|
||||
} else {
|
||||
type(of: target)
|
||||
}
|
||||
|
||||
loggings.append("Request: \(targetType) [\(Date())]")
|
||||
|
||||
switch result {
|
||||
case let .success(success):
|
||||
loggings
|
||||
.append("URL: \(success.request?.url?.absoluteString ?? target.baseURL.absoluteString + target.path)")
|
||||
loggings.append("Method: \(target.method.rawValue)")
|
||||
if let output = success.request?.httpBody?.toPrettyPrintedJSONString() {
|
||||
loggings.append("Request body: \n\(output)")
|
||||
}
|
||||
loggings.append("Status Code: \(success.statusCode)")
|
||||
|
||||
if let output = success.data.toPrettyPrintedJSONString() {
|
||||
loggings.append("Response: \n\(output)")
|
||||
} else if let string = String(data: success.data, encoding: .utf8) {
|
||||
loggings.append("Response: \(string)")
|
||||
} else {
|
||||
loggings.append("Response: \(success.data)")
|
||||
}
|
||||
|
||||
case let .failure(failure):
|
||||
loggings
|
||||
.append("URL: \(failure.response?.request?.url?.absoluteString ?? target.baseURL.absoluteString + target.path)")
|
||||
loggings.append("Method: \(target.method.rawValue)")
|
||||
if let output = failure.response?.request?.httpBody?.toPrettyPrintedJSONString() {
|
||||
loggings.append("Request body: \n\(output)")
|
||||
}
|
||||
if let errorResponseCode = failure.response?.statusCode {
|
||||
loggings.append("Error Code: \(errorResponseCode)")
|
||||
} else {
|
||||
loggings.append("Error Code: \(failure.errorCode)")
|
||||
}
|
||||
|
||||
if let errorOutput = failure.response?.data.toPrettyPrintedJSONString() {
|
||||
loggings.append("Error Response: \n\(errorOutput)")
|
||||
}
|
||||
|
||||
loggings.append("Error detail: \(failure.localizedDescription)")
|
||||
}
|
||||
|
||||
loggings = loggings.map { "🔵 " + $0 }
|
||||
let seperator = "==================================================================="
|
||||
loggings.insert(seperator, at: 0)
|
||||
loggings.append(seperator)
|
||||
loggings.forEach { print($0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
enum APIConfig {
|
||||
static let baseURL = URL(string: "https://cursor.com")!
|
||||
static let dashboardReferer = "https://cursor.com/dashboard"
|
||||
}
|
||||
|
||||
enum APIHeadersBuilder {
|
||||
static func jsonHeaders(cookieHeader: String?) -> [String: String] {
|
||||
var h: [String: String] = [
|
||||
"accept": "*/*",
|
||||
"content-type": "application/json",
|
||||
"origin": "https://cursor.com",
|
||||
"referer": APIConfig.dashboardReferer
|
||||
]
|
||||
if let cookieHeader, !cookieHeader.isEmpty { h["Cookie"] = cookieHeader }
|
||||
return h
|
||||
}
|
||||
|
||||
static func basicHeaders(cookieHeader: String?) -> [String: String] {
|
||||
var h: [String: String] = [
|
||||
"accept": "*/*",
|
||||
"referer": APIConfig.dashboardReferer
|
||||
]
|
||||
if let cookieHeader, !cookieHeader.isEmpty { h["Cookie"] = cookieHeader }
|
||||
return h
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
import VibeviewerModel
|
||||
import VibeviewerCore
|
||||
|
||||
public enum CursorServiceError: Error {
|
||||
case sessionExpired
|
||||
}
|
||||
|
||||
protocol CursorNetworkClient {
|
||||
func decodableRequest<T: DecodableTargetType>(
|
||||
_ target: T,
|
||||
decodingStrategy: JSONDecoder.KeyDecodingStrategy
|
||||
) async throws -> T
|
||||
.ResultType
|
||||
}
|
||||
|
||||
struct DefaultCursorNetworkClient: CursorNetworkClient {
|
||||
init() {}
|
||||
|
||||
func decodableRequest<T>(_ target: T, decodingStrategy: JSONDecoder.KeyDecodingStrategy) async throws -> T
|
||||
.ResultType where T: DecodableTargetType
|
||||
{
|
||||
try await HttpClient.decodableRequest(target, decodingStrategy: decodingStrategy)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol CursorService {
|
||||
func fetchMe(cookieHeader: String) async throws -> Credentials
|
||||
func fetchUsageSummary(cookieHeader: String) async throws -> VibeviewerModel.UsageSummary
|
||||
/// 仅 Team Plan 使用:返回当前用户的 free usage(以分计)。计算方式:includedSpendCents - hardLimitOverrideDollars*100,若小于0则为0
|
||||
func fetchTeamFreeUsageCents(teamId: Int, userId: Int, cookieHeader: String) async throws -> Int
|
||||
func fetchFilteredUsageEvents(
|
||||
startDateMs: String,
|
||||
endDateMs: String,
|
||||
userId: Int,
|
||||
page: Int,
|
||||
cookieHeader: String
|
||||
) async throws -> VibeviewerModel.FilteredUsageHistory
|
||||
func fetchModelsAnalytics(
|
||||
startDate: String,
|
||||
endDate: String,
|
||||
c: String,
|
||||
cookieHeader: String
|
||||
) async throws -> VibeviewerModel.ModelsUsageChartData
|
||||
/// 获取聚合使用事件(仅限 Pro 账号,非 Team 账号)
|
||||
/// - Parameters:
|
||||
/// - teamId: 团队 ID,Pro 账号传 nil
|
||||
/// - startDate: 开始日期(毫秒时间戳)
|
||||
/// - cookieHeader: Cookie 头
|
||||
func fetchAggregatedUsageEvents(
|
||||
teamId: Int?,
|
||||
startDate: Int64,
|
||||
cookieHeader: String
|
||||
) async throws -> VibeviewerModel.AggregatedUsageEvents
|
||||
/// 获取当前计费周期
|
||||
/// - Parameter cookieHeader: Cookie 头
|
||||
func fetchCurrentBillingCycle(cookieHeader: String) async throws -> VibeviewerModel.BillingCycle
|
||||
/// 获取当前计费周期(返回原始毫秒时间戳字符串)
|
||||
/// - Parameter cookieHeader: Cookie 头
|
||||
/// - Returns: (startDateMs: String, endDateMs: String) 毫秒时间戳字符串
|
||||
func fetchCurrentBillingCycleMs(cookieHeader: String) async throws -> (startDateMs: String, endDateMs: String)
|
||||
/// 通过 Filtered Usage Events 获取模型使用量图表数据(Pro 用户替代方案)
|
||||
/// - Parameters:
|
||||
/// - startDateMs: 开始日期(毫秒时间戳)
|
||||
/// - endDateMs: 结束日期(毫秒时间戳)
|
||||
/// - userId: 用户 ID
|
||||
/// - cookieHeader: Cookie 头
|
||||
/// - Returns: 模型使用量图表数据
|
||||
func fetchModelsUsageChartFromEvents(
|
||||
startDateMs: String,
|
||||
endDateMs: String,
|
||||
userId: Int,
|
||||
cookieHeader: String
|
||||
) async throws -> VibeviewerModel.ModelsUsageChartData
|
||||
}
|
||||
|
||||
public struct DefaultCursorService: CursorService {
|
||||
private let network: CursorNetworkClient
|
||||
private let decoding: JSONDecoder.KeyDecodingStrategy
|
||||
|
||||
// Public initializer that does not expose internal protocol types
|
||||
public init(decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) {
|
||||
self.network = DefaultCursorNetworkClient()
|
||||
self.decoding = decoding
|
||||
}
|
||||
|
||||
// Internal injectable initializer for tests
|
||||
init(network: any CursorNetworkClient, decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) {
|
||||
self.network = network
|
||||
self.decoding = decoding
|
||||
}
|
||||
|
||||
private func performRequest<T: DecodableTargetType>(_ target: T) async throws -> T.ResultType {
|
||||
do {
|
||||
return try await self.network.decodableRequest(target, decodingStrategy: self.decoding)
|
||||
} catch {
|
||||
if let moyaError = error as? MoyaError,
|
||||
case let .statusCode(response) = moyaError,
|
||||
[401, 403].contains(response.statusCode)
|
||||
{
|
||||
throw CursorServiceError.sessionExpired
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func fetchMe(cookieHeader: String) async throws -> Credentials {
|
||||
let dto: CursorMeResponse = try await self.performRequest(CursorGetMeAPI(cookieHeader: cookieHeader))
|
||||
return Credentials(
|
||||
userId: dto.userId,
|
||||
workosId: dto.workosId,
|
||||
email: dto.email,
|
||||
teamId: dto.teamId ?? 0,
|
||||
cookieHeader: cookieHeader,
|
||||
isEnterpriseUser: dto.isEnterpriseUser
|
||||
)
|
||||
}
|
||||
|
||||
public func fetchUsageSummary(cookieHeader: String) async throws -> VibeviewerModel.UsageSummary {
|
||||
let dto: CursorUsageSummaryResponse = try await self.performRequest(CursorUsageSummaryAPI(cookieHeader: cookieHeader))
|
||||
|
||||
// 解析日期
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
let billingCycleStart = dateFormatter.date(from: dto.billingCycleStart) ?? Date()
|
||||
let billingCycleEnd = dateFormatter.date(from: dto.billingCycleEnd) ?? Date()
|
||||
|
||||
// 映射计划使用情况
|
||||
let planUsage = VibeviewerModel.PlanUsage(
|
||||
used: dto.individualUsage.plan.used,
|
||||
limit: dto.individualUsage.plan.limit,
|
||||
remaining: dto.individualUsage.plan.remaining,
|
||||
breakdown: VibeviewerModel.PlanBreakdown(
|
||||
included: dto.individualUsage.plan.breakdown.included,
|
||||
bonus: dto.individualUsage.plan.breakdown.bonus,
|
||||
total: dto.individualUsage.plan.breakdown.total
|
||||
)
|
||||
)
|
||||
|
||||
// 映射按需使用情况(如果存在)
|
||||
let onDemandUsage: VibeviewerModel.OnDemandUsage? = {
|
||||
guard let individualOnDemand = dto.individualUsage.onDemand else { return nil }
|
||||
if individualOnDemand.used > 0 || (individualOnDemand.limit ?? 0) > 0 {
|
||||
return VibeviewerModel.OnDemandUsage(
|
||||
used: individualOnDemand.used,
|
||||
limit: individualOnDemand.limit,
|
||||
remaining: individualOnDemand.remaining,
|
||||
enabled: individualOnDemand.enabled
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
// 映射个人使用情况
|
||||
let individualUsage = VibeviewerModel.IndividualUsage(
|
||||
plan: planUsage,
|
||||
onDemand: onDemandUsage
|
||||
)
|
||||
|
||||
// 映射团队使用情况(如果存在)
|
||||
let teamUsage: VibeviewerModel.TeamUsage? = {
|
||||
guard let teamUsageData = dto.teamUsage,
|
||||
let teamOnDemand = teamUsageData.onDemand else { return nil }
|
||||
if teamOnDemand.used > 0 || (teamOnDemand.limit ?? 0) > 0 {
|
||||
return VibeviewerModel.TeamUsage(
|
||||
onDemand: VibeviewerModel.OnDemandUsage(
|
||||
used: teamOnDemand.used,
|
||||
limit: teamOnDemand.limit,
|
||||
remaining: teamOnDemand.remaining,
|
||||
enabled: teamOnDemand.enabled
|
||||
)
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
// 映射会员类型
|
||||
let membershipType = VibeviewerModel.MembershipType(rawValue: dto.membershipType) ?? .free
|
||||
|
||||
return VibeviewerModel.UsageSummary(
|
||||
billingCycleStart: billingCycleStart,
|
||||
billingCycleEnd: billingCycleEnd,
|
||||
membershipType: membershipType,
|
||||
limitType: dto.limitType,
|
||||
individualUsage: individualUsage,
|
||||
teamUsage: teamUsage
|
||||
)
|
||||
}
|
||||
|
||||
public func fetchFilteredUsageEvents(
|
||||
startDateMs: String,
|
||||
endDateMs: String,
|
||||
userId: Int,
|
||||
page: Int,
|
||||
cookieHeader: String
|
||||
) async throws -> VibeviewerModel.FilteredUsageHistory {
|
||||
let dto: CursorFilteredUsageResponse = try await self.performRequest(
|
||||
CursorFilteredUsageAPI(
|
||||
startDateMs: startDateMs,
|
||||
endDateMs: endDateMs,
|
||||
userId: userId,
|
||||
page: page,
|
||||
cookieHeader: cookieHeader
|
||||
)
|
||||
)
|
||||
let events: [VibeviewerModel.UsageEvent] = (dto.usageEventsDisplay ?? []).map { e in
|
||||
let tokenUsage = VibeviewerModel.TokenUsage(
|
||||
outputTokens: e.tokenUsage.outputTokens,
|
||||
inputTokens: e.tokenUsage.inputTokens,
|
||||
totalCents: e.tokenUsage.totalCents ?? 0.0,
|
||||
cacheWriteTokens: e.tokenUsage.cacheWriteTokens,
|
||||
cacheReadTokens: e.tokenUsage.cacheReadTokens
|
||||
)
|
||||
|
||||
// 计算请求次数:基于 token 使用情况,如果没有 token 信息则默认为 1
|
||||
let requestCount = Self.calculateRequestCount(from: e.tokenUsage)
|
||||
|
||||
return VibeviewerModel.UsageEvent(
|
||||
occurredAtMs: e.timestamp,
|
||||
modelName: e.model,
|
||||
kind: e.kind,
|
||||
requestCostCount: requestCount,
|
||||
usageCostDisplay: e.usageBasedCosts,
|
||||
usageCostCents: Self.parseCents(fromDollarString: e.usageBasedCosts),
|
||||
isTokenBased: e.isTokenBasedCall,
|
||||
userDisplayName: e.owningUser,
|
||||
cursorTokenFee: e.cursorTokenFee,
|
||||
tokenUsage: tokenUsage
|
||||
)
|
||||
}
|
||||
return VibeviewerModel.FilteredUsageHistory(totalCount: dto.totalUsageEventsCount ?? 0, events: events)
|
||||
}
|
||||
|
||||
public func fetchTeamFreeUsageCents(teamId: Int, userId: Int, cookieHeader: String) async throws -> Int {
|
||||
let dto: CursorTeamSpendResponse = try await self.performRequest(
|
||||
CursorGetTeamSpendAPI(
|
||||
teamId: teamId,
|
||||
page: 1,
|
||||
// pageSize is hardcoded to 100
|
||||
sortBy: "name",
|
||||
sortDirection: "asc",
|
||||
cookieHeader: cookieHeader
|
||||
)
|
||||
)
|
||||
|
||||
guard let me = dto.teamMemberSpend.first(where: { $0.userId == userId }) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let included = me.includedSpendCents ?? 0
|
||||
let overrideDollars = me.hardLimitOverrideDollars ?? 0
|
||||
let freeCents = max(included - overrideDollars * 100, 0)
|
||||
return freeCents
|
||||
}
|
||||
|
||||
public func fetchModelsAnalytics(
|
||||
startDate: String,
|
||||
endDate: String,
|
||||
c: String,
|
||||
cookieHeader: String
|
||||
) async throws -> VibeviewerModel.ModelsUsageChartData {
|
||||
let dto: CursorTeamModelsAnalyticsResponse = try await self.performRequest(
|
||||
CursorTeamModelsAnalyticsAPI(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
c: c,
|
||||
cookieHeader: cookieHeader
|
||||
)
|
||||
)
|
||||
return mapToModelsUsageChartData(dto)
|
||||
}
|
||||
|
||||
public func fetchAggregatedUsageEvents(
|
||||
teamId: Int?,
|
||||
startDate: Int64,
|
||||
cookieHeader: String
|
||||
) async throws -> VibeviewerModel.AggregatedUsageEvents {
|
||||
let dto: CursorAggregatedUsageEventsResponse = try await self.performRequest(
|
||||
CursorAggregatedUsageEventsAPI(
|
||||
teamId: teamId,
|
||||
startDate: startDate,
|
||||
cookieHeader: cookieHeader
|
||||
)
|
||||
)
|
||||
return mapToAggregatedUsageEvents(dto)
|
||||
}
|
||||
|
||||
public func fetchCurrentBillingCycle(cookieHeader: String) async throws -> VibeviewerModel.BillingCycle {
|
||||
let dto: CursorCurrentBillingCycleResponse = try await self.performRequest(
|
||||
CursorCurrentBillingCycleAPI(cookieHeader: cookieHeader)
|
||||
)
|
||||
return mapToBillingCycle(dto)
|
||||
}
|
||||
|
||||
public func fetchCurrentBillingCycleMs(cookieHeader: String) async throws -> (startDateMs: String, endDateMs: String) {
|
||||
let dto: CursorCurrentBillingCycleResponse = try await self.performRequest(
|
||||
CursorCurrentBillingCycleAPI(cookieHeader: cookieHeader)
|
||||
)
|
||||
return (startDateMs: dto.startDateEpochMillis, endDateMs: dto.endDateEpochMillis)
|
||||
}
|
||||
|
||||
public func fetchModelsUsageChartFromEvents(
|
||||
startDateMs: String,
|
||||
endDateMs: String,
|
||||
userId: Int,
|
||||
cookieHeader: String
|
||||
) async throws -> VibeviewerModel.ModelsUsageChartData {
|
||||
// 一次性获取 700 条数据(7 页,每页 100 条)
|
||||
var allEvents: [VibeviewerModel.UsageEvent] = []
|
||||
let maxPages = 7
|
||||
|
||||
// 并发获取所有页面的数据
|
||||
try await withThrowingTaskGroup(of: (page: Int, history: VibeviewerModel.FilteredUsageHistory).self) { group in
|
||||
for page in 1...maxPages {
|
||||
group.addTask {
|
||||
let history = try await self.fetchFilteredUsageEvents(
|
||||
startDateMs: startDateMs,
|
||||
endDateMs: endDateMs,
|
||||
userId: userId,
|
||||
page: page,
|
||||
cookieHeader: cookieHeader
|
||||
)
|
||||
return (page: page, history: history)
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有结果并按页码排序
|
||||
var results: [(page: Int, history: VibeviewerModel.FilteredUsageHistory)] = []
|
||||
for try await result in group {
|
||||
results.append(result)
|
||||
}
|
||||
results.sort { $0.page < $1.page }
|
||||
|
||||
// 合并所有事件
|
||||
for result in results {
|
||||
allEvents.append(contentsOf: result.history.events)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 ModelsUsageChartData
|
||||
return convertEventsToModelsUsageChart(events: allEvents, startDateMs: startDateMs, endDateMs: endDateMs)
|
||||
}
|
||||
|
||||
/// 映射当前计费周期 DTO 到领域模型
|
||||
private func mapToBillingCycle(_ dto: CursorCurrentBillingCycleResponse) -> VibeviewerModel.BillingCycle {
|
||||
let startDate = Date.fromMillisecondsString(dto.startDateEpochMillis) ?? Date()
|
||||
let endDate = Date.fromMillisecondsString(dto.endDateEpochMillis) ?? Date()
|
||||
return VibeviewerModel.BillingCycle(
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
}
|
||||
|
||||
/// 映射聚合使用事件 DTO 到领域模型
|
||||
private func mapToAggregatedUsageEvents(_ dto: CursorAggregatedUsageEventsResponse) -> VibeviewerModel.AggregatedUsageEvents {
|
||||
let aggregations = dto.aggregations.map { agg in
|
||||
VibeviewerModel.ModelAggregation(
|
||||
modelIntent: agg.modelIntent,
|
||||
inputTokens: Int(agg.inputTokens ?? "0") ?? 0,
|
||||
outputTokens: Int(agg.outputTokens ?? "0") ?? 0,
|
||||
cacheWriteTokens: Int(agg.cacheWriteTokens ?? "0") ?? 0,
|
||||
cacheReadTokens: Int(agg.cacheReadTokens ?? "0") ?? 0,
|
||||
totalCents: agg.totalCents
|
||||
)
|
||||
}
|
||||
|
||||
return VibeviewerModel.AggregatedUsageEvents(
|
||||
aggregations: aggregations,
|
||||
totalInputTokens: Int(dto.totalInputTokens) ?? 0,
|
||||
totalOutputTokens: Int(dto.totalOutputTokens) ?? 0,
|
||||
totalCacheWriteTokens: Int(dto.totalCacheWriteTokens) ?? 0,
|
||||
totalCacheReadTokens: Int(dto.totalCacheReadTokens) ?? 0,
|
||||
totalCostCents: dto.totalCostCents
|
||||
)
|
||||
}
|
||||
|
||||
/// 映射模型分析 DTO 到业务层柱状图数据
|
||||
private func mapToModelsUsageChartData(_ dto: CursorTeamModelsAnalyticsResponse) -> VibeviewerModel.ModelsUsageChartData {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = .current
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
// 将 DTO 数据转换为字典,方便查找
|
||||
var dataDict: [String: VibeviewerModel.ModelsUsageChartData.DataPoint] = [:]
|
||||
for item in dto.data {
|
||||
let dateLabel = formatDateLabelForChart(from: item.date)
|
||||
let modelUsages = item.modelBreakdown
|
||||
.map { (modelName, stats) in
|
||||
VibeviewerModel.ModelsUsageChartData.ModelUsage(
|
||||
modelName: modelName,
|
||||
requests: Int(stats.requests)
|
||||
)
|
||||
}
|
||||
.sorted { $0.requests > $1.requests }
|
||||
|
||||
dataDict[item.date] = VibeviewerModel.ModelsUsageChartData.DataPoint(
|
||||
date: item.date,
|
||||
dateLabel: dateLabel,
|
||||
modelUsages: modelUsages
|
||||
)
|
||||
}
|
||||
|
||||
// 生成最近7天的日期范围
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
var allDates: [Date] = []
|
||||
|
||||
for i in (0..<7).reversed() {
|
||||
if let date = calendar.date(byAdding: .day, value: -i, to: today) {
|
||||
allDates.append(date)
|
||||
}
|
||||
}
|
||||
|
||||
// 补足缺失的日期
|
||||
let dataPoints = allDates.map { date -> VibeviewerModel.ModelsUsageChartData.DataPoint in
|
||||
let dateString = formatter.string(from: date)
|
||||
|
||||
// 如果该日期有数据,使用现有数据;否则创建空数据点
|
||||
if let existingData = dataDict[dateString] {
|
||||
return existingData
|
||||
} else {
|
||||
let dateLabel = formatDateLabelForChart(from: dateString)
|
||||
return VibeviewerModel.ModelsUsageChartData.DataPoint(
|
||||
date: dateString,
|
||||
dateLabel: dateLabel,
|
||||
modelUsages: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return VibeviewerModel.ModelsUsageChartData(dataPoints: dataPoints)
|
||||
}
|
||||
|
||||
/// 将 YYYY-MM-DD 格式的日期字符串转换为 MM/dd 格式的图表标签
|
||||
private func formatDateLabelForChart(from dateString: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = .current
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
guard let date = formatter.date(from: dateString) else {
|
||||
return dateString
|
||||
}
|
||||
|
||||
let labelFormatter = DateFormatter()
|
||||
labelFormatter.locale = .current
|
||||
labelFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
labelFormatter.dateFormat = "MM/dd"
|
||||
return labelFormatter.string(from: date)
|
||||
}
|
||||
|
||||
/// 将使用事件列表转换为模型使用量图表数据
|
||||
/// - Parameters:
|
||||
/// - events: 使用事件列表
|
||||
/// - startDateMs: 开始日期(毫秒时间戳)
|
||||
/// - endDateMs: 结束日期(毫秒时间戳)
|
||||
/// - Returns: 模型使用量图表数据(确保至少7天)
|
||||
private func convertEventsToModelsUsageChart(
|
||||
events: [VibeviewerModel.UsageEvent],
|
||||
startDateMs: String,
|
||||
endDateMs: String
|
||||
) -> VibeviewerModel.ModelsUsageChartData {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = .current
|
||||
// 使用本地时区按“自然日”分组,避免凌晨时段被算到前一天(UTC)里
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
// 解析开始和结束日期
|
||||
guard let startMs = Int64(startDateMs),
|
||||
let endMs = Int64(endDateMs) else {
|
||||
return VibeviewerModel.ModelsUsageChartData(dataPoints: [])
|
||||
}
|
||||
|
||||
let startDate = Date(timeIntervalSince1970: TimeInterval(startMs) / 1000.0)
|
||||
let originalEndDate = Date(timeIntervalSince1970: TimeInterval(endMs) / 1000.0)
|
||||
let calendar = Calendar.current
|
||||
|
||||
// 为了避免 X 轴出现“未来一天”的空数据(例如今天是 24 号却出现 25 号),
|
||||
// 这里将用于生成日期刻度的结束日期截断到“今天 00:00”,
|
||||
// 但事件本身的时间范围仍然由后端返回的数据决定。
|
||||
let startOfToday = calendar.startOfDay(for: Date())
|
||||
let endDate: Date = originalEndDate > startOfToday ? startOfToday : originalEndDate
|
||||
|
||||
// 生成日期范围内的所有日期(从 startDate 到 endDate,均为自然日)
|
||||
var allDates: [Date] = []
|
||||
var currentDate = startDate
|
||||
|
||||
while currentDate <= endDate {
|
||||
allDates.append(currentDate)
|
||||
guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break }
|
||||
currentDate = nextDate
|
||||
}
|
||||
|
||||
// 如果数据不足7天,从今天往前补足7天
|
||||
if allDates.count < 7 {
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
allDates = []
|
||||
for i in (0..<7).reversed() {
|
||||
if let date = calendar.date(byAdding: .day, value: -i, to: today) {
|
||||
allDates.append(date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期分组统计每个模型的请求次数
|
||||
// dateString -> modelName -> requestCount
|
||||
var dateModelStats: [String: [String: Int]] = [:]
|
||||
|
||||
// 初始化所有日期
|
||||
for date in allDates {
|
||||
let dateString = formatter.string(from: date)
|
||||
dateModelStats[dateString] = [:]
|
||||
}
|
||||
|
||||
// 统计事件
|
||||
for event in events {
|
||||
guard let eventMs = Int64(event.occurredAtMs) else { continue }
|
||||
let eventDate = Date(timeIntervalSince1970: TimeInterval(eventMs) / 1000.0)
|
||||
let dateString = formatter.string(from: eventDate)
|
||||
|
||||
// 如果日期在范围内,统计
|
||||
if dateModelStats[dateString] != nil {
|
||||
let modelName = event.modelName
|
||||
let currentCount = dateModelStats[dateString]?[modelName] ?? 0
|
||||
dateModelStats[dateString]?[modelName] = currentCount + event.requestCostCount
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为 DataPoint 数组
|
||||
let dataPoints = allDates.map { date -> VibeviewerModel.ModelsUsageChartData.DataPoint in
|
||||
let dateString = formatter.string(from: date)
|
||||
let dateLabel = formatDateLabelForChart(from: dateString)
|
||||
|
||||
let modelStats = dateModelStats[dateString] ?? [:]
|
||||
let modelUsages = modelStats
|
||||
.map { (modelName, requests) in
|
||||
VibeviewerModel.ModelsUsageChartData.ModelUsage(
|
||||
modelName: modelName,
|
||||
requests: requests
|
||||
)
|
||||
}
|
||||
.sorted { $0.requests > $1.requests } // 按请求数降序排序
|
||||
|
||||
return VibeviewerModel.ModelsUsageChartData.DataPoint(
|
||||
date: dateString,
|
||||
dateLabel: dateLabel,
|
||||
modelUsages: modelUsages
|
||||
)
|
||||
}
|
||||
|
||||
return VibeviewerModel.ModelsUsageChartData(dataPoints: dataPoints)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension DefaultCursorService {
|
||||
static func parseCents(fromDollarString s: String) -> Int {
|
||||
// "$0.04" -> 4
|
||||
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let idx = trimmed.firstIndex(where: { ($0 >= "0" && $0 <= "9") || $0 == "." }) else { return 0 }
|
||||
let numberPart = trimmed[idx...]
|
||||
guard let value = Double(numberPart) else { return 0 }
|
||||
return Int((value * 100.0).rounded())
|
||||
}
|
||||
|
||||
static func calculateRequestCount(from tokenUsage: CursorTokenUsage) -> Int {
|
||||
// 基于 token 使用情况计算请求次数
|
||||
// 如果有 output tokens 或 input tokens,说明有实际的请求
|
||||
let hasOutputTokens = (tokenUsage.outputTokens ?? 0) > 0
|
||||
let hasInputTokens = (tokenUsage.inputTokens ?? 0) > 0
|
||||
|
||||
if hasOutputTokens || hasInputTokens {
|
||||
// 如果有 token 使用,至少算作 1 次请求
|
||||
return 1
|
||||
} else {
|
||||
// 如果没有 token 使用,可能是缓存读取或其他类型的请求
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import Alamofire
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, *)
|
||||
enum HttpClient {
|
||||
private static var _provider: MoyaProvider<MultiTarget>?
|
||||
|
||||
static var provider: MoyaProvider<MultiTarget> {
|
||||
if _provider == nil {
|
||||
_provider = createProvider()
|
||||
}
|
||||
return _provider!
|
||||
}
|
||||
|
||||
private static func createProvider() -> MoyaProvider<MultiTarget> {
|
||||
var plugins: [PluginType] = []
|
||||
plugins.append(SimpleNetworkLoggerPlugin())
|
||||
plugins.append(RequestErrorHandlingPlugin())
|
||||
|
||||
// 创建完全不验证 SSL 的配置
|
||||
let configuration = URLSessionConfiguration.af.default
|
||||
let session = Session(
|
||||
configuration: configuration,
|
||||
serverTrustManager: nil
|
||||
)
|
||||
|
||||
return MoyaProvider<MultiTarget>(session: session, plugins: plugins)
|
||||
}
|
||||
|
||||
// 用来防止mockprovider释放
|
||||
private static var _mockProvider: MoyaProvider<MultiTarget>!
|
||||
|
||||
static func mockProvider(_ reponseType: MockResponseType) -> MoyaProvider<MultiTarget> {
|
||||
let plugins = [NetworkLoggerPlugin(configuration: .init(logOptions: .successResponseBody))]
|
||||
let endpointClosure: (MultiTarget) -> Endpoint =
|
||||
switch reponseType {
|
||||
case let .success(data):
|
||||
{ (target: MultiTarget) -> Endpoint in
|
||||
Endpoint(
|
||||
url: URL(target: target).absoluteString,
|
||||
sampleResponseClosure: { .networkResponse(200, data ?? target.sampleData) },
|
||||
method: target.method,
|
||||
task: target.task,
|
||||
httpHeaderFields: target.headers
|
||||
)
|
||||
}
|
||||
case let .failure(error):
|
||||
{ (target: MultiTarget) -> Endpoint in
|
||||
Endpoint(
|
||||
url: URL(target: target).absoluteString,
|
||||
sampleResponseClosure: {
|
||||
.networkError(error ?? NSError(domain: "mock error", code: -1))
|
||||
},
|
||||
method: target.method,
|
||||
task: target.task,
|
||||
httpHeaderFields: target.headers
|
||||
)
|
||||
}
|
||||
}
|
||||
let provider = MoyaProvider<MultiTarget>(
|
||||
endpointClosure: endpointClosure,
|
||||
stubClosure: MoyaProvider.delayedStub(2),
|
||||
plugins: plugins
|
||||
)
|
||||
self._mockProvider = provider
|
||||
return provider
|
||||
}
|
||||
|
||||
enum MockResponseType {
|
||||
case success(Data?)
|
||||
case failure(NSError?)
|
||||
}
|
||||
|
||||
enum ProviderType {
|
||||
case normal
|
||||
case mockSuccess(Data?)
|
||||
case mockFailure(NSError?)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func decodableRequest<T: DecodableTargetType>(
|
||||
providerType: ProviderType = .normal,
|
||||
decodingStrategy: JSONDecoder
|
||||
.KeyDecodingStrategy = .useDefaultKeys,
|
||||
_ target: T,
|
||||
callbackQueue: DispatchQueue? = nil,
|
||||
completion: @escaping (_ result: Result<T.ResultType, Error>)
|
||||
-> Void
|
||||
) -> Moya.Cancellable {
|
||||
let provider: MoyaProvider<MultiTarget> =
|
||||
switch providerType {
|
||||
case .normal:
|
||||
self.provider
|
||||
case let .mockSuccess(data):
|
||||
self.mockProvider(.success(data))
|
||||
case let .mockFailure(error):
|
||||
self.mockProvider(.failure(error))
|
||||
}
|
||||
return provider.decodableRequest(
|
||||
target,
|
||||
decodingStrategy: decodingStrategy,
|
||||
callbackQueue: callbackQueue,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func request(
|
||||
providerType: ProviderType = .normal,
|
||||
_ target: some TargetType,
|
||||
callbackQueue: DispatchQueue? = nil,
|
||||
progressHandler: ProgressBlock? = nil,
|
||||
completion: @escaping (_ result: Result<Data, Error>) -> Void
|
||||
) -> Moya.Cancellable {
|
||||
let provider: MoyaProvider<MultiTarget> =
|
||||
switch providerType {
|
||||
case .normal:
|
||||
self.provider
|
||||
case let .mockSuccess(data):
|
||||
self.mockProvider(.success(data))
|
||||
case let .mockFailure(error):
|
||||
self.mockProvider(.failure(error))
|
||||
}
|
||||
return
|
||||
provider
|
||||
.request(MultiTarget(target), callbackQueue: callbackQueue, progress: progressHandler) {
|
||||
result in
|
||||
switch result {
|
||||
case let .success(rsp):
|
||||
completion(.success(rsp.data))
|
||||
case let .failure(error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func request(
|
||||
providerType: ProviderType = .normal,
|
||||
_ target: some TargetType,
|
||||
callbackQueue: DispatchQueue? = nil,
|
||||
progressHandler: ProgressBlock? = nil,
|
||||
completion: @escaping (_ result: Result<Response, Error>) -> Void
|
||||
) -> Moya.Cancellable {
|
||||
let provider: MoyaProvider<MultiTarget> =
|
||||
switch providerType {
|
||||
case .normal:
|
||||
self.provider
|
||||
case let .mockSuccess(data):
|
||||
self.mockProvider(.success(data))
|
||||
case let .mockFailure(error):
|
||||
self.mockProvider(.failure(error))
|
||||
}
|
||||
return
|
||||
provider
|
||||
.request(MultiTarget(target), callbackQueue: callbackQueue, progress: progressHandler) {
|
||||
result in
|
||||
switch result {
|
||||
case let .success(rsp):
|
||||
completion(.success(rsp))
|
||||
case let .failure(error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Async
|
||||
|
||||
static func decodableRequest<T: DecodableTargetType>(
|
||||
_ target: T,
|
||||
decodingStrategy: JSONDecoder
|
||||
.KeyDecodingStrategy = .useDefaultKeys
|
||||
) async throws -> T
|
||||
.ResultType
|
||||
{
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
HttpClient.decodableRequest(decodingStrategy: decodingStrategy, target, callbackQueue: nil) {
|
||||
result in
|
||||
switch result {
|
||||
case let .success(response):
|
||||
continuation.resume(returning: response)
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func request(_ target: some TargetType, progressHandler: ProgressBlock? = nil)
|
||||
async throws -> Data?
|
||||
{
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
HttpClient.request(target, callbackQueue: nil, progressHandler: progressHandler) {
|
||||
result in
|
||||
switch result {
|
||||
case let .success(response):
|
||||
continuation.resume(returning: response)
|
||||
case let .failure(error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum HttpClientError: Error {
|
||||
case missingParams
|
||||
case invalidateParams
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
|
||||
extension MoyaProvider where Target == MultiTarget {
|
||||
func decodableRequest<T: DecodableTargetType>(
|
||||
_ target: T,
|
||||
decodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys,
|
||||
callbackQueue: DispatchQueue? = nil,
|
||||
completion: @escaping (_ result: Result<T.ResultType, Error>) -> Void
|
||||
) -> Moya.Cancellable {
|
||||
request(MultiTarget(target), callbackQueue: callbackQueue) { [weak self] result in
|
||||
switch result {
|
||||
case let .success(response):
|
||||
do {
|
||||
let JSONDecoder = JSONDecoder()
|
||||
JSONDecoder.keyDecodingStrategy = decodingStrategy
|
||||
let responseObject = try response.map(
|
||||
T.ResultType.self,
|
||||
atKeyPath: target.decodeAtKeyPath,
|
||||
using: JSONDecoder
|
||||
)
|
||||
completion(.success(responseObject))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
self?.logDecodeError(error)
|
||||
}
|
||||
case let .failure(error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func logDecodeError(_ error: Error) {
|
||||
print("===================================================================")
|
||||
print("🔴 Decode Error: \(error)")
|
||||
print("===================================================================")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
struct CursorAggregatedUsageEventsAPI: DecodableTargetType {
|
||||
typealias ResultType = CursorAggregatedUsageEventsResponse
|
||||
|
||||
let teamId: Int?
|
||||
let startDate: Int64
|
||||
private let cookieHeader: String?
|
||||
|
||||
var baseURL: URL { APIConfig.baseURL }
|
||||
var path: String { "/api/dashboard/get-aggregated-usage-events" }
|
||||
var method: Moya.Method { .post }
|
||||
var task: Task {
|
||||
var params: [String: Any] = [
|
||||
"startDate": self.startDate
|
||||
]
|
||||
if let teamId = self.teamId {
|
||||
params["teamId"] = teamId
|
||||
}
|
||||
return .requestParameters(parameters: params, encoding: JSONEncoding.default)
|
||||
}
|
||||
|
||||
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
|
||||
var sampleData: Data {
|
||||
Data("""
|
||||
{
|
||||
"aggregations": [],
|
||||
"totalInputTokens": "0",
|
||||
"totalOutputTokens": "0",
|
||||
"totalCacheWriteTokens": "0",
|
||||
"totalCacheReadTokens": "0",
|
||||
"totalCostCents": 0.0
|
||||
}
|
||||
""".utf8)
|
||||
}
|
||||
|
||||
init(teamId: Int?, startDate: Int64, cookieHeader: String?) {
|
||||
self.teamId = teamId
|
||||
self.startDate = startDate
|
||||
self.cookieHeader = cookieHeader
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
struct CursorCurrentBillingCycleAPI: DecodableTargetType {
|
||||
typealias ResultType = CursorCurrentBillingCycleResponse
|
||||
|
||||
private let cookieHeader: String?
|
||||
|
||||
var baseURL: URL { APIConfig.baseURL }
|
||||
var path: String { "/api/dashboard/get-current-billing-cycle" }
|
||||
var method: Moya.Method { .post }
|
||||
var task: Task {
|
||||
.requestParameters(parameters: [:], encoding: JSONEncoding.default)
|
||||
}
|
||||
|
||||
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
|
||||
var sampleData: Data {
|
||||
Data("""
|
||||
{
|
||||
"startDateEpochMillis": "1763891472000",
|
||||
"endDateEpochMillis": "1764496272000"
|
||||
}
|
||||
""".utf8)
|
||||
}
|
||||
|
||||
init(cookieHeader: String?) {
|
||||
self.cookieHeader = cookieHeader
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
import VibeviewerModel
|
||||
|
||||
struct CursorFilteredUsageAPI: DecodableTargetType {
|
||||
typealias ResultType = CursorFilteredUsageResponse
|
||||
|
||||
let startDateMs: String
|
||||
let endDateMs: String
|
||||
let userId: Int
|
||||
let page: Int
|
||||
private let cookieHeader: String?
|
||||
|
||||
var baseURL: URL { APIConfig.baseURL }
|
||||
var path: String { "/api/dashboard/get-filtered-usage-events" }
|
||||
var method: Moya.Method { .post }
|
||||
var task: Task {
|
||||
let params: [String: Any] = [
|
||||
"startDate": self.startDateMs,
|
||||
"endDate": self.endDateMs,
|
||||
"userId": self.userId,
|
||||
"page": self.page,
|
||||
"pageSize": 100
|
||||
]
|
||||
return .requestParameters(parameters: params, encoding: JSONEncoding.default)
|
||||
}
|
||||
|
||||
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
|
||||
var sampleData: Data {
|
||||
Data("{\"totalUsageEventsCount\":1,\"usageEventsDisplay\":[]}".utf8)
|
||||
}
|
||||
|
||||
init(startDateMs: String, endDateMs: String, userId: Int, page: Int, cookieHeader: String?) {
|
||||
self.startDateMs = startDateMs
|
||||
self.endDateMs = endDateMs
|
||||
self.userId = userId
|
||||
self.page = page
|
||||
self.cookieHeader = cookieHeader
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
import VibeviewerModel
|
||||
|
||||
struct CursorGetMeAPI: DecodableTargetType {
|
||||
typealias ResultType = CursorMeResponse
|
||||
|
||||
var baseURL: URL { APIConfig.baseURL }
|
||||
var path: String { "/api/dashboard/get-me" }
|
||||
var method: Moya.Method { .get }
|
||||
var task: Task { .requestPlain }
|
||||
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
|
||||
var sampleData: Data {
|
||||
Data("{\"authId\":\"\",\"userId\":0,\"email\":\"\",\"workosId\":\"\",\"teamId\":0,\"isEnterpriseUser\":false}".utf8)
|
||||
}
|
||||
|
||||
private let cookieHeader: String?
|
||||
init(cookieHeader: String?) { self.cookieHeader = cookieHeader }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
struct CursorGetTeamSpendAPI: DecodableTargetType {
|
||||
typealias ResultType = CursorTeamSpendResponse
|
||||
|
||||
let teamId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let sortBy: String
|
||||
let sortDirection: String
|
||||
private let cookieHeader: String?
|
||||
|
||||
var baseURL: URL { APIConfig.baseURL }
|
||||
var path: String { "/api/dashboard/get-team-spend" }
|
||||
var method: Moya.Method { .post }
|
||||
var task: Task {
|
||||
let params: [String: Any] = [
|
||||
"teamId": self.teamId,
|
||||
"page": self.page,
|
||||
"pageSize": self.pageSize,
|
||||
"sortBy": self.sortBy,
|
||||
"sortDirection": self.sortDirection
|
||||
]
|
||||
return .requestParameters(parameters: params, encoding: JSONEncoding.default)
|
||||
}
|
||||
var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) }
|
||||
var sampleData: Data {
|
||||
Data("{\n \"teamMemberSpend\": [],\n \"subscriptionCycleStart\": \"0\",\n \"totalMembers\": 0,\n \"totalPages\": 0,\n \"totalByRole\": [],\n \"nextCycleStart\": \"0\",\n \"limitedUserCount\": 0,\n \"maxUserSpendCents\": 0,\n \"subscriptionLimitedUsers\": 0\n}".utf8)
|
||||
}
|
||||
|
||||
init(teamId: Int, page: Int = 1, pageSize: Int = 50, sortBy: String = "name", sortDirection: String = "asc", cookieHeader: String?) {
|
||||
self.teamId = teamId
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
self.sortBy = sortBy
|
||||
self.sortDirection = sortDirection
|
||||
self.cookieHeader = cookieHeader
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
struct CursorTeamModelsAnalyticsAPI: DecodableTargetType {
|
||||
typealias ResultType = CursorTeamModelsAnalyticsResponse
|
||||
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
let c: String
|
||||
private let cookieHeader: String?
|
||||
|
||||
var baseURL: URL { APIConfig.baseURL }
|
||||
var path: String { "/api/v2/analytics/team/models" }
|
||||
var method: Moya.Method { .get }
|
||||
var task: Task {
|
||||
let params: [String: Any] = [
|
||||
"startDate": self.startDate,
|
||||
"endDate": self.endDate,
|
||||
"c": self.c
|
||||
]
|
||||
return .requestParameters(parameters: params, encoding: URLEncoding.default)
|
||||
}
|
||||
|
||||
var headers: [String: String]? { APIHeadersBuilder.basicHeaders(cookieHeader: self.cookieHeader) }
|
||||
var sampleData: Data {
|
||||
Data("{}".utf8)
|
||||
}
|
||||
|
||||
init(startDate: String, endDate: String, c: String, cookieHeader: String?) {
|
||||
self.startDate = startDate
|
||||
self.endDate = endDate
|
||||
self.c = c
|
||||
self.cookieHeader = cookieHeader
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
struct CursorUsageSummaryAPI: DecodableTargetType {
|
||||
typealias ResultType = CursorUsageSummaryResponse
|
||||
|
||||
let cookieHeader: String
|
||||
|
||||
var baseURL: URL {
|
||||
URL(string: "https://cursor.com")!
|
||||
}
|
||||
|
||||
var path: String {
|
||||
"/api/usage-summary"
|
||||
}
|
||||
|
||||
var method: Moya.Method {
|
||||
.get
|
||||
}
|
||||
|
||||
var task: Moya.Task {
|
||||
.requestPlain
|
||||
}
|
||||
|
||||
var headers: [String: String]? {
|
||||
[
|
||||
"accept": "*/*",
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"cache-control": "no-cache",
|
||||
"dnt": "1",
|
||||
"pragma": "no-cache",
|
||||
"priority": "u=1, i",
|
||||
"referer": "https://cursor.com/dashboard?tab=usage",
|
||||
"sec-ch-ua": "\"Not=A?Brand\";v=\"24\", \"Chromium\";v=\"140\"",
|
||||
"sec-ch-ua-arch": "\"arm\"",
|
||||
"sec-ch-ua-bitness": "\"64\"",
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": "\"macOS\"",
|
||||
"sec-ch-ua-platform-version": "\"15.3.1\"",
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||
"Cookie": cookieHeader
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
protocol DecodableTargetType: TargetType {
|
||||
associatedtype ResultType: Decodable
|
||||
|
||||
var decodeAtKeyPath: String? { get }
|
||||
}
|
||||
|
||||
extension DecodableTargetType {
|
||||
var decodeAtKeyPath: String? { nil }
|
||||
|
||||
var validationType: ValidationType {
|
||||
.successCodes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Testing
|
||||
|
||||
@Test func placeholderTest() async throws {
|
||||
// Placeholder test to ensure test target builds correctly
|
||||
#expect(true)
|
||||
}
|
||||
Reference in New Issue
Block a user