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

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

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

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

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

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

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

View File

@@ -0,0 +1,42 @@
{
"originHash" : "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
}

View 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"]
),
]
)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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 "
]
}
}

View File

@@ -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) }
}
}

View File

@@ -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
}
}

View File

@@ -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 usageincludedSpendCents - hardLimitOverrideDollars*10000
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: IDPro 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
}
// 77
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
}
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
enum HttpClientError: Error {
case missingParams
case invalidateParams
}

View File

@@ -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("===================================================================")
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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 }
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
]
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
import Testing
@Test func placeholderTest() async throws {
// Placeholder test to ensure test target builds correctly
#expect(true)
}