Skip to content

Commit 09ef91c

Browse files
authored
Merge pull request #986 from iicdii/fix/cursor-enterprise-usage
fix(cursor): restore Enterprise included and on-demand usage
2 parents 17ec2b9 + d514522 commit 09ef91c

10 files changed

Lines changed: 732 additions & 8 deletions

File tree

Sources/OpenUsage/Providers/Cursor/CursorProvider.swift

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,13 @@ final class CursorProvider: ProviderRuntime {
115115
planInfoUnavailable: planInfoUnavailable
116116
)
117117
if fallback.shouldFallback {
118-
let mapped = try await requestBasedResult(
118+
var mapped = try await usageSummaryAndRequestResult(
119119
accessToken: currentToken,
120120
planName: planName,
121121
unavailableMessage: fallback.message
122122
)
123-
return snapshot(mapped)
123+
let history = await appendSpendLines(to: &mapped.lines, accessToken: currentToken)
124+
return snapshot(mapped, usageHistory: history)
124125
}
125126

126127
if shouldTryGenericRequestFallback(usage: usage) {
@@ -349,6 +350,31 @@ final class CursorProvider: ProviderRuntime {
349350
}
350351
}
351352

353+
private func usageSummaryAndRequestResult(
354+
accessToken: String,
355+
planName: String?,
356+
unavailableMessage: String
357+
) async throws -> CursorMappedUsage {
358+
let summary = await fetchOptionalJSONObject(label: "usage-summary", request: {
359+
try await self.usageClient.fetchUsageSummary(accessToken: accessToken)
360+
})
361+
if let summary, !CursorUsageSummaryMapper.hasUsableSummaryPayload(summary) {
362+
AppLog.warn(LogTag.plugin("cursor"), "optional usage-summary response contained no usable usage fields")
363+
}
364+
let requestUsage = await fetchOptionalJSONObject(label: "request-based usage", request: {
365+
try await self.usageClient.fetchRequestBasedUsage(accessToken: accessToken)
366+
})
367+
if let requestUsage, !CursorUsageSummaryMapper.hasUsableRequestPayload(requestUsage) {
368+
AppLog.warn(LogTag.plugin("cursor"), "optional request-based usage response contained no usable usage fields")
369+
}
370+
return try CursorUsageSummaryMapper.map(
371+
summary: summary,
372+
requestUsage: requestUsage,
373+
planName: planName,
374+
unavailableMessage: unavailableMessage
375+
)
376+
}
377+
352378
private func shouldTryGenericRequestFallback(usage: [String: Any]) -> Bool {
353379
CursorPlanUsageFacts(usage: usage).shouldTryGenericRequestFallback
354380
}

Sources/OpenUsage/Providers/Cursor/CursorUsageClient.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ struct CursorUsageClient: Sendable {
1111
static let refreshURL = URL(string: "https://api2.cursor.sh/oauth/token")!
1212
static let creditsURL = URL(string: "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCreditGrantsBalance")!
1313
static let restUsageURL = URL(string: "https://cursor.com/api/usage")!
14+
static let usageSummaryURL = URL(string: "https://cursor.com/api/usage-summary")!
1415
static let stripeURL = URL(string: "https://cursor.com/api/auth/stripe")!
1516
static let exportCSVURL = URL(string: "https://cursor.com/api/dashboard/export-usage-events-csv")!
1617
static let clientID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
@@ -62,6 +63,16 @@ struct CursorUsageClient: Sendable {
6263
))
6364
}
6465

66+
func fetchUsageSummary(accessToken: String) async throws -> HTTPResponse? {
67+
guard let session = Self.session(from: accessToken) else { return nil }
68+
return try await http.send(HTTPRequest(
69+
method: "GET",
70+
url: Self.usageSummaryURL,
71+
headers: ["Cookie": "WorkosCursorSessionToken=\(session.sessionToken)"],
72+
timeout: 10
73+
))
74+
}
75+
6576
func fetchStripeBalance(accessToken: String) async throws -> HTTPResponse? {
6677
guard let session = Self.session(from: accessToken) else { return nil }
6778
return try await http.send(HTTPRequest(
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import Foundation
2+
3+
/// Combines the two REST payloads used by Cursor Enterprise/team dashboards. The request endpoint
4+
/// carries the included request allowance, while usage-summary carries structured percentages,
5+
/// user-scoped on-demand spend, and exact billing-cycle bounds. Neither response is sufficient alone.
6+
enum CursorUsageSummaryMapper {
7+
static func hasUsableSummaryPayload(_ summary: [String: Any]) -> Bool {
8+
let start = (summary["billingCycleStart"] as? String).flatMap(OpenUsageISO8601.date(from:))
9+
let end = (summary["billingCycleEnd"] as? String).flatMap(OpenUsageISO8601.date(from:))
10+
if let start, let end, end > start {
11+
return true
12+
}
13+
14+
let individual = summary["individualUsage"] as? [String: Any]
15+
let team = summary["teamUsage"] as? [String: Any]
16+
let plan = individual?["plan"] as? [String: Any]
17+
if ["totalPercentUsed", "autoPercentUsed", "apiPercentUsed"].contains(where: {
18+
ProviderParse.number(plan?[$0]) != nil
19+
}) {
20+
return true
21+
}
22+
return [individual?["onDemand"], individual?["overall"], team?["onDemand"], team?["pooled"]]
23+
.contains(where: usableDollarBucket)
24+
}
25+
26+
static func hasUsableRequestPayload(_ usage: [String: Any]) -> Bool {
27+
if let requests = usage["gpt-4"] as? [String: Any],
28+
let limit = ProviderParse.number(requests["maxRequestUsage"]), limit > 0 {
29+
return true
30+
}
31+
return (usage["startOfMonth"] as? String).flatMap(OpenUsageISO8601.date(from:)) != nil
32+
}
33+
34+
static func map(
35+
summary: [String: Any]?,
36+
requestUsage: [String: Any]?,
37+
planName: String?,
38+
unavailableMessage: String
39+
) throws -> CursorMappedUsage {
40+
let cycle = billingCycle(summary: summary, requestUsage: requestUsage)
41+
var lines: [MetricLine] = []
42+
43+
let hasRequests = appendRequests(requestUsage, cycle: cycle, to: &lines)
44+
if !hasRequests {
45+
appendSummaryTotal(summary, cycle: cycle, to: &lines)
46+
}
47+
appendStructuredPercentages(summary, cycle: cycle, to: &lines)
48+
appendOnDemand(summary, cycle: cycle, to: &lines)
49+
50+
guard !lines.isEmpty else {
51+
throw CursorUsageError.requestBasedUnavailable(unavailableMessage)
52+
}
53+
54+
return CursorMappedUsage(
55+
plan: planLabel(planName) ?? planLabel(summary?["membershipType"] as? String),
56+
lines: lines
57+
)
58+
}
59+
60+
/// The default Total Usage widget must carry the included allowance on request-based Enterprise
61+
/// plans. Keep the legacy Requests line too, so users who manually enabled that optional widget do
62+
/// not lose it after upgrading.
63+
private static func appendRequests(
64+
_ usage: [String: Any]?,
65+
cycle: BillingCycle,
66+
to lines: inout [MetricLine]
67+
) -> Bool {
68+
guard let requests = usage?["gpt-4"] as? [String: Any],
69+
let limit = ProviderParse.number(requests["maxRequestUsage"]),
70+
limit > 0
71+
else {
72+
return false
73+
}
74+
let used = max(
75+
0,
76+
ProviderParse.number(requests["numRequests"])
77+
?? ProviderParse.number(requests["numRequestsTotal"])
78+
?? 0
79+
)
80+
for label in ["Total usage", "Requests"] {
81+
lines.append(.progress(
82+
label: label,
83+
used: used,
84+
limit: limit,
85+
format: .count(suffix: "requests"),
86+
resetsAt: cycle.resetsAt,
87+
periodDurationMs: cycle.periodDurationMs
88+
))
89+
}
90+
return true
91+
}
92+
93+
/// Supports both the live `individualUsage.plan` shape and the pooled/overall variants reported by
94+
/// other Enterprise accounts. A request allowance wins because it is the dashboard's included cap.
95+
private static func appendSummaryTotal(
96+
_ summary: [String: Any]?,
97+
cycle: BillingCycle,
98+
to lines: inout [MetricLine]
99+
) {
100+
let individual = summary?["individualUsage"] as? [String: Any]
101+
let team = summary?["teamUsage"] as? [String: Any]
102+
let limitType = (summary?["limitType"] as? String)?.lowercased()
103+
104+
if limitType == "team", let pooled = dollarMeter(team?["pooled"]) {
105+
appendDollarProgress(pooled, label: "Total usage", cycle: cycle, to: &lines)
106+
return
107+
}
108+
if let percent = ProviderParse.number((individual?["plan"] as? [String: Any])?["totalPercentUsed"]) {
109+
lines.append(.progress(
110+
label: "Total usage",
111+
used: percent,
112+
limit: 100,
113+
format: .percent,
114+
resetsAt: cycle.resetsAt,
115+
periodDurationMs: cycle.periodDurationMs
116+
))
117+
return
118+
}
119+
if let overall = dollarMeter(individual?["overall"]) {
120+
appendDollarProgress(overall, label: "Total usage", cycle: cycle, to: &lines)
121+
return
122+
}
123+
if let pooled = dollarMeter(team?["pooled"]) {
124+
appendDollarProgress(pooled, label: "Total usage", cycle: cycle, to: &lines)
125+
}
126+
}
127+
128+
private static func appendStructuredPercentages(
129+
_ summary: [String: Any]?,
130+
cycle: BillingCycle,
131+
to lines: inout [MetricLine]
132+
) {
133+
let plan = ((summary?["individualUsage"] as? [String: Any])?["plan"] as? [String: Any])
134+
for (key, label) in [("autoPercentUsed", "Auto usage"), ("apiPercentUsed", "API usage")] {
135+
guard let percent = ProviderParse.number(plan?[key]) else { continue }
136+
lines.append(.progress(
137+
label: label,
138+
used: percent,
139+
limit: 100,
140+
format: .percent,
141+
resetsAt: cycle.resetsAt,
142+
periodDurationMs: cycle.periodDurationMs
143+
))
144+
}
145+
}
146+
147+
/// The dashboard's headline On-Demand card is user-scoped. Only use the organization aggregate
148+
/// when Cursor omits the individual bucket for that account shape.
149+
private static func appendOnDemand(
150+
_ summary: [String: Any]?,
151+
cycle: BillingCycle,
152+
to lines: inout [MetricLine]
153+
) {
154+
let individual = summary?["individualUsage"] as? [String: Any]
155+
let team = summary?["teamUsage"] as? [String: Any]
156+
if appendOnDemandBucket(individual?["onDemand"], cycle: cycle, to: &lines) {
157+
return
158+
}
159+
_ = appendOnDemandBucket(team?["onDemand"], cycle: cycle, to: &lines)
160+
}
161+
162+
/// Returns whether a usable row was emitted. An Enterprise response can include an individual
163+
/// placeholder bucket (`enabled: false` or a zero limit) alongside a valid team bucket, so merely
164+
/// finding the individual dictionary must not suppress the fallback.
165+
private static func appendOnDemandBucket(
166+
_ value: Any?,
167+
cycle: BillingCycle,
168+
to lines: inout [MetricLine]
169+
) -> Bool {
170+
guard let bucket = value as? [String: Any], bucket["enabled"] as? Bool != false else {
171+
return false
172+
}
173+
if let meter = dollarMeter(bucket) {
174+
appendDollarProgress(meter, label: "On-demand", cycle: cycle, to: &lines)
175+
return true
176+
}
177+
if let usedCents = ProviderParse.number(bucket["used"]), usedCents > 0 {
178+
lines.append(.values(
179+
label: "On-demand",
180+
values: [MetricValue(number: ProviderParse.centsToDollars(usedCents), kind: .dollars)]
181+
))
182+
return true
183+
}
184+
return false
185+
}
186+
187+
private static func dollarMeter(_ value: Any?) -> (used: Double, limit: Double)? {
188+
guard let bucket = value as? [String: Any],
189+
bucket["enabled"] as? Bool != false,
190+
let limit = ProviderParse.number(bucket["limit"]),
191+
limit > 0
192+
else {
193+
return nil
194+
}
195+
let reportedUsed = ProviderParse.number(bucket["used"])
196+
let inferredUsed = max(0, limit - (ProviderParse.number(bucket["remaining"]) ?? limit))
197+
let used = reportedUsed.flatMap { $0 > 0 ? $0 : nil } ?? inferredUsed
198+
return (max(0, used), limit)
199+
}
200+
201+
private static func usableDollarBucket(_ value: Any?) -> Bool {
202+
guard let bucket = value as? [String: Any], bucket["enabled"] as? Bool != false else {
203+
return false
204+
}
205+
return dollarMeter(bucket) != nil || (ProviderParse.number(bucket["used"]) ?? 0) > 0
206+
}
207+
208+
private static func appendDollarProgress(
209+
_ meter: (used: Double, limit: Double),
210+
label: String,
211+
cycle: BillingCycle,
212+
to lines: inout [MetricLine]
213+
) {
214+
lines.append(.progress(
215+
label: label,
216+
used: ProviderParse.centsToDollars(meter.used),
217+
limit: ProviderParse.centsToDollars(meter.limit),
218+
format: .dollars,
219+
resetsAt: cycle.resetsAt,
220+
periodDurationMs: cycle.periodDurationMs
221+
))
222+
}
223+
224+
private static func billingCycle(
225+
summary: [String: Any]?,
226+
requestUsage: [String: Any]?
227+
) -> BillingCycle {
228+
let start = (summary?["billingCycleStart"] as? String).flatMap(OpenUsageISO8601.date(from:))
229+
let end = (summary?["billingCycleEnd"] as? String).flatMap(OpenUsageISO8601.date(from:))
230+
if let start, let end, end > start {
231+
return BillingCycle(
232+
resetsAt: end,
233+
periodDurationMs: Int(end.timeIntervalSince(start) * 1000)
234+
)
235+
}
236+
237+
let requestStart = (requestUsage?["startOfMonth"] as? String).flatMap(OpenUsageISO8601.date(from:))
238+
return BillingCycle(
239+
resetsAt: requestStart?.addingTimeInterval(TimeInterval(CursorUsageMapper.billingPeriodMs) / 1000),
240+
periodDurationMs: CursorUsageMapper.billingPeriodMs
241+
)
242+
}
243+
244+
private static func planLabel(_ value: String?) -> String? {
245+
guard let value else { return nil }
246+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
247+
return trimmed.isEmpty ? nil : trimmed.titleCased(separator: \.isWhitespace)
248+
}
249+
250+
private struct BillingCycle {
251+
var resetsAt: Date?
252+
var periodDurationMs: Int
253+
}
254+
}

Sources/OpenUsage/Services/LocalLimitsAPI.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ enum LocalLimitsAPI {
9595

9696
init?(resource: LimitResourceDescriptor, line: MetricLine) {
9797
kind = resource.kind
98-
unit = resource.unit
98+
unit = Self.progressUnit(line) ?? resource.unit
9999

100100
switch (resource.source, line) {
101101
case (.progress, .progress(_, let rawUsed, let rawLimit, _, let reset, let periodMs, _)):
@@ -131,6 +131,21 @@ enum LocalLimitsAPI {
131131
}
132132
}
133133

134+
/// A descriptor names the stable resource and supplies a fallback unit for value rows. Progress
135+
/// rows carry their actual runtime unit, which can vary by plan (for example Cursor Total Usage
136+
/// is percent on individual plans and requests on request-based Enterprise plans).
137+
private static func progressUnit(_ line: MetricLine) -> String? {
138+
guard case .progress(_, _, _, let format, _, _, _) = line else { return nil }
139+
switch format {
140+
case .percent:
141+
return "percent"
142+
case .dollars:
143+
return "usd"
144+
case .count(let suffix):
145+
return suffix.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
146+
}
147+
}
148+
134149
private mutating func applyProgress(
135150
used rawUsed: Double,
136151
limit rawLimit: Double,

0 commit comments

Comments
 (0)