diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 762007bdc..64469d350 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -321,12 +321,16 @@ extension UsageMenuCardView.Model { let latest = snapshot.daily.max { lhs, rhs in lhs.date < rhs.date } var details: [String] = [] if let topModel = Self.topCostModel(from: snapshot.daily) { - details.append("\(L("Top model")): \(Self.shortModelName(topModel))") + let topLabel = provider == .codex && + UsageMenuCardView.Model.isCodexDashboardCreditCostSnapshot(snapshot) + ? "Top source" + : L("Top model") + details.append("\(topLabel): \(Self.shortModelName(topModel))") } if let requestCount = snapshot.last30DaysRequests { details.append("\(requestHistoryTitle): \(UsageFormatter.tokenCountString(requestCount)) \(L("requests"))") } - if let hint = Self.tokenUsageHint(provider: provider) { + if let hint = Self.tokenUsageHint(provider: provider, snapshot: snapshot) { details.append(hint) } else { details.append(L("cost_estimate_hint")) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 542ac63a2..177b0c040 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -111,27 +111,41 @@ extension UsageMenuCardView.Model { return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, - hintLine: Self.tokenUsageHint(provider: provider), + hintLine: Self.tokenUsageHint(provider: provider, snapshot: snapshot), errorLine: err, errorCopyText: (error?.isEmpty ?? true) ? nil : error) } - static func tokenUsageHint(provider: UsageProvider) -> String? { + static func tokenUsageHint(provider: UsageProvider, snapshot: CostUsageTokenSnapshot) -> String? { switch provider { case .codex: - L("Estimated from local Codex logs for the selected account.") + if self.isCodexDashboardCreditCostSnapshot(snapshot) { + return "Estimated from OpenAI dashboard credits for the selected account." + } + return L("Estimated from local Codex logs for the selected account.") case .claude: - UsageFormatter.costEstimateHint(provider: provider) + return UsageFormatter.costEstimateHint(provider: provider) case .vertexai: - L("cost_estimate_hint") + return L("cost_estimate_hint") case .bedrock: - L("AWS Cost Explorer billing can lag.") + return L("AWS Cost Explorer billing can lag.") case .openai: - L("Reported by OpenAI Admin API organization usage.") + return L("Reported by OpenAI Admin API organization usage.") case .mistral: - L("Reported by Mistral billing usage.") + return L("Reported by Mistral billing usage.") default: - nil + return nil + } + } + + static func isCodexDashboardCreditCostSnapshot(_ snapshot: CostUsageTokenSnapshot) -> Bool { + let dashboardServices = Set(["exec", "desktop app", "cli", "unknown"]) + return snapshot.daily.contains { entry in + (entry.modelsUsed ?? []).contains { + dashboardServices.contains($0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } || (entry.modelBreakdowns ?? []).contains { + dashboardServices.contains($0.modelName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 4262c4edf..34ac39305 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -461,7 +461,7 @@ extension StatusItemController { let hasCreditsHistory = codexProjection?.hasCreditsHistory == true let hasUsageBreakdown = codexProjection?.hasUsageBreakdown == true let hasCostHistory = self.settings.isCostUsageEffectivelyEnabled(for: currentProvider) && - (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) + (self.tokenSnapshotForCostHistorySubmenu(provider: currentProvider)?.daily.isEmpty == false) let canShowBuyCredits = self.settings.showOptionalCreditsAndExtraUsage && codexProjection?.canShowBuyCredits == true let hasOpenAIWebMenuItems = !showAllAccounts && @@ -1515,7 +1515,13 @@ extension StatusItemController { if UsageStore.tokenCostRequiresProviderSnapshot(provider) { return projected } - return projected ?? self.store.tokenSnapshot(for: provider) + let local = projected ?? self.store.tokenSnapshot(for: provider) + if provider == .codex, + let dashboard = self.codexDashboardCostSnapshotForLiveCard(localSnapshot: local) + { + return dashboard + } + return local } func makeOpenAIAPIUsageSubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index adbc71d3c..6d6676f4f 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -36,6 +36,15 @@ extension StatusItemController { snapshotOverride: snapshotOverride, errorOverride: errorOverride, now: now) + let localTokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot + let dashboardTokenSnapshot: CostUsageTokenSnapshot? = if target == .codex, + surface == .liveCard + { + self.codexDashboardCostSnapshotForLiveCard( + projection: codexProjection, + localSnapshot: localTokenSnapshot, + now: now) + } else { nil } let credits: CreditsSnapshot? let creditsError: String? let dashboard: OpenAIDashboardSnapshot? @@ -48,7 +57,7 @@ extension StatusItemController { dashboard = nil dashboardError = codexProjection.userFacingErrors.dashboard if surface == .liveCard { - tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot + tokenSnapshot = dashboardTokenSnapshot ?? localTokenSnapshot tokenError = self.store.tokenError(for: target) } else { tokenSnapshot = projectedTokenSnapshot @@ -128,6 +137,21 @@ extension StatusItemController { AccountInfo(email: account.email, plan: account.workspaceLabel) } + func codexDashboardCostSnapshotForLiveCard( + projection: CodexConsumerProjection? = nil, + localSnapshot: CostUsageTokenSnapshot? = nil, + now: Date = Date()) -> CostUsageTokenSnapshot? + { + let projection = projection ?? self.store.codexConsumerProjectionIfNeeded( + for: .codex, + surface: .liveCard, + now: now) + guard projection?.dashboardVisibility == .attached else { return nil } + return self.store.openAIDashboard?.toCostUsageTokenSnapshot( + historyDays: self.settings.costUsageHistoryDays, + merging: localSnapshot) + } + private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { guard self.settings.quotaWarningMarkersVisible else { return [] } guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] } diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index 83a31f30d..5aece75ba 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -375,13 +375,16 @@ struct UsageBreakdownChartMenuView: View { } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) - let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2))) + let totalCredits = Self.creditsString(day.totalCreditsUsed) + let totalUSD = Self.usdString(fromCredits: day.totalCreditsUsed) + let total = "\(totalUSD) · \(totalCredits) credits" if day.services.isEmpty { return ("\(dayLabel): \(total)", nil) } if day.services.count <= 1, let first = day.services.first { - let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) - return ("\(dayLabel): \(used)", first.service) + let used = "\(Self.usdString(fromCredits: first.creditsUsed)) · " + + "\(Self.creditsString(first.creditsUsed)) credits" + return ("\(dayLabel): \(total)", "\(first.service) \(used)") } let services = day.services @@ -390,9 +393,19 @@ struct UsageBreakdownChartMenuView: View { return lhs.creditsUsed > rhs.creditsUsed } .prefix(3) - .map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" } + .map { "\($0.service) \(Self.usdString(fromCredits: $0.creditsUsed))" } .joined(separator: " · ") return ("\(dayLabel): \(total)", services) } + + private static func usdString(fromCredits credits: Double) -> String { + UsageFormatter.currencyString( + OpenAIDashboardSnapshot.codexUSD(fromCredits: credits), + currencyCode: "USD") + } + + private static func creditsString(_ credits: Double) -> String { + credits.formatted(.number.precision(.fractionLength(0...2))) + } } diff --git a/Sources/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index c070a7ee5..013f9b961 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -127,6 +127,8 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { } extension OpenAIDashboardSnapshot { + public static let codexCreditsPerUSD = 25.0 + public func toUsageSnapshot( provider: UsageProvider = .codex, accountEmail: String? = nil, @@ -144,6 +146,83 @@ extension OpenAIDashboardSnapshot { guard let creditsRemaining else { return nil } return CreditsSnapshot(remaining: creditsRemaining, events: self.creditEvents, updatedAt: self.updatedAt) } + + public func toCostUsageTokenSnapshot( + historyDays: Int = 30, + merging localSnapshot: CostUsageTokenSnapshot? = nil) -> CostUsageTokenSnapshot? + { + let clampedHistoryDays = max(1, min(365, historyDays)) + let breakdown = OpenAIDashboardDailyBreakdown + .removingSkillUsageServices(from: self.usageBreakdown) + .sorted { lhs, rhs in lhs.day < rhs.day } + .suffix(clampedHistoryDays) + guard !breakdown.isEmpty else { return nil } + + let daily = breakdown.compactMap { day -> CostUsageDailyReport.Entry? in + let costUSD = Self.codexUSD(fromCredits: day.totalCreditsUsed) + guard costUSD > 0 else { return nil } + let services = day.services + .filter { $0.creditsUsed > 0 } + .sorted { lhs, rhs in + if lhs.creditsUsed == rhs.creditsUsed { return lhs.service < rhs.service } + return lhs.creditsUsed > rhs.creditsUsed + } + let modelBreakdowns = services.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.service, + costUSD: Self.codexUSD(fromCredits: $0.creditsUsed), + totalTokens: nil) + } + return CostUsageDailyReport.Entry( + date: day.day, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: costUSD, + modelsUsed: services.map(\.service), + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + guard !daily.isEmpty else { return nil } + + let latest = daily.last + let totalCostUSD = daily.compactMap(\.costUSD).reduce(0, +) + let mergedDaily = localSnapshot.map { Self.mergeDashboardCostDailyEntries(daily, with: $0.daily) } ?? daily + return CostUsageTokenSnapshot( + sessionTokens: localSnapshot?.sessionTokens, + sessionCostUSD: latest?.costUSD, + sessionRequests: localSnapshot?.sessionRequests, + last30DaysTokens: localSnapshot?.last30DaysTokens, + last30DaysCostUSD: totalCostUSD > 0 ? totalCostUSD : nil, + last30DaysRequests: localSnapshot?.last30DaysRequests, + historyDays: clampedHistoryDays, + daily: mergedDaily, + updatedAt: self.updatedAt) + } + + public static func codexUSD(fromCredits credits: Double) -> Double { + credits / self.codexCreditsPerUSD + } + + private static func mergeDashboardCostDailyEntries( + _ dashboardEntries: [CostUsageDailyReport.Entry], + with localEntries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.Entry] + { + let localByDate = Dictionary(localEntries.map { ($0.date, $0) }, uniquingKeysWith: { _, latest in latest }) + return dashboardEntries.map { dashboard in + guard let local = localByDate[dashboard.date] else { return dashboard } + return CostUsageDailyReport.Entry( + date: dashboard.date, + inputTokens: local.inputTokens, + outputTokens: local.outputTokens, + cacheReadTokens: local.cacheReadTokens, + cacheCreationTokens: local.cacheCreationTokens, + totalTokens: local.totalTokens, + requestCount: local.requestCount, + costUSD: dashboard.costUSD, + modelsUsed: dashboard.modelsUsed, + modelBreakdowns: dashboard.modelBreakdowns) + } + } } public struct OpenAIDashboardDailyBreakdown: Codable, Equatable, Sendable { diff --git a/Tests/CodexBarTests/MenuCardCostHintTests.swift b/Tests/CodexBarTests/MenuCardCostHintTests.swift index 086e9c633..485449ea6 100644 --- a/Tests/CodexBarTests/MenuCardCostHintTests.swift +++ b/Tests/CodexBarTests/MenuCardCostHintTests.swift @@ -93,4 +93,98 @@ struct MenuCardCostHintTests { #expect(model.tokenUsage?.monthLine.hasPrefix("Today: ") == true) } + + @Test + func `codex dashboard credit cost uses dashboard hint and omits token suffix`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: 19.95, + last30DaysTokens: nil, + last30DaysCostUSD: 123.45, + daily: [ + .init( + date: "2026-06-19", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: 19.95, + modelsUsed: ["Exec"], + modelBreakdowns: [ + .init(modelName: "Exec", costUSD: 18.29, totalTokens: nil), + ]), + ], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage?.sessionLine == "Today: $19.95") + #expect(model.tokenUsage?.monthLine == "Last 30 days: $123.45") + #expect(model.tokenUsage?.hintLine?.contains("dashboard credits") == true) + } + + @Test + func `codex dashboard credit cost keeps local token suffix when merged`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 30_000_000, + sessionCostUSD: 19.64, + last30DaysTokens: 4_700_000_000, + last30DaysCostUSD: 123.45, + daily: [ + .init( + date: "2026-06-19", + inputTokens: 20_000_000, + outputTokens: 10_000_000, + totalTokens: 30_000_000, + costUSD: 19.64, + modelsUsed: ["Exec", "Desktop App"], + modelBreakdowns: [ + .init(modelName: "Exec", costUSD: 18.29, totalTokens: nil), + ]), + ], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage?.sessionLine == "Today: $19.64 · 30M tokens") + #expect(model.tokenUsage?.monthLine == "Last 30 days: $123.45 · 4.7B tokens") + #expect(model.tokenUsage?.hintLine?.contains("dashboard credits") == true) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift index b6547e14f..124bbc3c5 100644 --- a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift @@ -91,4 +91,97 @@ struct OpenAIDashboardModelsTests { totalCreditsUsed: 4), ]) } + + @Test + func `usage breakdown converts dashboard exec credits to cost snapshot`() throws { + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [ + OpenAIDashboardDailyBreakdown( + day: "2026-06-18", + services: [ + OpenAIDashboardServiceUsage(service: "Exec", creditsUsed: 25), + ], + totalCreditsUsed: 25), + OpenAIDashboardDailyBreakdown( + day: "2026-06-19", + services: [ + OpenAIDashboardServiceUsage(service: "Exec", creditsUsed: 457.34), + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 33.65), + OpenAIDashboardServiceUsage(service: "Skillusage:imagegen", creditsUsed: 9), + ], + totalCreditsUsed: 500), + ], + creditsPurchaseURL: nil, + updatedAt: updatedAt) + + let cost = try #require(snapshot.toCostUsageTokenSnapshot(historyDays: 30)) + + #expect(cost.sessionTokens == nil) + #expect(cost.last30DaysTokens == nil) + #expect(abs((cost.sessionCostUSD ?? 0) - 19.6396) < 0.0001) + #expect(abs((cost.last30DaysCostUSD ?? 0) - 20.6396) < 0.0001) + #expect(cost.daily.map(\.date) == ["2026-06-18", "2026-06-19"]) + #expect(cost.daily.last?.modelsUsed == ["Exec", "Desktop App"]) + let modelBreakdowns = try #require(cost.daily.last?.modelBreakdowns) + #expect(modelBreakdowns.map(\.modelName) == ["Exec", "Desktop App"]) + #expect(abs((modelBreakdowns[0].costUSD ?? 0) - 18.2936) < 0.0001) + #expect(abs((modelBreakdowns[1].costUSD ?? 0) - 1.346) < 0.0001) + #expect(cost.updatedAt == updatedAt) + } + + @Test + func `usage breakdown cost snapshot merges local token context without replacing dashboard USD`() throws { + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [ + OpenAIDashboardDailyBreakdown( + day: "2026-06-19", + services: [ + OpenAIDashboardServiceUsage(service: "Exec", creditsUsed: 457.34), + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 33.65), + ], + totalCreditsUsed: 490.99), + ], + creditsPurchaseURL: nil, + updatedAt: updatedAt) + let local = CostUsageTokenSnapshot( + sessionTokens: 30_000_000, + sessionCostUSD: 28.23, + last30DaysTokens: 4_700_000_000, + last30DaysCostUSD: 3528.07, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-06-19", + inputTokens: 20_000_000, + outputTokens: 10_000_000, + totalTokens: 30_000_000, + costUSD: 28.23, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: [ + .init(modelName: "gpt-5.5", costUSD: 28.23, totalTokens: 30_000_000), + ]), + ], + updatedAt: updatedAt.addingTimeInterval(-60)) + + let cost = try #require(snapshot.toCostUsageTokenSnapshot(historyDays: 30, merging: local)) + + #expect(cost.sessionTokens == 30_000_000) + #expect(cost.last30DaysTokens == 4_700_000_000) + #expect(abs((cost.sessionCostUSD ?? 0) - 19.6396) < 0.0001) + #expect(abs((cost.last30DaysCostUSD ?? 0) - 19.6396) < 0.0001) + let day = try #require(cost.daily.first) + #expect(day.totalTokens == 30_000_000) + #expect(abs((day.costUSD ?? 0) - 19.6396) < 0.0001) + #expect(day.modelsUsed == ["Exec", "Desktop App"]) + #expect(day.modelBreakdowns?.map(\.modelName) == ["Exec", "Desktop App"]) + } }