diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index e060ff378e..51b8eb891f 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -252,10 +252,10 @@ extension UsageMenuCardView.Model { { return Self.poeInlineDashboard(usage, now: input.now) } - if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), + if [.codex, .claude, .vertexai, .bedrock, .cursor].contains(input.provider), input.tokenCostInlineDashboardEnabled, let tokenSnapshot = input.tokenSnapshot, - !tokenSnapshot.daily.isEmpty + !tokenSnapshot.daily.isEmpty || tokenSnapshot.meteredCostUSD != nil { return Self.costHistoryInlineDashboard( provider: input.provider, @@ -425,26 +425,35 @@ extension UsageMenuCardView.Model { } else { "\(providerName) \(periodLabel) cost trend" } + var kpis = [ + InlineUsageDashboardModel.KPI( + title: provider == .codex + ? "\(L("Today")) · \(L("codex_api_estimate_header"))" + : usesLatestPrimary ? L("Latest") : L("Today"), + value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", + emphasis: true), + .init( + title: historyTitle, + value: snapshot.last30DaysCostUSD + .map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", + emphasis: false), + .init( + title: tokenHistoryTitle, + value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", + emphasis: false), + ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest) + if provider == .cursor, let meteredCostUSD = snapshot.meteredCostUSD { + kpis.insert( + .init( + title: "Cursor-metered", + value: Self.costString(meteredCostUSD, currencyCode: snapshot.currencyCode), + emphasis: true), + at: 0) + } var model = InlineUsageDashboardModel( accessibilityLabel: accessibilityLabel, valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode), - kpis: [ - .init( - title: provider == .codex - ? "\(L("Today")) · \(L("codex_api_estimate_header"))" - : usesLatestPrimary ? L("Latest") : L("Today"), - value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", - emphasis: true), - .init( - title: historyTitle, - value: snapshot.last30DaysCostUSD - .map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", - emphasis: false), - .init( - title: tokenHistoryTitle, - value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", - emphasis: false), - ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest), + kpis: kpis, points: points, detailLines: details) model.currencyCode = snapshot.currencyCode @@ -707,7 +716,9 @@ extension UsageMenuCardView.Model { } } return tokens.max { - if $0.value == $1.value { return $0.key > $1.key } + if $0.value == $1.value { + return $0.key > $1.key + } return $0.value < $1.value }?.key } @@ -720,7 +731,9 @@ extension UsageMenuCardView.Model { } } return tokens.max { - if $0.value == $1.value { return $0.key > $1.key } + if $0.value == $1.value { + return $0.key > $1.key + } return $0.value < $1.value }?.key } @@ -738,7 +751,9 @@ extension UsageMenuCardView.Model { } private static func costValueStyle(currencyCode: String) -> InlineUsageDashboardModel.ValueStyle { - if currencyCode == "USD" { return .currencyUSD } + if currencyCode == "USD" { + return .currencyUSD + } let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = currencyCode @@ -770,7 +785,9 @@ extension UsageMenuCardView.Model { } } return scores.max { - if $0.value.cost == $1.value.cost { return $0.value.tokens < $1.value.tokens } + if $0.value.cost == $1.value.cost { + return $0.value.tokens < $1.value.tokens + } return $0.value.cost < $1.value.cost }?.key } diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 834e678008..64a37c5045 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -112,6 +112,7 @@ extension UsageMenuCardView.Model.TokenUsageSection { MenuCardHeightFingerprint.join([ MenuCardHeightFingerprint.field("session", self.sessionLine), MenuCardHeightFingerprint.field("month", self.monthLine), + MenuCardHeightFingerprint.field("metered", self.meteredLine), MenuCardHeightFingerprint.field("comparisons", self.comparisonLines.joined(separator: "|")), MenuCardHeightFingerprint.field("hint", self.hintLine), MenuCardHeightFingerprint.field("error", self.errorLine), diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index fc587dd330..85ed112063 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -150,10 +150,17 @@ extension UsageMenuCardView.Model { } return "\(windowLabel): \(monthCost)" }() + // Plan-metered spend over the same window (what the provider actually deducts); + // only providers that report it (currently Cursor) populate `meteredCostUSD`. + let meteredLine: String? = snapshot.meteredCostUSD.map { + let amount = UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased()) + } let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, + meteredLine: meteredLine, comparisonLines: comparisonPeriodsEnabled ? snapshot.comparisonSummaries().map { Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) @@ -194,7 +201,7 @@ extension UsageMenuCardView.Model { L("codex_api_estimate_not_billed"), L("codex_api_estimate_hint"), ] - case .claude: + case .claude, .cursor: [UsageFormatter.costEstimateHint(provider: provider)] case .vertexai: [L("cost_estimate_hint")] diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index ca2c5e6552..503b282f45 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -253,6 +253,7 @@ extension UsageMenuCardView.Model { case let (current?, candidate?): current.hintLine == candidate.hintLine && current.errorLine == candidate.errorLine && + (current.meteredLine == nil) == (candidate.meteredLine == nil) && current.comparisonLines.count == candidate.comparisonLines.count default: false diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index cd54ddb0a2..c228940462 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -86,14 +86,18 @@ struct UsageMenuCardView: View { struct TokenUsageSection { let sessionLine: String let monthLine: String + let meteredLine: String? let comparisonLines: [String] let hintLine: String? let errorLine: String? let errorCopyText: String? + /// Explicit initializer so `meteredLine`/`comparisonLines` default to empty: callers + /// that predate them (and providers that never report them) keep their call sites. init( sessionLine: String, monthLine: String, + meteredLine: String? = nil, comparisonLines: [String] = [], hintLine: String?, errorLine: String?, @@ -101,6 +105,7 @@ struct UsageMenuCardView: View { { self.sessionLine = sessionLine self.monthLine = monthLine + self.meteredLine = meteredLine self.comparisonLines = comparisonLines self.hintLine = hintLine self.errorLine = errorLine @@ -212,39 +217,10 @@ struct UsageMenuCardView: View { Divider() } if let tokenUsage = liveModel.tokenUsage { - VStack(alignment: .leading, spacing: 6) { - Text(UsageMenuCardView.Model.tokenUsageHeader(provider: liveModel.provider)) - .font(.body) - .fontWeight(.medium) - Text(tokenUsage.sessionLine) - .font(.footnote) - .lineLimit(1) - Text(tokenUsage.monthLine) - .font(.footnote) - .lineLimit(1) - ForEach(tokenUsage.comparisonLines, id: \.self) { line in - Text(line) - .font(.footnote) - .lineLimit(1) - } - if let hint = tokenUsage.hintLine, !hint.isEmpty { - Text(hint) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } - if let error = tokenUsage.errorLine, !error.isEmpty { - Text(error) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - .overlay { - ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error) - } - } - } + TokenUsageSectionContent( + provider: liveModel.provider, + tokenUsage: tokenUsage, + lineFont: .footnote) } } } @@ -428,6 +404,56 @@ private struct CopyIconButton: View { } } +/// Shared token-cost block (header, Today/window/metered/comparison lines, hint, error) used by +/// both the inline card body and the standalone cost section; only the value-line font differs. +private struct TokenUsageSectionContent: View { + let provider: UsageProvider + let tokenUsage: UsageMenuCardView.Model.TokenUsageSection + let lineFont: Font + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(UsageMenuCardView.Model.tokenUsageHeader(provider: self.provider)) + .font(.body) + .fontWeight(.medium) + Text(self.tokenUsage.sessionLine) + .font(self.lineFont) + .lineLimit(1) + Text(self.tokenUsage.monthLine) + .font(self.lineFont) + .lineLimit(1) + if let metered = self.tokenUsage.meteredLine, !metered.isEmpty { + Text(metered) + .font(self.lineFont) + .lineLimit(1) + } + ForEach(self.tokenUsage.comparisonLines, id: \.self) { line in + Text(line) + .font(self.lineFont) + .lineLimit(1) + } + if let hint = self.tokenUsage.hintLine, !hint.isEmpty { + Text(hint) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + } + if let error = self.tokenUsage.errorLine, !error.isEmpty { + Text(error) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + .overlay { + ClickToCopyOverlay(copyText: self.tokenUsage.errorCopyText ?? error) + } + } + } + } +} + private struct ProviderCostContent: View { let section: UsageMenuCardView.Model.ProviderCostSection let progressColor: Color @@ -757,39 +783,10 @@ struct UsageMenuCardCostSectionView: View { if hasTokenCost { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = liveModel.tokenUsage { - VStack(alignment: .leading, spacing: 6) { - Text(UsageMenuCardView.Model.tokenUsageHeader(provider: liveModel.provider)) - .font(.body) - .fontWeight(.medium) - Text(tokenUsage.sessionLine) - .font(.caption) - .lineLimit(1) - Text(tokenUsage.monthLine) - .font(.caption) - .lineLimit(1) - ForEach(tokenUsage.comparisonLines, id: \.self) { line in - Text(line) - .font(.caption) - .lineLimit(1) - } - if let hint = tokenUsage.hintLine, !hint.isEmpty { - Text(hint) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } - if let error = tokenUsage.errorLine, !error.isEmpty { - Text(error) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - .overlay { - ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error) - } - } - } + TokenUsageSectionContent( + provider: liveModel.provider, + tokenUsage: tokenUsage, + lineFont: .caption) } } .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) diff --git a/Sources/CodexBar/PreferencesMenuPane.swift b/Sources/CodexBar/PreferencesMenuPane.swift index cc8c984c3c..8813cb86b7 100644 --- a/Sources/CodexBar/PreferencesMenuPane.swift +++ b/Sources/CodexBar/PreferencesMenuPane.swift @@ -126,6 +126,7 @@ struct CostSummarySettingsSection: View { Text(L("cost_auto_refresh_info")) self.costStatusLine(provider: .claude) self.costStatusLine(provider: .codex) + self.costStatusLine(provider: .cursor) } } } @@ -135,7 +136,7 @@ struct CostSummarySettingsSection: View { private func costStatusLine(provider: UsageProvider) -> Text { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - guard provider == .claude || provider == .codex else { + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return Text(String(format: L("cost_status_unsupported"), name)) } diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index 0f5039f002..9dcc43edfb 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -102,6 +102,7 @@ extension StatusItemController { let lines = [ tokenUsage?.sessionLine, tokenUsage?.monthLine, + tokenUsage?.meteredLine, ] .compactMap(\.self) + (tokenUsage?.comparisonLines ?? []) @@ -127,6 +128,7 @@ extension StatusItemController { let primaryLines = ([ tokenUsage?.sessionLine, tokenUsage?.monthLine, + tokenUsage?.meteredLine, ] .compactMap(\.self) + (tokenUsage?.comparisonLines ?? []) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 90b16f1e01..9bc8f0c2e2 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -19,12 +19,33 @@ struct TokenSnapshotPublication: Sendable, Equatable { } extension UsageStore { + enum CursorCostCookiePreparation { + case proceed(String?) + case reject + } + + func prepareCursorCostCookie(for provider: UsageProvider) -> CursorCostCookiePreparation { + guard provider == .cursor, self.settings.cursorCookieSource == .manual else { + return .proceed(nil) + } + guard let header = CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) else { + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider] = "Cursor cost requires a non-empty Manual cookie header." + self.tokenFailureGates[provider]?.reset() + return .reject + } + return .proceed(header) + } + func loadTokenUsageSnapshot( provider: UsageProvider, force: Bool, now: Date, codexHomePath: String?, - historyDays: Int) async throws -> CostUsageTokenSnapshot + historyDays: Int, + cursorCookieHeaderOverride: String? = nil) async throws -> CostUsageTokenSnapshot { if let override = self._test_tokenUsageSnapshotLoaderOverride { return try await override(provider, force, now, codexHomePath, historyDays) @@ -50,6 +71,7 @@ extension UsageStore { allowVertexClaudeFallback: !self.isEnabled(.claude), codexHomePath: codexHomePath, historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, bypassScannerDebounce: true) } @@ -275,8 +297,37 @@ extension UsageStore { func tokenSnapshotScopeSignature(for provider: UsageProvider) -> String { let scope = self.tokenCostScope(for: provider) - return "\(scope.signature)|historyDays=\(self.settings.costUsageHistoryDays)" + + let historyDays = self.settings.costUsageHistoryDays + let base = "\(scope.signature)|historyDays=\(historyDays)" + "|settingsRevision=\(self.settings.costUsageSettingsRevision)" + guard provider == .cursor else { + return base + } + + let source = self.settings.cursorCookieSource + if source == .manual { + let headerFingerprint = CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader) + .map(CookieHeaderCache.credentialFingerprint) ?? "missing" + return "\(base)|cursorCookie=manual:\(headerFingerprint)" + } + + let credentialFingerprint = CookieHeaderCache.loadForDisplay(provider: .cursor) + .map { CookieHeaderCache.credentialFingerprint($0.cookieHeader) } ?? "unresolved" + return self.cursorCostScopeSignature( + historyDays: historyDays, + source: source, + credentialFingerprint: credentialFingerprint) + } + + func cursorCostScopeSignature( + historyDays: Int, + source: ProviderCookieSource, + credentialFingerprint: String) -> String + { + let scope = self.tokenCostScope(for: .cursor) + return "\(scope.signature)|historyDays=\(historyDays)" + + "|settingsRevision=\(self.settings.costUsageSettingsRevision)" + + "|cursorCookie=\(source.rawValue):\(credentialFingerprint)" } func tokenRefreshCanReuseCurrentSnapshot( @@ -297,16 +348,47 @@ extension UsageStore { provider: UsageProvider, publicationRevision: ProviderPublicationRevision, providerConfigRevision: UInt64, - costScopeSignature: String) -> Bool + historyDays: Int, + costScopeSignature: String, + fetchedCredentialScopeFingerprint: String? = nil) -> Bool { guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), self.settings.providerConfigRevision(for: provider) == providerConfigRevision, self.settings.costUsageEnabled, - self.isEnabled(provider) + self.isEnabled(provider), + self.settings.costUsageHistoryDays == historyDays else { return false } - return self.tokenSnapshotScopeSignature(for: provider) == costScopeSignature + let currentSignature = self.tokenSnapshotScopeSignature(for: provider) + if provider == .cursor, + self.settings.cursorCookieSource == .auto, + costScopeSignature.contains("|cursorCookie=auto:"), + let fetchedCredentialScopeFingerprint + { + let resolvedSignature = self.cursorCostScopeSignature( + historyDays: historyDays, + source: .auto, + credentialFingerprint: fetchedCredentialScopeFingerprint) + return currentSignature == resolvedSignature + } + return currentSignature == costScopeSignature + } + + func completedTokenCostScopeSignature( + provider: UsageProvider, + historyDays: Int, + initialSignature: String, + snapshot: CostUsageTokenSnapshot) -> String + { + guard provider == .cursor, + self.settings.cursorCookieSource == .auto, + let fingerprint = snapshot.credentialScopeFingerprint + else { return initialSignature } + return self.cursorCostScopeSignature( + historyDays: historyDays, + source: .auto, + credentialFingerprint: fingerprint) } func tokenSnapshot( diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3c9a16b98c..0710bca135 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1311,11 +1311,7 @@ extension UsageStore { func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { - self.clearTokenSnapshot(for: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenFetchScope.removeValue(forKey: provider) + self.resetTokenUsageState(for: provider) return } @@ -1333,20 +1329,19 @@ extension UsageStore { } guard self.settings.isCostUsageEffectivelyEnabled(for: provider) else { - self.clearTokenSnapshot(for: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenFetchScope.removeValue(forKey: provider) + self.resetTokenUsageState(for: provider) return } guard self.isEnabled(provider) else { - self.clearTokenSnapshot(for: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenFetchScope.removeValue(forKey: provider) + self.resetTokenUsageState(for: provider) + return + } + + // Cursor cost honors the same cookie policy as status: when the user set the cookie source + // to Off, skip the network fetch entirely (mirrors CursorProviderDescriptor.checkStatus). + if provider == .cursor, self.settings.cursorCookieSource == .off { + self.resetTokenUsageState(for: provider) return } @@ -1354,6 +1349,11 @@ extension UsageStore { let now = Date() let historyDays = self.settings.costUsageHistoryDays + // Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so + // cost and status share the same session; other sources fall back to auto resolution. + guard case let .proceed(cursorCookieHeaderOverride) = self.prepareCursorCostCookie(for: provider) else { + return + } let costScope = self.tokenCostScope(for: provider) let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider) let publicationRevision = self.providerPublicationRevision(for: provider) @@ -1391,13 +1391,21 @@ extension UsageStore { force: force, now: now, codexHomePath: costScope.codexHomePath, - historyDays: historyDays) + historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride) try Task.checkCancellation() + let completedCostScopeSignature = self.completedTokenCostScopeSignature( + provider: provider, + historyDays: historyDays, + initialSignature: costScopeSignature, + snapshot: snapshot) guard self.tokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, providerConfigRevision: providerConfigRevision, - costScopeSignature: costScopeSignature) + historyDays: historyDays, + costScopeSignature: costScopeSignature, + fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint) else { self.clearTokenFetchMetadataIfMatching( provider: provider, @@ -1406,25 +1414,19 @@ extension UsageStore { self.requestTokenRefreshAfterStaleCompletion(for: provider) return } + self.lastTokenFetchScope[provider] = completedCostScopeSignature - guard !snapshot.daily.isEmpty else { + guard !snapshot.daily.isEmpty || snapshot.meteredCostUSD != nil else { self.publishConfirmedEmptyTokenSnapshot(for: provider) self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider) self.tokenFailureGates[provider]?.recordSuccess() return } - let duration = Date().timeIntervalSince(startedAt) - let sessionCost = snapshot.sessionCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" - let monthCost = snapshot.last30DaysCostUSD - .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" - let durationText = String(format: "%.2f", duration) - let message = - "cost usage success provider=\(provider.rawValue) " + - "duration=\(durationText)s " + - "today=\(sessionCost) " + - "historyDays=\(historyDays) windowCost=\(monthCost)" - self.tokenCostLogger.info(message) + self.logTokenUsageSuccess( + provider: provider, + snapshot: snapshot, + historyDays: historyDays, + startedAt: startedAt) self.publishTokenSnapshot(snapshot, for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() @@ -1434,6 +1436,7 @@ extension UsageStore { provider: provider, publicationRevision: publicationRevision, providerConfigRevision: providerConfigRevision, + historyDays: historyDays, costScopeSignature: costScopeSignature) else { self.clearTokenFetchMetadataIfMatching( @@ -1473,6 +1476,33 @@ extension UsageStore { } } + private func resetTokenUsageState(for provider: UsageProvider) { + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) + } + + private func logTokenUsageSuccess( + provider: UsageProvider, + snapshot: CostUsageTokenSnapshot, + historyDays: Int, + startedAt: Date) + { + let durationText = String(format: "%.2f", Date().timeIntervalSince(startedAt)) + let sessionCost = snapshot.sessionCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let message = + "cost usage success provider=\(provider.rawValue) " + + "duration=\(durationText)s " + + "today=\(sessionCost) " + + "historyDays=\(historyDays) windowCost=\(monthCost)" + self.tokenCostLogger.info(message) + } + private func clearTokenFetchMetadataIfMatching( provider: UsageProvider, attemptedAt: Date, diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index cc6dcea545..0497db6ea7 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -3,7 +3,15 @@ import Commander import Foundation extension CodexBarCLI { - private static let costSupportedProviders: Set = [.claude, .codex] + private static let costSupportedProviders: Set = { + #if os(macOS) + [.claude, .codex, .cursor] + #else + // Cursor cost relies on the macOS-only dashboard fetch path; `supportsTokenSnapshot(.cursor)` + // is false elsewhere, so don't advertise Cursor cost where it can only fail. + [.claude, .codex] + #endif + }() static func runCost(_ values: ParsedValues) async { let output = CLIOutputPreferences.from(values: values) @@ -23,7 +31,7 @@ extension CodexBarCLI { guard !providers.isEmpty else { Self.exit( code: .failure, - message: "Error: cost is only supported for Claude and Codex.", + message: "Error: cost is only supported for \(Self.costSupportedProviderNames()).", output: output, kind: .args) } @@ -32,6 +40,18 @@ extension CodexBarCLI { let forceRefresh = values.flags.contains("refresh") let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format) let historyDays = Self.decodeCostHistoryDays(from: values) + // Cursor cost reuses the same cookie-source policy as usage fetches: reject the fetch when the + // user set Cursor cookies to Off, and forward the Manual header so the dashboard request uses + // the configured session instead of auto-resolving a different one. + let cursorCookieSettings: ProviderSettingsSnapshot.CursorProviderSettings? + let cursorCookieSettingsError: Error? + do { + cursorCookieSettings = try Self.cursorCookieSettings(config: config, providers: providers) + cursorCookieSettingsError = nil + } catch { + cursorCookieSettings = nil + cursorCookieSettingsError = error + } let groupBy = Self.decodeCostGroupBy(from: values) if groupBy == .project { let unsupportedProjectProviders = providers.filter { $0 != .codex } @@ -50,12 +70,27 @@ extension CodexBarCLI { var exitCode: ExitCode = .success for provider in providers where groupBy != .project || provider == .codex || format == .json { + if let error = Self.cursorCostAvailabilityError( + provider, + settings: cursorCookieSettings, + resolutionError: cursorCookieSettingsError) + { + exitCode = Self.mapError(error) + if format == .json { + payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error)) + } else if !output.jsonOnly { + Self.writeStderr("Error: \(error.localizedDescription)\n") + } + continue + } do { - // Cost usage is local-only; it does not require web/CLI provider fetches. + // Claude/Codex cost comes from local logs; Cursor cost is fetched from its + // cookie-authenticated dashboard API via the shared session resolution. let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, forceRefresh: forceRefresh, historyDays: historyDays, + cursorCookieHeaderOverride: Self.cursorCostHeaderOverride(provider, settings: cursorCookieSettings), refreshPricingInBackground: false) switch format { case .text: @@ -125,8 +160,17 @@ extension CodexBarCLI { "\(historyLabel): \(monthCost) · \($0) tokens" } ?? "\(historyLabel): \(monthCost)" + // Plan-metered spend over the same window (what Cursor actually deducts), shown + // alongside the API-rate estimate. Only providers like Cursor report it. + let meteredLine: String? = snapshot.meteredCostUSD.map { + let amount = UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + return "Cursor-metered: \(amount) (\(historyLabel.lowercased()))" + } + let hintLine = Self.costEstimateHint(provider: provider) - return [header, todayLine, monthLine, hintLine].joined(separator: "\n") + return [header, todayLine, monthLine, meteredLine, hintLine] + .compactMap(\.self) + .joined(separator: "\n") } private static func renderProjectCostText(header: String, snapshot: CostUsageTokenSnapshot) -> String { @@ -206,7 +250,7 @@ extension CodexBarCLI { return CostPayload( provider: provider.rawValue, - source: "local", + source: provider == .cursor ? "web" : "local", updatedAt: snapshot?.updatedAt ?? (error == nil ? nil : Date()), currencyCode: snapshot?.currencyCode, sessionTokens: snapshot?.sessionTokens, @@ -214,6 +258,7 @@ extension CodexBarCLI { historyDays: snapshot?.historyDays, last30DaysTokens: snapshot?.last30DaysTokens, last30DaysCostUSD: snapshot?.last30DaysCostUSD, + meteredCostUSD: snapshot?.meteredCostUSD, daily: daily, projects: projects, totals: snapshot.flatMap(Self.costTotals(from:)), @@ -318,6 +363,72 @@ extension CodexBarCLI { else { return .none } return CostGroupBy(rawValue: raw.lowercased()) ?? .none } + + /// Human-readable list of providers that support a cost report, used by both `cost` and serve. + static func costSupportedProviderNames() -> String { + self.costSupportedProviders + .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } + .sorted() + .joined(separator: ", ") + } + + /// Resolve the configured Cursor cookie settings (source + manual header) the same way the CLI + /// usage path does, so Cursor cost honors Off/Manual instead of always auto-resolving a session. + /// Shared by `cost` and the serve `/cost` route. + static func cursorCookieSettings( + config: CodexBarConfig, + providers: [UsageProvider]) throws -> ProviderSettingsSnapshot.CursorProviderSettings? + { + guard providers.contains(.cursor) else { return nil } + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let context = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try context.resolvedAccounts(for: .cursor).first + return context.settingsSnapshot(for: .cursor, account: account)?.cursor + } + + /// Return the actionable error for a Cursor cost fetch disabled by cookie-source policy. + static func cursorCostAvailabilityError( + _ provider: UsageProvider, + settings: ProviderSettingsSnapshot.CursorProviderSettings?, + resolutionError: Error? = nil) -> Error? + { + guard provider == .cursor else { return nil } + if let resolutionError { + return resolutionError + } + guard let settings else { return nil } + switch settings.cookieSource { + case .off: + return CursorCostAvailabilityError.cookieSourceOff + case .manual where CookieHeaderNormalizer.normalize(settings.manualCookieHeader) == nil: + return CursorCostAvailabilityError.manualCookieMissing + default: + return nil + } + } + + /// Manual cookie header to forward for a Cursor cost fetch, or nil for auto/non-cursor sources. + static func cursorCostHeaderOverride( + _ provider: UsageProvider, + settings: ProviderSettingsSnapshot.CursorProviderSettings?) -> String? + { + guard provider == .cursor, settings?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(settings?.manualCookieHeader) + } +} + +enum CursorCostAvailabilityError: LocalizedError { + case cookieSourceOff + case manualCookieMissing + + var errorDescription: String? { + switch self { + case .cookieSourceOff: + "Cursor cost is unavailable because the Cursor cookie source is set to Off." + case .manualCookieMissing: + "Cursor cost requires a non-empty Manual cookie header." + } + } } struct CostOptions: CommanderParsable { @@ -370,6 +481,7 @@ struct CostPayload: Encodable, Sendable { let historyDays: Int? let last30DaysTokens: Int? let last30DaysCostUSD: Double? + let meteredCostUSD: Double? let daily: [CostDailyEntryPayload] let projects: [CostProjectPayload] let totals: CostTotalsPayload? @@ -385,6 +497,7 @@ struct CostPayload: Encodable, Sendable { historyDays: Int?, last30DaysTokens: Int?, last30DaysCostUSD: Double?, + meteredCostUSD: Double? = nil, daily: [CostDailyEntryPayload], projects: [CostProjectPayload] = [], totals: CostTotalsPayload?, @@ -399,6 +512,7 @@ struct CostPayload: Encodable, Sendable { self.historyDays = historyDays self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD + self.meteredCostUSD = meteredCostUSD self.daily = daily self.projects = projects self.totals = totals diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 7db84788c6..5588e4601f 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -1306,18 +1306,41 @@ extension CodexBarCLI { let providers = Self.costProviders(from: selection) guard !providers.isEmpty else { - return Self.serveError(status: .badRequest, message: "cost is only supported for Claude and Codex") + return Self.serveError( + status: .badRequest, + message: "cost is only supported for \(Self.costSupportedProviderNames())") } + // Cursor cost honors the same cookie policy here as the `cost` command: return a provider + // error when the source is Off and forward the Manual header for an enabled fetch. + let cursorCookieSettings: ProviderSettingsSnapshot.CursorProviderSettings? + let cursorCookieSettingsError: Error? + do { + cursorCookieSettings = try Self.cursorCookieSettings(config: context.config, providers: providers) + cursorCookieSettingsError = nil + } catch { + cursorCookieSettings = nil + cursorCookieSettingsError = error + } let fetcher = CostUsageFetcher() let payload = await Self.serveCollectCostPayloads( providers: providers, context: context.collection) { provider in + if let error = Self.cursorCostAvailabilityError( + provider, + settings: cursorCookieSettings, + resolutionError: cursorCookieSettingsError) + { + return Self.makeCostPayload(provider: provider, snapshot: nil, error: error) + } do { let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, forceRefresh: false, + cursorCookieHeaderOverride: Self.cursorCostHeaderOverride( + provider, + settings: cursorCookieSettings), refreshPricingInBackground: Self.serveCostRefreshesPricingInBackground) return Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) } catch { diff --git a/Sources/CodexBarCore/CookieHeaderCache+Fingerprint.swift b/Sources/CodexBarCore/CookieHeaderCache+Fingerprint.swift new file mode 100644 index 0000000000..b3be9f71da --- /dev/null +++ b/Sources/CodexBarCore/CookieHeaderCache+Fingerprint.swift @@ -0,0 +1,22 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#endif + +extension CookieHeaderCache { + /// Stable, non-reversible identifier for a normalized credential. Safe for cache-scope + /// comparisons; never expose the cookie header itself to UI, logs, or persisted reports. + public static func credentialFingerprint(_ cookieHeader: String) -> String { + let normalized = CookieHeaderNormalizer.normalize(cookieHeader) ?? cookieHeader + #if canImport(CryptoKit) + return SHA256.hash(data: Data(normalized.utf8)) + .map { String(format: "%02x", $0) } + .joined() + #else + let digest = normalized.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in + (partial ^ UInt64(byte)) &* 1_099_511_628_211 + } + return String(digest, radix: 16) + #endif + } +} diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 162f5ac825..295fa75e06 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -3,6 +3,8 @@ import Foundation public enum CostUsageError: LocalizedError, Sendable { case unsupportedProvider(UsageProvider) case timedOut(seconds: Int) + case cursorPaginationIncomplete(expected: Int?, received: Int) + case cursorPaginationInconsistent(expected: Int, received: Int) public var errorDescription: String? { switch self { @@ -13,6 +15,13 @@ public enum CostUsageError: LocalizedError, Sendable { return "Cost refresh timed out after \(seconds / 60)m." } return "Cost refresh timed out after \(seconds)s." + case let .cursorPaginationIncomplete(expected, received): + if let expected { + return "Cursor cost refresh was incomplete (received \(received) of \(expected) events)." + } + return "Cursor cost refresh reached its pagination safety limit after \(received) events." + case let .cursorPaginationInconsistent(expected, received): + return "Cursor cost pagination was inconsistent (expected \(expected), received \(received) events)." } } } @@ -65,6 +74,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + cursorCookieHeaderOverride: String? = nil, allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true) async throws -> CostUsageTokenSnapshot @@ -77,6 +87,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, @@ -92,6 +103,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + cursorCookieHeaderOverride: String? = nil, allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, @@ -105,6 +117,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, @@ -165,6 +178,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + cursorCookieHeaderOverride: String? = nil, allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, @@ -175,7 +189,7 @@ public struct CostUsageFetcher: Sendable { modelsDevClient: ModelsDevClient = ModelsDevClient(), retryUnknownPricing: Bool = true) async throws -> CostUsageTokenSnapshot { - guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { + guard self.supportsTokenSnapshot(provider) else { throw CostUsageError.unsupportedProvider(provider) } @@ -184,16 +198,14 @@ public struct CostUsageFetcher: Sendable { // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now - if provider == .bedrock { - let daily = try await Self.loadBedrockDailyReport( - environment: environment, - since: since, - until: until) - return Self.tokenSnapshot( - from: daily, - now: now, - historyDays: clampedHistoryDays, - useCurrentLocalDayForSession: false) + if let remoteSnapshot = try await self.loadRemoteTokenSnapshot( + provider: provider, + environment: environment, + now: now, + historyDays: clampedHistoryDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride) + { + return remoteSnapshot } var options = Self.resolvedScannerOptions( @@ -323,6 +335,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, @@ -541,6 +554,23 @@ public struct CostUsageFetcher: Sendable { return cachedSnapshot.flatMap(\.self) } + /// Providers whose token-cost snapshot `loadTokenSnapshot` can produce. Cursor is + /// macOS-only because it reuses the macOS Cursor session resolution. + static func supportsTokenSnapshot(_ provider: UsageProvider) -> Bool { + switch provider { + case .codex, .claude, .vertexai, .bedrock: + return true + case .cursor: + #if os(macOS) + return true + #else + return false + #endif + default: + return false + } + } + private static func loadBedrockDailyReport( environment: [String: String], since: Date, @@ -554,11 +584,52 @@ public struct CostUsageFetcher: Sendable { environment: environment) } + /// Snap a Cursor window start to the local day boundary so the dashboard query keeps full days. + /// `since` arrives as the current instant N-1 days back, so a 1-day window would otherwise become + /// an empty exact-instant range; snapping to 00:00 keeps all of today (and the first day's early + /// hours for wider windows). + static func cursorWindowStart(_ since: Date?, calendar: Calendar = .current) -> Date? { + since.map { calendar.startOfDay(for: $0) } + } + + #if os(macOS) + /// Fetch Cursor's per-day token-cost plus its Cursor-metered total via the cookie-authenticated + /// dashboard API, reusing the same session resolution as the Cursor status probe. Like Codex and + /// Claude, the report covers the rolling `historyDays` window and the session line is tied to the + /// current local day (so a stale latest entry is never labeled as Today). + private static func loadCursorTokenSnapshot( + now: Date, + since: Date?, + historyDays: Int, + cookieHeaderOverride: String? = nil) async throws -> CostUsageTokenSnapshot + { + let probe = CursorStatusProbe(browserDetection: BrowserDetection()) + // `since` arrives as the current instant N-1 days back; snap it to the local day boundary so + // the dashboard query keeps the full first day (and all of today for a 1-day window) instead + // of filtering out earlier events at the same time-of-day. + let windowStart = Self.cursorWindowStart(since) + let report = try await probe.fetchCostReport( + since: windowStart, + until: now, + cookieHeaderOverride: cookieHeaderOverride) + return Self.tokenSnapshot( + from: report.daily, + now: now, + historyDays: historyDays, + useCurrentLocalDayForSession: true, + meteredCostUSD: report.meteredCostUSD, + credentialScopeFingerprint: report.credentialScopeFingerprint) + } + #endif + static func tokenSnapshot( from daily: CostUsageDailyReport, now: Date, historyDays: Int = 30, useCurrentLocalDayForSession: Bool = true, + meteredCostUSD: Double? = nil, + credentialScopeFingerprint: String? = nil, + historyLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], sessions: [CostUsageSessionBreakdown] = [], updatedAt: Date? = nil) -> CostUsageTokenSnapshot @@ -595,6 +666,9 @@ public struct CostUsageFetcher: Sendable { last30DaysTokens: last30DaysTokens, last30DaysCostUSD: last30DaysCostUSD, historyDays: historyDays, + historyLabel: historyLabel, + meteredCostUSD: meteredCostUSD, + credentialScopeFingerprint: credentialScopeFingerprint, daily: daily.data, projects: projects, sessions: sessions, @@ -814,3 +888,37 @@ public struct CostUsageFetcher: Sendable { } } } + +extension CostUsageFetcher { + fileprivate static func loadRemoteTokenSnapshot( + provider: UsageProvider, + environment: [String: String], + now: Date, + historyDays: Int, + cursorCookieHeaderOverride: String?) async throws -> CostUsageTokenSnapshot? + { + let since = Calendar.current.date(byAdding: .day, value: -(historyDays - 1), to: now) ?? now + if provider == .bedrock { + let daily = try await Self.loadBedrockDailyReport( + environment: environment, + since: since, + until: now) + return Self.tokenSnapshot( + from: daily, + now: now, + historyDays: historyDays, + useCurrentLocalDayForSession: false) + } + + #if os(macOS) + if provider == .cursor { + return try await self.loadCursorTokenSnapshot( + now: now, + since: since, + historyDays: historyDays, + cookieHeaderOverride: cursorCookieHeaderOverride) + } + #endif + return nil + } +} diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 09da6f1a65..d5b7849b0b 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -73,6 +73,13 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let historyDays: Int public let historyCoverageIsEstablished: Bool public let historyLabel: String? + /// Provider-metered spend over the same window as `last30DaysCostUSD` — what the plan + /// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor) + /// report this; `nil` when unknown. + public let meteredCostUSD: Double? + /// Internal credential scope used to prevent cross-account cache publication. This is a + /// non-reversible fingerprint, not account identity, and is not emitted by CLI payloads. + public let credentialScopeFingerprint: String? public let daily: [CostUsageDailyReport.Entry] public let projects: [CostUsageProjectBreakdown] public let sessions: [CostUsageSessionBreakdown] @@ -89,6 +96,8 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyDays: Int = 30, historyCoverageIsEstablished: Bool = true, historyLabel: String? = nil, + meteredCostUSD: Double? = nil, + credentialScopeFingerprint: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], sessions: [CostUsageSessionBreakdown] = [], @@ -105,6 +114,8 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyDays = historyDays self.historyCoverageIsEstablished = historyCoverageIsEstablished self.historyLabel = historyLabel + self.meteredCostUSD = meteredCostUSD + self.credentialScopeFingerprint = credentialScopeFingerprint self.daily = daily self.projects = projects self.sessions = sessions diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index 0a11544e24..244ae107be 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -34,8 +34,8 @@ public enum CursorProviderDescriptor { ProviderColor(hex: 0xEDECEC), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Cursor cost summary is not supported." }), + supportsTokenCost: true, + noDataMessage: { "No Cursor cost usage found. Sign in to Cursor in your browser or the Cursor app." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CursorStatusFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift new file mode 100644 index 0000000000..82b819a4b3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift @@ -0,0 +1,245 @@ +import Foundation + +#if os(macOS) || os(Linux) +extension CursorStatusProbe { + private struct CachedSessionFetchContext { + let cookieHeaderOverride: String? + let allowAppAuthFallback: Bool + let logger: ((String) -> Void)? + let log: (String) -> Void + let perform: @Sendable (String, String?) async throws -> Value + } + + private enum CachedSessionFetchResult { + case succeeded(Value) + case resumeFallback + } + + /// Resolve a working Cursor session, preserving selected-account and cache-ownership rules. + func resolveSession( + cookieHeaderOverride: String? = nil, + allowCachedSessions: Bool = true, + allowAppAuthFallback: Bool = true, + logger: ((String) -> Void)? = nil, + perform: @escaping @Sendable ( + _ cookieHeader: String, + _ requestUsageUserIDFallback: String?) async throws -> Value) + async throws -> Value + { + let log: (String) -> Void = { msg in logger?("[cursor] \(msg)") } + var firstRecoverableError: CursorStatusProbeError? + + if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { + log("Using manual cookie header") + return try await perform(override, nil) + } + + // A browser fallback started by this refresh must not overwrite a concurrently committed login. + var cacheObservation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + + if allowCachedSessions, + let cached = CookieHeaderCache.load(provider: .cursor), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + let context = CachedSessionFetchContext( + cookieHeaderOverride: cookieHeaderOverride, + allowAppAuthFallback: allowAppAuthFallback, + logger: logger, + log: log, + perform: perform) + switch try await self.fetchCachedSession(cached, context: context) { + case let .succeeded(value): + return value + case .resumeFallback: + #if os(macOS) + cacheObservation = cacheObservation.afterOwnedClear() + #endif + } + } + + #if os(macOS) + let browserCandidates = self.browserCookieImportOrder.cookieImportCandidates(using: self.browserDetection) + switch try await self.scanResolvedBrowsers( + browserCandidates, + importSessions: { browser in + CursorCookieImporter.importSessionsIfPresent( + browser: browser, + browserDetection: self.browserDetection, + logger: log) + }, + attemptFetch: { session in + try await self.resolveImportedSession( + session, + perform: perform, + log: log, + cacheObservation: cacheObservation) + }) + { + case let .succeeded(value): + return value + case .exhausted: + break + } + + switch try await self.scanResolvedBrowsers( + browserCandidates, + importSessions: { browser in + CursorCookieImporter.importDomainCookieSessionsIfPresent( + browser: browser, + browserDetection: self.browserDetection, + logger: log) + }, + attemptFetch: { session in + try await self.resolveImportedSession( + session, + perform: perform, + log: log, + cacheObservation: cacheObservation) + }) + { + case let .succeeded(value): + return value + case .exhausted: + break + } + #endif + + if allowCachedSessions, + let value = try await self.fetchStoredSession( + perform: perform, + log: log, + cacheObservation: cacheObservation) + { + return value + } + + // Transient errors for an explicit session must not silently switch accounts. + if let firstRecoverableError { + throw firstRecoverableError + } + + if allowAppAuthFallback, + let appSession = try? self.appAuthStore.loadSession(), + appSession.isUsable + { + log("Using Cursor.app local auth fallback") + let cookieHeader = try appSession.cookieHeader() + let fetchedValue: Value? + do { + fetchedValue = try await perform(cookieHeader, appSession.userID()) + } catch let error as CursorStatusProbeError { + fetchedValue = nil + if case .notLoggedIn = error { + log("Cursor.app local auth was rejected") + } else { + firstRecoverableError = firstRecoverableError ?? error + } + } catch { + fetchedValue = nil + firstRecoverableError = firstRecoverableError ?? .networkError(error.localizedDescription) + } + if let fetchedValue { + #if os(macOS) + let context = ResolvedSessionReconciliationContext( + cookieHeader: cookieHeader, + sourceLabel: "Cursor.app local auth", + cacheObservation: cacheObservation, + perform: perform, + log: log) + return try await self.reconcileResolvedSession(value: fetchedValue, context: context) + #else + return fetchedValue + #endif + } + } + + if let firstRecoverableError { + throw firstRecoverableError + } + throw CursorStatusProbeError.noSessionCookie + } + + private func fetchStoredSession( + perform: @escaping @Sendable (String, String?) async throws -> Value, + log: @escaping (String) -> Void, + cacheObservation: CookieHeaderCache.ConditionalMutationObservation) async throws -> Value? + { + let storedCookies = await CursorSessionStore.shared.getCookies() + guard !storedCookies.isEmpty else { return nil } + + log("Using stored session cookies") + let cookieHeader = storedCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + let value: Value + do { + value = try await perform(cookieHeader, nil) + } catch let error as CursorStatusProbeError { + if case .notLoggedIn = error { + await CursorSessionStore.shared.clearCookies() + log("Stored session invalid, cleared") + return nil + } + log("Stored session failed: \(error.localizedDescription)") + throw error + } catch { + log("Stored session failed: \(error.localizedDescription)") + throw CursorStatusProbeError.networkError(error.localizedDescription) + } + + #if os(macOS) + let context = ResolvedSessionReconciliationContext( + cookieHeader: cookieHeader, + sourceLabel: "Stored Cursor session", + cacheObservation: cacheObservation, + perform: perform, + log: log) + return try await self.reconcileResolvedSession(value: value, context: context) + #else + return value + #endif + } + + private func fetchCachedSession( + _ cached: CookieHeaderCache.Entry, + context: CachedSessionFetchContext) async throws -> CachedSessionFetchResult + { + context.log("Using cached cookie header from \(cached.sourceLabel)") + do { + return try await .succeeded(context.perform(cached.cookieHeader, nil)) + } catch let error as CursorStatusProbeError { + guard case .notLoggedIn = error else { throw error } + if let replacement = CookieHeaderCache.load(provider: .cursor), replacement != cached { + if cached.authenticationFailurePolicy == .stopFallback, + replacement.authenticationFailurePolicy != .stopFallback + { + context.log("Selected cached session was rejected; ignoring an unselected cache replacement") + throw error + } + context.log("Cached session changed while its request was in flight; retrying replacement") + return try await .succeeded(self.resolveSession( + cookieHeaderOverride: context.cookieHeaderOverride, + allowCachedSessions: true, + allowAppAuthFallback: context.allowAppAuthFallback, + logger: context.logger, + perform: context.perform)) + } + if cached.authenticationFailurePolicy == .stopFallback { + context.log("Selected cached session was rejected; refusing automatic account fallback") + throw error + } + guard CookieHeaderCache.clearIfCurrent(provider: .cursor, expected: cached) else { + if let replacement = CookieHeaderCache.load(provider: .cursor), replacement != cached { + context.log("Cached session changed before stale-session cleanup; retrying replacement") + return try await .succeeded(self.resolveSession( + cookieHeaderOverride: context.cookieHeaderOverride, + allowCachedSessions: true, + allowAppAuthFallback: context.allowAppAuthFallback, + logger: context.logger, + perform: context.perform)) + } + throw error + } + return .resumeFallback + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index a996000e1e..13c407f622 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -929,15 +929,38 @@ public actor CursorSessionStore { } } +// MARK: - Cursor Cost Report + +/// A windowed Cursor cost report: the API-rate per-day breakdown plus the Cursor-metered +/// total (what the plan actually deducts) over the same window. +/// +/// `daily` carries vendor list-price costs (`tokenUsage.totalCents`); `meteredCostUSD` sums +/// each event's `chargedCents` and is `nil` when the events reported no metered amount. +public struct CursorCostReport: Sendable { + public let daily: CostUsageDailyReport + public let meteredCostUSD: Double? + public let credentialScopeFingerprint: String + + public init( + daily: CostUsageDailyReport, + meteredCostUSD: Double?, + credentialScopeFingerprint: String) + { + self.daily = daily + self.meteredCostUSD = meteredCostUSD + self.credentialScopeFingerprint = credentialScopeFingerprint + } +} + // MARK: - Cursor Status Probe public struct CursorStatusProbe: Sendable { public let baseURL: URL public var timeout: TimeInterval = 15.0 - private let browserDetection: BrowserDetection - private let browserCookieImportOrder: BrowserCookieImportOrder + let browserDetection: BrowserDetection + let browserCookieImportOrder: BrowserCookieImportOrder private let urlSession: any ProviderHTTPTransport - private let appAuthStore: any CursorAppAuthSessionProviding + let appAuthStore: any CursorAppAuthSessionProviding public init( baseURL: URL = URL(string: "https://cursor.com")!, @@ -990,194 +1013,52 @@ public struct CursorStatusProbe: Sendable { logger: ((String) -> Void)? = nil) async throws -> CursorStatusSnapshot { - let log: (String) -> Void = { msg in logger?("[cursor] \(msg)") } - var firstRecoverableError: CursorStatusProbeError? - - if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { - log("Using manual cookie header") - return try await self.fetchWithCookieHeader(override) - } - - #if os(macOS) - // Capture before any authentication request can suspend. A later browser fallback must not replace an - // interactive login that commits while this refresh is awaiting an earlier cached-session request. - var cacheObservation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) - #endif - - if allowCachedSessions, - let cached = CookieHeaderCache.load(provider: .cursor), - !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - switch try await self.fetchCachedSession( - cached, - cookieHeaderOverride: cookieHeaderOverride, - allowAppAuthFallback: allowAppAuthFallback, - logger: logger, - log: log) - { - case let .succeeded(snapshot): return snapshot - case .resumeFallback: - #if os(macOS) - // The cached-session path cleared its own stale entry. Browser fallback now owns that empty slot, - // while the original gate generation still protects any concurrent interactive login. - cacheObservation = cacheObservation.afterOwnedClear() - #endif - } - } - - #if os(macOS) - // Try each browser in order. The first browser that *has* session cookie names is not always valid - // (e.g. stale Chrome tokens); keep trying until the API accepts a session or we run out of browsers. - // The refresh-start observation prevents this asynchronous fallback from overwriting an interactive login. - let browserCandidates = self.browserCookieImportOrder.cookieImportCandidates(using: self.browserDetection) - switch await self.scanBrowsers( - browserCandidates, - importSessions: { browser in - CursorCookieImporter.importSessionsIfPresent( - browser: browser, - browserDetection: self.browserDetection, - logger: log) - }, - attemptFetch: { session in - await self.fetchIfSessionAccepted( - session, - log: log, - cacheObservation: cacheObservation) - }) - { - case let .succeeded(snapshot): - return snapshot - case let .exhausted(error): - firstRecoverableError = error ?? firstRecoverableError - } - - switch await self.scanBrowsers( - browserCandidates, - importSessions: { browser in - CursorCookieImporter.importDomainCookieSessionsIfPresent( - browser: browser, - browserDetection: self.browserDetection, - logger: log) - }, - attemptFetch: { session in - await self.fetchIfSessionAccepted( - session, - log: log, - cacheObservation: cacheObservation) - }) - { - case let .succeeded(snapshot): - return snapshot - case let .exhausted(error): - firstRecoverableError = error ?? firstRecoverableError - } - #endif - - // Fall back to stored session cookies (from "Add Account" login flow) - if allowCachedSessions { - let storedCookies = await CursorSessionStore.shared.getCookies() - if !storedCookies.isEmpty { - log("Using stored session cookies") - let cookieHeader = storedCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") - do { - return try await self.fetchWithCookieHeader(cookieHeader) - } catch let error as CursorStatusProbeError { - if case .notLoggedIn = error { - // Clear only when auth is invalid; keep for transient failures. - await CursorSessionStore.shared.clearCookies() - log("Stored session invalid, cleared") - } else { - log("Stored session failed: \(error.localizedDescription)") - firstRecoverableError = firstRecoverableError ?? error - } - } catch { - log("Stored session failed: \(error.localizedDescription)") - firstRecoverableError = firstRecoverableError ?? .networkError(error.localizedDescription) - } - } - } - - // A transient failure for an explicitly selected session must not switch to Cursor.app's account. - if let firstRecoverableError { - throw firstRecoverableError - } - - // Last fallback: derive Cursor's first-party web session from the app token in its global state DB. - // Reusing the web flow preserves modern billing, legacy request quotas, and account-scoped identity. - if allowAppAuthFallback, - let appSession = try? self.appAuthStore.loadSession(), - appSession.isUsable - { - log("Using Cursor.app local auth fallback") - do { - return try await self.fetchWithAppAuthSession(appSession) - } catch let error as CursorStatusProbeError { - if case .notLoggedIn = error { - log("Cursor.app local auth was rejected") - } else { - firstRecoverableError = firstRecoverableError ?? error - } - } catch { - firstRecoverableError = firstRecoverableError ?? .networkError(error.localizedDescription) - } - } - - if let firstRecoverableError { - throw firstRecoverableError + try await self.resolveSession( + cookieHeaderOverride: cookieHeaderOverride, + allowCachedSessions: allowCachedSessions, + allowAppAuthFallback: allowAppAuthFallback, + logger: logger) + { cookieHeader, requestUsageUserIDFallback in + try await self.fetchWithCookieHeader( + cookieHeader, + requestUsageUserIDFallback: requestUsageUserIDFallback) } - - throw CursorStatusProbeError.noSessionCookie } - private enum CachedSessionFetchResult { - case succeeded(CursorStatusSnapshot) - case resumeFallback - } - - private func fetchCachedSession( - _ cached: CookieHeaderCache.Entry, - cookieHeaderOverride: String?, - allowAppAuthFallback: Bool, - logger: ((String) -> Void)?, - log: @escaping (String) -> Void) async throws -> CachedSessionFetchResult + #if os(macOS) + /// Fetch Cursor token-cost data using the same hardened session resolution as status. + public func fetchCostReport( + since: Date?, + until: Date?, + calendar: Calendar = .current, + cookieHeaderOverride: String? = nil, + allowCachedSessions: Bool = true, + allowAppAuthFallback: Bool = true, + logger: (@Sendable (String) -> Void)? = nil) async throws -> CursorCostReport { - log("Using cached cookie header from \(cached.sourceLabel)") - do { - return try await .succeeded(self.fetchWithCookieHeader(cached.cookieHeader)) - } catch let error as CursorStatusProbeError { - guard case .notLoggedIn = error else { throw error } - if let replacement = CookieHeaderCache.load(provider: .cursor), replacement != cached { - if cached.authenticationFailurePolicy == .stopFallback, - replacement.authenticationFailurePolicy != .stopFallback - { - log("Selected cached session was rejected; ignoring an unselected cache replacement") - throw error - } - log("Cached session changed while its request was in flight; retrying replacement") - return try await .succeeded(self.fetch( - cookieHeaderOverride: cookieHeaderOverride, - allowCachedSessions: true, - allowAppAuthFallback: allowAppAuthFallback, - logger: logger)) - } - if cached.authenticationFailurePolicy == .stopFallback { - log("Selected cached session was rejected; refusing automatic account fallback") - throw error - } - guard CookieHeaderCache.clearIfCurrent(provider: .cursor, expected: cached) else { - if let replacement = CookieHeaderCache.load(provider: .cursor), replacement != cached { - log("Cached session changed before stale-session cleanup; retrying replacement") - return try await .succeeded(self.fetch( - cookieHeaderOverride: cookieHeaderOverride, - allowCachedSessions: true, - allowAppAuthFallback: allowAppAuthFallback, - logger: logger)) - } - throw error - } - return .resumeFallback + let fetcher = CursorUsageEventsFetcher( + baseURL: self.baseURL, + transport: self.urlSession, + timeout: self.timeout) + return try await self.resolveSession( + cookieHeaderOverride: cookieHeaderOverride, + allowCachedSessions: allowCachedSessions, + allowAppAuthFallback: allowAppAuthFallback, + logger: logger) + { cookieHeader, _ in + let result = try await fetcher.fetchUsage( + cookieHeader: cookieHeader, + since: since, + until: until, + calendar: calendar, + logger: logger) + return CursorCostReport( + daily: result.daily, + meteredCostUSD: result.meteredCostUSD, + credentialScopeFingerprint: CookieHeaderCache.credentialFingerprint(cookieHeader)) } } + #endif /// Fetch every API-valid session from the exact browser that opened an interactive login URL. /// Results remain uncommitted until the caller chooses one and calls ``commitBrowserLoginSession(_:)``. @@ -1358,6 +1239,102 @@ public struct CursorStatusProbe: Sendable { return .exhausted(firstFailure) } + enum ResolvedSessionFetchOutcome { + case succeeded(Value) + case tryNextBrowser + } + + enum ResolvedSessionScanResult { + case succeeded(Value) + case exhausted + } + + struct ResolvedSessionReconciliationContext { + let cookieHeader: String + let sourceLabel: String + let cacheObservation: CookieHeaderCache.ConditionalMutationObservation + let perform: @Sendable (String, String?) async throws -> Value + let log: (String) -> Void + } + + func scanResolvedBrowsers( + _ browsers: [Browser], + importSessions: (Browser) -> [CursorCookieImporter.SessionInfo], + attemptFetch: (CursorCookieImporter.SessionInfo) async throws + -> ResolvedSessionFetchOutcome) async throws + -> ResolvedSessionScanResult + { + for browser in browsers { + let sessions = importSessions(browser) + guard !sessions.isEmpty else { continue } + for session in sessions { + switch try await attemptFetch(session) { + case let .succeeded(value): + return .succeeded(value) + case .tryNextBrowser: + continue + } + } + } + return .exhausted + } + + func resolveImportedSession( + _ session: CursorCookieImporter.SessionInfo, + perform: @escaping @Sendable (String, String?) async throws -> Value, + log: @escaping (String) -> Void, + cacheObservation: CookieHeaderCache.ConditionalMutationObservation) async throws + -> ResolvedSessionFetchOutcome + { + log("Trying Cursor session from \(session.sourceLabel)") + let value: Value + do { + value = try await perform(session.cookieHeader, nil) + } catch let error as CursorStatusProbeError { + if case .notLoggedIn = error { + log("Cursor API rejected cookies from \(session.sourceLabel); trying next browser if any") + return .tryNextBrowser + } + log("Cursor fetch failed using \(session.sourceLabel): \(error.localizedDescription)") + throw error + } catch { + log("Cursor fetch failed using \(session.sourceLabel): \(error.localizedDescription)") + throw error + } + let context = ResolvedSessionReconciliationContext( + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel, + cacheObservation: cacheObservation, + perform: perform, + log: log) + let reconciled = try await self.reconcileResolvedSession(value: value, context: context) + return .succeeded(reconciled) + } + + func reconcileResolvedSession( + value: Value, + context: ResolvedSessionReconciliationContext) async throws -> Value + { + let stored = CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + expected: context.cacheObservation, + cookieHeader: context.cookieHeader, + sourceLabel: context.sourceLabel) + guard !stored else { return value } + guard let replacement = CookieHeaderCache.load(provider: .cursor) else { + context.log("Cursor session from \(context.sourceLabel) lost cache ownership without a replacement") + throw CursorStatusProbeError.networkError("Cursor session changed during refresh") + } + let fetchedFingerprint = CookieHeaderCache.credentialFingerprint(context.cookieHeader) + let replacementFingerprint = CookieHeaderCache.credentialFingerprint(replacement.cookieHeader) + guard replacementFingerprint != fetchedFingerprint else { + context.log("Cursor session from \(context.sourceLabel) was cached concurrently; accepting its result") + return value + } + context.log("Cursor session changed while \(context.sourceLabel) was in flight; retrying current cache") + return try await context.perform(replacement.cookieHeader, nil) + } + func fetchIfSessionAccepted( _ session: CursorCookieImporter.SessionInfo, log: @escaping (String) -> Void, diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift b/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift new file mode 100644 index 0000000000..e6d7ee79c0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift @@ -0,0 +1,634 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(macOS) + +// MARK: - Cursor Usage Event Models + +/// One page of `POST /api/dashboard/get-filtered-usage-events`. +/// +/// `totalUsageEventsCount` reports the total number of events matching the query +/// so pagination can stop once every page has been collected. +struct CursorUsageEventsPage: Decodable, Sendable { + let totalUsageEventsCount: Int? + let usageEventsDisplay: [CursorUsageEvent] + + private enum CodingKeys: String, CodingKey { + case totalUsageEventsCount + case usageEventsDisplay + } + + init(totalUsageEventsCount: Int?, usageEventsDisplay: [CursorUsageEvent]) { + self.totalUsageEventsCount = totalUsageEventsCount + self.usageEventsDisplay = usageEventsDisplay + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedCount = CursorEventNumber.int64(container, .totalUsageEventsCount) + .flatMap(Int.init(exactly:)) + if let decodedCount, decodedCount < 0 { + throw DecodingError.dataCorruptedError( + forKey: .totalUsageEventsCount, + in: container, + debugDescription: "Cursor usage event count cannot be negative") + } + self.totalUsageEventsCount = decodedCount + self.usageEventsDisplay = try container.decode([CursorUsageEvent].self, forKey: .usageEventsDisplay) + } +} + +/// A single account usage event as returned by the Cursor dashboard API. +struct CursorUsageEvent: Decodable, Sendable, Hashable { + /// Event time in Unix milliseconds (the API serializes this as a string). + let timestampMS: Int64? + let model: String? + let tokenUsage: CursorEventTokenUsage? + let kind: String? + let requestsCosts: Double? + let usageBasedCosts: String? + let isTokenBasedCall: Bool? + let owningUser: String? + let owningTeam: String? + let cursorTokenFee: Double? + let isChargeable: Bool? + let isHeadless: Bool? + /// What the plan actually deducts, in cents. Distinct from the notional token cost. + let chargedCents: Double? + + private enum CodingKeys: String, CodingKey { + case timestamp + case model + case tokenUsage + case kind + case requestsCosts + case usageBasedCosts + case isTokenBasedCall + case owningUser + case owningTeam + case cursorTokenFee + case isChargeable + case isHeadless + case chargedCents + } + + init( + timestampMS: Int64?, + model: String?, + tokenUsage: CursorEventTokenUsage?, + kind: String? = nil, + requestsCosts: Double? = nil, + usageBasedCosts: String? = nil, + isTokenBasedCall: Bool? = nil, + owningUser: String? = nil, + owningTeam: String? = nil, + cursorTokenFee: Double? = nil, + isChargeable: Bool? = nil, + isHeadless: Bool? = nil, + chargedCents: Double? = nil) + { + self.timestampMS = timestampMS + self.model = model + self.tokenUsage = tokenUsage + self.kind = kind + self.requestsCosts = requestsCosts + self.usageBasedCosts = usageBasedCosts + self.isTokenBasedCall = isTokenBasedCall + self.owningUser = owningUser + self.owningTeam = owningTeam + self.cursorTokenFee = cursorTokenFee + self.isChargeable = isChargeable + self.isHeadless = isHeadless + self.chargedCents = chargedCents + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.timestampMS = CursorEventNumber.int64(container, .timestamp) + self.model = (try? container.decode(String.self, forKey: .model)).flatMap { $0.isEmpty ? nil : $0 } + self.tokenUsage = try? container.decode(CursorEventTokenUsage.self, forKey: .tokenUsage) + self.kind = try? container.decode(String.self, forKey: .kind) + self.requestsCosts = CursorEventNumber.double(container, .requestsCosts) + self.usageBasedCosts = try? container.decode(String.self, forKey: .usageBasedCosts) + self.isTokenBasedCall = try? container.decode(Bool.self, forKey: .isTokenBasedCall) + self.owningUser = CursorEventNumber.string(container, .owningUser) + self.owningTeam = CursorEventNumber.string(container, .owningTeam) + self.cursorTokenFee = CursorEventNumber.double(container, .cursorTokenFee) + self.isChargeable = try? container.decode(Bool.self, forKey: .isChargeable) + self.isHeadless = try? container.decode(Bool.self, forKey: .isHeadless) + self.chargedCents = CursorEventNumber.double(container, .chargedCents) + } + + var validTimestampMS: Int64? { + guard let timestampMS = self.timestampMS, timestampMS > 0 else { return nil } + return timestampMS + } +} + +/// Token counts and the authoritative token-cost carried by each usage event. +/// +/// `totalCents` matches public vendor list pricing, so it is used directly as the +/// cost (converted to USD). Token counts mirror ccusage's mapping, with +/// `cacheWriteTokens` treated as cache-creation input. +struct CursorEventTokenUsage: Decodable, Sendable, Hashable { + let inputTokens: Int + let outputTokens: Int + let cacheWriteTokens: Int + let cacheReadTokens: Int + let totalCents: Double? + + private enum CodingKeys: String, CodingKey { + case inputTokens + case outputTokens + case cacheWriteTokens + case cacheReadTokens + case totalCents + } + + init(inputTokens: Int, outputTokens: Int, cacheWriteTokens: Int, cacheReadTokens: Int, totalCents: Double?) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheWriteTokens = cacheWriteTokens + self.cacheReadTokens = cacheReadTokens + self.totalCents = totalCents + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.inputTokens = CursorEventNumber.int(container, .inputTokens) + self.outputTokens = CursorEventNumber.int(container, .outputTokens) + self.cacheWriteTokens = CursorEventNumber.int(container, .cacheWriteTokens) + self.cacheReadTokens = CursorEventNumber.int(container, .cacheReadTokens) + self.totalCents = CursorEventNumber.double(container, .totalCents) + } + + var totalTokens: Int { + var total = 0 + for value in [self.inputTokens, self.outputTokens, self.cacheWriteTokens, self.cacheReadTokens] { + guard value >= 0 else { return 0 } + let (sum, overflow) = total.addingReportingOverflow(value) + guard !overflow else { return 0 } + total = sum + } + return total + } + + var hasTokens: Bool { + self.totalTokens > 0 + } +} + +/// Result of fetching Cursor usage for a window. +/// +/// `daily` carries the API-rate per-day, per-model breakdown (vendor list price from +/// `tokenUsage.totalCents`). `meteredCostUSD` is what Cursor's plan actually deducts over the +/// same window (sum of each event's `chargedCents`); it is `nil` when any valid event omits +/// its metered amount, so callers never mistake a partial sum for the complete window total. +struct CursorCostFetchResult: Sendable { + let daily: CostUsageDailyReport + let meteredCostUSD: Double? +} + +/// Lenient numeric decoding because Cursor serializes some numbers as strings. +private enum CursorEventNumber { + static func string(_ container: KeyedDecodingContainer, _ key: K) -> String? { + if let value = try? container.decode(String.self, forKey: key) { + return value + } + if let value = try? container.decode(Int64.self, forKey: key) { + return String(value) + } + return nil + } + + static func int(_ container: KeyedDecodingContainer, _ key: K) -> Int { + if let value = try? container.decode(Int.self, forKey: key) { + return value + } + if let value = try? container.decode(Double.self, forKey: key) { + return Int(exactly: value) ?? 0 + } + if let value = try? container.decode(String.self, forKey: key) { + return Int(value) ?? Double(value).flatMap(Int.init(exactly:)) ?? 0 + } + return 0 + } + + static func double(_ container: KeyedDecodingContainer, _ key: K) -> Double? { + if let value = try? container.decode(Double.self, forKey: key) { + return value.isFinite ? value : nil + } + if let value = try? container.decode(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decode(String.self, forKey: key) { + guard let decoded = Double(value), decoded.isFinite else { return nil } + return decoded + } + return nil + } + + static func int64(_ container: KeyedDecodingContainer, _ key: K) -> Int64? { + if let value = try? container.decode(Int64.self, forKey: key) { + return value + } + if let value = try? container.decode(Double.self, forKey: key) { + return Int64(exactly: value) + } + if let value = try? container.decode(String.self, forKey: key) { + return Int64(value) ?? Double(value).flatMap(Int64.init(exactly:)) + } + return nil + } +} + +// MARK: - Cursor Usage Events Fetcher + +/// Fetches Cursor token-cost data from the cookie-authenticated dashboard API. +/// +/// The caller supplies a resolved `Cookie` header (see ``CursorStatusProbe``); this +/// type only knows how to page the usage endpoints and shape them into a +/// ``CursorCostFetchResult``. Keeping the network surface separate from session +/// resolution makes the mapping unit-testable with a stubbed transport. +struct CursorUsageEventsFetcher: Sendable { + let baseURL: URL + let transport: any ProviderHTTPTransport + var timeout: TimeInterval + var pageSize: Int + /// Hard cap so a paging bug can never loop forever (200 * 1000 = 200k events). + var maxPages: Int + + init( + baseURL: URL = URL(string: "https://cursor.com")!, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + timeout: TimeInterval = 30, + pageSize: Int = 1000, + maxPages: Int = 200) + { + self.baseURL = baseURL + self.transport = transport + self.timeout = timeout + self.pageSize = pageSize + self.maxPages = maxPages + } + + /// Fetch usage events for the given window (or all history when both bounds are nil) + /// and shape them into the API-rate per-day report plus the Cursor-metered window total. + /// + /// A single fetch backs both numbers, so they always cover the exact same window. + func fetchUsage( + cookieHeader: String, + since: Date?, + until: Date?, + calendar: Calendar = .current, + logger: ((String) -> Void)? = nil) async throws -> CursorCostFetchResult + { + let events = try await self.fetchAllEvents( + cookieHeader: cookieHeader, + since: since, + until: until, + logger: logger) + return CursorCostFetchResult( + daily: Self.makeDailyReport(from: events, calendar: calendar), + meteredCostUSD: Self.meteredCostUSD(from: events)) + } + + private func fetchAllEvents( + cookieHeader: String, + since: Date?, + until: Date?, + logger: ((String) -> Void)?) async throws -> [CursorUsageEvent] + { + var pages: [[CursorUsageEvent]] = [] + var expectedTotal: Int? + var completed = false + for page in 1...self.maxPages { + let response = try await self.fetchPage( + cookieHeader: cookieHeader, + page: page, + since: since, + until: until) + let pageEvents = response.usageEventsDisplay + if let total = response.totalUsageEventsCount { + if let expectedTotal, expectedTotal != total { + throw CostUsageError.cursorPaginationInconsistent( + expected: expectedTotal, + received: total) + } + expectedTotal = total + } + if pageEvents.isEmpty { + completed = true + break + } + pages.append(pageEvents) + let received = pages.reduce(0) { $0 + $1.count } + logger?("[cursor-cost] page \(page): \(pageEvents.count) events (\(received) raw total)") + if pageEvents.count < self.pageSize { + completed = true + break + } + } + let rawEvents = pages.flatMap(\.self) + // A full final page at the safety cap is ambiguous even when its raw count reaches the + // reported total: Cursor can repeat rows at page boundaries. Require an empty/short page + // to prove completion before publishing a window total. + if !completed { + throw CostUsageError.cursorPaginationIncomplete(expected: expectedTotal, received: rawEvents.count) + } + guard let expectedTotal else { return rawEvents } + guard rawEvents.count >= expectedTotal else { + throw CostUsageError.cursorPaginationIncomplete(expected: expectedTotal, received: rawEvents.count) + } + guard rawEvents.count > expectedTotal else { return rawEvents } + + // The endpoint exposes no stable event ID. Reconcile only the exact number of duplicate + // rows proven by its authoritative count, choosing matches at adjacent page boundaries. + // Equal rows remain distinct when the reported count includes both of them. + var removalsRemaining = rawEvents.count - expectedTotal + var reconciled = pages.first ?? [] + for index in pages.indices.dropFirst() { + let page = pages[index] + let overlap = Self.boundaryOverlap(previousPage: pages[index - 1], currentPage: page) + let removalCount = min(overlap, removalsRemaining) + reconciled.append(contentsOf: page.dropFirst(removalCount)) + removalsRemaining -= removalCount + } + guard removalsRemaining == 0, reconciled.count == expectedTotal else { + throw CostUsageError.cursorPaginationInconsistent( + expected: expectedTotal, + received: rawEvents.count) + } + return reconciled + } + + private func fetchPage( + cookieHeader: String, + page: Int, + since: Date?, + until: Date?) async throws -> CursorUsageEventsPage + { + let request = try self.makeRequest( + path: "/api/dashboard/get-filtered-usage-events", + cookieHeader: cookieHeader, + body: FilteredUsageRequest( + page: page, + pageSize: self.pageSize, + startDate: Self.millisString(since), + endDate: Self.millisString(until))) + let (data, response) = try await self.transport.data(for: request) + try Self.validate(response) + return try JSONDecoder().decode(CursorUsageEventsPage.self, from: data) + } + + // MARK: Request Building + + private struct FilteredUsageRequest: Encodable { + let page: Int + let pageSize: Int + let startDate: String? + let endDate: String? + } + + private func makeRequest(path: String, cookieHeader: String, body: some Encodable) throws -> URLRequest { + var request = URLRequest(url: self.baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.timeoutInterval = self.timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + // Cursor enforces CSRF on these POST endpoints: a matching Origin is required. + request.setValue(self.originHeader, forHTTPHeaderField: "Origin") + request.httpBody = try JSONEncoder().encode(body) + return request + } + + private var originHeader: String { + guard let scheme = self.baseURL.scheme, let host = self.baseURL.host else { + return "https://cursor.com" + } + return "\(scheme)://\(host)" + } + + private static func validate(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw CursorStatusProbeError.networkError("Invalid response") + } + if http.statusCode == 401 { + throw CursorStatusProbeError.notLoggedIn + } + guard http.statusCode == 200 else { + throw CursorStatusProbeError.networkError("HTTP \(http.statusCode)") + } + } + + private static func millisString(_ date: Date?) -> String? { + date.map { String(Int64(($0.timeIntervalSince1970 * 1000).rounded())) } + } + + /// The endpoint exposes no stable event ID. Detect exact overlap only at adjacent page + /// boundaries; the caller uses the authoritative total count to decide whether removal is valid. + private static func boundaryOverlap( + previousPage: [CursorUsageEvent], + currentPage: [CursorUsageEvent]) -> Int + { + let limit = min(previousPage.count, currentPage.count) + guard limit > 0 else { return 0 } + for count in stride(from: limit, through: 1, by: -1) + where previousPage.suffix(count).elementsEqual(currentPage.prefix(count)) + { + return count + } + return 0 + } + + // MARK: Mapping + + /// Cursor-metered spend in USD: the sum of each event's `chargedCents` (what the plan + /// deducts), distinct from the API-rate `tokenUsage.totalCents`. Returns `nil` when no + /// valid event omitted `chargedCents`, so callers never publish a partial lower-bound total. + static func meteredCostUSD(from events: [CursorUsageEvent]) -> Double? { + var totalCents = 0.0 + var sawValidEvent = false + for event in events { + guard event.validTimestampMS != nil else { continue } + sawValidEvent = true + guard let cents = event.chargedCents else { return nil } + guard cents >= 0 else { return nil } + let nextTotal = totalCents + cents + guard nextTotal.isFinite else { return nil } + totalCents = nextTotal + } + return sawValidEvent ? totalCents / 100.0 : nil + } + + /// Group usage events into per-day, per-model cost entries. + /// + /// Events without token usage (or with all-zero token counts) are skipped, matching + /// ccusage. `totalCents / 100` is the authoritative cost and `cacheWriteTokens` maps + /// to cache-creation input. + static func makeDailyReport( + from events: [CursorUsageEvent], + calendar: Calendar = .current) -> CostUsageDailyReport + { + var days: [String: [String: ModelAccumulator]] = [:] + for event in events { + guard let timestampMS = event.validTimestampMS, + let usage = event.tokenUsage, + usage.hasTokens + else { continue } + let date = Date(timeIntervalSince1970: Double(timestampMS) / 1000.0) + let dayKey = CostUsageLocalDay.key(from: date, calendar: calendar) + let model = event.model ?? "unknown" + var modelsForDay = days[dayKey] ?? [:] + var accumulator = modelsForDay[model] ?? ModelAccumulator() + accumulator.add(usage) + modelsForDay[model] = accumulator + days[dayKey] = modelsForDay + } + + let entries = days.keys.sorted().map { dayKey in + Self.makeEntry(date: dayKey, models: days[dayKey] ?? [:]) + } + return CostUsageDailyReport(data: entries, summary: Self.makeSummary(from: entries)) + } + + private struct ModelAccumulator { + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var costUSD: Double? = 0 + var requestCount: Int? = 0 + + mutating func add(_ usage: CursorEventTokenUsage) { + self.inputTokens = Self.checkedSum(self.inputTokens, usage.inputTokens) + self.outputTokens = Self.checkedSum(self.outputTokens, usage.outputTokens) + self.cacheReadTokens = Self.checkedSum(self.cacheReadTokens, usage.cacheReadTokens) + self.cacheCreationTokens = Self.checkedSum(self.cacheCreationTokens, usage.cacheWriteTokens) + self.costUSD = Self.checkedCostSum(self.costUSD, usage.totalCents) + self.requestCount = Self.checkedSum(self.requestCount, 1) + } + + var totalTokens: Int? { + Self.checkedSum([ + self.inputTokens, + self.outputTokens, + self.cacheReadTokens, + self.cacheCreationTokens, + ]) + } + + private static func checkedSum(_ lhs: Int?, _ rhs: Int) -> Int? { + guard let lhs else { return nil } + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? nil : sum + } + + static func checkedSum(_ values: [Int?]) -> Int? { + values.reduce(0 as Int?) { partial, value in + guard let value else { return nil } + return Self.checkedSum(partial, value) + } + } + + static func checkedCostSum(_ lhsUSD: Double?, _ rhsCents: Double?) -> Double? { + guard let lhsUSD, let rhsCents, rhsCents >= 0 else { return nil } + let sum = lhsUSD + rhsCents / 100.0 + return sum.isFinite ? sum : nil + } + } + + private static func makeEntry(date: String, models: [String: ModelAccumulator]) -> CostUsageDailyReport.Entry { + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var requestCount: Int? = 0 + var costUSD: Double? = 0 + var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + + for (model, accumulator) in models { + inputTokens = ModelAccumulator.checkedSum([inputTokens, accumulator.inputTokens]) + outputTokens = ModelAccumulator.checkedSum([outputTokens, accumulator.outputTokens]) + cacheReadTokens = ModelAccumulator.checkedSum([cacheReadTokens, accumulator.cacheReadTokens]) + cacheCreationTokens = ModelAccumulator.checkedSum([cacheCreationTokens, accumulator.cacheCreationTokens]) + requestCount = ModelAccumulator.checkedSum([requestCount, accumulator.requestCount]) + costUSD = Self.checkedUSDTotal(costUSD, accumulator.costUSD) + breakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: accumulator.costUSD, + totalTokens: accumulator.totalTokens, + requestCount: accumulator.requestCount)) + } + + return CostUsageDailyReport.Entry( + date: date, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, + cacheCreationTokens: cacheCreationTokens, + totalTokens: ModelAccumulator.checkedSum([ + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens, + ]), + requestCount: requestCount, + costUSD: costUSD, + modelsUsed: models.keys.sorted(), + modelBreakdowns: Self.sortedBreakdowns(breakdowns)) + } + + private static func makeSummary(from entries: [CostUsageDailyReport.Entry]) -> CostUsageDailyReport.Summary { + var totalInput: Int? = 0 + var totalOutput: Int? = 0 + var totalCacheRead: Int? = 0 + var totalCacheCreation: Int? = 0 + var totalTokens: Int? = 0 + var totalCost: Double? = 0 + for entry in entries { + totalInput = ModelAccumulator.checkedSum([totalInput, entry.inputTokens]) + totalOutput = ModelAccumulator.checkedSum([totalOutput, entry.outputTokens]) + totalCacheRead = ModelAccumulator.checkedSum([totalCacheRead, entry.cacheReadTokens]) + totalCacheCreation = ModelAccumulator.checkedSum([totalCacheCreation, entry.cacheCreationTokens]) + totalTokens = ModelAccumulator.checkedSum([totalTokens, entry.totalTokens]) + totalCost = Self.checkedUSDTotal(totalCost, entry.costUSD) + } + return CostUsageDailyReport.Summary( + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + cacheReadTokens: totalCacheRead, + cacheCreationTokens: totalCacheCreation, + totalTokens: totalTokens, + totalCostUSD: totalCost) + } + + private static func checkedUSDTotal(_ lhs: Double?, _ rhs: Double?) -> Double? { + guard let lhs, let rhs else { return nil } + let sum = lhs + rhs + return sum.isFinite ? sum : nil + } + + private static func sortedBreakdowns( + _ breakdowns: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] + { + breakdowns.sorted { lhs, rhs in + let lhsCost = lhs.costUSD ?? -1 + let rhsCost = rhs.costUSD ?? -1 + if lhsCost != rhsCost { + return lhsCost > rhsCost + } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } + return lhs.modelName < rhs.modelName + } + } +} + +#endif diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index ad21a0e8f0..c8674fab5f 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -237,6 +237,8 @@ public enum UsageFormatter { case .claude: "Estimated from local Claude logs at API rates; token totals include cache read/write tokens " + "and may differ from Claude Code /status." + case .cursor: + "From Cursor's usage dashboard at vendor token rates; may differ from your invoice." default: self.costEstimateHint } diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 86dd682932..6e0bfc7e87 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -297,4 +297,54 @@ struct CLICostTests { #expect(hint.contains("Estimated")) #expect(UsageFormatter.costEstimateHint(provider: .claude).contains("cache read/write tokens")) } + + @Test + func `cursor cookie source off produces a failed JSON payload`() throws { + let settings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .off, + manualCookieHeader: nil) + let error = try #require(CodexBarCLI.cursorCostAvailabilityError(.cursor, settings: settings)) + let payload = CodexBarCLI.makeCostPayload(provider: .cursor, snapshot: nil, error: error) + let json = try #require(CodexBarCLI.encodeJSON([payload], pretty: false)) + + #expect(CodexBarCLI.mapError(error) == .failure) + #expect(json.contains("\"provider\":\"cursor\"")) + #expect(json.contains("\"code\":1")) + #expect(json.contains("cookie source is set to Off")) + #expect(CodexBarCLI.cursorCostAvailabilityError(.cursor, settings: nil) == nil) + #expect(CodexBarCLI.cursorCostAvailabilityError(.codex, settings: settings) == nil) + } + + @Test + func `cursor manual cookie source rejects an empty header`() throws { + let settings = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .manual, + manualCookieHeader: " ") + let error = try #require(CodexBarCLI.cursorCostAvailabilityError(.cursor, settings: settings)) + + #expect(CodexBarCLI.mapError(error) == .failure) + #expect(error.localizedDescription.contains("non-empty Manual cookie header")) + #expect(CodexBarCLI.cursorCostHeaderOverride(.cursor, settings: settings) == nil) + } + + @Test + func `cursor settings resolution errors fail closed`() throws { + let resolutionError = CursorCostSettingsTestError() + let error = try #require(CodexBarCLI.cursorCostAvailabilityError( + .cursor, + settings: nil, + resolutionError: resolutionError)) + + #expect(error.localizedDescription == resolutionError.localizedDescription) + #expect(CodexBarCLI.cursorCostAvailabilityError( + .codex, + settings: nil, + resolutionError: resolutionError) == nil) + } +} + +private struct CursorCostSettingsTestError: LocalizedError { + var errorDescription: String? { + "Cursor settings resolution failed." + } } diff --git a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift index 1e3c87b8e9..a69abb8f5c 100644 --- a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift +++ b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift @@ -85,6 +85,27 @@ struct CostUsageTokenSnapshotDaySelectionTests { #expect(snapshot.sessionTokens == 300) } + @Test + func `cursor window start snaps to the local day boundary`() throws { + let calendar = Calendar.current + + // historyDays > 1: a midday instant several days back snaps to that day's 00:00. + let midday = try Self.localNoon(year: 2026, month: 5, day: 15) + let snapped = try #require(CostUsageFetcher.cursorWindowStart(midday, calendar: calendar)) + #expect(snapped == calendar.startOfDay(for: midday)) + #expect(snapped <= midday) + + // historyDays == 1: `since` is `now`, so the window must still cover all of today (00:00 today), + // not collapse to the current instant. + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let today = try #require(CostUsageFetcher.cursorWindowStart(now, calendar: calendar)) + #expect(today == calendar.startOfDay(for: now)) + #expect(calendar.isDate(today, inSameDayAs: now)) + #expect(today <= now) + + #expect(CostUsageFetcher.cursorWindowStart(nil, calendar: calendar) == nil) + } + @Test func `token snapshot distinguishes omitted and explicitly unknown currency`() { let omitted = CostUsageTokenSnapshot( diff --git a/Tests/CodexBarTests/CursorImportedSessionScanningTests.swift b/Tests/CodexBarTests/CursorImportedSessionScanningTests.swift index caae041ca9..ab6a4f7714 100644 --- a/Tests/CodexBarTests/CursorImportedSessionScanningTests.swift +++ b/Tests/CodexBarTests/CursorImportedSessionScanningTests.swift @@ -21,6 +21,39 @@ struct CursorImportedSessionScanningTests { } } + @Test + func `resolved browser scan stops on non authentication data failure`() async { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let attempts = LockedArray() + let paginationError = CostUsageError.cursorPaginationIncomplete(expected: 10, received: 5) + + let error = await #expect(throws: CostUsageError.self) { + _ = try await probe.scanResolvedBrowsers( + [.chrome], + importSessions: { _ in + [ + Self.makeSessionInfo(sourceLabel: "Account A"), + Self.makeSessionInfo(sourceLabel: "Account B"), + ] + }, + attemptFetch: { session in + attempts.append(session.sourceLabel) + if session.sourceLabel == "Account A" { + throw paginationError + } + return .succeeded("wrong account") + }) + } + + guard case let .cursorPaginationIncomplete(expected, received) = error else { + Issue.record("Expected the original pagination error") + return + } + #expect(expected == 10) + #expect(received == 5) + #expect(attempts.snapshot() == ["Account A"]) + } + @Test func `browser login candidates return every valid unique session without committing cache`() async throws { let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) @@ -234,6 +267,84 @@ struct CursorImportedSessionScanningTests { } } + @Test + func `resolved session accepts result when the same credential is cached concurrently`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let session = Self.makeSessionInfo(sourceLabel: "Background", cookieValue: "background") + let service = "cursor-login-same-credential-race-\(UUID().uuidString)" + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + let outcome = try await probe.resolveImportedSession( + session, + perform: { cookieHeader, _ in + #expect(CookieHeaderCache.storeResult( + provider: .cursor, + cookieHeader: cookieHeader, + sourceLabel: "Interactive login")) + return cookieHeader + }, + log: { _ in }, + cacheObservation: observation) + + guard case let .succeeded(cookieHeader) = outcome else { + Issue.record("Expected the matching concurrent credential result") + return + } + #expect(cookieHeader == session.cookieHeader) + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == session.cookieHeader) + } + } + } + + @Test + func `resolved session retries a different credential cached concurrently`() async throws { + let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let session = Self.makeSessionInfo(sourceLabel: "Background", cookieValue: "background") + let replacement = "fixtureSession=replacement" + let attempts = LockedArray() + let service = "cursor-login-replacement-race-\(UUID().uuidString)" + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + let outcome = try await probe.resolveImportedSession( + session, + perform: { cookieHeader, _ in + attempts.append(cookieHeader) + if cookieHeader == session.cookieHeader { + #expect(CookieHeaderCache.storeResult( + provider: .cursor, + cookieHeader: replacement, + sourceLabel: "Interactive login")) + } + return cookieHeader + }, + log: { _ in }, + cacheObservation: observation) + + guard case let .succeeded(cookieHeader) = outcome else { + Issue.record("Expected the replacement credential result") + return + } + #expect(cookieHeader == replacement) + #expect(attempts.snapshot() == [session.cookieHeader, replacement]) + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == replacement) + } + } + } + @Test func `imported session scan continues after non auth failure until later success`() async { let probe = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index b201adc07d..40584bbe68 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -915,6 +915,8 @@ extension CursorStatusProbeTests { } } + CookieHeaderCache.clear(provider: .cursor) + defer { CookieHeaderCache.clear(provider: .cursor) } let baseURL = try #require(URL(string: "https://cursor-web.test")) let snapshot = try await CursorStatusProbe( baseURL: baseURL, @@ -1018,6 +1020,8 @@ extension CursorStatusProbeTests { } } + CookieHeaderCache.clear(provider: .cursor) + defer { CookieHeaderCache.clear(provider: .cursor) } let baseURL = try #require(URL(string: "https://cursor.test")) let accessToken = try makeCursorAppAuthToken() let snapshot = try await CursorStatusProbe( diff --git a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift new file mode 100644 index 0000000000..2573d238eb --- /dev/null +++ b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift @@ -0,0 +1,690 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct CursorUsageEventsFetcherTests { + // MARK: - Helpers + + private static let baseURL = URL(string: "https://cursor.test")! + + /// Calendar pinned to UTC so timestamp-to-day grouping is deterministic across machines. + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + /// Cost math runs through `cents / 100`, so compare with a tolerance rather than `==`. + private static func approxEqual(_ actual: Double?, _ expected: Double, tolerance: Double = 1e-9) -> Bool { + guard let actual else { return false } + return abs(actual - expected) < tolerance + } + + private static func httpResponse(_ body: String, statusCode: Int = 200) -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: baseURL, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } + + private static func event( + timestampMS: Int64, + model: String, + input: Int = 0, + output: Int = 0, + cacheWrite: Int = 0, + cacheRead: Int = 0, + totalCents: Double?, + isChargeable: Bool? = nil, + chargedCents: Double? = nil) -> CursorUsageEvent + { + CursorUsageEvent( + timestampMS: timestampMS, + model: model, + tokenUsage: CursorEventTokenUsage( + inputTokens: input, + outputTokens: output, + cacheWriteTokens: cacheWrite, + cacheReadTokens: cacheRead, + totalCents: totalCents), + isChargeable: isChargeable, + chargedCents: chargedCents) + } + + /// Reads the `page` field from a stubbed request body so the handler can return pages. + private struct PageProbe: Decodable { + let page: Int? + } + + private static func requestedPage(_ request: URLRequest) -> Int { + guard let body = request.httpBody, + let probe = try? JSONDecoder().decode(PageProbe.self, from: body) + else { return 1 } + return probe.page ?? 1 + } + + // MARK: - Mapping + + @Test + func `makeDailyReport groups events by local day and model with cents converted to USD`() { + // 2023-11-14T22:13:20Z and one hour later share a UTC day; the third event is two days later. + let day1 = Int64(1_700_000_000_000) + let day1Later = day1 + 3_600_000 + let day3 = day1 + 172_800_000 + + let events = [ + Self.event(timestampMS: day1, model: "claude-4.5-sonnet", input: 100, output: 50, totalCents: 100), + Self.event(timestampMS: day1Later, model: "claude-4.5-sonnet", input: 10, output: 5, totalCents: 23), + Self.event(timestampMS: day1, model: "gpt-5", input: 200, output: 20, totalCents: 500), + Self.event(timestampMS: day3, model: "claude-4.5-sonnet", input: 1, output: 1, totalCents: 9), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 2) + + let firstDay = report.data[0] + #expect(firstDay.date == "2023-11-14") + // Two models on day one; the gpt-5 row is more expensive so it sorts first. + #expect(firstDay.modelBreakdowns?.count == 2) + #expect(firstDay.modelBreakdowns?.first?.modelName == "gpt-5") + #expect(firstDay.modelsUsed == ["claude-4.5-sonnet", "gpt-5"]) + // claude rows merge: (100 + 23) cents, gpt-5 row: 500 cents -> $6.23 total for the day. + #expect(Self.approxEqual(firstDay.costUSD, 6.23)) + #expect(firstDay.requestCount == 3) + #expect(firstDay.totalTokens == 100 + 50 + 10 + 5 + 200 + 20) + + let claudeBreakdown = firstDay.modelBreakdowns?.first { $0.modelName == "claude-4.5-sonnet" } + #expect(Self.approxEqual(claudeBreakdown?.costUSD, 1.23)) + #expect(claudeBreakdown?.requestCount == 2) + + let lastDay = report.data[1] + #expect(lastDay.date == "2023-11-16") + #expect(Self.approxEqual(lastDay.costUSD, 0.09)) + + // Summary aggregates every day. + #expect(Self.approxEqual(report.summary?.totalCostUSD, 6.32)) + } + + @Test + func `makeDailyReport skips events without token usage`() { + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude-4.5-sonnet", totalCents: 0), + Self.event(timestampMS: 1_700_000_000_000, model: "claude-4.5-sonnet", input: 5, totalCents: 12), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 1) + #expect(report.data[0].requestCount == 1) + #expect(Self.approxEqual(report.data[0].costUSD, 0.12)) + } + + @Test + func `meteredCostUSD rejects a partial sum when an event omits chargedCents`() { + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude", input: 5, totalCents: 994, chargedCents: 4), + Self.event(timestampMS: 1_700_000_001_000, model: "gpt-5", input: 5, totalCents: 500, chargedCents: 8), + Self.event(timestampMS: 1_700_000_002_000, model: "default", input: 5, totalCents: 12), + ] + + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: events) == nil) + } + + @Test + func `meteredCostUSD returns nil when no event reports chargedCents`() { + let events = [ + Self.event(timestampMS: 1_700_000_000_000, model: "claude", input: 5, totalCents: 994), + ] + + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: events) == nil) + } + + @Test + func `meteredCostUSD includes plan consumption not marked additionally chargeable`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "claude", + input: 5, + totalCents: 994, + isChargeable: false, + chargedCents: 40), + Self.event( + timestampMS: 1_700_000_001_000, + model: "gpt-5", + input: 5, + totalCents: 500, + isChargeable: true, + chargedCents: 8), + Self.event( + timestampMS: 1_700_000_002_000, + model: "legacy", + input: 5, + totalCents: 100, + chargedCents: 4), + ] + + // Cursor's dashboard reconciliation sums chargedCents even for included-plan events. + #expect(Self.approxEqual(CursorUsageEventsFetcher.meteredCostUSD(from: events), 0.52)) + } + + // MARK: - Snapshot + + @Test + func `session cost tracks the current local day, not the latest entry`() throws { + // Cursor labels the session line "Today", so a stale latest day must not leak into it. This + // mirrors loadCursorTokenSnapshot, which builds the snapshot with current-local-day semantics. + let calendar = Calendar.current + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 18, hour: 12))) + let twoDaysAgo = try #require(calendar.date(byAdding: .day, value: -2, to: now)) + let event = Self.event( + timestampMS: Int64(twoDaysAgo.timeIntervalSince1970 * 1000), + model: "claude-4.5-sonnet", + input: 100, + output: 50, + totalCents: 150) + + let report = CursorUsageEventsFetcher.makeDailyReport(from: [event], calendar: calendar) + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now, useCurrentLocalDayForSession: true) + + // No usage today -> session is zero, while the window total still reflects the older day. + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(Self.approxEqual(snapshot.last30DaysCostUSD, 1.5)) + } + + // MARK: - Decoding + + @Test + func `decodes string-encoded numbers leniently`() throws { + let json = """ + { + "totalUsageEventsCount": "2", + "usageEventsDisplay": [ + { + "timestamp": "1700000000000", + "model": "claude-4.5-sonnet", + "tokenUsage": { + "inputTokens": "100", + "outputTokens": 50, + "cacheWriteTokens": "10", + "cacheReadTokens": "5", + "totalCents": "12.5" + } + } + ] + } + """ + let page = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + + #expect(page.totalUsageEventsCount == 2) + let event = try #require(page.usageEventsDisplay.first) + #expect(event.timestampMS == 1_700_000_000_000) + #expect(event.tokenUsage?.inputTokens == 100) + #expect(event.tokenUsage?.cacheWriteTokens == 10) + #expect(Self.approxEqual(event.tokenUsage?.totalCents, 12.5)) + } + + @Test(arguments: [ + #"{"totalUsageEventsCount":0}"#, + #"{"totalUsageEventsCount":0,"usageEventsDisplay":{}}"#, + #"{"error":"temporarily unavailable"}"#, + ]) + func `page decoding rejects missing or malformed event arrays`(json: String) { + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + } + } + + @Test(arguments: ["-1", String(Int.min)]) + func `page decoding rejects negative event counts`(count: String) { + let json = #"{"totalUsageEventsCount":\#(count),"usageEventsDisplay":[]}"# + + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + } + } + + @Test + func `invalid and out of range numeric fields fail closed without trapping`() throws { + let json = """ + { + "totalUsageEventsCount": "Infinity", + "usageEventsDisplay": [ + { + "timestamp": "Infinity", + "model": "fixture-model", + "chargedCents": "NaN", + "tokenUsage": { + "inputTokens": "Infinity", + "outputTokens": "1e999", + "cacheWriteTokens": "-Infinity", + "cacheReadTokens": "NaN", + "totalCents": "Infinity" + } + } + ] + } + """ + let page = try JSONDecoder().decode(CursorUsageEventsPage.self, from: Data(json.utf8)) + let event = try #require(page.usageEventsDisplay.first) + + #expect(page.totalUsageEventsCount == nil) + #expect(event.timestampMS == nil) + #expect(event.chargedCents == nil) + #expect(event.tokenUsage?.inputTokens == 0) + #expect(event.tokenUsage?.outputTokens == 0) + #expect(event.tokenUsage?.cacheWriteTokens == 0) + #expect(event.tokenUsage?.cacheReadTokens == 0) + #expect(event.tokenUsage?.totalCents == nil) + } + + @Test + func `reports skip events without a valid timestamp`() { + let event = CursorUsageEvent( + timestampMS: nil, + model: "fixture-model", + tokenUsage: CursorEventTokenUsage( + inputTokens: 10, + outputTokens: 5, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalCents: 100), + chargedCents: 25) + + let report = CursorUsageEventsFetcher.makeDailyReport(from: [event], calendar: Self.utcCalendar) + + #expect(report.data.isEmpty) + #expect(report.summary?.totalCostUSD == 0) + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: [event]) == nil) + } + + @Test + func `token totals fail closed on overflow`() { + let usage = CursorEventTokenUsage( + inputTokens: Int.max, + outputTokens: 1, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalCents: nil) + + #expect(usage.totalTokens == 0) + #expect(!usage.hasTokens) + } + + @Test + func `reports preserve unknown cost when a token event omits total cents`() { + let event = Self.event( + timestampMS: 1_700_000_000_000, + model: "fixture-model", + input: 5, + totalCents: nil) + + let report = CursorUsageEventsFetcher.makeDailyReport(from: [event], calendar: Self.utcCalendar) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 5) + #expect(report.data[0].costUSD == nil) + #expect(report.data[0].modelBreakdowns?.first?.costUSD == nil) + #expect(report.summary?.totalCostUSD == nil) + } + + @Test + func `reports preserve unknown aggregate tokens on cross event overflow`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "fixture-model", + input: Int.max, + totalCents: 1), + Self.event( + timestampMS: 1_700_000_001_000, + model: "fixture-model", + input: Int.max, + totalCents: 1), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == nil) + #expect(report.data[0].totalTokens == nil) + #expect(report.data[0].requestCount == 2) + #expect(report.data[0].modelBreakdowns?.first?.totalTokens == nil) + #expect(report.summary?.totalInputTokens == nil) + #expect(report.summary?.totalTokens == nil) + #expect(Self.approxEqual(report.summary?.totalCostUSD, 0.02)) + } + + @Test + func `metered totals fail closed on overflow`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "fixture-model", + input: 1, + totalCents: 1, + chargedCents: Double.greatestFiniteMagnitude), + Self.event( + timestampMS: 1_700_000_001_000, + model: "fixture-model", + input: 1, + totalCents: 1, + chargedCents: Double.greatestFiniteMagnitude), + ] + + #expect(CursorUsageEventsFetcher.meteredCostUSD(from: events) == nil) + } + + // MARK: - Fetching + + @Test + func `fetchUsage paginates, dedupes, sums metered cents, and sends Origin and Cookie headers`() async throws { + // swiftlint:disable line_length + let firstEvent = #""" + {"timestamp":"1700000000000","model":"claude-4.5-sonnet","tokenUsage":{"inputTokens":100,"outputTokens":50,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":100},"chargedCents":4} + """# + let secondEvent = #""" + {"timestamp":"1700003600000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4} + """# + // 1_700_005_400_000 is 2023-11-14T23:43:20Z: a distinct event still inside the same UTC day. + let thirdEvent = #""" + {"timestamp":"1700005400000","model":"gpt-5","tokenUsage":{"inputTokens":1,"outputTokens":1,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":25},"chargedCents":8} + """# + // swiftlint:enable line_length + + let transport = ProviderHTTPTransportStub { request in + switch Self.requestedPage(request) { + case 1: + // Full page of two distinct events; total signals one more remains. + Self.httpResponse(""" + {"totalUsageEventsCount":3,"usageEventsDisplay":[\(firstEvent),\(secondEvent)]} + """) + case 2: + // Second event repeats (must dedupe) alongside one new event. + Self.httpResponse(""" + {"totalUsageEventsCount":3,"usageEventsDisplay":[\(secondEvent),\(thirdEvent)]} + """) + default: + Self.httpResponse(#"{"totalUsageEventsCount":3,"usageEventsDisplay":[]}"#) + } + } + + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 2) + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + // Three unique events across one UTC day -> one entry with two models. + #expect(result.daily.data.count == 1) + #expect(result.daily.data[0].requestCount == 3) + #expect(Self.approxEqual(result.daily.data[0].costUSD, 1.75)) + // Metered total dedupes the same way: (4 + 4 + 8) cents -> $0.16. + #expect(Self.approxEqual(result.meteredCostUSD, 0.16)) + + let requests = await transport.requests() + #expect(requests.count == 3) + for request in requests { + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/dashboard/get-filtered-usage-events") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://cursor.test") + #expect(request.value(forHTTPHeaderField: "Cookie") == "WorkosCursorSessionToken=abc") + let body = try #require(request.httpBody) + let fields = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(fields["teamId"] == nil) + } + } + + @Test + func `pagination preserves rows with matching tokens but distinct billing fields`() async throws { + // swiftlint:disable line_length + let first = #"{"timestamp":"1700000000000","model":"gpt-5","kind":"USAGE_EVENT_KIND_USAGE_BASED","owningUser":"42","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + let second = #"{"timestamp":"1700000000000","model":"gpt-5","kind":"USAGE_EVENT_KIND_USAGE_BASED","owningUser":"42","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":75},"chargedCents":8}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { request in + switch Self.requestedPage(request) { + case 1: + Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(first)]}") + case 2: + Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(second)]}") + default: + Self.httpResponse(#"{"totalUsageEventsCount":2,"usageEventsDisplay":[]}"#) + } + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 1, + maxPages: 3) + + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + #expect(result.daily.data.first?.requestCount == 2) + #expect(Self.approxEqual(result.daily.data.first?.costUSD, 1.25)) + #expect(Self.approxEqual(result.meteredCostUSD, 0.12)) + } + + @Test + func `pagination preserves identical rows when the reported count includes both`() async throws { + // swiftlint:disable line_length + let event = #"{"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { request in + if Self.requestedPage(request) <= 2 { + return Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(event)]}") + } + return Self.httpResponse(#"{"totalUsageEventsCount":2,"usageEventsDisplay":[]}"#) + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 1, + maxPages: 3) + + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + #expect(result.daily.data.first?.requestCount == 2) + #expect(Self.approxEqual(result.daily.data.first?.costUSD, 1.0)) + #expect(Self.approxEqual(result.meteredCostUSD, 0.08)) + } + + @Test + func `pagination fails closed when a full safety cap page reaches the raw total`() async { + // swiftlint:disable line_length + let first = #"{"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + let second = #"{"timestamp":"1700000001000","model":"gpt-5","tokenUsage":{"inputTokens":20,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":75},"chargedCents":8}"# + let third = #"{"timestamp":"1700000002000","model":"gpt-5","tokenUsage":{"inputTokens":30,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":100},"chargedCents":12}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { request in + switch Self.requestedPage(request) { + case 1: + Self.httpResponse("{\"totalUsageEventsCount\":4,\"usageEventsDisplay\":[\(first),\(second)]}") + default: + Self.httpResponse("{\"totalUsageEventsCount\":4,\"usageEventsDisplay\":[\(second),\(third)]}") + } + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 2, + maxPages: 2) + + let error = await #expect(throws: CostUsageError.self) { + _ = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + } + guard case let .cursorPaginationIncomplete(expected, received) = error else { + Issue.record("Expected cursorPaginationIncomplete") + return + } + #expect(expected == 4) + #expect(received == 4) + } + + @Test + func `pagination fails closed when the reported total changes between pages`() async { + // swiftlint:disable line_length + let first = #"{"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + let second = #"{"timestamp":"1700000001000","model":"gpt-5","tokenUsage":{"inputTokens":20,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":75},"chargedCents":8}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { request in + if Self.requestedPage(request) == 1 { + return Self.httpResponse("{\"totalUsageEventsCount\":1,\"usageEventsDisplay\":[\(first)]}") + } + return Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(second)]}") + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 1, + maxPages: 2) + + let error = await #expect(throws: CostUsageError.self) { + _ = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + } + guard case let .cursorPaginationInconsistent(expected, received) = error else { + Issue.record("Expected cursorPaginationInconsistent") + return + } + #expect(expected == 1) + #expect(received == 2) + } + + @Test + func `cost report carries the exact fetched credential scope`() async throws { + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse(#"{"totalUsageEventsCount":0,"usageEventsDisplay":[]}"#) + } + let probe = CursorStatusProbe( + baseURL: Self.baseURL, + timeout: 1, + browserDetection: BrowserDetection(cacheTTL: 0), + urlSession: transport) + let cookie = "WorkosCursorSessionToken=abc" + + let report = try await probe.fetchCostReport( + since: nil, + until: nil, + cookieHeaderOverride: cookie) + + #expect(report.credentialScopeFingerprint == CookieHeaderCache.credentialFingerprint(cookie)) + } + + @Test + func `fetchUsage fails instead of publishing a truncated pagination window`() async { + // swiftlint:disable line_length + let event = #"{"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50},"chargedCents":4}"# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse("{\"totalUsageEventsCount\":2,\"usageEventsDisplay\":[\(event)]}") + } + let fetcher = CursorUsageEventsFetcher( + baseURL: Self.baseURL, + transport: transport, + pageSize: 1, + maxPages: 1) + + let error = await #expect(throws: CostUsageError.self) { + _ = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + } + guard case let .cursorPaginationIncomplete(expected, received) = error else { + Issue.record("Expected cursorPaginationIncomplete") + return + } + #expect(expected == 2) + #expect(received == 1) + } + + @Test + func `fetchUsage reports nil metered total when events omit chargedCents`() async throws { + // swiftlint:disable line_length + let event = #""" + {"timestamp":"1700000000000","model":"gpt-5","tokenUsage":{"inputTokens":10,"outputTokens":5,"cacheWriteTokens":0,"cacheReadTokens":0,"totalCents":50}} + """# + // swiftlint:enable line_length + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse("{\"totalUsageEventsCount\":1,\"usageEventsDisplay\":[\(event)]}") + } + + let fetcher = CursorUsageEventsFetcher(baseURL: Self.baseURL, transport: transport, pageSize: 2) + let result = try await fetcher.fetchUsage( + cookieHeader: "WorkosCursorSessionToken=abc", + since: nil, + until: nil, + calendar: Self.utcCalendar) + + #expect(result.meteredCostUSD == nil) + #expect(Self.approxEqual(result.daily.data.first?.costUSD, 0.50)) + } + + @Test + func `fetchUsage surfaces not logged in on 401`() async { + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse(#"{"error":"unauthorized"}"#, statusCode: 401) + } + let fetcher = CursorUsageEventsFetcher(baseURL: Self.baseURL, transport: transport) + + let error = await #expect(throws: CursorStatusProbeError.self) { + _ = try await fetcher.fetchUsage(cookieHeader: "x=y", since: nil, until: nil) + } + let isNotLoggedIn = error.map { thrown in + if case .notLoggedIn = thrown { + return true + } + return false + } ?? false + #expect(isNotLoggedIn) + } + + @Test + func `fetchUsage preserves a 403 as a non authentication failure`() async { + let transport = ProviderHTTPTransportStub { _ in + Self.httpResponse(#"{"error":"forbidden"}"#, statusCode: 403) + } + let fetcher = CursorUsageEventsFetcher(baseURL: Self.baseURL, transport: transport) + + let error = await #expect(throws: CursorStatusProbeError.self) { + _ = try await fetcher.fetchUsage(cookieHeader: "x=y", since: nil, until: nil) + } + guard case let .networkError(message) = error else { + Issue.record("Expected networkError") + return + } + #expect(message == "HTTP 403") + } + + @Test + func `cost fetcher reports Cursor as a supported token-snapshot provider`() { + #expect(CostUsageFetcher.supportsTokenSnapshot(.cursor)) + } +} +#endif diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index e594a78a1c..7ff5863981 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -225,6 +225,45 @@ struct InlineCostHistoryDashboardLabelTests { "Local usage × public API prices · not a subscription bill or plan value")) } + @Test + func `cursor metered-only snapshot remains visible in inline dashboard`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: 30, + meteredCostUSD: 1.25, + daily: [], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let dashboard = try #require(model.inlineUsageDashboard) + #expect(dashboard.kpis.first?.title == "Cursor-metered") + #expect(dashboard.kpis.first?.value == "$1.25") + #expect(dashboard.points.isEmpty) + } + @Test func `token-only inline dashboard leaves currencyCode nil`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index e8b092416e..105ef14a56 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -68,7 +68,7 @@ struct SpendDashboardModelTests { let providers = Set(ProviderDescriptorRegistry.all .filter(\.tokenCost.supportsTokenCost) .map(\.id)) - #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock]) + #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock, .cursor]) } @Test diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index d2b7fba157..e9b5d139e8 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -1,8 +1,8 @@ -import CodexBarCore import Foundation import Observation import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor struct UsageStoreCoverageTests { @@ -66,6 +66,116 @@ struct UsageStoreCoverageTests { #expect(store.isStale) } + @Test + func `cursor credential fingerprint is stable and does not expose the cookie`() { + let cookie = "fixture=a" + let fingerprint = CookieHeaderCache.credentialFingerprint(cookie) + + #expect(fingerprint == CookieHeaderCache.credentialFingerprint(" \(cookie) ")) + #expect(fingerprint != CookieHeaderCache.credentialFingerprint("fixture=b")) + #expect(!fingerprint.contains("fixture=a")) + } + + @Test + func `cursor manual cost refresh rejects an empty cookie without falling back`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-cursor-manual-cost") + settings.costUsageEnabled = true + settings.cursorCookieSource = .manual + settings.cursorCookieHeader = " " + let metadata = try #require(ProviderRegistry.shared.metadata[.cursor]) + settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + let store = Self.makeUsageStore(settings: settings) + let invoked = ObservationFlag() + store._test_tokenUsageSnapshotLoaderOverride = { _, _, now, _, _ in + invoked.set() + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + meteredCostUSD: 1, + daily: [], + updatedAt: now) + } + + await store.refreshTokenUsage(.cursor, force: true) + + #expect(!invoked.get()) + #expect(store.tokenSnapshot(for: .cursor) == nil) + #expect(store.tokenError(for: .cursor)?.contains("non-empty Manual cookie header") == true) + #expect(store.tokenSnapshotScopeSignature(for: .cursor).contains("manual:missing")) + } + + @Test + func `cursor metered-only cost refresh publishes the snapshot`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-cursor-metered-only") + settings.costUsageEnabled = true + settings.cursorCookieSource = .manual + settings.cursorCookieHeader = "fixture=cursor" + let metadata = try #require(ProviderRegistry.shared.metadata[.cursor]) + settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + let store = Self.makeUsageStore(settings: settings) + store._test_tokenUsageSnapshotLoaderOverride = { _, _, now, _, _ in + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + meteredCostUSD: 1.25, + daily: [], + updatedAt: now) + } + + await store.refreshTokenUsage(.cursor, force: true) + + #expect(store.tokenSnapshot(for: .cursor)?.meteredCostUSD == 1.25) + #expect(store.tokenError(for: .cursor) == nil) + } + + @Test + func `cursor auto credential resolution cannot relax a changed history window`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-cursor-history-race") + settings.costUsageEnabled = true + settings.costUsageHistoryDays = 30 + settings.cursorCookieSource = .auto + let metadata = try #require(ProviderRegistry.shared.metadata[.cursor]) + settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + let store = Self.makeUsageStore(settings: settings) + let cookie = "fixture=resolved" + let fingerprint = CookieHeaderCache.credentialFingerprint(cookie) + let generation = CookieHeaderCache.beginDisplayReadGenerationForTesting(provider: .cursor) + let previousEntry = CookieHeaderCache.currentDisplayEntryForTesting(provider: .cursor) + _ = CookieHeaderCache.commitDisplaySnapshotIfCurrentForTesting( + provider: .cursor, + entry: CookieHeaderCache.Entry( + cookieHeader: cookie, + storedAt: Date(), + sourceLabel: "test"), + generation: generation) + defer { + _ = CookieHeaderCache.commitDisplaySnapshotIfCurrentForTesting( + provider: .cursor, + entry: previousEntry, + generation: generation) + } + + let initialSignature = store.cursorCostScopeSignature( + historyDays: 30, + source: .auto, + credentialFingerprint: "unresolved") + let revision = store.providerPublicationRevision(for: .cursor) + let providerConfigRevision = settings.providerConfigRevision(for: .cursor) + settings.costUsageHistoryDays = 7 + + #expect(!store.tokenRefreshPublicationIsCurrent( + provider: .cursor, + publicationRevision: revision, + providerConfigRevision: providerConfigRevision, + historyDays: 30, + costScopeSignature: initialSignature, + fetchedCredentialScopeFingerprint: fingerprint)) + } + @Test func `source label adds open AI web`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-source") diff --git a/docs/cli.md b/docs/cli.md index 5e92a724a3..c82547f198 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,7 +44,9 @@ See `docs/configuration.md` for the schema. ## Command - `codexbar` defaults to the `usage` command. - `--format text|json` (default: text). -- `codexbar cost` prints local token cost usage for Claude + Codex without web/CLI access. +- `codexbar cost` prints token cost usage for Claude, Codex, and Cursor. + - Claude and Codex are scanned from local session logs without web/CLI access. + - Cursor is fetched from the cookie-authenticated cursor.com dashboard API (macOS only; see `docs/cursor.md`) and honors the configured cookie source: a non-empty Manual header is required and forwarded, while Off fails explicitly instead of silently omitting Cursor. - `--format text|json` (default: text). - `--refresh` ignores cached scans. - `codexbar cards` prints a one-shot usage snapshot as a responsive terminal card grid. @@ -141,12 +143,14 @@ payloads include the visible account label in `account`. ### Cost JSON payload `codexbar cost --format json` emits an array of payloads (one per provider). -- `provider`, `source`, `updatedAt` +- `provider`, `source` (`local` for Claude/Codex log scans, `web` for Cursor dashboard data), `updatedAt` - `sessionTokens`, `sessionCostUSD` - `last30DaysTokens`, `last30DaysCostUSD` +- Cursor only: `meteredCostUSD` — what Cursor's plan actually deducts over the window, alongside the API-rate estimate in `last30DaysCostUSD`. - `daily[]`: `date`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`, `modelsUsed`, `modelBreakdowns[]` (`modelName`, `cost`) - Codex only: `projects[]`: `name`, `path`, `totalTokens`, `totalCost`, `daily[]`, `modelBreakdowns[]`, `sources[]` - `totals`: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost` +- `error`: structured provider error when a fetch fails (for example Cursor requested while its cookie source is Off). ## Example usage ``` @@ -155,10 +159,11 @@ codexbar --provider claude # force Claude codexbar --provider all # query all registered providers codexbar --format json --pretty # machine output codexbar --format json --provider both -codexbar cost # local cost usage (default 30-day window + today) +codexbar cost # cost usage (default 30-day window + today) codexbar cost --days 90 # choose a 1...365 day cost window codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty +codexbar cost --provider cursor # Cursor dashboard cost (API-rate + Cursor-metered) codexbar serve --port 8080 # localhost HTTP JSON server codexbar serve --request-timeout 0 # disable serve request deadlines CODEXBAR_DASHBOARD_TOKEN=YOUR_TOKEN codexbar serve # token-gated dashboard snapshot diff --git a/docs/cursor.md b/docs/cursor.md index 7826df3cfa..e16bdbacc7 100644 --- a/docs/cursor.md +++ b/docs/cursor.md @@ -85,6 +85,27 @@ When **Settings → Advanced → Track provider local storage** is enabled on ma The storage detail lists measured paths and their sizes. CodexBar does not delete Cursor data. +## Token cost (dashboard API) +The cost summary's Cursor section is opt-in: it only fetches when **Show cost summary** is enabled and the Cursor provider is on. +Unlike Claude and Codex cost (scanned from local session logs on this machine), Cursor cost is remote, account-wide data from the cursor.com dashboard, so it covers usage from every machine on the account. + +Auth reuses the exact status-probe session resolution and cookie-source policy: +- **Auto**: cached cookie header → browser cookie import → stored WebKit session → Cursor.app local auth. +- **Manual**: a non-empty pasted cookie header is required and forwarded as-is, so cost and status share the same session; an empty header fails closed instead of falling back to another account. +- **Off**: the fetch is skipped in the app; `codexbar cost --provider cursor` fails explicitly and `/cost` returns a provider error row. + +Fetch behavior: +- `POST https://cursor.com/api/dashboard/get-filtered-usage-events` (cookie-authenticated; requires a matching `Origin` for CSRF). +- Pages of 1000 events (up to 200 pages), with exact page-boundary overlap removed before aggregation. Reaching the safety cap or otherwise receiving fewer events than Cursor reports fails the refresh instead of publishing a partial total. +- The window start is snapped to the local day boundary so a 1-day window covers all of today and wider windows keep their full first day. + +Two totals are reported from the same events: +- **API-rate estimate**: vendor list price from each event's `tokenUsage` cents, aggregated per day/model (comparable to the Claude/Codex estimates). +- **Cursor-metered** (`meteredCostUSD`): what Cursor's plan actually deducts over the window, shown as its own "Cursor-metered:" line. +- Metered-only request events remain visible even when Cursor does not include token details; cookie/config resolution failures stop the fetch instead of falling back to another session. + +Caching: the app holds the snapshot for an in-memory hourly TTL, keyed by the history window plus the cookie source and resolved account (manual-cookie hash or auto-mode account fingerprint), so switching accounts or pasting a new cookie invalidates it immediately. + ## Snapshot mapping - Primary: plan usage percent (included plan). - Secondary: Auto + Composer usage percent.