diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 84cca3c3d..a85abe836 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -93,6 +93,7 @@ public struct CursorUsageSummary: Codable, Sendable { public struct CursorIndividualUsage: Codable, Sendable { public let plan: CursorPlanUsage? + public let overall: CursorPlanUsage? public let onDemand: CursorOnDemandUsage? } @@ -128,6 +129,7 @@ public struct CursorOnDemandUsage: Codable, Sendable { public struct CursorTeamUsage: Codable, Sendable { public let onDemand: CursorOnDemandUsage? + public let pooled: CursorPlanUsage? } // MARK: - Cursor Usage API Models (Legacy Request-Based Plans) @@ -689,15 +691,23 @@ public struct CursorStatusProbe: Sendable { return formatter.date(from: dateString) ?? ISO8601DateFormatter().date(from: dateString) } + // Cursor uses different keys across account types: + // - individualUsage.plan: classic personal plans + // - individualUsage.overall: enterprise/team accounts + // - teamUsage.pooled: fallback pooled quota for some org plans + let primaryUsage = summary.individualUsage?.plan + ?? summary.individualUsage?.overall + ?? summary.teamUsage?.pooled + // Convert cents to USD (plan percent derives from raw values to avoid percent unit mismatches). - // Use plan.limit directly - breakdown.total represents total *used* credits, not the limit. - let planUsedRaw = Double(summary.individualUsage?.plan?.used ?? 0) - let planLimitRaw = Double(summary.individualUsage?.plan?.limit ?? 0) + // Use limit directly - breakdown.total represents usage, not the plan cap. + let planUsedRaw = Double(primaryUsage?.used ?? 0) + let planLimitRaw = Double(primaryUsage?.limit ?? 0) let planUsed = planUsedRaw / 100.0 let planLimit = planLimitRaw / 100.0 let planPercentUsed: Double = if planLimitRaw > 0 { (planUsedRaw / planLimitRaw) * 100 - } else if let totalPercentUsed = summary.individualUsage?.plan?.totalPercentUsed { + } else if let totalPercentUsed = primaryUsage?.totalPercentUsed { totalPercentUsed <= 1 ? totalPercentUsed * 100 : totalPercentUsed } else { 0 diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index dda883589..958f96ae2 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -49,6 +49,43 @@ struct CursorStatusProbeTests { #expect(summary.teamUsage?.onDemand?.limit == 50000) } + @Test + func `parses team overall usage summary`() throws { + let json = """ + { + "billingCycleStart": "2026-03-01T00:00:00.000Z", + "billingCycleEnd": "2026-04-01T00:00:00.000Z", + "membershipType": "enterprise", + "limitType": "team", + "individualUsage": { + "overall": { + "enabled": true, + "used": 10985, + "limit": 15000, + "remaining": 4015 + } + }, + "teamUsage": { + "pooled": { + "enabled": true, + "used": 4749435, + "limit": 10693200, + "remaining": 5943765 + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let summary = try JSONDecoder().decode(CursorUsageSummary.self, from: data) + + #expect(summary.membershipType == "enterprise") + #expect(summary.individualUsage?.plan == nil) + #expect(summary.individualUsage?.overall?.used == 10985) + #expect(summary.individualUsage?.overall?.limit == 15000) + #expect(summary.teamUsage?.pooled?.used == 4749435) + #expect(summary.teamUsage?.pooled?.limit == 10693200) + } + @Test func `parses minimal usage summary`() throws { let json = """ @@ -140,6 +177,7 @@ struct CursorStatusProbeTests { autoPercentUsed: nil, apiPercentUsed: nil, totalPercentUsed: 0.40625), + overall: nil, onDemand: nil), teamUsage: nil), userInfo: nil, @@ -170,6 +208,7 @@ struct CursorStatusProbeTests { autoPercentUsed: nil, apiPercentUsed: nil, totalPercentUsed: 0.5), + overall: nil, onDemand: nil), teamUsage: nil), userInfo: nil, @@ -178,6 +217,49 @@ struct CursorStatusProbeTests { #expect(snapshot.planPercentUsed == 50.0) } + @Test + func `uses overall usage when plan bucket is absent`() { + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: "2026-03-01T00:00:00.000Z", + billingCycleEnd: "2026-04-01T00:00:00.000Z", + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: nil, + overall: CursorPlanUsage( + enabled: true, + used: 10985, + limit: 15000, + remaining: 4015, + breakdown: nil, + autoPercentUsed: nil, + apiPercentUsed: nil, + totalPercentUsed: nil), + onDemand: nil), + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPlanUsage( + enabled: true, + used: 4749435, + limit: 10693200, + remaining: 5943765, + breakdown: nil, + autoPercentUsed: nil, + apiPercentUsed: nil, + totalPercentUsed: nil))), + userInfo: nil, + rawJSON: nil) + + #expect(snapshot.planUsedUSD == 109.85) + #expect(snapshot.planLimitUSD == 150.0) + #expect(abs(snapshot.planPercentUsed - 73.23333333333333) < 0.0001) + } + @Test func `converts snapshot to usage snapshot`() { let snapshot = CursorStatusSnapshot(