diff --git a/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift b/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift index 4b841566f..e96dce8c8 100644 --- a/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift +++ b/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift @@ -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") @@ -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) + } let mapped = try await requestBasedResult( accessToken: currentToken, planName: planName, @@ -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), diff --git a/Sources/OpenUsage/Providers/Cursor/CursorUsageClient.swift b/Sources/OpenUsage/Providers/Cursor/CursorUsageClient.swift index 69283ece8..56531ec02 100644 --- a/Sources/OpenUsage/Providers/Cursor/CursorUsageClient.swift +++ b/Sources/OpenUsage/Providers/Cursor/CursorUsageClient.swift @@ -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" @@ -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) diff --git a/Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift b/Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift index 3ee512716..a0dd3dea9 100644 --- a/Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift +++ b/Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift @@ -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]) + } + static func shouldUseRequestBasedFallback( usage: [String: Any], planName: String?, diff --git a/Sources/OpenUsage/Stores/DefaultLayout.swift b/Sources/OpenUsage/Stores/DefaultLayout.swift index 8831b56ae..e38b60ed3 100644 --- a/Sources/OpenUsage/Stores/DefaultLayout.swift +++ b/Sources/OpenUsage/Stores/DefaultLayout.swift @@ -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 diff --git a/Tests/OpenUsageTests/CursorProviderTests.swift b/Tests/OpenUsageTests/CursorProviderTests.swift index 282562c59..a33a19cf1 100644 --- a/Tests/OpenUsageTests/CursorProviderTests.swift +++ b/Tests/OpenUsageTests/CursorProviderTests.swift @@ -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 @@ -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?)? { diff --git a/Tests/OpenUsageTests/LayoutStoreTests.swift b/Tests/OpenUsageTests/LayoutStoreTests.swift index f8e412321..6991a0bc0 100644 --- a/Tests/OpenUsageTests/LayoutStoreTests.swift +++ b/Tests/OpenUsageTests/LayoutStoreTests.swift @@ -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" ]) } @@ -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" ]) } diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index e83e97faa..8372b6742 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -7,11 +7,12 @@ Tracks your Cursor plan usage using the login from the Cursor app. | Metric | Meaning | |---|---| | Credits | Credit balance left from grants and prepaid account balance | -| Total Usage | Plan usage for the billing cycle (percent; dollars on team plans) | +| Total Usage | Plan usage for the billing cycle (percent on pro plans; dollars on team plans). On enterprise accounts using `/api/usage-summary`, this is your individual monthly usage (`individualUsage.overall`) — the same "Your monthly usage" figure on the Cursor dashboard. | +| Team Pool | Enterprise/team pooled spend (`teamUsage.pooled` from usage-summary). Only shown when individual monthly usage is also available, so Total Usage and Team Pool are not duplicated. | | Requests | Request count vs. cap (team/enterprise accounts) | | Auto Usage | Auto-model usage percent | | API Usage | API usage percent | -| Extra Usage | On-demand spend; shown as a meter only when Cursor returns a limit | +| Extra Usage | On-demand spend (`teamUsage.onDemand` on enterprise); shown as a meter only when Cursor returns a limit | | Plan | Your plan name (optional widget) | ## Where credentials come from @@ -29,4 +30,4 @@ Cursor's per-day spend tiles (Today / Yesterday / Last 30 Days) and the Usage Tr ## Under the hood -Connect RPC on `api2.cursor.sh` (dashboard usage), REST fallback at `cursor.com/api/usage` for request-based accounts, and Stripe balance at `cursor.com/api/auth/stripe`. A 401/403 triggers one token refresh and retry. Per-day spend imputation uses token counts priced through the shared [model pricing](../pricing.md); Cursor-native models (`auto`, `composer-*`, …) come from its supplement layer, which maintainers sync from [Cursor models & pricing](https://cursor.com/docs/models-and-pricing.md). (The usage-events CSV export at `cursor.com/api/dashboard/export-usage-events-csv` previously fed the spend history; it's not currently fetched — see above.) +Connect RPC on `api2.cursor.sh` (dashboard usage), REST fallback at `cursor.com/api/usage` for request-based accounts, `cursor.com/api/usage-summary` for enterprise/team token-based accounts when dashboard usage is empty, and Stripe balance at `cursor.com/api/auth/stripe`. On enterprise accounts, usage-summary maps `individualUsage.overall` to Total Usage (monthly individual spend in cents) and `teamUsage.pooled` to Team Pool; when only pooled data is returned, it falls back to Total Usage. A 401/403 triggers one token refresh and retry. Per-day spend imputation uses token counts priced through the shared [model pricing](../pricing.md); Cursor-native models (`auto`, `composer-*`, …) come from its supplement layer, which maintainers sync from [Cursor models & pricing](https://cursor.com/docs/models-and-pricing.md). (The usage-events CSV export at `cursor.com/api/dashboard/export-usage-events-csv` previously fed the spend history; it's not currently fetched — see above.)