diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index 48db614f9..3e9607bbe 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift @@ -90,6 +90,25 @@ struct CursorProviderImplementation: ProviderImplementation { @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { + // Auto / API pool breakdown (token-based pro plans) + if let pool = context.snapshot?.cursorPoolUsage { + let autoUsedPct = Int(pool.autoPercentUsed.rounded()) + let apiUsedPct = Int(pool.apiPercentUsed.rounded()) + + if let autoTotal = pool.autoPoolTotal, let autoUsed = pool.autoPoolUsed { + entries.append(.text("Auto: \(autoUsed) / \(autoTotal) used (\(autoUsedPct)%)", .primary)) + } else { + entries.append(.text("Auto: \(autoUsedPct)% used", .primary)) + } + + if let apiTotal = pool.apiPoolTotal, let apiUsed = pool.apiPoolUsed { + entries.append(.text("API: \(apiUsed) / \(apiTotal) used (\(apiUsedPct)%)", .primary)) + } else { + entries.append(.text("API: \(apiUsedPct)% used", .primary)) + } + } + + // On-demand (pay-as-you-go) cost guard let cost = context.snapshot?.providerCost, cost.currencyCode != "Quota" else { return } let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) if cost.limit > 0 { diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorPoolUsage.swift b/Sources/CodexBarCore/Providers/Cursor/CursorPoolUsage.swift new file mode 100644 index 000000000..65fe5819d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Cursor/CursorPoolUsage.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Usage snapshot for Cursor's Auto and API model pools (token-based pro plans). +/// +/// Cursor separates quota into two buckets: +/// - **Auto pool** – includes base quota + bonus credits; used when Cursor picks the model automatically. +/// - **API pool** – base quota only; used when a specific named/external model is selected. +public struct CursorPoolUsage: Codable, Sendable { + /// Percentage of Auto pool consumed (0-100). Accounts for bonus credits. + public let autoPercentUsed: Double + /// Percentage of API pool consumed (0-100). Base plan limit only. + public let apiPercentUsed: Double + /// Total Auto pool size in requests (included base + bonus credits). + public let autoPoolTotal: Int? + /// Total API pool size in requests (base plan limit). + public let apiPoolTotal: Int? + /// Requests consumed from the Auto pool. + public let autoPoolUsed: Int? + /// Requests consumed from the API pool (estimated from percent × total). + public let apiPoolUsed: Int? + + public init( + autoPercentUsed: Double, + apiPercentUsed: Double, + autoPoolTotal: Int?, + apiPoolTotal: Int?, + autoPoolUsed: Int?, + apiPoolUsed: Int?) + { + self.autoPercentUsed = autoPercentUsed + self.apiPercentUsed = apiPercentUsed + self.autoPoolTotal = autoPoolTotal + self.apiPoolTotal = apiPoolTotal + self.autoPoolUsed = autoPoolUsed + self.apiPoolUsed = apiPoolUsed + } + + public var autoRemainingPercent: Double { max(0, 100 - self.autoPercentUsed) } + public var apiRemainingPercent: Double { max(0, 100 - self.apiPercentUsed) } +} diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index e694e2e77..d11d14d48 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -10,8 +10,8 @@ public enum CursorProviderDescriptor { metadata: ProviderMetadata( id: .cursor, displayName: "Cursor", - sessionLabel: "Plan", - weeklyLabel: "On-Demand", + sessionLabel: "Auto", + weeklyLabel: "API", opusLabel: nil, supportsOpus: false, supportsCredits: true, diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 84cca3c3d..2e1ba232d 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -199,6 +199,23 @@ public struct CursorStatusSnapshot: Sendable { /// Raw API response for debugging public let rawJSON: String? + // MARK: - Auto / API Pool Fields + + /// Percentage of Auto model pool used (includes bonus credits, 0-100). + /// Shown as primary bar in the menu. + public let autoPercentUsed: Double? + /// Percentage of API model pool used (named/external models quota, 0-100). + /// Shown as secondary bar in the menu. + public let apiPercentUsed: Double? + /// Total Auto pool size in requests (included + bonus) + public let autoPoolTotal: Int? + /// Total API pool size in requests + public let apiPoolTotal: Int? + /// Requests used in Auto pool + public let autoPoolUsed: Int? + /// Requests used in API pool (derived from percent × total) + public let apiPoolUsed: Int? + // MARK: - Legacy Plan (Request-Based) Fields /// Requests used this billing cycle (legacy plans only) @@ -224,6 +241,12 @@ public struct CursorStatusSnapshot: Sendable { accountEmail: String?, accountName: String?, rawJSON: String?, + autoPercentUsed: Double? = nil, + apiPercentUsed: Double? = nil, + autoPoolTotal: Int? = nil, + apiPoolTotal: Int? = nil, + autoPoolUsed: Int? = nil, + apiPoolUsed: Int? = nil, requestsUsed: Int? = nil, requestsLimit: Int? = nil) { @@ -239,19 +262,30 @@ public struct CursorStatusSnapshot: Sendable { self.accountEmail = accountEmail self.accountName = accountName self.rawJSON = rawJSON + self.autoPercentUsed = autoPercentUsed + self.apiPercentUsed = apiPercentUsed + self.autoPoolTotal = autoPoolTotal + self.apiPoolTotal = apiPoolTotal + self.autoPoolUsed = autoPoolUsed + self.apiPoolUsed = apiPoolUsed self.requestsUsed = requestsUsed self.requestsLimit = requestsLimit } /// Convert to UsageSnapshot for the common provider interface public func toUsageSnapshot() -> UsageSnapshot { - // Primary: For legacy request-based plans, use request usage; otherwise use plan percentage + let resetDesc = self.billingCycleEnd.map { Self.formatResetDate($0) } + + // Primary: Auto model pool (includes bonus credits). + // For legacy request-based plans, fall back to raw request ratio. let primaryUsedPercent: Double = if self.isLegacyRequestPlan, let used = self.requestsUsed, let limit = self.requestsLimit, limit > 0 { (Double(used) / Double(limit)) * 100 + } else if let auto = self.autoPercentUsed { + auto } else { self.planPercentUsed } @@ -260,22 +294,29 @@ public struct CursorStatusSnapshot: Sendable { usedPercent: primaryUsedPercent, windowMinutes: nil, resetsAt: self.billingCycleEnd, - resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) + resetDescription: resetDesc) - // Always use individual on-demand values (what users see in their Cursor dashboard). - // Team values are aggregates across all members, not useful for individual tracking. + // Secondary: API model pool (named/external models quota). + let secondary: RateWindow? = if let api = self.apiPercentUsed { + RateWindow( + usedPercent: api, + windowMinutes: nil, + resetsAt: self.billingCycleEnd, + resetDescription: resetDesc) + } else { + nil + } + + // Tertiary: On-demand (pay-as-you-go) usage as percentage of limit. let resolvedOnDemandUsed = self.onDemandUsedUSD let resolvedOnDemandLimit = self.onDemandLimitUSD - // Secondary: On-demand usage as percentage of individual limit - let secondary: RateWindow? = if let limit = resolvedOnDemandLimit, - limit > 0 - { + let tertiary: RateWindow? = if let limit = resolvedOnDemandLimit, limit > 0 { RateWindow( usedPercent: (resolvedOnDemandUsed / limit) * 100, windowMinutes: nil, resetsAt: self.billingCycleEnd, - resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) }) + resetDescription: resetDesc) } else { nil } @@ -302,6 +343,21 @@ public struct CursorStatusSnapshot: Sendable { nil } + // Auto/API pool breakdown (only for token-based plans that expose these percentages) + let cursorPoolUsage: CursorPoolUsage? = if let auto = self.autoPercentUsed, + let api = self.apiPercentUsed + { + CursorPoolUsage( + autoPercentUsed: auto, + apiPercentUsed: api, + autoPoolTotal: self.autoPoolTotal, + apiPoolTotal: self.apiPoolTotal, + autoPoolUsed: self.autoPoolUsed, + apiPoolUsed: self.apiPoolUsed) + } else { + nil + } + let identity = ProviderIdentitySnapshot( providerID: .cursor, accountEmail: self.accountEmail, @@ -310,9 +366,10 @@ public struct CursorStatusSnapshot: Sendable { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, + tertiary: tertiary, providerCost: providerCost, cursorRequests: cursorRequests, + cursorPoolUsage: cursorPoolUsage, updatedAt: Date(), identity: identity) } @@ -713,6 +770,19 @@ public struct CursorStatusProbe: Sendable { let requestsUsed: Int? = requestUsage?.gpt4?.numRequestsTotal ?? requestUsage?.gpt4?.numRequests let requestsLimit: Int? = requestUsage?.gpt4?.maxRequestUsage + // Auto pool: uses autoPercentUsed from the API (accounts for bonus credits on top of included quota). + // API pool: uses apiPercentUsed from the API (named/external model quota). + let planData = summary.individualUsage?.plan + let autoPercentUsed: Double? = planData?.autoPercentUsed + let apiPercentUsed: Double? = planData?.apiPercentUsed + let autoPoolTotal: Int? = planData?.breakdown?.total // included + bonus + let apiPoolTotal: Int? = planData?.limit // base plan limit (API cap) + let autoPoolUsed: Int? = planData?.used + let apiPoolUsed: Int? = apiPercentUsed.flatMap { pct -> Int? in + guard let total = apiPoolTotal, total > 0 else { return nil } + return Int((pct / 100.0) * Double(total)) + } + return CursorStatusSnapshot( planPercentUsed: planPercentUsed, planUsedUSD: planUsed, @@ -726,6 +796,12 @@ public struct CursorStatusProbe: Sendable { accountEmail: userInfo?.email, accountName: userInfo?.name, rawJSON: rawJSON, + autoPercentUsed: autoPercentUsed, + apiPercentUsed: apiPercentUsed, + autoPoolTotal: autoPoolTotal, + apiPoolTotal: apiPoolTotal, + autoPoolUsed: autoPoolUsed, + apiPoolUsed: apiPoolUsed, requestsUsed: requestsUsed, requestsLimit: requestsLimit) } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index f2370e9ca..74dc41b10 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -56,6 +56,8 @@ public struct UsageSnapshot: Codable, Sendable { public let minimaxUsage: MiniMaxUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? public let cursorRequests: CursorRequestUsage? + /// Auto + API pool breakdown for token-based Cursor pro plans. + public let cursorPoolUsage: CursorPoolUsage? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -81,6 +83,7 @@ public struct UsageSnapshot: Codable, Sendable { minimaxUsage: MiniMaxUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, + cursorPoolUsage: CursorPoolUsage? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) { @@ -92,6 +95,7 @@ public struct UsageSnapshot: Codable, Sendable { self.minimaxUsage = minimaxUsage self.openRouterUsage = openRouterUsage self.cursorRequests = cursorRequests + self.cursorPoolUsage = cursorPoolUsage self.updatedAt = updatedAt self.identity = identity } @@ -106,6 +110,7 @@ public struct UsageSnapshot: Codable, Sendable { self.minimaxUsage = nil // Not persisted, fetched fresh each time self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time + self.cursorPoolUsage = nil // Not persisted, fetched fresh each time self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { self.identity = identity