Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions Sources/OpenUsage/Providers/Cursor/CursorProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,13 @@ final class CursorProvider: ProviderRuntime {
planInfoUnavailable: planInfoUnavailable
)
if fallback.shouldFallback {
let mapped = try await requestBasedResult(
var mapped = try await usageSummaryAndRequestResult(
accessToken: currentToken,
planName: planName,
unavailableMessage: fallback.message
)
return snapshot(mapped)
let history = await appendSpendLines(to: &mapped.lines, accessToken: currentToken)
return snapshot(mapped, usageHistory: history)
}

if shouldTryGenericRequestFallback(usage: usage) {
Expand Down Expand Up @@ -349,6 +350,31 @@ final class CursorProvider: ProviderRuntime {
}
}

private func usageSummaryAndRequestResult(
accessToken: String,
planName: String?,
unavailableMessage: String
) async throws -> CursorMappedUsage {
let summary = await fetchOptionalJSONObject(label: "usage-summary", request: {
try await self.usageClient.fetchUsageSummary(accessToken: accessToken)
})
if let summary, !CursorUsageSummaryMapper.hasUsableSummaryPayload(summary) {
AppLog.warn(LogTag.plugin("cursor"), "optional usage-summary response contained no usable usage fields")
}
let requestUsage = await fetchOptionalJSONObject(label: "request-based usage", request: {
try await self.usageClient.fetchRequestBasedUsage(accessToken: accessToken)
})
if let requestUsage, !CursorUsageSummaryMapper.hasUsableRequestPayload(requestUsage) {
AppLog.warn(LogTag.plugin("cursor"), "optional request-based usage response contained no usable usage fields")
}
return try CursorUsageSummaryMapper.map(
summary: summary,
requestUsage: requestUsage,
planName: planName,
unavailableMessage: unavailableMessage
)
}

private func shouldTryGenericRequestFallback(usage: [String: Any]) -> Bool {
CursorPlanUsageFacts(usage: usage).shouldTryGenericRequestFallback
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/OpenUsage/Providers/Cursor/CursorUsageClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct CursorUsageClient: Sendable {
static let refreshURL = URL(string: "https://api2.cursor.sh/oauth/token")!
static let creditsURL = URL(string: "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCreditGrantsBalance")!
static let restUsageURL = URL(string: "https://cursor.com/api/usage")!
static let usageSummaryURL = URL(string: "https://cursor.com/api/usage-summary")!
static let stripeURL = URL(string: "https://cursor.com/api/auth/stripe")!
static let exportCSVURL = URL(string: "https://cursor.com/api/dashboard/export-usage-events-csv")!
static let clientID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
Expand Down Expand Up @@ -62,6 +63,16 @@ struct CursorUsageClient: Sendable {
))
}

func fetchUsageSummary(accessToken: String) async throws -> HTTPResponse? {
guard let session = Self.session(from: accessToken) else { return nil }
return try await http.send(HTTPRequest(
method: "GET",
url: Self.usageSummaryURL,
headers: ["Cookie": "WorkosCursorSessionToken=\(session.sessionToken)"],
timeout: 10
))
}

func fetchStripeBalance(accessToken: String) async throws -> HTTPResponse? {
guard let session = Self.session(from: accessToken) else { return nil }
return try await http.send(HTTPRequest(
Expand Down
254 changes: 254 additions & 0 deletions Sources/OpenUsage/Providers/Cursor/CursorUsageSummaryMapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import Foundation

/// Combines the two REST payloads used by Cursor Enterprise/team dashboards. The request endpoint
/// carries the included request allowance, while usage-summary carries structured percentages,
/// user-scoped on-demand spend, and exact billing-cycle bounds. Neither response is sufficient alone.
enum CursorUsageSummaryMapper {
static func hasUsableSummaryPayload(_ summary: [String: Any]) -> Bool {
let start = (summary["billingCycleStart"] as? String).flatMap(OpenUsageISO8601.date(from:))
let end = (summary["billingCycleEnd"] as? String).flatMap(OpenUsageISO8601.date(from:))
if let start, let end, end > start {
return true
}

let individual = summary["individualUsage"] as? [String: Any]
let team = summary["teamUsage"] as? [String: Any]
let plan = individual?["plan"] as? [String: Any]
if ["totalPercentUsed", "autoPercentUsed", "apiPercentUsed"].contains(where: {
ProviderParse.number(plan?[$0]) != nil
}) {
return true
}
return [individual?["onDemand"], individual?["overall"], team?["onDemand"], team?["pooled"]]
.contains(where: usableDollarBucket)
}

static func hasUsableRequestPayload(_ usage: [String: Any]) -> Bool {
if let requests = usage["gpt-4"] as? [String: Any],
let limit = ProviderParse.number(requests["maxRequestUsage"]), limit > 0 {
return true
}
return (usage["startOfMonth"] as? String).flatMap(OpenUsageISO8601.date(from:)) != nil
}

static func map(
summary: [String: Any]?,
requestUsage: [String: Any]?,
planName: String?,
unavailableMessage: String
) throws -> CursorMappedUsage {
let cycle = billingCycle(summary: summary, requestUsage: requestUsage)
var lines: [MetricLine] = []

let hasRequests = appendRequests(requestUsage, cycle: cycle, to: &lines)
if !hasRequests {
appendSummaryTotal(summary, cycle: cycle, to: &lines)
}
appendStructuredPercentages(summary, cycle: cycle, to: &lines)
appendOnDemand(summary, cycle: cycle, to: &lines)

guard !lines.isEmpty else {
throw CursorUsageError.requestBasedUnavailable(unavailableMessage)
}

return CursorMappedUsage(
plan: planLabel(planName) ?? planLabel(summary?["membershipType"] as? String),
lines: lines
)
}

/// The default Total Usage widget must carry the included allowance on request-based Enterprise
/// plans. Keep the legacy Requests line too, so users who manually enabled that optional widget do
/// not lose it after upgrading.
private static func appendRequests(
_ usage: [String: Any]?,
cycle: BillingCycle,
to lines: inout [MetricLine]
) -> Bool {
guard let requests = usage?["gpt-4"] as? [String: Any],
let limit = ProviderParse.number(requests["maxRequestUsage"]),
limit > 0
else {
return false
}
let used = max(
0,
ProviderParse.number(requests["numRequests"])
?? ProviderParse.number(requests["numRequestsTotal"])
?? 0
)
for label in ["Total usage", "Requests"] {
lines.append(.progress(
label: label,
used: used,
limit: limit,
format: .count(suffix: "requests"),
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs
))
}
return true
}

/// Supports both the live `individualUsage.plan` shape and the pooled/overall variants reported by
/// other Enterprise accounts. A request allowance wins because it is the dashboard's included cap.
private static func appendSummaryTotal(
_ summary: [String: Any]?,
cycle: BillingCycle,
to lines: inout [MetricLine]
) {
let individual = summary?["individualUsage"] as? [String: Any]
let team = summary?["teamUsage"] as? [String: Any]
let limitType = (summary?["limitType"] as? String)?.lowercased()

if limitType == "team", let pooled = dollarMeter(team?["pooled"]) {
appendDollarProgress(pooled, label: "Total usage", cycle: cycle, to: &lines)
return
}
if let percent = ProviderParse.number((individual?["plan"] as? [String: Any])?["totalPercentUsed"]) {
lines.append(.progress(
label: "Total usage",
used: percent,
limit: 100,
format: .percent,
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs
))
return
}
if let overall = dollarMeter(individual?["overall"]) {
appendDollarProgress(overall, label: "Total usage", cycle: cycle, to: &lines)
return
}
if let pooled = dollarMeter(team?["pooled"]) {
appendDollarProgress(pooled, label: "Total usage", cycle: cycle, to: &lines)
}
}

private static func appendStructuredPercentages(
_ summary: [String: Any]?,
cycle: BillingCycle,
to lines: inout [MetricLine]
) {
let plan = ((summary?["individualUsage"] as? [String: Any])?["plan"] as? [String: Any])
for (key, label) in [("autoPercentUsed", "Auto usage"), ("apiPercentUsed", "API usage")] {
guard let percent = ProviderParse.number(plan?[key]) else { continue }
lines.append(.progress(
label: label,
used: percent,
limit: 100,
format: .percent,
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs
))
}
}

/// The dashboard's headline On-Demand card is user-scoped. Only use the organization aggregate
/// when Cursor omits the individual bucket for that account shape.
private static func appendOnDemand(
_ summary: [String: Any]?,
cycle: BillingCycle,
to lines: inout [MetricLine]
) {
let individual = summary?["individualUsage"] as? [String: Any]
let team = summary?["teamUsage"] as? [String: Any]
if appendOnDemandBucket(individual?["onDemand"], cycle: cycle, to: &lines) {
return
}
_ = appendOnDemandBucket(team?["onDemand"], cycle: cycle, to: &lines)
}

/// Returns whether a usable row was emitted. An Enterprise response can include an individual
/// placeholder bucket (`enabled: false` or a zero limit) alongside a valid team bucket, so merely
/// finding the individual dictionary must not suppress the fallback.
private static func appendOnDemandBucket(
_ value: Any?,
cycle: BillingCycle,
to lines: inout [MetricLine]
) -> Bool {
guard let bucket = value as? [String: Any], bucket["enabled"] as? Bool != false else {
return false
}
if let meter = dollarMeter(bucket) {
appendDollarProgress(meter, label: "On-demand", cycle: cycle, to: &lines)
return true
}
if let usedCents = ProviderParse.number(bucket["used"]), usedCents > 0 {
lines.append(.values(
label: "On-demand",
values: [MetricValue(number: ProviderParse.centsToDollars(usedCents), kind: .dollars)]
))
return true
}
return false
}

private static func dollarMeter(_ value: Any?) -> (used: Double, limit: Double)? {
guard let bucket = value as? [String: Any],
bucket["enabled"] as? Bool != false,
let limit = ProviderParse.number(bucket["limit"]),
limit > 0
else {
return nil
}
let reportedUsed = ProviderParse.number(bucket["used"])
let inferredUsed = max(0, limit - (ProviderParse.number(bucket["remaining"]) ?? limit))
let used = reportedUsed.flatMap { $0 > 0 ? $0 : nil } ?? inferredUsed
return (max(0, used), limit)
}

private static func usableDollarBucket(_ value: Any?) -> Bool {
guard let bucket = value as? [String: Any], bucket["enabled"] as? Bool != false else {
return false
}
return dollarMeter(bucket) != nil || (ProviderParse.number(bucket["used"]) ?? 0) > 0
}

private static func appendDollarProgress(
_ meter: (used: Double, limit: Double),
label: String,
cycle: BillingCycle,
to lines: inout [MetricLine]
) {
lines.append(.progress(
label: label,
used: ProviderParse.centsToDollars(meter.used),
limit: ProviderParse.centsToDollars(meter.limit),
format: .dollars,
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs
))
}

private static func billingCycle(
summary: [String: Any]?,
requestUsage: [String: Any]?
) -> BillingCycle {
let start = (summary?["billingCycleStart"] as? String).flatMap(OpenUsageISO8601.date(from:))
let end = (summary?["billingCycleEnd"] as? String).flatMap(OpenUsageISO8601.date(from:))
if let start, let end, end > start {
return BillingCycle(
resetsAt: end,
periodDurationMs: Int(end.timeIntervalSince(start) * 1000)
)
}

let requestStart = (requestUsage?["startOfMonth"] as? String).flatMap(OpenUsageISO8601.date(from:))
return BillingCycle(
resetsAt: requestStart?.addingTimeInterval(TimeInterval(CursorUsageMapper.billingPeriodMs) / 1000),
periodDurationMs: CursorUsageMapper.billingPeriodMs
)
}

private static func planLabel(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed.titleCased(separator: \.isWhitespace)
}

private struct BillingCycle {
var resetsAt: Date?
var periodDurationMs: Int
}
}
17 changes: 16 additions & 1 deletion Sources/OpenUsage/Services/LocalLimitsAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ enum LocalLimitsAPI {

init?(resource: LimitResourceDescriptor, line: MetricLine) {
kind = resource.kind
unit = resource.unit
unit = Self.progressUnit(line) ?? resource.unit

switch (resource.source, line) {
case (.progress, .progress(_, let rawUsed, let rawLimit, _, let reset, let periodMs, _)):
Expand Down Expand Up @@ -131,6 +131,21 @@ enum LocalLimitsAPI {
}
}

/// A descriptor names the stable resource and supplies a fallback unit for value rows. Progress
/// rows carry their actual runtime unit, which can vary by plan (for example Cursor Total Usage
/// is percent on individual plans and requests on request-based Enterprise plans).
private static func progressUnit(_ line: MetricLine) -> String? {
guard case .progress(_, _, _, let format, _, _, _) = line else { return nil }
switch format {
case .percent:
return "percent"
case .dollars:
return "usd"
case .count(let suffix):
return suffix.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
}
}

private mutating func applyProgress(
used rawUsed: Double,
limit rawLimit: Double,
Expand Down
Loading