diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a6d5a01f..07a4ae58b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,16 @@ jobs: - name: Run tests run: swift test + + - name: Package PR 986 local development app + run: | + ./script/build_and_run.sh build + ditto -c -k --keepParent dist/OpenUsage.app OpenUsage-PR986.app.zip + + - name: Upload PR 986 local development app + uses: actions/upload-artifact@v4 + with: + name: OpenUsage-PR986-${{ github.event.pull_request.head.sha }} + path: OpenUsage-PR986.app.zip + if-no-files-found: error + retention-days: 1 diff --git a/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift b/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift index 3a5efa200..4eab03e5c 100644 --- a/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift +++ b/Sources/OpenUsage/Providers/Cursor/CursorProvider.swift @@ -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) { @@ -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 } diff --git a/Sources/OpenUsage/Providers/Cursor/CursorUsageClient.swift b/Sources/OpenUsage/Providers/Cursor/CursorUsageClient.swift index 69283ece8..d7eb09739 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" @@ -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( diff --git a/Sources/OpenUsage/Providers/Cursor/CursorUsageSummaryMapper.swift b/Sources/OpenUsage/Providers/Cursor/CursorUsageSummaryMapper.swift new file mode 100644 index 000000000..818444bc8 --- /dev/null +++ b/Sources/OpenUsage/Providers/Cursor/CursorUsageSummaryMapper.swift @@ -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 + } +} diff --git a/Sources/OpenUsage/Services/LocalLimitsAPI.swift b/Sources/OpenUsage/Services/LocalLimitsAPI.swift index 35c086419..a9f9daa97 100644 --- a/Sources/OpenUsage/Services/LocalLimitsAPI.swift +++ b/Sources/OpenUsage/Services/LocalLimitsAPI.swift @@ -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, _)): @@ -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, diff --git a/Tests/OpenUsageTests/CursorUsageSummaryTests.swift b/Tests/OpenUsageTests/CursorUsageSummaryTests.swift new file mode 100644 index 000000000..a88c6a3c3 --- /dev/null +++ b/Tests/OpenUsageTests/CursorUsageSummaryTests.swift @@ -0,0 +1,325 @@ +import XCTest +@testable import OpenUsage + +final class CursorUsageSummaryMapperTests: XCTestCase { + func testCombinesLiveEnterpriseRequestAndIndividualOnDemandShapes() throws { + let mapped = try CursorUsageSummaryMapper.map( + summary: [ + "billingCycleStart": "2026-07-01T00:00:00.000Z", + "billingCycleEnd": "2026-08-01T00:00:00.000Z", + "membershipType": "enterprise", + "limitType": "team", + "individualUsage": [ + "plan": [ + "enabled": true, + "limit": 0, + "autoPercentUsed": 0, + "apiPercentUsed": 6.25, + "totalPercentUsed": 6.25 + ], + "onDemand": ["enabled": true, "used": 0, "limit": 25_000, "remaining": 25_000] + ], + "teamUsage": [ + "onDemand": ["enabled": true, "used": 75_000, "limit": 600_000, "remaining": 525_000] + ] + ], + requestUsage: [ + "gpt-4": ["numRequests": 37, "numRequestsTotal": 37, "maxRequestUsage": 750], + "startOfMonth": "2026-07-01T00:00:00.000Z" + ], + planName: "Enterprise", + unavailableMessage: "unavailable" + ) + + XCTAssertEqual(mapped.plan, "Enterprise") + let total = try XCTUnwrap(progress(mapped.lines, "Total usage")) + XCTAssertEqual(total.used, 37) + XCTAssertEqual(total.limit, 750) + XCTAssertEqual(total.format, .count(suffix: "requests")) + XCTAssertEqual(total.resetsAt, OpenUsageISO8601.date(from: "2026-08-01T00:00:00.000Z")) + XCTAssertEqual(total.periodDurationMs, 31 * 24 * 3_600 * 1_000) + + let requests = try XCTUnwrap(progress(mapped.lines, "Requests")) + XCTAssertEqual(requests.used, 37) + XCTAssertEqual(requests.limit, 750) + XCTAssertEqual(progress(mapped.lines, "Auto usage")?.used, 0) + XCTAssertEqual(progress(mapped.lines, "API usage")?.used, 6.25) + + let onDemand = try XCTUnwrap(progress(mapped.lines, "On-demand")) + XCTAssertEqual(onDemand.used, 0) + XCTAssertEqual(onDemand.limit, 250) + XCTAssertEqual(onDemand.format, .dollars) + } + + func testFallsBackToPooledTotalAndTeamOnDemandWhenIndividualBucketsAreMissing() throws { + let mapped = try CursorUsageSummaryMapper.map( + summary: [ + "membershipType": "team", + "limitType": "team", + "teamUsage": [ + "pooled": ["enabled": true, "used": 125_000, "limit": 4_000_000, "remaining": 3_875_000], + "onDemand": ["enabled": true, "used": 50_000, "limit": 500_000, "remaining": 450_000] + ] + ], + requestUsage: nil, + planName: nil, + unavailableMessage: "unavailable" + ) + + XCTAssertEqual(mapped.plan, "Team") + XCTAssertEqual(progress(mapped.lines, "Total usage")?.used, 1_250) + XCTAssertEqual(progress(mapped.lines, "Total usage")?.limit, 40_000) + XCTAssertEqual(progress(mapped.lines, "On-demand")?.used, 500) + XCTAssertEqual(progress(mapped.lines, "On-demand")?.limit, 5_000) + } + + func testFallsBackToTeamOnDemandWhenIndividualBucketIsDisabled() throws { + let mapped = try CursorUsageSummaryMapper.map( + summary: [ + "individualUsage": [ + "plan": ["totalPercentUsed": 12], + "onDemand": ["enabled": false, "used": 0, "limit": 0] + ], + "teamUsage": [ + "onDemand": ["enabled": true, "used": 25_000, "limit": 100_000] + ] + ], + requestUsage: nil, + planName: "Enterprise", + unavailableMessage: "unavailable" + ) + + XCTAssertEqual(progress(mapped.lines, "On-demand")?.used, 250) + XCTAssertEqual(progress(mapped.lines, "On-demand")?.limit, 1_000) + } + + func testPositiveRemainingDeltaWinsOverReportedZeroSpend() throws { + let mapped = try CursorUsageSummaryMapper.map( + summary: [ + "individualUsage": [ + "plan": ["totalPercentUsed": 25], + "onDemand": ["enabled": true, "used": 0, "limit": 100_000, "remaining": 75_000] + ] + ], + requestUsage: nil, + planName: "Enterprise", + unavailableMessage: "unavailable" + ) + + XCTAssertEqual(progress(mapped.lines, "On-demand")?.used, 250) + XCTAssertEqual(progress(mapped.lines, "On-demand")?.limit, 1_000) + } + + func testRequestPayloadAlonePopulatesDefaultTotalAndOptionalRequests() throws { + let mapped = try CursorUsageSummaryMapper.map( + summary: nil, + requestUsage: [ + "gpt-4": ["numRequests": 19, "maxRequestUsage": 300], + "startOfMonth": "2026-07-01T00:00:00.000Z" + ], + planName: "Enterprise", + unavailableMessage: "unavailable" + ) + + XCTAssertEqual(progress(mapped.lines, "Total usage")?.used, 19) + XCTAssertEqual(progress(mapped.lines, "Total usage")?.limit, 300) + XCTAssertEqual(progress(mapped.lines, "Requests")?.used, 19) + XCTAssertEqual(progress(mapped.lines, "Requests")?.limit, 300) + } + + func testThrowsWhenBothFallbackPayloadsHaveNoUsableMetrics() { + XCTAssertThrowsError(try CursorUsageSummaryMapper.map( + summary: ["individualUsage": ["plan": ["limit": 0]]], + requestUsage: ["gpt-4": ["maxRequestUsage": 0]], + planName: "Enterprise", + unavailableMessage: "Enterprise usage unavailable" + )) { error in + XCTAssertEqual( + error as? CursorUsageError, + .requestBasedUnavailable("Enterprise usage unavailable") + ) + } + } + + private func progress( + _ lines: [MetricLine], + _ label: String + ) -> (used: Double, limit: Double, format: ProgressFormat, resetsAt: Date?, periodDurationMs: Int?)? { + guard case .progress(_, let used, let limit, let format, let resetsAt, let periodDurationMs, _) = + lines.first(where: { $0.label == label }) + else { + return nil + } + return (used, limit, format, resetsAt, periodDurationMs) + } +} + +@MainActor +final class CursorEnterpriseProviderTests: XCTestCase { + func testRefreshCombinesEnterpriseMetersAndStillAppendsUsageHistory() async throws { + let now = try XCTUnwrap(OpenUsageISO8601.date(from: "2026-07-13T12:00:00.000Z")) + let accessToken = makeSummaryCursorJWT(sub: "google-oauth2|enterprise-user") + let csv = """ + Date,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Cost + 2026-07-13T10:00:00Z,composer-1,No,0,1000,0,100,Included + """ + let http = RoutingHTTPClient { request in + if request.url == CursorUsageClient.usageURL { + return HTTPResponse( + statusCode: 200, + headers: [:], + body: Data(#"{"billingCycleStart":"1783923142900","billingCycleEnd":"1783923142900","displayThreshold":100}"#.utf8) + ) + } + if request.url == CursorUsageClient.planURL { + return HTTPResponse( + statusCode: 200, + headers: [:], + body: Data(#"{"planInfo":{"planName":"Enterprise","price":"Custom"}}"#.utf8) + ) + } + if request.url == CursorUsageClient.usageSummaryURL { + XCTAssertEqual( + request.headers["Cookie"], + "WorkosCursorSessionToken=enterprise-user%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", + "limitType": "team", + "individualUsage": { + "plan": { "enabled": true, "autoPercentUsed": 0, "apiPercentUsed": 6.25, "totalPercentUsed": 6.25 }, + "onDemand": { "enabled": true, "used": 0, "limit": 25000, "remaining": 25000 } + }, + "teamUsage": { + "onDemand": { "enabled": true, "used": 75000, "limit": 600000, "remaining": 525000 } + } + } + """.utf8)) + } + if request.url.absoluteString.hasPrefix(CursorUsageClient.restUsageURL.absoluteString) { + return HTTPResponse(statusCode: 200, headers: [:], body: Data(""" + { + "gpt-4": { "numRequests": 37, "numRequestsTotal": 37, "maxRequestUsage": 750 }, + "startOfMonth": "2026-07-01T00:00:00.000Z" + } + """.utf8)) + } + if request.url.absoluteString.hasPrefix(CursorUsageClient.exportCSVURL.absoluteString) { + return HTTPResponse(statusCode: 200, headers: [:], body: Data(csv.utf8)) + } + return HTTPResponse(statusCode: 404, headers: [:], body: Data()) + } + let provider = CursorProvider( + authStore: CursorAuthStore( + sqlite: SummaryCursorSQLite(values: [CursorAuthStore.accessTokenKey: accessToken]), + keychain: FakeKeychain() + ), + usageClient: CursorUsageClient(http: http), + now: { now }, + pricing: { TestPricing.bundled } + ) + + let snapshot = await provider.refresh() + + XCTAssertNil(snapshot.errorCategory) + XCTAssertEqual(snapshot.plan, "Enterprise") + XCTAssertEqual(progress(snapshot.lines, "Total usage")?.used, 37) + XCTAssertEqual(progress(snapshot.lines, "Total usage")?.limit, 750) + XCTAssertEqual(progress(snapshot.lines, "On-demand")?.limit, 250) + XCTAssertNotNil(snapshot.lines.first { $0.label == "Usage Trend" }) + XCTAssertNotNil(snapshot.lines.first { $0.label == "Today" }) + XCTAssertEqual(snapshot.usageHistory?.series.daily.count, 1) + XCTAssertEqual(snapshot.usageHistory?.series.daily.first?.date, "2026-07-13") + XCTAssertTrue(http.requests.contains { $0.url == CursorUsageClient.usageSummaryURL }) + XCTAssertTrue(http.requests.contains { request in + guard request.url.path == CursorUsageClient.restUsageURL.path, + let components = URLComponents(url: request.url, resolvingAgainstBaseURL: false) + else { + return false + } + return components.queryItems?.contains { + $0.name == "user" && $0.value == "enterprise-user" + } == true + }) + XCTAssertTrue(http.requests.contains { $0.url.absoluteString.hasPrefix(CursorUsageClient.exportCSVURL.absoluteString) }) + + let descriptors = provider.widgetDescriptors + let runtime = TestProviderRuntime( + provider: provider.provider, + descriptors: descriptors, + snapshot: snapshot + ) + let defaults = isolatedDefaults("default-widget") + let store = WidgetDataStore( + registry: WidgetRegistry(providers: [provider.provider], descriptors: descriptors), + providers: [runtime], + cache: isolatedCache(defaults), + defaults: defaults + ) + await store.refreshAll() + + let totalDescriptor = try XCTUnwrap(descriptors.first { $0.id == "cursor.usage" }) + let totalData = store.data(for: totalDescriptor) + XCTAssertTrue(DefaultLayout.metricIDs.contains("cursor.usage")) + XCTAssertFalse(DefaultLayout.metricIDs.contains("cursor.requests")) + XCTAssertTrue(totalData.hasData) + XCTAssertEqual(totalData.kind, .count) + XCTAssertEqual(totalData.used, 37) + XCTAssertEqual(totalData.limit, 750) + XCTAssertEqual(totalData.countSuffix, "requests") + + let onDemandDescriptor = try XCTUnwrap(descriptors.first { $0.id == "cursor.onDemand" }) + let onDemandData = store.data(for: onDemandDescriptor) + XCTAssertTrue(onDemandData.hasData) + XCTAssertEqual(onDemandData.kind, .dollars) + XCTAssertEqual(onDemandData.used, 0) + XCTAssertEqual(onDemandData.limit, 250) + } + + private func progress(_ lines: [MetricLine], _ label: String) -> (used: Double, limit: Double)? { + guard case .progress(_, let used, let limit, _, _, _, _) = lines.first(where: { $0.label == label }) else { + return nil + } + return (used, limit) + } + + private func isolatedDefaults(_ name: String) -> UserDefaults { + let suiteName = "OpenUsageTests.CursorEnterprise.\(name).\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func isolatedCache(_ defaults: UserDefaults) -> ProviderSnapshotCache { + ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }) + } +} + +private func makeSummaryCursorJWT(sub: String, exp: Double = 9_999_999_999) -> String { + let payload = #"{"sub":"\#(sub)","exp":\#(exp)}"# + let encoded = Data(payload.utf8).base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + return "a.\(encoded).c" +} + +private final class SummaryCursorSQLite: SQLiteAccessing, @unchecked Sendable { + private let values: [String: String] + + init(values: [String: String]) { + self.values = values + } + + func queryValue(path: String, sql: String) throws -> String? { + for (key, value) in values where sql.contains(key) { + return value + } + return nil + } + + func execute(path: String, sql: String) throws {} +} diff --git a/Tests/OpenUsageTests/LocalLimitsAPITests.swift b/Tests/OpenUsageTests/LocalLimitsAPITests.swift index 3a0b92ed1..771adf3d8 100644 --- a/Tests/OpenUsageTests/LocalLimitsAPITests.swift +++ b/Tests/OpenUsageTests/LocalLimitsAPITests.swift @@ -166,6 +166,39 @@ final class LocalLimitsAPITests: XCTestCase { XCTAssertNil(resource["utilization"]) } + func testProgressResourceUnitFollowsRuntimeMetricFormat() throws { + let provider = Provider(id: "cursor", displayName: "Cursor", icon: .providerMark("cursor")) + let total = WidgetDescriptor.percent( + id: "cursor.usage", provider: provider, title: "Total Usage", metricLabel: "Total usage" + ).exportingLimit("totalUsage", unit: "percent") + let snapshot = ProviderSnapshot( + providerID: "cursor", + displayName: "Cursor", + lines: [.progress( + label: "Total usage", used: 37, limit: 750, + format: .count(suffix: "requests") + )], + refreshedAt: fetchedAt + ) + let state = LocalUsageAPI.State( + enabledOrderedIDs: ["cursor"], + knownIDs: ["cursor"], + snapshots: ["cursor": snapshot], + limitDescriptors: ["cursor": [total]], + generatedAt: generatedAt + ) + + let root = try json(LocalUsageAPI.respond(method: "GET", path: "/v1/limits", state: state).body) + let providers = try XCTUnwrap(root["providers"] as? [String: Any]) + let cursor = try XCTUnwrap(providers["cursor"] as? [String: Any]) + let resources = try XCTUnwrap(cursor["resources"] as? [String: Any]) + let resource = try XCTUnwrap(resources["totalUsage"] as? [String: Any]) + + XCTAssertEqual(resource["unit"] as? String, "requests") + XCTAssertEqual(resource["used"] as? Double, 37) + XCTAssertEqual(resource["limit"] as? Double, 750) + } + @MainActor func testEveryProviderDeclaresTheApprovedPublicResourceKeys() { let defaults = UserDefaults(suiteName: "LocalLimitsAPITests.\(UUID().uuidString)")! diff --git a/docs/assets/cursor-enterprise-live.png b/docs/assets/cursor-enterprise-live.png new file mode 100644 index 000000000..a5d3531b0 Binary files /dev/null and b/docs/assets/cursor-enterprise-live.png differ diff --git a/docs/local-http-api.md b/docs/local-http-api.md index 652a78350..72f94d4f2 100644 --- a/docs/local-http-api.md +++ b/docs/local-http-api.md @@ -87,6 +87,9 @@ the provider supplies that meaning. A provider or resource with no current value invented as zero. `expiresAt` is always `fetchedAt` plus the same five-minute freshness interval used by the app and CLI; `stale` says whether that instant has passed. Refresh failures appear in `errors` as `{"providerId":"…","message":"…"}` while a last-good provider snapshot remains available. +For bounded progress resources, `unit` follows the provider's live metric format. For example, Cursor +`totalUsage` is `percent` on percentage-based plans, `requests` on request-based Enterprise plans, and +`usd` when Cursor reports a dollar pool. ### Public resources diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index 652c787c0..78ad7bd70 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -7,11 +7,11 @@ 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) | -| Requests | Request count vs. cap (team/enterprise accounts) | +| Total Usage | Plan usage for the billing cycle (percent or dollars; included request count vs. cap on request-based Enterprise accounts) | +| Requests | Optional copy of the included request count vs. cap for custom layouts | | 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; user-scoped when available, otherwise the team aggregate; shown as a meter when Cursor returns a limit | When Cursor reports your plan name, OpenUsage shows it beside the provider name. @@ -26,9 +26,9 @@ Today, Yesterday, Last 30 Days, and Usage Trend come from Cursor's usage export. ## Troubleshooting - **"Not logged in" / token errors** — open Cursor and make sure you're signed in, then refresh. -- **Some metrics missing** — Cursor omits fields depending on plan type (e.g. Requests only exists on request-based accounts); missing metrics simply show "No data". +- **Some metrics missing** — Cursor omits fields depending on plan type; missing metrics simply show "No data". - **Optional lookup failed** — plan, credit-grant, prepaid-balance, and request-fallback failures stay nonfatal when primary usage is available. OpenUsage records fixed, credential-free reasons in the diagnostic log. ## Under the hood -Connect RPC on `api2.cursor.sh` (dashboard usage), REST fallback at `cursor.com/api/usage` for request-based accounts, Stripe balance at `cursor.com/api/auth/stripe`, and the usage-events CSV export at `cursor.com/api/dashboard/export-usage-events-csv`. The primary dashboard usage request refreshes the token and retries once after a 401/403; optional endpoint failures stay nonfatal and are recorded in the diagnostic log. Per-day spend imputation uses exported 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). +Connect RPC on `api2.cursor.sh` (dashboard usage), combined REST fallback at `cursor.com/api/usage` and `cursor.com/api/usage-summary` for Enterprise/team accounts, Stripe balance at `cursor.com/api/auth/stripe`, and the usage-events CSV export at `cursor.com/api/dashboard/export-usage-events-csv`. The fallback combines the included request allowance with structured percentages and user-scoped on-demand spend; neither REST response is treated as the whole account snapshot by itself. The primary dashboard usage request refreshes the token and retries once after a 401/403; optional endpoint failures stay nonfatal when the other fallback response is usable and are recorded in the diagnostic log. Per-day spend imputation uses exported 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). diff --git a/docs/research/cursor-enterprise-usage.md b/docs/research/cursor-enterprise-usage.md new file mode 100644 index 000000000..ab748a404 --- /dev/null +++ b/docs/research/cursor-enterprise-usage.md @@ -0,0 +1,57 @@ +# Cursor Enterprise Included and On-Demand Usage + +## Problem + +Cursor Enterprise accounts can return no usable `planUsage` from +`GetCurrentPeriodUsage`. OpenUsage then returns early with only the legacy +request-based `Requests` line. That line is optional and disabled by default, +so the enabled Cursor widgets all render `No data` even when `/api/usage` +contains a valid included-request allowance. + +The existing fallback also cannot show included requests and on-demand spend +at the same time because it chooses one response and returns before the other +REST data and usage history are appended. + +## Observed API shapes + +A live Enterprise account was checked on 2026-07-13 with identifiers and exact +account totals omitted: + +- `GetCurrentPeriodUsage`: billing-cycle fields only; no `enabled` or + `planUsage`. +- `GetPlanInfo`: `planName = Enterprise`. +- `GET /api/usage`: `gpt-4.numRequests` and a positive + `gpt-4.maxRequestUsage`, plus `startOfMonth`. +- `GET /api/usage-summary`: ISO billing-cycle bounds, + `membershipType = enterprise`, `limitType = team`, structured percentages in + `individualUsage.plan`, a user-scoped `individualUsage.onDemand`, and a + team-scoped `teamUsage.onDemand`. This response did not contain + `teamUsage.pooled` or `individualUsage.overall`. + +## Required behavior + +1. Fetch both usage-summary and request-based usage for the existing strict + Enterprise/team fallback. +2. Use a valid request allowance as the existing default `Total Usage` meter + and keep the optional `Requests` meter for backwards compatibility. +3. Prefer user-scoped on-demand usage over the team aggregate; use the team + bucket only when the user bucket is unavailable. +4. Map structured Auto/API percentages from `individualUsage.plan`. +5. Fall back to the known pooled/overall usage-summary variants when request + counts are unavailable. +6. Append Cursor usage-history rows after fallback mapping, as on the normal + usage path. +7. Add no widget IDs and change no layout defaults. + +## Acceptance checks + +- A live-shaped Enterprise fixture renders included request usage, Auto/API + percentages, and the individual on-demand dollar cap together. +- The team on-demand aggregate does not replace a valid individual cap. +- A pooled usage-summary fixture still maps to Total Usage. +- Request-only Enterprise responses retain the previous Requests output while + also populating the default Total Usage widget. +- When neither REST response has a usable meter, the existing friendly + fallback error remains visible. +- The full Swift test suite and release build pass, followed by a rebuild and + launch against the live Enterprise account.