diff --git a/AgentBar/Models/SubscriptionPlan.swift b/AgentBar/Models/SubscriptionPlan.swift index 59bead6..411532f 100644 --- a/AgentBar/Models/SubscriptionPlan.swift +++ b/AgentBar/Models/SubscriptionPlan.swift @@ -47,15 +47,20 @@ enum CursorPlan: String, CaseIterable, Codable, Sendable { case teams = "Teams" case custom = "Custom" - /// Approximate monthly premium request estimate (varies by model). - /// Claude Sonnet ~225, GPT-5 ~500, Gemini ~550 per $20. - var monthlyRequestEstimate: Double { + /// Approximate monthly included-usage allotment, in US dollars. + /// + /// Since June 2025 Cursor bills on credit/usage-based pricing rather than a + /// premium-request count, so usage is tracked as the dollar value of model + /// usage consumed. These figures estimate each plan's included allotment + /// (Ultra includes ~20x Pro); users on `.custom` can override the limit in + /// Settings. + var monthlyUsageLimitUSD: Double { switch self { - case .free: return 50 - case .pro: return 500 - case .proPlus: return 1500 - case .ultra: return 5000 - case .teams: return 1000 + case .free: return 0 + case .pro: return 20 + case .proPlus: return 60 + case .ultra: return 400 + case .teams: return 40 case .custom: return 0 } } diff --git a/AgentBar/Services/CursorUsageProvider.swift b/AgentBar/Services/CursorUsageProvider.swift index e348546..656964e 100644 --- a/AgentBar/Services/CursorUsageProvider.swift +++ b/AgentBar/Services/CursorUsageProvider.swift @@ -3,32 +3,33 @@ import SQLite3 // MARK: - API Response Models +/// Legacy `/api/usage` response. +/// +/// Cursor froze the per-model request counters (`numRequests`, `numTokens`, …) at 0 +/// when it moved to credit/usage-based pricing in June 2025, so we only read +/// `startOfMonth` from this endpoint to anchor the billing period. Real usage comes +/// from `dashboard/get-aggregated-usage-events`. struct CursorUsageResponse: Decodable, Sendable { let startOfMonth: String? - let modelUsages: [String: CursorModelUsage] - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: DynamicCodingKey.self) - let startOfMonthKey = DynamicCodingKey(stringValue: "startOfMonth")! - startOfMonth = try container.decodeIfPresent(String.self, forKey: startOfMonthKey) +} - var decodedModelUsages: [String: CursorModelUsage] = [:] - for key in container.allKeys where key.stringValue != "startOfMonth" { - if let usage = try? container.decode(CursorModelUsage.self, forKey: key) { - decodedModelUsages[key.stringValue] = usage - } - } - modelUsages = decodedModelUsages +/// `dashboard/get-aggregated-usage-events` response — the authoritative usage source +/// under Cursor's credit-based pricing. `totalCostCents / 100` is the dollar spend the +/// Cursor dashboard shows for the queried window. +struct CursorAggregatedUsageResponse: Decodable, Sendable { + struct Aggregation: Decodable, Sendable { + let totalCents: Double? } - var allModelUsages: [CursorModelUsage] { - Array(modelUsages.values) - } -} + let totalCostCents: Double? + let aggregations: [Aggregation]? -struct CursorModelUsage: Decodable, Sendable { - let numRequests: Int? - let maxRequestUsage: Int? + /// Spend in cents for the window. Prefers the server-provided total and falls + /// back to summing the per-model aggregations if it is absent. + var resolvedCents: Double { + if let totalCostCents { return totalCostCents } + return (aggregations ?? []).compactMap(\.totalCents).reduce(0, +) + } } // MARK: - Provider @@ -37,23 +38,32 @@ final class CursorUsageProvider: UsageProviderProtocol, @unchecked Sendable { let serviceType: ServiceType = .cursor private let session: URLSession - private let monthlyRequestLimit: Double + private let monthlyUsageLimitUSD: Double private let dbPathProvider: @Sendable () -> String private let defaults: UserDefaults + /// Legacy endpoint, kept only for the `startOfMonth` billing anchor. static let apiBaseURL = "https://www.cursor.com/api/usage" + /// Current usage endpoint (credit-based pricing). + static let aggregatedUsageURL = "https://cursor.com/api/dashboard/get-aggregated-usage-events" + /// Required `Origin` for the dashboard endpoint; without it the API responds 403 + /// "Invalid origin for state-changing request". + static let dashboardOrigin = "https://cursor.com" + + private static let cacheKey = "cursorUsageCache.monthly" + static let defaultDBPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" }() init( - monthlyRequestLimit: Double = CursorPlan.pro.monthlyRequestEstimate, + monthlyUsageLimitUSD: Double = CursorPlan.pro.monthlyUsageLimitUSD, session: URLSession = .shared, dbPathProvider: (@Sendable () -> String)? = nil, defaults: UserDefaults = .standard ) { - self.monthlyRequestLimit = monthlyRequestLimit + self.monthlyUsageLimitUSD = monthlyUsageLimitUSD self.session = session self.dbPathProvider = dbPathProvider ?? { Self.defaultDBPath } self.defaults = defaults @@ -74,23 +84,49 @@ final class CursorUsageProvider: UsageProviderProtocol, @unchecked Sendable { private func fetchUsageFromAPI() async throws -> UsageData { let dbPath = dbPathProvider() - // 1. Read JWT from SQLite + // 1. Read JWT from SQLite and derive the user id. let jwt = try readAccessToken(from: dbPath) - - // 2. Decode JWT to extract user ID let userId = try Self.decodeUserIdFromJWT(jwt) + let cookie = Self.sessionCookie(userId: userId, jwt: jwt) + + // 2. Anchor the billing period via the legacy endpoint. Its request counters are + // always 0 under credit-based pricing, so only `startOfMonth` is used. + let legacy = try await fetchLegacyUsage(userId: userId, cookie: cookie) + let periodStart = legacy.startOfMonth.flatMap(DateUtils.parseISO8601) + ?? Self.startOfCurrentMonthUTC() + let resetTime = legacy.startOfMonth.flatMap(Self.parseStartOfMonthReset) + ?? CopilotUsageProvider.firstOfNextMonthUTC() + + // 3. Real usage: dollars spent this period from aggregated usage events. + let spentCents = try await fetchAggregatedSpendCents(cookie: cookie, since: periodStart) + + let metric = UsageMetric( + used: spentCents / 100.0, + total: monthlyUsageLimitUSD, + unit: .dollars, + resetTime: resetTime + ) + saveMetricCache(metric, forKey: Self.cacheKey) - // 3. Call Cursor usage API + return UsageData( + service: .cursor, + fiveHourUsage: metric, + weeklyUsage: nil, + lastUpdated: Date(), + isAvailable: true, + planName: resolvedPlanName() + ) + } + + // MARK: - API Calls + + private func fetchLegacyUsage(userId: String, cookie: String) async throws -> CursorUsageResponse { var components = URLComponents(string: Self.apiBaseURL) components?.queryItems = [URLQueryItem(name: "user", value: userId)] guard let url = components?.url else { throw APIError.invalidResponse } - let encodedUserId = Self.percentEncodeCookieComponent(userId) - let encodedJWT = Self.percentEncodeCookieComponent(jwt) - let cookie = "\(encodedUserId)%3A%3A\(encodedJWT)" - var request = URLRequest(url: url, timeoutInterval: 10) request.httpMethod = "GET" request.setValue("WorkosCursorSessionToken=\(cookie)", forHTTPHeaderField: "Cookie") @@ -98,72 +134,69 @@ final class CursorUsageProvider: UsageProviderProtocol, @unchecked Sendable { request.setValue("AgentBar", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) + try Self.validate(response) + return try JSONDecoder().decode(CursorUsageResponse.self, from: data) + } - guard let httpResponse = response as? HTTPURLResponse else { + private func fetchAggregatedSpendCents(cookie: String, since periodStart: Date) async throws -> Double { + guard let url = URL(string: Self.aggregatedUsageURL) else { throw APIError.invalidResponse } + let body: [String: Any] = [ + "teamId": -1, + "startDate": String(Int(periodStart.timeIntervalSince1970 * 1000)), + "endDate": String(Int(Date().timeIntervalSince1970 * 1000)) + ] + + // Aggregating a full billing period of usage events is slow on a cold + // request, so allow more time than the lightweight legacy call. + var request = URLRequest(url: url, timeoutInterval: 30) + request.httpMethod = "POST" + request.setValue("WorkosCursorSessionToken=\(cookie)", forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // The dashboard API rejects requests without a matching Origin (CSRF guard). + request.setValue(Self.dashboardOrigin, forHTTPHeaderField: "Origin") + request.setValue("AgentBar", forHTTPHeaderField: "User-Agent") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + try Self.validate(response) + return try JSONDecoder().decode(CursorAggregatedUsageResponse.self, from: data).resolvedCents + } + + private static func validate(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } guard (200...299).contains(httpResponse.statusCode) else { if httpResponse.statusCode == 401 { throw APIError.unauthorized } throw APIError.httpError(httpResponse.statusCode) } + } - let usageResponse = try JSONDecoder().decode(CursorUsageResponse.self, from: data) - - // 4. Sum requests across all model buckets - let totalRequests = usageResponse.allModelUsages.compactMap(\.numRequests).reduce(0, +) - - // 5. Determine total from maxRequestUsage or plan limit - let apiLimit = usageResponse.allModelUsages.compactMap(\.maxRequestUsage).max() - let total = Double(apiLimit ?? Int(monthlyRequestLimit)) - - // 6. Compute reset time from startOfMonth + 1 month - let resetTime: Date? - if let startStr = usageResponse.startOfMonth { - resetTime = Self.parseStartOfMonthReset(startStr) - } else { - resetTime = CopilotUsageProvider.firstOfNextMonthUTC() - } - - let metric = UsageMetric( - used: Double(totalRequests), - total: total, - unit: .requests, - resetTime: resetTime - ) - saveMetricCache(metric, forKey: "cursorUsageCache.monthly") - - let planName = (defaults.string(forKey: "cursorPlan") + private func resolvedPlanName() -> String { + (defaults.string(forKey: "cursorPlan") .flatMap { CursorPlan(rawValue: $0) } ?? .pro).rawValue - - return UsageData( - service: .cursor, - fiveHourUsage: metric, - weeklyUsage: nil, - lastUpdated: Date(), - isAvailable: true, - planName: planName - ) } // MARK: - Usage Caching private func cachedOrThrow(_ error: Error) throws -> UsageData { let now = Date() - guard let cached = validCachedMetric(forKey: "cursorUsageCache.monthly", now: now) else { + guard let cached = validCachedMetric(forKey: Self.cacheKey, now: now) else { throw error } - let planName = (defaults.string(forKey: "cursorPlan") - .flatMap { CursorPlan(rawValue: $0) } ?? .pro).rawValue return UsageData( service: .cursor, fiveHourUsage: cached, weeklyUsage: nil, lastUpdated: now, isAvailable: true, - planName: planName + planName: resolvedPlanName() ) } @@ -171,7 +204,7 @@ final class CursorUsageProvider: UsageProviderProtocol, @unchecked Sendable { guard defaults.object(forKey: "\(key).used") != nil else { return nil } let used = defaults.double(forKey: "\(key).used") let total = defaults.object(forKey: "\(key).total") != nil - ? defaults.double(forKey: "\(key).total") : monthlyRequestLimit + ? defaults.double(forKey: "\(key).total") : monthlyUsageLimitUSD let resetTimestamp = defaults.object(forKey: "\(key).resetTime") as? Double let resetTime = resetTimestamp.map { Date(timeIntervalSince1970: $0) } @@ -183,7 +216,7 @@ final class CursorUsageProvider: UsageProviderProtocol, @unchecked Sendable { clearMetricCache(forKey: key) return nil } - return UsageMetric(used: used, total: total, unit: .requests, resetTime: resetTime) + return UsageMetric(used: used, total: total, unit: .dollars, resetTime: resetTime) } private func saveMetricCache(_ metric: UsageMetric, forKey key: String) { @@ -248,6 +281,13 @@ final class CursorUsageProvider: UsageProviderProtocol, @unchecked Sendable { // MARK: - Helpers + /// Builds the `WorkosCursorSessionToken` value: `::`, percent-encoded. + static func sessionCookie(userId: String, jwt: String) -> String { + let encodedUserId = percentEncodeCookieComponent(userId) + let encodedJWT = percentEncodeCookieComponent(jwt) + return "\(encodedUserId)%3A%3A\(encodedJWT)" + } + static func base64URLDecode(_ string: String) -> Data? { var base64 = string .replacingOccurrences(of: "-", with: "+") @@ -272,4 +312,12 @@ final class CursorUsageProvider: UsageProviderProtocol, @unchecked Sendable { calendar.timeZone = TimeZone(identifier: "UTC")! return calendar.date(byAdding: .month, value: 1, to: startDate) } + + /// Fallback billing-period start when the API omits `startOfMonth`. + static func startOfCurrentMonthUTC() -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let components = calendar.dateComponents([.year, .month], from: Date()) + return calendar.date(from: components) ?? Date() + } } diff --git a/AgentBar/ViewModels/UsageViewModel.swift b/AgentBar/ViewModels/UsageViewModel.swift index 67ce2fd..18f10ad 100644 --- a/AgentBar/ViewModels/UsageViewModel.swift +++ b/AgentBar/ViewModels/UsageViewModel.swift @@ -164,7 +164,7 @@ final class UsageViewModel: ObservableObject { if isEnabled("cursorEnabled", in: defaults) { providers.append(CursorUsageProvider( - monthlyRequestLimit: cursorMonthlyLimit(in: defaults) + monthlyUsageLimitUSD: cursorUsageLimitUSD(in: defaults) )) } @@ -197,12 +197,12 @@ final class UsageViewModel: ObservableObject { defaults.double(forKey: "geminiDailyLimit").nonZero ?? 1_000 } - private static func cursorMonthlyLimit(in defaults: UserDefaults) -> Double { + private static func cursorUsageLimitUSD(in defaults: UserDefaults) -> Double { let plan = CursorPlan.resolveAndMigrateStoredPlan(in: defaults) if plan == .custom { - return defaults.double(forKey: "cursorMonthlyLimit").nonZero ?? CursorPlan.pro.monthlyRequestEstimate + return defaults.double(forKey: "cursorMonthlyLimit").nonZero ?? CursorPlan.pro.monthlyUsageLimitUSD } - return plan.monthlyRequestEstimate + return plan.monthlyUsageLimitUSD } } diff --git a/AgentBar/Views/Settings/SettingsView.swift b/AgentBar/Views/Settings/SettingsView.swift index ee0e3cb..3ef959a 100644 --- a/AgentBar/Views/Settings/SettingsView.swift +++ b/AgentBar/Views/Settings/SettingsView.swift @@ -38,7 +38,7 @@ struct SettingsView: View { @AppStorage("cursorEnabled") private var cursorEnabled = true @AppStorage("cursorPlan") private var cursorPlan: String = CursorPlan.pro.rawValue - @AppStorage("cursorMonthlyLimit") private var cursorMonthlyLimit: Double = 500 + @AppStorage("cursorMonthlyLimit") private var cursorMonthlyLimit: Double = CursorPlan.pro.monthlyUsageLimitUSD @AppStorage("zaiEnabled") private var zaiEnabled = true @AppStorage(BuyMeACoffeeSettings.hideButtonKey) private var hideBuyMeACoffeeButton = false @@ -270,17 +270,17 @@ struct SettingsView: View { } .onChange(of: cursorPlan) { newValue in if let plan = CursorPlan(rawValue: newValue), plan != .custom { - cursorMonthlyLimit = plan.monthlyRequestEstimate + cursorMonthlyLimit = plan.monthlyUsageLimitUSD } notifyLimitsChanged() } HStack { - Text("Est. monthly requests:") + Text("Est. monthly usage:") TextField("", value: $cursorMonthlyLimit, format: .number) .frame(width: 120) .disabled(cursorPlan != CursorPlan.custom.rawValue) - Text("requests") + Text("USD") .foregroundStyle(.secondary) } .onChange(of: cursorMonthlyLimit) { _ in @@ -289,7 +289,7 @@ struct SettingsView: View { } } - Text("Credit-based pricing since June 2025. Actual limit varies by model. Token is auto-read from Cursor's local database.") + Text("Credit-based pricing since June 2025. Usage is the dollar value of model usage in the current billing period; the included allotment is an estimate. Token is auto-read from Cursor's local database.") .font(.caption) .foregroundStyle(.secondary) } @@ -644,7 +644,7 @@ struct SettingsView: View { cursorPlan = resolvedPlan.rawValue if resolvedPlan != .custom { - cursorMonthlyLimit = resolvedPlan.monthlyRequestEstimate + cursorMonthlyLimit = resolvedPlan.monthlyUsageLimitUSD } } diff --git a/AgentBarTests/CursorUsageProviderTests.swift b/AgentBarTests/CursorUsageProviderTests.swift index 5a1b23b..d9a030a 100644 --- a/AgentBarTests/CursorUsageProviderTests.swift +++ b/AgentBarTests/CursorUsageProviderTests.swift @@ -19,22 +19,16 @@ final class CursorUsageProviderTests: XCTestCase { super.tearDown() } - func testFetchesUsageFromAPI() async throws { - // Create a temp SQLite DB with a test JWT + func testFetchesDollarUsageFromAggregatedEndpoint() async throws { let dbPath = try createTempDB(jwt: makeTestJWT(sub: "user_abc123")) - let json = """ - { - "startOfMonth": "2026-02-01T00:00:00.000Z", - "gpt-4": {"numRequests": 30, "numRequestsTotal": 30, "maxRequestUsage": 500, "numTokens": 50000}, - "gpt-3.5-turbo": {"numRequests": 10, "numRequestsTotal": 10, "maxRequestUsage": null, "numTokens": 20000}, - "cursor-small": {"numRequests": 5, "numRequestsTotal": 5, "maxRequestUsage": null, "numTokens": 10000} - } - """ - CursorMockURLProtocol.stubResponse(data: Data(json.utf8), statusCode: 200) + CursorMockURLProtocol.stub( + legacy: #"{"startOfMonth": "2026-02-01T00:00:00.000Z"}"#, + aggregated: #"{"totalCostCents": 12345.6, "aggregations": []}"# + ) let provider = CursorUsageProvider( - monthlyRequestLimit: 500, + monthlyUsageLimitUSD: 400, session: CursorMockURLProtocol.session(), dbPathProvider: { dbPath }, defaults: testDefaults @@ -44,22 +38,45 @@ final class CursorUsageProviderTests: XCTestCase { XCTAssertEqual(usage.service, .cursor) XCTAssertTrue(usage.isAvailable) - XCTAssertEqual(usage.fiveHourUsage.unit, .requests) - XCTAssertEqual(usage.fiveHourUsage.used, 45) // 30 + 10 + 5 - XCTAssertEqual(usage.fiveHourUsage.total, 500) // from maxRequestUsage + XCTAssertEqual(usage.fiveHourUsage.unit, .dollars) + XCTAssertEqual(usage.fiveHourUsage.used, 123.456, accuracy: 0.0001) // totalCostCents / 100 + XCTAssertEqual(usage.fiveHourUsage.total, 400) // plan USD allotment XCTAssertNil(usage.weeklyUsage) } + func testSumsAggregationsWhenTotalCostCentsMissing() async throws { + let dbPath = try createTempDB(jwt: makeTestJWT(sub: "user_sum")) + + // No top-level totalCostCents -> fall back to summing per-model aggregations. + CursorMockURLProtocol.stub( + legacy: #"{"startOfMonth": "2026-02-01T00:00:00.000Z"}"#, + aggregated: #""" + {"aggregations": [ + {"modelIntent": "gpt-5", "totalCents": 1000.0}, + {"modelIntent": "claude", "totalCents": 250.5} + ]} + """# + ) + + let provider = CursorUsageProvider( + monthlyUsageLimitUSD: 400, + session: CursorMockURLProtocol.session(), + dbPathProvider: { dbPath }, + defaults: testDefaults + ) + + let usage = try await provider.fetchUsage() + + XCTAssertEqual(usage.fiveHourUsage.used, 12.505, accuracy: 0.0001) // (1000 + 250.5) / 100 + } + func testComputesResetFromStartOfMonth() async throws { let dbPath = try createTempDB(jwt: makeTestJWT(sub: "user_xyz")) - let json = """ - { - "startOfMonth": "2026-02-01T00:00:00.000Z", - "gpt-4": {"numRequests": 0, "numRequestsTotal": 0, "maxRequestUsage": 500, "numTokens": 0} - } - """ - CursorMockURLProtocol.stubResponse(data: Data(json.utf8), statusCode: 200) + CursorMockURLProtocol.stub( + legacy: #"{"startOfMonth": "2026-02-01T00:00:00.000Z"}"#, + aggregated: #"{"totalCostCents": 0}"# + ) let provider = CursorUsageProvider( session: CursorMockURLProtocol.session(), @@ -79,6 +96,26 @@ final class CursorUsageProviderTests: XCTestCase { XCTAssertEqual(components.day, 1) } + func testUsesPlanLimitAsTotal() async throws { + let dbPath = try createTempDB(jwt: makeTestJWT(sub: "user_total")) + + CursorMockURLProtocol.stub( + legacy: #"{"startOfMonth": "2026-02-01T00:00:00.000Z"}"#, + aggregated: #"{"totalCostCents": 5000}"# + ) + + let provider = CursorUsageProvider( + monthlyUsageLimitUSD: 60, + session: CursorMockURLProtocol.session(), + dbPathProvider: { dbPath }, + defaults: testDefaults + ) + + let usage = try await provider.fetchUsage() + + XCTAssertEqual(usage.fiveHourUsage.total, 60) + } + func testHandlesMissingDatabase() async { let provider = CursorUsageProvider( dbPathProvider: { "/nonexistent/path/state.vscdb" }, @@ -89,28 +126,32 @@ final class CursorUsageProviderTests: XCTestCase { XCTAssertFalse(isConfigured) } - func testHandlesNullMaxRequestUsage() async throws { - let dbPath = try createTempDB(jwt: makeTestJWT(sub: "user_test")) + func testSendsRequiredOriginHeaderOnAggregatedRequest() async throws { + let dbPath = try createTempDB(jwt: makeTestJWT(sub: "user_origin")) - let json = """ - { - "startOfMonth": "2026-02-01T00:00:00.000Z", - "gpt-4": {"numRequests": 100, "numRequestsTotal": 100, "maxRequestUsage": null, "numTokens": 0} - } - """ - CursorMockURLProtocol.stubResponse(data: Data(json.utf8), statusCode: 200) + CursorMockURLProtocol.stub( + legacy: #"{"startOfMonth": "2026-02-01T00:00:00.000Z"}"#, + aggregated: #"{"totalCostCents": 100}"# + ) let provider = CursorUsageProvider( - monthlyRequestLimit: 500, session: CursorMockURLProtocol.session(), dbPathProvider: { dbPath }, defaults: testDefaults ) - let usage = try await provider.fetchUsage() + _ = try await provider.fetchUsage() - // When maxRequestUsage is null, should fall back to plan limit - XCTAssertEqual(usage.fiveHourUsage.total, 500) + // The dashboard endpoint rejects requests without a matching Origin (CSRF guard). + let aggregatedRequest = CursorMockURLProtocol.capturedRequests.first { + $0.url?.absoluteString.contains("aggregated-usage-events") == true + } + XCTAssertNotNil(aggregatedRequest) + XCTAssertEqual(aggregatedRequest?.httpMethod, "POST") + XCTAssertEqual(aggregatedRequest?.value(forHTTPHeaderField: "Origin"), "https://cursor.com") + XCTAssertTrue( + aggregatedRequest?.value(forHTTPHeaderField: "Cookie")?.hasPrefix("WorkosCursorSessionToken=") == true + ) } func testEncodesReservedCharactersInUserAndCookieHeader() async throws { @@ -118,18 +159,13 @@ final class CursorUsageProviderTests: XCTestCase { let jwt = makeTestJWT(sub: userId) let dbPath = try createTempDB(jwt: jwt) - let json = """ - { - "startOfMonth": "2026-02-01T00:00:00.000Z", - "gpt-4": {"numRequests": 1, "numRequestsTotal": 1, "maxRequestUsage": 500, "numTokens": 100} - } - """ - CursorMockURLProtocol.stubResponse(data: Data(json.utf8), statusCode: 200) + CursorMockURLProtocol.stub( + legacy: #"{"startOfMonth": "2026-02-01T00:00:00.000Z"}"#, + aggregated: #"{"totalCostCents": 1}"# + ) CursorMockURLProtocol.onRequest = { request in - guard let url = request.url else { - XCTFail("Missing request URL") - return - } + // Only assert on the legacy GET, which carries the `user` query item. + guard let url = request.url, url.path.contains("/api/usage") else { return } let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let queryUserId = components?.queryItems?.first(where: { $0.name == "user" })?.value @@ -152,54 +188,6 @@ final class CursorUsageProviderTests: XCTestCase { _ = try await provider.fetchUsage() } - func testIncludesUnknownModelBucketsInTotals() async throws { - let dbPath = try createTempDB(jwt: makeTestJWT(sub: "user_dynamic")) - - let json = """ - { - "startOfMonth": "2026-02-01T00:00:00.000Z", - "gpt-4": {"numRequests": 11, "numRequestsTotal": 11, "maxRequestUsage": null, "numTokens": 1000}, - "gpt-4.1-mini": {"numRequests": 7, "numRequestsTotal": 7, "maxRequestUsage": null, "numTokens": 700} - } - """ - CursorMockURLProtocol.stubResponse(data: Data(json.utf8), statusCode: 200) - - let provider = CursorUsageProvider( - monthlyRequestLimit: 500, - session: CursorMockURLProtocol.session(), - dbPathProvider: { dbPath }, - defaults: testDefaults - ) - - let usage = try await provider.fetchUsage() - - XCTAssertEqual(usage.fiveHourUsage.used, 18) - } - - func testUsesMaximumMaxRequestUsageAcrossBuckets() async throws { - let dbPath = try createTempDB(jwt: makeTestJWT(sub: "user_limits")) - - let json = """ - { - "startOfMonth": "2026-02-01T00:00:00.000Z", - "gpt-4": {"numRequests": 10, "numRequestsTotal": 10, "maxRequestUsage": 200, "numTokens": 1000}, - "claude-3.5-sonnet": {"numRequests": 2, "numRequestsTotal": 2, "maxRequestUsage": 800, "numTokens": 500} - } - """ - CursorMockURLProtocol.stubResponse(data: Data(json.utf8), statusCode: 200) - - let provider = CursorUsageProvider( - monthlyRequestLimit: 500, - session: CursorMockURLProtocol.session(), - dbPathProvider: { dbPath }, - defaults: testDefaults - ) - - let usage = try await provider.fetchUsage() - - XCTAssertEqual(usage.fiveHourUsage.total, 800) - } - func testJWTDecoding() throws { let jwt = makeTestJWT(sub: "user_12345") let userId = try CursorUsageProvider.decodeUserIdFromJWT(jwt) @@ -259,20 +247,34 @@ final class CursorUsageProviderTests: XCTestCase { // MARK: - Mock URL Protocol +/// Routes the provider's two calls to separate stubs: the legacy `/api/usage` GET and +/// the `dashboard/get-aggregated-usage-events` POST. private final class CursorMockURLProtocol: URLProtocol, @unchecked Sendable { - nonisolated(unsafe) static var stubData: Data = Data() - nonisolated(unsafe) static var stubStatusCode: Int = 200 + struct Stub { + var data: Data + var status: Int + } + + nonisolated(unsafe) static var legacyStub = Stub(data: Data(), status: 200) + nonisolated(unsafe) static var aggregatedStub = Stub(data: Data(), status: 200) + nonisolated(unsafe) static var capturedRequests: [URLRequest] = [] nonisolated(unsafe) static var onRequest: ((URLRequest) -> Void)? static func reset() { - stubData = Data() - stubStatusCode = 200 + legacyStub = Stub(data: Data(), status: 200) + aggregatedStub = Stub(data: Data(), status: 200) + capturedRequests = [] onRequest = nil } - static func stubResponse(data: Data, statusCode: Int) { - stubData = data - stubStatusCode = statusCode + static func stub( + legacy: String, + legacyStatus: Int = 200, + aggregated: String, + aggregatedStatus: Int = 200 + ) { + legacyStub = Stub(data: Data(legacy.utf8), status: legacyStatus) + aggregatedStub = Stub(data: Data(aggregated.utf8), status: aggregatedStatus) } static func session() -> URLSession { @@ -281,21 +283,30 @@ private final class CursorMockURLProtocol: URLProtocol, @unchecked Sendable { return URLSession(configuration: config) } + private static func isAggregated(_ request: URLRequest) -> Bool { + request.url?.absoluteString.contains("aggregated-usage-events") ?? false + } + override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { + CursorMockURLProtocol.capturedRequests.append(request) CursorMockURLProtocol.onRequest?(request) + let stub = CursorMockURLProtocol.isAggregated(request) + ? CursorMockURLProtocol.aggregatedStub + : CursorMockURLProtocol.legacyStub + let response = HTTPURLResponse( url: request.url!, - statusCode: CursorMockURLProtocol.stubStatusCode, + statusCode: stub.status, httpVersion: nil, headerFields: nil )! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: CursorMockURLProtocol.stubData) + client?.urlProtocol(self, didLoad: stub.data) client?.urlProtocolDidFinishLoading(self) }