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
27 changes: 27 additions & 0 deletions Sources/OpenUsage/Providers/Cursor/CursorProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ final class CursorProvider: ProviderRuntime {
.percent(id: "cursor.auto", provider: provider, title: "Auto Usage", metricLabel: "Auto usage"),
.percent(id: "cursor.api", provider: provider, title: "API Usage", metricLabel: "API usage"),
.boundedDollars(id: "cursor.onDemand", provider: provider, title: "Extra Usage", metricLabel: "On-demand", limit: 100, valueWord: "spent"),
.boundedDollars(id: "cursor.teamPool", provider: provider, title: "Team Pool", metricLabel: "Team pool", limit: 100, valueWord: "spent"),
.boundedCount(id: "cursor.requests", provider: provider, title: "Requests", limit: 500,
suffix: "requests", periodDurationMs: CursorUsageMapper.billingPeriodMs),
.dollarBalance(id: "cursor.credits", provider: provider, title: "Credits", valueWord: "left")
Expand Down Expand Up @@ -109,6 +110,10 @@ final class CursorProvider: ProviderRuntime {
planInfoUnavailable: planInfoUnavailable
)
if fallback.shouldFallback {
if CursorUsageMapper.shouldUseUsageSummaryFallback(usage: usage, planName: planName),
let mapped = try? await usageSummaryResult(accessToken: currentToken, planName: planName) {
return snapshot(mapped)
}
Comment on lines +113 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent suppression of connectivity errors from usageSummaryResult

try? discards any error thrown by usageSummaryResult — including CursorUsageError.connectionFailed if cursor.com is reachable from the gRPC endpoint (api2.cursor.sh) but not from the REST endpoint. When that happens the provider silently falls through to requestBasedResult, which will also fail and ultimately surface "Enterprise usage data unavailable" rather than "Connection failed". This is consistent with the shouldTryGenericRequestFallback block above, but there the suppressed path is a speculative optimistic attempt; here it is the primary enterprise code path. Consider propagating at least connection-class errors instead of uniformly swallowing them.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/OpenUsage/Providers/Cursor/CursorProvider.swift
Line: 112-115

Comment:
**Silent suppression of connectivity errors from `usageSummaryResult`**

`try?` discards any error thrown by `usageSummaryResult` — including `CursorUsageError.connectionFailed` if `cursor.com` is reachable from the gRPC endpoint (`api2.cursor.sh`) but not from the REST endpoint. When that happens the provider silently falls through to `requestBasedResult`, which will also fail and ultimately surface "Enterprise usage data unavailable" rather than "Connection failed". This is consistent with the `shouldTryGenericRequestFallback` block above, but there the suppressed path is a speculative optimistic attempt; here it is the primary enterprise code path. Consider propagating at least connection-class errors instead of uniformly swallowing them.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

let mapped = try await requestBasedResult(
accessToken: currentToken,
planName: planName,
Expand Down Expand Up @@ -243,6 +248,28 @@ final class CursorProvider: ProviderRuntime {
return ProviderParse.jsonObject(response.body)
}

private func usageSummaryResult(accessToken: String, planName: String?) async throws -> CursorMappedUsage {
guard let response = try await usageClient.fetchUsageSummary(accessToken: accessToken),
(200..<300).contains(response.statusCode),
let body = ProviderParse.jsonObject(response.body)
else {
throw CursorUsageError.requestBasedUnavailable("Enterprise usage data unavailable. Try again later.")
}
let creditGrants = await fetchCreditGrants(accessToken: accessToken)
let stripeResponse = try? await usageClient.fetchStripeBalance(accessToken: accessToken)
let stripeBalanceCents = CursorUsageMapper.stripeBalanceCents(from: stripeResponse ?? nil)
var mapped = try CursorUsageMapper.mapUsageSummary(
body,
planName: planName,
creditGrants: creditGrants,
stripeBalanceCents: stripeBalanceCents
)
if Self.spendTrackingEnabled {
await appendSpendLines(to: &mapped.lines, accessToken: accessToken)
}
return mapped
}

private func requestBasedResult(accessToken: String, planName: String?, unavailableMessage: String) async throws -> CursorMappedUsage {
do {
guard let response = try await usageClient.fetchRequestBasedUsage(accessToken: accessToken),
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 @@ -48,6 +49,16 @@ struct CursorUsageClient: Sendable {
try await connectPost(Self.creditsURL, accessToken: accessToken)
}

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 fetchRequestBasedUsage(accessToken: String) async throws -> HTTPResponse? {
guard let session = Self.session(from: accessToken) else { return nil }
var components = URLComponents(url: Self.restUsageURL, resolvingAgainstBaseURL: false)
Expand Down
146 changes: 146 additions & 0 deletions Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,152 @@ enum CursorUsageMapper {
return CursorMappedUsage(plan: planLabel(planName), lines: lines)
}

static func mapUsageSummary(
_ summary: [String: Any],
planName: String?,
creditGrants: [String: Any]?,
stripeBalanceCents: Double
) throws -> CursorMappedUsage {
var lines: [MetricLine] = []
appendCredits(creditGrants: creditGrants, stripeBalanceCents: stripeBalanceCents, to: &lines)

let cycle = billingCycleFromSummary(summary)
let teamUsage = summary["teamUsage"] as? [String: Any]
let individualUsage = summary["individualUsage"] as? [String: Any]
let pooled = teamUsage?["pooled"] as? [String: Any]
let onDemand = teamUsage?["onDemand"] as? [String: Any]
let overall = individualUsage?["overall"] as? [String: Any]

let hasIndividualOverall = appendDollarUsageBucket(
overall,
label: "Total usage",
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs,
to: &lines
)
var hasUsageMetric = hasIndividualOverall
if !hasUsageMetric {
hasUsageMetric = appendDollarUsageBucket(
pooled,
label: "Total usage",
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs,
to: &lines
)
} else if appendDollarUsageBucket(
pooled,
label: "Team pool",
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs,
to: &lines
) {
hasUsageMetric = true
}

if let autoPercent = percentFromDisplayMessage(summary["autoModelSelectedDisplayMessage"] as? String) {
hasUsageMetric = true
lines.append(.progress(
label: "Auto usage",
used: autoPercent,
limit: 100,
format: .percent,
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs
))
}

if let apiPercent = percentFromDisplayMessage(summary["namedModelSelectedDisplayMessage"] as? String) {
hasUsageMetric = true
lines.append(.progress(
label: "API usage",
used: apiPercent,
limit: 100,
format: .percent,
resetsAt: cycle.resetsAt,
periodDurationMs: cycle.periodDurationMs
))
}

if appendDollarUsageBucket(onDemand, label: "On-demand", to: &lines) {
hasUsageMetric = true
}

guard hasUsageMetric else {
throw CursorUsageError.requestBasedUnavailable("Enterprise usage data unavailable. Try again later.")
}

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

static func shouldUseUsageSummaryFallback(usage: [String: Any], planName: String?) -> Bool {
let (shouldFallback, _) = shouldUseRequestBasedFallback(
usage: usage,
planName: planName,
planInfoUnavailable: false
)
guard shouldFallback else { return false }

let normalizedPlan = planName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
if normalizedPlan == "enterprise" || normalizedPlan == "team" {
return true
}

let spendLimitUsage = usage["spendLimitUsage"] as? [String: Any]
let pooledLimit = ProviderParse.number(spendLimitUsage?["pooledLimit"]) ?? 0
return (spendLimitUsage?["limitType"] as? String)?.lowercased() == "team" || pooledLimit > 0
}

private static func appendDollarUsageBucket(
_ bucket: [String: Any]?,
label: String,
resetsAt: Date? = nil,
periodDurationMs: Int? = nil,
to lines: inout [MetricLine]
) -> Bool {
guard bucket?["enabled"] as? Bool != false,
let limitCents = ProviderParse.number(bucket?["limit"]),
limitCents > 0
else {
return false
}
let usedCents = ProviderParse.number(bucket?["used"])
?? (limitCents - (ProviderParse.number(bucket?["remaining"]) ?? 0))
lines.append(.progress(
label: label,
used: ProviderParse.centsToDollars(usedCents),
limit: ProviderParse.centsToDollars(limitCents),
format: .dollars,
resetsAt: resetsAt,
periodDurationMs: periodDurationMs
))
return true
}

private static func billingCycleFromSummary(_ summary: [String: Any]) -> (resetsAt: Date?, periodDurationMs: Int) {
let cycleStart = (summary["billingCycleStart"] as? String).flatMap(OpenUsageISO8601.date(from:))
let cycleEnd = (summary["billingCycleEnd"] as? String).flatMap(OpenUsageISO8601.date(from:))
guard let cycleStart, let cycleEnd, cycleEnd > cycleStart else {
return (cycleEnd, billingPeriodMs)
}
return (
cycleEnd,
Int(cycleEnd.timeIntervalSince(cycleStart) * 1000)
)
}

private static func percentFromDisplayMessage(_ message: String?) -> Double? {
guard let message else { return nil }
let pattern = #"(\d+(?:\.\d+)?)\s*%"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
let range = Range(match.range(at: 1), in: message)
else {
return nil
}
return Double(message[range])
}
Comment on lines +320 to +330

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The NSRegularExpression is compiled from scratch on every call to percentFromDisplayMessage. The pattern is static, so a one-time static let avoids repeated compilation.

Suggested change
private static func percentFromDisplayMessage(_ message: String?) -> Double? {
guard let message else { return nil }
let pattern = #"(\d+(?:\.\d+)?)\s*%"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
let range = Range(match.range(at: 1), in: message)
else {
return nil
}
return Double(message[range])
}
private static let percentRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)\s*%"#)
private static func percentFromDisplayMessage(_ message: String?) -> Double? {
guard let message,
let regex = percentRegex,
let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
let range = Range(match.range(at: 1), in: message)
else {
return nil
}
return Double(message[range])
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift
Line: 311-321

Comment:
The `NSRegularExpression` is compiled from scratch on every call to `percentFromDisplayMessage`. The pattern is static, so a one-time `static let` avoids repeated compilation.

```suggestion
    private static let percentRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)\s*%"#)

    private static func percentFromDisplayMessage(_ message: String?) -> Double? {
        guard let message,
              let regex = percentRegex,
              let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
              let range = Range(match.range(at: 1), in: message)
        else {
            return nil
        }
        return Double(message[range])
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex


static func shouldUseRequestBasedFallback(
usage: [String: Any],
planName: String?,
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenUsage/Stores/DefaultLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ enum DefaultLayout {
// model-specific limits), credits, reset details, and spend rows sit below the caret.
"codex.spark", "codex.sparkWeekly",
"codex.credits", "codex.rateLimitResets", "codex.today", "codex.yesterday", "codex.last30",
"cursor.onDemand", "cursor.requests", "cursor.credits",
"cursor.onDemand", "cursor.teamPool", "cursor.requests", "cursor.credits",
"cursor.today", "cursor.yesterday", "cursor.last30",
// Copilot: Credits (the metered premium pool) + Extra Usage stay above the fold; Chat +
// Completions sit below the caret. They carry real counts on free only — on paid they're
Expand Down
99 changes: 99 additions & 0 deletions Tests/OpenUsageTests/CursorProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,53 @@ final class CursorUsageMapperTests: XCTestCase {
XCTAssertEqual(total.limit, 400, accuracy: 0.001) // of a $400.00 limit
XCTAssertFalse(mapped.lines.contains { $0.label == "Bonus spend" })
}

func testMapsEnterpriseUsageSummary() throws {
let mapped = try CursorUsageMapper.mapUsageSummary(
[
"billingCycleStart": "2026-07-01T00:00:00.000Z",
"billingCycleEnd": "2026-08-01T00:00:00.000Z",
"membershipType": "enterprise",
"limitType": "team",
"autoModelSelectedDisplayMessage": "You've used 12.5% of your included total usage",
"namedModelSelectedDisplayMessage": "You've used 7.5% of your included API usage",
"individualUsage": [
"overall": ["enabled": true, "used": 71, "limit": 10_000, "remaining": 9_929]
],
"teamUsage": [
"onDemand": ["enabled": true, "used": 0, "limit": 5_000_000, "remaining": 5_000_000],
"pooled": ["enabled": true, "used": 3_479_810, "limit": 60_000_000, "remaining": 56_520_190]
]
],
planName: nil,
creditGrants: nil,
stripeBalanceCents: 0
)

XCTAssertEqual(mapped.plan, "Enterprise")
let total = try XCTUnwrap(progress(mapped.lines, "Total usage"))
XCTAssertEqual(total.used, 0.71, accuracy: 0.01)
XCTAssertEqual(total.limit, 100, accuracy: 0.01)
XCTAssertEqual(total.resetsAt, OpenUsageISO8601.date(from: "2026-08-01T00:00:00.000Z"))
let teamPool = try XCTUnwrap(progress(mapped.lines, "Team pool"))
XCTAssertEqual(teamPool.used, 34_798.10, accuracy: 0.01)
XCTAssertEqual(teamPool.limit, 600_000, accuracy: 0.01)
XCTAssertEqual(teamPool.resetsAt, OpenUsageISO8601.date(from: "2026-08-01T00:00:00.000Z"))
XCTAssertEqual(progress(mapped.lines, "Auto usage")?.used, 12.5)
XCTAssertEqual(progress(mapped.lines, "API usage")?.used, 7.5)
let onDemand = try XCTUnwrap(progress(mapped.lines, "On-demand"))
XCTAssertEqual(onDemand.used, 0)
XCTAssertEqual(onDemand.limit, 50_000, accuracy: 0.01)
}

func testShouldUseUsageSummaryFallbackForEnterprise() {
let usage: [String: Any] = [
"enabled": true,
"planUsage": ["totalPercentUsed": NSNull()]
]
XCTAssertTrue(CursorUsageMapper.shouldUseUsageSummaryFallback(usage: usage, planName: "enterprise"))
XCTAssertFalse(CursorUsageMapper.shouldUseUsageSummaryFallback(usage: usage, planName: "pro plan"))
}
}

@MainActor
Expand Down Expand Up @@ -222,6 +269,58 @@ final class CursorProviderTests: XCTestCase {
XCTAssertEqual(progress(snapshot.lines, "API usage")?.used, 7.5)
XCTAssertEqual(progress(snapshot.lines, "On-demand")?.used, 40)
}

func testRefreshFallsBackToUsageSummaryForEnterprise() async {
let accessToken = makeCursorJWT(sub: "google-oauth2|user_enterprise")
let http = RoutingHTTPClient { request in
let url = request.url.absoluteString
if url.contains("GetCurrentPeriodUsage") {
return HTTPResponse(statusCode: 200, headers: [:], body: Data("""
{
"enabled": true,
"planUsage": {}
}
""".utf8))
}
if url.contains("GetPlanInfo") {
return HTTPResponse(statusCode: 200, headers: [:], body: Data(#"{"planInfo":{"planName":"enterprise"}}"#.utf8))
}
if url.contains("/api/usage-summary") {
XCTAssertEqual(request.headers["Cookie"], "WorkosCursorSessionToken=user_enterprise%3A%3A\(accessToken)")
return HTTPResponse(statusCode: 200, headers: [:], body: Data("""
{
"billingCycleStart": "2026-07-01T00:00:00.000Z",
"billingCycleEnd": "2026-08-01T00:00:00.000Z",
"membershipType": "enterprise",
"teamUsage": {
"pooled": { "enabled": true, "used": 100000, "limit": 4000000, "remaining": 3900000 }
}
}
""".utf8))
}
if url.contains("GetCreditGrantsBalance") {
return HTTPResponse(statusCode: 200, headers: [:], body: Data(#"{"hasCreditGrants":false}"#.utf8))
}
if url.contains("/api/auth/stripe") {
return HTTPResponse(statusCode: 200, headers: [:], body: Data(#"{"customerBalance":"0"}"#.utf8))
}
return HTTPResponse(statusCode: 404, headers: [:], body: Data())
}
let provider = CursorProvider(
authStore: CursorAuthStore(
sqlite: FakeSQLite(values: [CursorAuthStore.accessTokenKey: accessToken]),
keychain: FakeKeychain()
),
usageClient: CursorUsageClient(http: http),
now: { Date(timeIntervalSince1970: 1_800_000_000) }
)

let snapshot = await provider.refresh()

XCTAssertEqual(snapshot.plan, "Enterprise")
XCTAssertEqual(progress(snapshot.lines, "Total usage")?.used, 1000, accuracy: 0.01)
XCTAssertEqual(progress(snapshot.lines, "Total usage")?.limit, 40_000, accuracy: 0.01)
}
}

private func progress(_ lines: [MetricLine], _ label: String) -> (used: Double, limit: Double, resetsAt: Date?, periodDurationMs: Int?)? {
Expand Down
4 changes: 2 additions & 2 deletions Tests/OpenUsageTests/LayoutStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ final class LayoutStoreTests: XCTestCase {
])
// Cursor's spend tiles + usage trend are enabled, so they trail the live meters in declaration order.
XCTAssertEqual(store.orderedSupportedMetrics(for: "cursor").map(\.id), [
"cursor.usage", "cursor.auto", "cursor.api", "cursor.onDemand", "cursor.requests",
"cursor.usage", "cursor.auto", "cursor.api", "cursor.onDemand", "cursor.teamPool", "cursor.requests",
"cursor.credits", "cursor.trend", "cursor.today", "cursor.yesterday", "cursor.last30"
])
}
Expand Down Expand Up @@ -685,7 +685,7 @@ final class LayoutStoreTests: XCTestCase {
// today/yesterday/last30 rows sit below the caret alongside the other secondary metrics.
XCTAssertEqual(primaryByProvider["cursor"], ["cursor.usage", "cursor.auto", "cursor.api", "cursor.trend"])
XCTAssertEqual(expandedByProvider["cursor"], [
"cursor.onDemand", "cursor.requests", "cursor.credits",
"cursor.onDemand", "cursor.teamPool", "cursor.requests", "cursor.credits",
"cursor.today", "cursor.yesterday", "cursor.last30"
])
}
Expand Down
Loading