diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index f45b88e316..91621db129 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -48,6 +48,7 @@ struct CostHistoryChartMenuView: View { private let historyDays: Int private let windowLabel: String? private let projects: [CostUsageProjectBreakdown] + private let sessions: [CostUsageSessionBreakdown] private let width: CGFloat @State private var selectedDateKey: String? @@ -59,6 +60,7 @@ struct CostHistoryChartMenuView: View { historyDays: Int = 30, windowLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], + sessions: [CostUsageSessionBreakdown] = [], width: CGFloat) { self.provider = provider @@ -68,6 +70,7 @@ struct CostHistoryChartMenuView: View { self.historyDays = max(1, min(365, historyDays)) self.windowLabel = windowLabel self.projects = projects + self.sessions = sessions self.width = width } @@ -284,6 +287,10 @@ struct CostHistoryChartMenuView: View { } .frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading) } + + if !self.sessions.isEmpty { + self.sessionsBlock + } } .padding(.horizontal, 16) .padding(.vertical, Self.verticalPadding) @@ -327,8 +334,95 @@ struct CostHistoryChartMenuView: View { private static let projectSourceIndent: CGFloat = 10 private static let projectMoreRowHeight: CGFloat = 16 private static let maxVisibleProjectSourceRows = 2 + private static let sessionRowHeight: CGFloat = 44 + private static let sessionRowSpacing: CGFloat = 5 + private static let maxVisibleSessionRows = 5 static let verticalPadding: CGFloat = 10 + private var sessionsBlock: some View { + let visibleCount = min(self.sessions.count, Self.maxVisibleSessionRows) + return VStack(alignment: .leading, spacing: Self.sessionRowSpacing) { + HStack { + Text("Conversations (\(self.windowLabel ?? Self.windowLabel(days: self.historyDays)))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + Text("\(self.sessions.count)") + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: Self.sessionRowSpacing) { + ForEach(self.sessions) { session in + self.sessionRow(session) + } + } + } + .scrollIndicators(self.sessions.count > visibleCount ? .visible : .hidden) + .frame( + height: CGFloat(visibleCount) * Self.sessionRowHeight + + CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing, + alignment: .topLeading) + } + .frame( + height: Self.detailPrimaryLineHeight + Self.sessionRowSpacing + + CGFloat(visibleCount) * Self.sessionRowHeight + + CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing, + alignment: .topLeading) + } + + private func sessionRow(_ session: CostUsageSessionBreakdown) -> some View { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text("Session \(Self.shortSessionID(session.sessionID))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Text(Self.sessionUsageLine(session)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + Text(session.lastActivity, format: .dateTime.month(.abbreviated).day().hour().minute()) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + } + Spacer(minLength: 8) + Text(session.costUSD.map(self.costString) ?? "—") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(height: Self.sessionRowHeight, alignment: .topLeading) + .accessibilityElement(children: .combine) + } + + static func shortSessionID(_ sessionID: String) -> String { + let trimmed = sessionID.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 12 else { return trimmed } + return "\(trimmed.prefix(4))...\(trimmed.suffix(8))" + } + + private static func sessionUsageLine(_ session: CostUsageSessionBreakdown) -> String { + let models = session.modelBreakdowns.map(\.modelName) + let modelLabel = if models.isEmpty { + "Unknown model" + } else if models.count == 1 { + models[0] + } else { + "\(models[0]) +\(models.count - 1)" + } + let input = session.inputTokens.map(UsageFormatter.tokenCountString) ?? "—" + let cached = session.cachedInputTokens.map(UsageFormatter.tokenCountString) ?? "—" + let output = session.outputTokens.map(UsageFormatter.tokenCountString) ?? "—" + return "\(modelLabel) · \(input) input · \(cached) cached · \(output) output" + } + static func windowLabel(days: Int) -> String { if days == 1 { return L("Today") @@ -788,6 +882,7 @@ extension CostHistoryChartMenuView { let hasDailyEntries: Bool let daily: [VisibleDailyFingerprint] let projects: [VisibleProjectFingerprint] + let sessions: [VisibleSessionFingerprint] } struct VisibleDailyFingerprint: Equatable { @@ -824,11 +919,23 @@ extension CostHistoryChartMenuView { let totalCostBitPattern: UInt64? } + struct VisibleSessionFingerprint: Equatable { + let sessionID: String + let lastActivityBitPattern: UInt64 + let inputTokens: Int? + let cachedInputTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let costBitPattern: UInt64? + let models: [VisibleModelBreakdownFingerprint] + } + static func renderFingerprint( from snapshot: CostUsageTokenSnapshot, provider: UsageProvider) -> RenderFingerprint { let projects = provider == .codex ? snapshot.projects : [] + let sessions = provider == .codex ? snapshot.sessions : [] return RenderFingerprint( currencyCode: snapshot.currencyCode, historyDays: snapshot.historyDays, @@ -854,6 +961,26 @@ extension CostHistoryChartMenuView { totalTokens: source.totalTokens, totalCostBitPattern: source.totalCostUSD.map(\.bitPattern)) }) + }, + sessions: sessions.map { session in + VisibleSessionFingerprint( + sessionID: session.sessionID, + lastActivityBitPattern: session.lastActivity.timeIntervalSince1970.bitPattern, + inputTokens: session.inputTokens, + cachedInputTokens: session.cachedInputTokens, + outputTokens: session.outputTokens, + totalTokens: session.totalTokens, + costBitPattern: session.costUSD.map(\.bitPattern), + models: session.modelBreakdowns.map { item in + VisibleModelBreakdownFingerprint( + modelName: item.modelName, + costBitPattern: item.costUSD.map(\.bitPattern), + totalTokens: item.totalTokens, + standardCostBitPattern: item.standardCostUSD.map(\.bitPattern), + priorityCostBitPattern: item.priorityCostUSD.map(\.bitPattern), + standardTokens: item.standardCostUSD == nil ? nil : item.standardTokens, + priorityTokens: item.priorityCostUSD == nil ? nil : item.priorityTokens) + }) }) } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 101ce3b7c7..ca2c5e6552 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -341,6 +341,15 @@ extension UsageMenuCardView.Model { else { return nil } + // Local Codex session costs are independent from OAuth, CLI quota, and OpenAI web + // dashboard access. Do not present a failed account-level quota fetch as a failure of + // a valid local API-key ledger. + if input.codexLocalSessionCostLedgerEnabled, + self.hasLocalCodexTokenUsage(input), + self.isRemoteCodexQuotaFetchError(lastError) + { + return nil + } if self.shouldShowRateLimitsUnavailablePlaceholder(input: input, lastError: lastError) { return nil } @@ -402,6 +411,10 @@ extension UsageMenuCardView.Model { self.tokenUsageSnapshot(input: input) != nil } + private static func isRemoteCodexQuotaFetchError(_ error: String) -> Bool { + error.localizedCaseInsensitiveContains("Codex usage is temporarily unavailable") + } + private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool { let currentError = lastError ?? input.lastError if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index c3bcde4ea4..1e7094a989 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -22,6 +22,7 @@ extension UsageMenuCardView.Model { let usageBarsShowUsed: Bool let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool + let codexLocalSessionCostLedgerEnabled: Bool let tokenCostInlineDashboardEnabled: Bool let tokenCostMenuSectionEnabled: Bool let costComparisonPeriodsEnabled: Bool @@ -57,6 +58,7 @@ extension UsageMenuCardView.Model { usageBarsShowUsed: Bool, resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, + codexLocalSessionCostLedgerEnabled: Bool = false, tokenCostInlineDashboardEnabled: Bool? = nil, tokenCostMenuSectionEnabled: Bool? = nil, costComparisonPeriodsEnabled: Bool = false, @@ -91,6 +93,7 @@ extension UsageMenuCardView.Model { self.usageBarsShowUsed = usageBarsShowUsed self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled + self.codexLocalSessionCostLedgerEnabled = codexLocalSessionCostLedgerEnabled self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 386c100738..abf01793e8 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -98,7 +98,9 @@ struct ProvidersPane: View { isPresented: Binding( get: { self.activeConfirmation != nil }, set: { isPresented in - if !isPresented { self.activeConfirmation = nil } + if !isPresented { + self.activeConfirmation = nil + } }), actions: { if let active = self.activeConfirmation { @@ -684,6 +686,7 @@ struct ProvidersPane: View { usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), + codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: provider), // Display style only controls the main menu. Provider details always expose // available cost data in their Usage section. diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index f21275e6db..5744287c09 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -76,6 +76,22 @@ struct CodexProviderImplementation: ProviderImplementation { ].joined(separator: " ") return [ + ProviderSettingsToggleDescriptor( + id: "codex-local-session-cost-ledger", + title: "Local session cost estimates", + subtitle: [ + "Uses this Mac's Codex sessions instead of the selected managed account's session history.", + "Works with organization API keys and does not require OpenAI billing or administrator access.", + "Uses locally cached or bundled model prices without making a network request.", + "This provider-specific toggle does not enable cost summaries for other providers.", + ].joined(separator: " "), + binding: context.boolBinding(\.codexLocalSessionCostLedgerEnabled), + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", title: "Historical tracking", @@ -165,8 +181,11 @@ struct CodexProviderImplementation: ProviderImplementation { return [ ProviderSettingsPickerDescriptor( id: "codex-usage-source", - title: "Usage source", - subtitle: "Auto falls back to the next source if the preferred one fails.", + title: "Quota usage source", + subtitle: [ + "Controls live session and weekly quota fetching only.", + "Local session cost estimates work independently.", + ].joined(separator: " "), binding: usageBinding, options: usageOptions, isVisible: nil, diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index af04e29329..e6b7d06682 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -9,7 +9,7 @@ extension UsageStore { func codexCreditsFetcher() -> UsageFetcher { // Credits are remote Codex account state, so they need the same managed-home routing as the - // primary Codex usage fetch. Local token-cost scanning intentionally stays ambient-system scoped. + // primary Codex usage fetch. Token-cost scanning owns its selected managed or ambient scope separately. self.makeFetchContext(provider: .codex, override: nil).fetcher } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 5985a08abe..e9d1db56c0 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -384,6 +384,15 @@ extension SettingsStore { } } + var codexLocalSessionCostLedgerEnabled: Bool { + get { self.defaultsState.codexLocalSessionCostLedgerEnabled } + set { + self.defaultsState.codexLocalSessionCostLedgerEnabled = newValue + self.userDefaults.set(newValue, forKey: "codexLocalSessionCostLedgerEnabled") + self.noteBackgroundWorkSettingsChanged() + } + } + var costUsageHistoryDays: Int { get { self.defaultsState.costUsageHistoryDays } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 6f2ac24e30..605a62f13d 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -37,6 +37,7 @@ extension SettingsStore { _ = self.menuBarMetricPreferencesRaw _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled + _ = self.codexLocalSessionCostLedgerEnabled _ = self.costUsageHistoryDays _ = self.costComparisonPeriodsEnabled _ = self.costSummaryDisplayStyle diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 3f8579c7e9..96866fd371 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -292,8 +292,9 @@ extension SettingsStore { } func isCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool { - self.costUsageEnabled - && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost + let isEnabled = self.costUsageEnabled || + (provider == .codex && self.codexLocalSessionCostLedgerEnabled) + return isEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost } var resetTimeDisplayStyle: ResetTimeDisplayStyle { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6f4ec81780..b53bffdd1c 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -444,6 +444,8 @@ extension SettingsStore { let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults) let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false + let codexLocalSessionCostLedgerEnabled = userDefaults.object( + forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) let costComparisonPeriodsEnabled = userDefaults.object( @@ -535,6 +537,7 @@ extension SettingsStore { copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled, copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, + codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, costUsageHistoryDays: costUsageHistoryDays, costComparisonPeriodsEnabled: costComparisonPeriodsEnabled, costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 77d9583842..daa9552220 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -38,6 +38,7 @@ struct SettingsDefaultsState { var copilotBudgetExtrasEnabled: Bool var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool + var codexLocalSessionCostLedgerEnabled: Bool var costUsageHistoryDays: Int var costComparisonPeriodsEnabled: Bool var costSummaryDisplayStyleRaw: String diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 52ffa1628c..3e4b13e6df 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -473,6 +473,7 @@ extension StatusItemController { historyDays: tokenSnapshot.historyDays, windowLabel: tokenSnapshot.historyLabel, projects: provider == .codex ? tokenSnapshot.projects : [], + sessions: provider == .codex ? tokenSnapshot.sessions : [], width: width) let hosting = MenuHostingView(rootView: chartView) hosting.applyMeasuredHeight( diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 3d82578043..7ae8f28c18 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -127,6 +127,7 @@ extension StatusItemController { usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), + codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), tokenCostMenuSectionEnabled: !UsageStore.tokenCostRequiresProviderSnapshot(target) && self.settings.costSummaryShowsSubmenu(for: target), diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 07abec81da..6f8adbcdef 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -110,6 +110,7 @@ extension StatusItemController { from: dashboard?.usageBreakdown ?? []) var parts = [ "costEnabled=\(self.settings.costUsageEnabled ? "1" : "0")", + "codexLocalCost=\(self.settings.codexLocalSessionCostLedgerEnabled ? "1" : "0")", "costStyle=\(self.settings.costSummaryDisplayStyle.rawValue)", "openAIAttached=\(self.store.openAIDashboardAttachmentAuthorized ? "1" : "0")", "openAILogin=\(self.store.openAIDashboardRequiresLogin ? "1" : "0")", diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 91a3299827..90b16f1e01 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -32,6 +32,7 @@ extension UsageStore { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout + let allowPricingRefresh = provider != .codex || !self.settings.codexLocalSessionCostLedgerEnabled let environment = provider == .bedrock ? ProviderRegistry.makeEnvironment( base: self.environmentBase, @@ -49,6 +50,7 @@ extension UsageStore { allowVertexClaudeFallback: !self.isEnabled(.claude), codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, bypassScannerDebounce: true) } group.addTask { @@ -174,7 +176,7 @@ extension UsageStore { @discardableResult func hydrateCachedTokenSnapshots(now: Date = Date()) -> Task? { - guard self.settings.costUsageEnabled else { return nil } + guard self.settings.isCostUsageEffectivelyEnabled(for: .codex) else { return nil } guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { return nil } @@ -207,7 +209,7 @@ extension UsageStore { guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex), self.settings.providerConfigRevision(for: .codex) == providerConfigRevision, self.settings.costUsageSettingsRevision == costUsageSettingsRevision, - self.settings.costUsageEnabled, + self.settings.isCostUsageEffectivelyEnabled(for: .codex), self.isEnabled(.codex), self.tokenCostScope(for: .codex).signature == scope.signature, self.settings.costUsageHistoryDays == historyDays, @@ -240,12 +242,35 @@ extension UsageStore { guard provider == .codex else { return (nil, provider.rawValue) } - let homePath = self.settings.activeManagedCodexRemoteHomePath? - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let homePath, !homePath.isEmpty else { + if self.settings.codexLocalSessionCostLedgerEnabled { return (nil, "codex:ambient") } - return (homePath, "codex:managed:\(homePath)") + let activeSource = self.settings.codexActiveSource + switch activeSource { + case .liveSystem: + return (nil, "codex:ambient") + case let .managedAccount(id): + let homePath = self.settings.managedCodexRemoteHomePath(forActiveSource: activeSource)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let homePath, !homePath.isEmpty { + return (homePath, "codex:managed:\(homePath)") + } + let unavailablePath = Self.costUsageCacheDirectory() + .appendingPathComponent("unavailable-managed", isDirectory: true) + .appendingPathComponent(id.uuidString, isDirectory: true) + .path + return (unavailablePath, "codex:managed:unavailable:\(id.uuidString)") + case .profileHome: + let homePath = self.settings.profileCodexHomePath(forActiveSource: activeSource)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let homePath, !homePath.isEmpty { + return (homePath, "codex:profile:\(homePath)") + } + let unavailablePath = Self.costUsageCacheDirectory() + .appendingPathComponent("unavailable-profile", isDirectory: true) + .path + return (unavailablePath, "codex:profile-unavailable") + } } func tokenSnapshotScopeSignature(for provider: UsageProvider) -> String { diff --git a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift index dc49160cd1..00ef0abb24 100644 --- a/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift +++ b/Sources/CodexBar/UsageStore+TokenRefreshSequence.swift @@ -21,7 +21,9 @@ extension UsageStore { func scheduleTokenRefresh() { guard self.tokenRefreshSequenceTask == nil, !self.hasForcedRefreshEnrichmentInFlight else { return } - if self.startPendingTokenRefreshRetryIfPossible() { return } + if self.startPendingTokenRefreshRetryIfPossible() { + return + } self.startTokenRefreshSequence(force: false, scope: .all) } @@ -119,7 +121,7 @@ extension UsageStore { private func startPendingTokenRefreshRetryIfPossible() -> Bool { guard !self.tokenRefreshRetryProviders.isEmpty, self.tokenRefreshSequenceTask == nil, - self.settings.costUsageEnabled + self.settings.costUsageEnabled || self.settings.codexLocalSessionCostLedgerEnabled else { return false } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 89d5e08d3b..3c9a16b98c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1332,7 +1332,7 @@ extension UsageStore { return } - guard self.settings.costUsageEnabled else { + guard self.settings.isCostUsageEffectivelyEnabled(for: provider) else { self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() @@ -1384,11 +1384,8 @@ extension UsageStore { .debug("cost usage start provider=\(provider.rawValue) force=\(force)") do { - // Codex cost usage scans local session logs from this machine. That data is - // intentionally presented as provider-level local telemetry rather than managed-account - // remote state, so managed Codex account selection does not retarget that fetch. - // If the UI later needs account-scoped token history, it should label and source that - // separately instead of silently changing the meaning of this section. + // Codex cost usage scans the explicit token-cost scope: selected managed account by + // default, or this Mac's ambient Codex home when the local ledger is enabled. let snapshot = try await self.loadTokenUsageSnapshot( provider: provider, force: force, diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 1967165c79..162f5ac825 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -65,6 +65,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true) async throws -> CostUsageTokenSnapshot { @@ -76,6 +77,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: false, @@ -90,6 +92,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot @@ -102,6 +105,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: bypassScannerDebounce, @@ -117,6 +121,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, automaticCodexScanByteLimit _: Int64?) async throws -> CostUsageTokenSnapshot { @@ -128,6 +133,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground) } @@ -135,6 +141,22 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } + private static func resolvedScannerOptions( + _ override: CostUsageScanner.Options?, + provider: UsageProvider, + codexHomePath: String?) -> CostUsageScanner.Options + { + var options = override ?? CostUsageScanner.Options() + if provider == .codex, + let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHomePath.isEmpty + { + options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } + return options + } + static func loadTokenSnapshot( provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -143,6 +165,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, + allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, bypassScannerDebounce: Bool = false, @@ -173,30 +196,21 @@ public struct CostUsageFetcher: Sendable { useCurrentLocalDayForSession: false) } - var options = overrideScannerOptions ?? CostUsageScanner.Options() - if provider == .codex, - let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), - !codexHomePath.isEmpty - { - options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true) - .appendingPathComponent("sessions", isDirectory: true) - } - if retryUnknownPricing, provider == .codex || provider == .claude { - let pricingCacheRoot = options.cacheRoot - if refreshPricingInBackground { - Task.detached(priority: .utility) { - await ModelsDevPricingPipeline.refreshIfNeeded( - now: now, - cacheRoot: pricingCacheRoot, - client: modelsDevClient) - } - } else { - await ModelsDevPricingPipeline.refreshIfNeeded( - now: now, - cacheRoot: pricingCacheRoot, - client: modelsDevClient) - } - } + var options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: provider, + codexHomePath: codexHomePath) + let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false + await Self.refreshPricingIfAllowed( + options: PricingRefreshOptions( + provider: provider, + isAllowed: allowPricingRefresh, + retryUnknown: retryUnknownPricing, + inBackground: refreshPricingInBackground), + now: now, + cacheRoot: options.cacheRoot, + client: modelsDevClient) if provider == .vertexai { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly @@ -249,15 +263,25 @@ public struct CostUsageFetcher: Sendable { } var projects: [CostUsageProjectBreakdown] = [] + var sessions: [CostUsageSessionBreakdown] = [] var piDaily: CostUsageDailyReport? if provider == .codex { - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot) + let roots = CostUsageScanner.codexSessionsRoots(options: scanOptions) + let cache = CostUsageScanner.codexCache( + CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot), + scopedTo: roots) + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, - range: CostUsageScanner.CostUsageDayRange(since: since, until: until), + range: range, modelsDevCacheRoot: scanOptions.cacheRoot) + sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: scanOptions.cacheRoot, + sessionRoots: roots) } - if includePiSessions, provider == .codex || provider == .claude { + if includePiSessions, provider == .claude || (provider == .codex && shouldMergePiUsage) { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -274,11 +298,15 @@ public struct CostUsageFetcher: Sendable { if provider == .codex { projects = Self.mergedProjectBreakdowns( projects + [piDaily.flatMap(Self.unknownProjectBreakdown(from:))].compactMap(\.self)) + if piDaily?.data.isEmpty == false { + sessions = [] + } } - return (daily: daily, projects: projects) + return (daily: daily, projects: projects, sessions: sessions) } - if retryUnknownPricing, + if allowPricingRefresh, + retryUnknownPricing, let request = Self.unknownPricingRefreshRequest( provider: provider, daily: scanResult.daily, @@ -295,6 +323,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, + allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, scannerOptions: options, @@ -307,7 +336,35 @@ public struct CostUsageFetcher: Sendable { from: scanResult.daily, now: now, historyDays: clampedHistoryDays, - projects: scanResult.projects) + projects: scanResult.projects, + sessions: scanResult.sessions) + } + + private struct PricingRefreshOptions: Sendable { + let provider: UsageProvider + let isAllowed: Bool + let retryUnknown: Bool + let inBackground: Bool + } + + private static func refreshPricingIfAllowed( + options: PricingRefreshOptions, + now: Date, + cacheRoot: URL?, + client: ModelsDevClient) async + { + guard options.isAllowed, + options.retryUnknown, + options.provider == .codex || options.provider == .claude + else { return } + + if options.inBackground { + Task.detached(priority: .utility) { + await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: cacheRoot, client: client) + } + } else { + await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: cacheRoot, client: client) + } } private struct UnknownPricingRefreshRequest: Sendable { @@ -404,9 +461,13 @@ public struct CostUsageFetcher: Sendable { let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) let options = overrideScannerOptions ?? CostUsageScanner.Options() - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + let roots = CostUsageScanner.codexSessionsRoots(options: options) + let cache = CostUsageScanner.codexCache( + CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot), + scopedTo: roots) var reports: [CostUsageDailyReport] = [] var projects: [CostUsageProjectBreakdown] = [] + var sessions: [CostUsageSessionBreakdown] = [] // Raw inputs for the derived result fields below: the native cache's own scan // time, every constituent scan time, and whether a second source joined the merge. var nativeScanAt: Date? @@ -428,6 +489,11 @@ public struct CostUsageFetcher: Sendable { nativeScanAt = scanAt scanTimes.append(scanAt) } + sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot, + sessionRoots: roots) if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, @@ -452,6 +518,9 @@ public struct CostUsageFetcher: Sendable { if let piProject = Self.unknownProjectBreakdown(from: piResult.report) { projects.append(piProject) } + if !piResult.report.data.isEmpty { + sessions = [] + } } guard !reports.isEmpty else { return nil } @@ -465,6 +534,7 @@ public struct CostUsageFetcher: Sendable { now: now, historyDays: clampedHistoryDays, projects: Self.mergedProjectBreakdowns(projects), + sessions: sessions, updatedAt: scanTimes.min()), lastRefreshAt: piMerged ? nil : nativeScanAt) } @@ -490,6 +560,7 @@ public struct CostUsageFetcher: Sendable { historyDays: Int = 30, useCurrentLocalDayForSession: Bool = true, projects: [CostUsageProjectBreakdown] = [], + sessions: [CostUsageSessionBreakdown] = [], updatedAt: Date? = nil) -> CostUsageTokenSnapshot { let sessionEntry = useCurrentLocalDayForSession @@ -526,6 +597,7 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, daily: daily.data, projects: projects, + sessions: sessions, updatedAt: updatedAt ?? now) } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 61ba9c3333..09da6f1a65 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -22,6 +22,46 @@ public struct CostUsageWindowSummary: Sendable, Equatable { } } +/// An estimated local Codex conversation total derived from one session log. +/// This is intentionally distinct from account-level billing or quota data. +public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable { + public let sessionID: String + public let lastActivity: Date + public let inputTokens: Int? + public let cachedInputTokens: Int? + public let outputTokens: Int? + public let totalTokens: Int? + public let requestCount: Int? + public let costUSD: Double? + public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] + + public var id: String { + self.sessionID + } + + public init( + sessionID: String, + lastActivity: Date, + inputTokens: Int?, + cachedInputTokens: Int?, + outputTokens: Int?, + totalTokens: Int?, + requestCount: Int?, + costUSD: Double?, + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]) + { + self.sessionID = sessionID + self.lastActivity = lastActivity + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.requestCount = requestCount + self.costUSD = costUSD + self.modelBreakdowns = modelBreakdowns + } +} + public struct CostUsageTokenSnapshot: Sendable, Equatable { public let sessionTokens: Int? public let sessionCostUSD: Double? @@ -35,6 +75,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let historyLabel: String? public let daily: [CostUsageDailyReport.Entry] public let projects: [CostUsageProjectBreakdown] + public let sessions: [CostUsageSessionBreakdown] public let updatedAt: Date public init( @@ -50,6 +91,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], + sessions: [CostUsageSessionBreakdown] = [], updatedAt: Date) { self.sessionTokens = sessionTokens @@ -65,6 +107,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyLabel = historyLabel self.daily = daily self.projects = projects + self.sessions = sessions self.updatedAt = updatedAt } @@ -109,13 +152,19 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { return (entry, date) } .max { lhs, rhs in - if lhs.date != rhs.date { return lhs.date < rhs.date } + if lhs.date != rhs.date { + return lhs.date < rhs.date + } let lCost = lhs.entry.costUSD ?? -1 let rCost = rhs.entry.costUSD ?? -1 - if lCost != rCost { return lCost < rCost } + if lCost != rCost { + return lCost < rCost + } let lTokens = lhs.entry.totalTokens ?? -1 let rTokens = rhs.entry.totalTokens ?? -1 - if lTokens != rTokens { return lTokens < rTokens } + if lTokens != rTokens { + return lTokens < rTokens + } return lhs.entry.date < rhs.entry.date }?.entry } @@ -128,7 +177,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { let dayKey = CostUsageLocalDay.key(from: date, calendar: calendar) return entries.first { entry in let rawDate = entry.date.trimmingCharacters(in: .whitespacesAndNewlines) - if rawDate == dayKey { return true } + if rawDate == dayKey { + return true + } guard let parsed = CostUsageDateParser.parse(rawDate) else { return false } return CostUsageLocalDay.key(from: parsed, calendar: calendar) == dayKey } @@ -352,8 +403,12 @@ public struct CostUsageDailyReport: Sendable, Decodable { (try? container.decodeIfPresent([String].self, forKey: key)).flatMap(\.self) } - if let modelsUsed = decodeStringList(.modelsUsed) { return modelsUsed } - if let models = decodeStringList(.models) { return models } + if let modelsUsed = decodeStringList(.modelsUsed) { + return modelsUsed + } + if let models = decodeStringList(.models) { + return models + } guard container.contains(.models) else { return nil } @@ -918,16 +973,22 @@ enum CostUsageDateParser { key: self.isoWithFractionalSecondsKey, options: [.withInternetDateTime, .withFractionalSeconds]) .date(from: trimmed) - { return d } + { + return d + } if let d = self.isoFormatter(key: self.isoInternetDateTimeKey, options: [.withInternetDateTime]) .date(from: trimmed) - { return d } + { + return d + } if let d = self.dateFormatter(key: self.dayFormatterKey, format: "yyyy-MM-dd").date(from: trimmed) { return d } if let d = self.dateFormatter(key: self.monthDayYearFormatterKey, format: "MMM d, yyyy") .date(from: trimmed) - { return d } + { + return d + } return nil } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 807ef3013d..6e90578f19 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "8be945d1c2519e9b" + static let value = "d0de5cc7644a2b91" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift index b6b1efee0f..5e79e86c07 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -1,6 +1,83 @@ import Foundation extension CostUsageScanner { + static func codexCache(_ cache: CostUsageCache, scopedTo roots: [URL]) -> CostUsageCache { + var scoped = cache + scoped.files = cache.files.filter { filePath, _ in + Self.isWithinCodexRoots(fileURL: URL(fileURLWithPath: filePath), roots: roots) + } + scoped.days = [:] + for usage in scoped.files.values { + Self.applyFileDays(cache: &scoped, fileDays: usage.days, sign: 1) + } + return scoped + } + + static func buildCodexSessionBreakdownsFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + sessionRoots: [URL]? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:], + modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = { + CostUsagePricing.modelsDevCatalog(cacheRoot: $0) + }) -> [CostUsageSessionBreakdown] + { + let resolvedModelsDevCatalog = modelsDevCatalog + ?? modelsDevCatalogLoader(modelsDevCacheRoot) + ?? ModelsDevCatalog(providers: [:]) + var latestFileBySessionID: [String: (path: String, usage: CostUsageFileUsage)] = [:] + + for (filePath, usage) in cache.files { + if let sessionRoots, + !Self.isWithinCodexRoots(fileURL: URL(fileURLWithPath: filePath), roots: sessionRoots) + { + continue + } + guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + continue + } + let sessionID = usage.sessionId ?? URL(fileURLWithPath: filePath).deletingPathExtension().lastPathComponent + guard !sessionID.isEmpty else { continue } + if let existing = latestFileBySessionID[sessionID], existing.usage.mtimeUnixMs >= usage.mtimeUnixMs { + continue + } + latestFileBySessionID[sessionID] = (filePath, usage) + } + + return latestFileBySessionID.compactMap { sessionID, file in + var fileCache = CostUsageCache() + fileCache.files[file.path] = file.usage + fileCache.days = file.usage.days + let report = Self.buildCodexReportFromCache( + cache: fileCache, + range: range, + modelsDevCatalog: resolvedModelsDevCatalog, + priorityTurns: priorityTurns) + guard !report.data.isEmpty else { return nil } + + let summary = report.summary + let requestCounts = report.data.compactMap(\.requestCount) + return CostUsageSessionBreakdown( + sessionID: sessionID, + lastActivity: Date(timeIntervalSince1970: TimeInterval(file.usage.mtimeUnixMs) / 1000), + inputTokens: summary?.totalInputTokens, + cachedInputTokens: summary?.cacheReadTokens, + outputTokens: summary?.totalOutputTokens, + totalTokens: summary?.totalTokens, + requestCount: requestCounts.isEmpty ? nil : requestCounts.reduce(0, +), + costUSD: summary?.totalCostUSD, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: report.data) ?? []) + } + .sorted { lhs, rhs in + if lhs.lastActivity != rhs.lastActivity { + return lhs.lastActivity > rhs.lastActivity + } + return lhs.sessionID > rhs.sessionID + } + } + static func buildCodexProjectBreakdownsFromCache( cache: CostUsageCache, range: CostUsageDayRange, @@ -55,10 +132,14 @@ extension CostUsageScanner { .sorted { lhs, rhs in let lhsCost = lhs.totalCostUSD ?? -1 let rhsCost = rhs.totalCostUSD ?? -1 - if lhsCost != rhsCost { return lhsCost > rhsCost } + if lhsCost != rhsCost { + return lhsCost > rhsCost + } let lhsTokens = lhs.totalTokens ?? -1 let rhsTokens = rhs.totalTokens ?? -1 - if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } } @@ -96,10 +177,14 @@ extension CostUsageScanner { .sorted { lhs, rhs in let lhsCost = lhs.totalCostUSD ?? -1 let rhsCost = rhs.totalCostUSD ?? -1 - if lhsCost != rhsCost { return lhsCost > rhsCost } + if lhsCost != rhsCost { + return lhsCost > rhsCost + } let lhsTokens = lhs.totalTokens ?? -1 let rhsTokens = rhs.totalTokens ?? -1 - if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 7981668fa7..f4817f6467 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -885,7 +885,7 @@ enum CostUsageScanner { .appendingPathComponent("sessions", isDirectory: true) } - private static func codexSessionsRoots(options: Options) -> [URL] { + static func codexSessionsRoots(options: Options) -> [URL] { let root = self.defaultCodexSessionsRoot(options: options) if let archived = self.codexArchivedSessionsRoot(sessionsRoot: root) { return [root, archived] @@ -1183,7 +1183,7 @@ enum CostUsageScanner { return out } - private static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { + static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { let filePath = fileURL.standardizedFileURL.path return roots.contains { root in let rootPath = root.standardizedFileURL.path diff --git a/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift b/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift new file mode 100644 index 0000000000..3b86be3845 --- /dev/null +++ b/Tests/CodexBarTests/CodexLocalSessionCostSettingsTests.swift @@ -0,0 +1,139 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct CodexLocalSessionCostSettingsTests { + @Test + func `codex exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-codex") + let context = fixture.settingsContext(provider: .codex) + + let pickers = CodexProviderImplementation().settingsPickers(context: context) + let toggles = CodexProviderImplementation().settingsToggles(context: context) + #expect(pickers.contains(where: { $0.id == "codex-usage-source" })) + let usagePicker = try #require(pickers.first(where: { $0.id == "codex-usage-source" })) + #expect(usagePicker.title == "Quota usage source") + #expect(usagePicker.subtitle.contains("Local session cost estimates work independently")) + let cookiePicker = try #require(pickers.first(where: { $0.id == "codex-cookie-source" })) + #expect(cookiePicker.placement == .connection) + let localLedgerToggle = try #require(toggles.first(where: { $0.id == "codex-local-session-cost-ledger" })) + #expect(localLedgerToggle.title == "Local session cost estimates") + #expect(localLedgerToggle.subtitle.contains("organization API keys")) + #expect(!localLedgerToggle.binding.wrappedValue) + localLedgerToggle.binding.wrappedValue = true + #expect(fixture.settings.codexLocalSessionCostLedgerEnabled) + #expect(!fixture.settings.costUsageEnabled) + #expect(fixture.settings.isCostUsageEffectivelyEnabled(for: .codex)) + #expect(!fixture.settings.isCostUsageEffectivelyEnabled(for: .claude)) + #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) + let sparkToggle = try #require(toggles.first(where: { $0.id == "codex-spark-usage-visible" })) + #expect(sparkToggle.title == "Show Codex Spark usage") + #expect(sparkToggle.subtitle.contains("menu and provider preview")) + #expect(sparkToggle.binding.wrappedValue) + #expect(sparkToggle.isEnabled?() == true) + + sparkToggle.binding.wrappedValue = false + #expect(fixture.settings.codexSparkUsageVisible == false) + + fixture.settings.showOptionalCreditsAndExtraUsage = false + #expect(sparkToggle.isEnabled?() == false) + } + + @Test + func `codex local ledger ignores the managed account home`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-local-ledger") + fixture.settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-codex-home" + fixture.settings.codexActiveSource = .managedAccount(id: UUID()) + defer { fixture.settings._test_activeManagedCodexRemoteHomePath = nil } + + let managedScope = fixture.store.tokenCostScope(for: .codex) + fixture.settings.codexLocalSessionCostLedgerEnabled = true + let localScope = fixture.store.tokenCostScope(for: .codex) + + #expect(managedScope.codexHomePath == "/tmp/managed-codex-home") + #expect(managedScope.signature == "codex:managed:/tmp/managed-codex-home") + #expect(localScope.codexHomePath == nil) + #expect(localScope.signature == "codex:ambient") + } + + @Test + func `unresolved managed cost scope never falls back to ambient sessions`() throws { + let fixture = try self.makeSettingsFixture(suite: "CodexLocalSessionCostSettingsTests-managed-unresolved") + let accountID = UUID() + fixture.settings.codexActiveSource = .managedAccount(id: accountID) + + let scope = fixture.store.tokenCostScope(for: .codex) + + #expect(scope.codexHomePath != nil) + #expect(scope.signature != "codex:ambient") + #expect(scope.signature.hasPrefix("codex:managed:")) + } + + private func makeSettingsFixture(suite: String) throws -> Fixture { + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + return Fixture(settings: settings, store: store) + } + + private struct Fixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() + + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] + } +} diff --git a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift index 74343bc9c6..fc7f57eb46 100644 --- a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift +++ b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift @@ -201,6 +201,11 @@ struct CodexSubagentAccountingIntegrationTests { #expect(childUsages.allSatisfy { $0.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey }) + let sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + #expect(sessions.count == 3) + #expect(sessions.allSatisfy { $0.totalTokens == 55 }) } @Test diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index 281bb25b54..2ae20458b0 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -744,7 +744,8 @@ struct CostHistoryChartMenuViewTests { historyDays: Int = 30, historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry]? = nil, - projects: [CostUsageProjectBreakdown]? = nil) -> CostUsageTokenSnapshot + projects: [CostUsageProjectBreakdown]? = nil, + sessions: [CostUsageSessionBreakdown] = []) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( sessionTokens: 123, @@ -765,6 +766,7 @@ struct CostHistoryChartMenuViewTests { modelBreakdowns: nil), ], projects: projects ?? self.makeProjects(count: projectCount, sourcesPerProject: 1), + sessions: sessions, updatedAt: Date()) } @@ -776,6 +778,7 @@ struct CostHistoryChartMenuViewTests { historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry]? = nil, projects: [CostUsageProjectBreakdown]? = nil, + sessions: [CostUsageSessionBreakdown] = [], provider: UsageProvider = .codex) -> CostHistoryChartMenuView.RenderFingerprint { CostHistoryChartMenuView.renderFingerprint(from: self.makeSnapshot( @@ -785,7 +788,8 @@ struct CostHistoryChartMenuViewTests { historyDays: historyDays, historyLabel: historyLabel, daily: daily, - projects: projects), provider: provider) + projects: projects, + sessions: sessions), provider: provider) } private static func makeProjects(count: Int, sourcesPerProject: Int) -> [CostUsageProjectBreakdown] { @@ -859,3 +863,37 @@ struct CostHistoryChartMenuViewTests { sources: sources) } } + +extension CostHistoryChartMenuViewTests { + @Test + func `session labels distinguish concurrent uuid v7 identifiers`() { + let first = CostHistoryChartMenuView.shortSessionID("019f6d91-970b-7e13-b08e-000000000001") + let second = CostHistoryChartMenuView.shortSessionID("019f6d91-970b-7e13-b08e-000000000002") + + #expect(first == "019f...00000001") + #expect(second == "019f...00000002") + #expect(first != second) + } + + @Test + @MainActor + func `render fingerprint tracks displayed session token components`() { + func session(input: Int?, cached: Int?, output: Int?) -> CostUsageSessionBreakdown { + CostUsageSessionBreakdown( + sessionID: "session-1", + lastActivity: Date(timeIntervalSince1970: 100), + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + totalTokens: 110, + requestCount: 1, + costUSD: 0.01, + modelBreakdowns: []) + } + + let base = Self.fingerprint(sessions: [session(input: 100, cached: 20, output: 10)]) + #expect(base != Self.fingerprint(sessions: [session(input: 90, cached: 20, output: 10)])) + #expect(base != Self.fingerprint(sessions: [session(input: 100, cached: 10, output: 10)])) + #expect(base != Self.fingerprint(sessions: [session(input: 100, cached: 20, output: 20)])) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index 668121e0ba..bcf9d25777 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -353,6 +353,7 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(cached?.sessionTokens == 207) #expect(cached?.last30DaysTokens == 207) + #expect(cached?.sessions.isEmpty == true) } @Test diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index f357ec9e42..5db8108693 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct CostUsageFetcherTests { @Test func `fetcher scopes codex history to selected codex home`() async throws { @@ -17,23 +18,44 @@ struct CostUsageFetcherTests { filename: "ambient.jsonl", tokens: 100) try Self.writeCodexSessionFile(homeRoot: otherHome, env: env, day: day, filename: "managed.jsonl", tokens: 10) + _ = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_ambient.jsonl", + contents: env.jsonl([[ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 50, "output": 5, "totalTokens": 55], + ], + ]])) let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) let ambient = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, codexHomePath: env.codexHomeRoot.path, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) let managed = try await CostUsageFetcher.loadTokenSnapshot( provider: .codex, now: day, codexHomePath: otherHome.path, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) #expect(ambient.sessionTokens == 100) #expect(managed.sessionTokens == 10) } +} +extension CostUsageFetcherTests { @Test func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws { let env = try CostUsageTestEnvironment() @@ -582,6 +604,7 @@ struct CostUsageFetcherTests { #expect(breakdown.modelName == "gpt-5.4") #expect(abs((breakdown.costUSD ?? 0) - (nativeCost + piCost)) < 0.000001) #expect(breakdown.totalTokens == 170) + #expect(snapshot.sessions.isEmpty) } @Test @@ -875,3 +898,108 @@ struct CostUsageFetcherTests { ]).write(to: url, atomically: true, encoding: .utf8) } } + +extension CostUsageFetcherTests { + @Test + func `fetcher returns individual codex conversations for the selected history window`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let firstURL = try env.writeCodexSessionFile( + day: day, + filename: "first.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["session_id": "first-session"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "model": "openai/gpt-5.4", + "last_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ], + ])) + let secondURL = try env.writeCodexSessionFile( + day: day, + filename: "second.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["session_id": "second-session"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "model": "openai/gpt-5.4", + "last_token_usage": [ + "input_tokens": 40, + "cached_input_tokens": 5, + "output_tokens": 5, + ], + ], + ], + ], + ])) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(10)], + ofItemAtPath: firstURL.path) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(20)], + ofItemAtPath: secondURL.path) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: options, + piScannerOptions: piOptions) + + #expect(snapshot.sessions.map(\.sessionID) == ["second-session", "first-session"]) + let first = try #require(snapshot.sessions.first(where: { $0.sessionID == "first-session" })) + #expect(first.inputTokens == 100) + #expect(first.cachedInputTokens == 20) + #expect(first.outputTokens == 10) + #expect(first.totalTokens == 110) + #expect(first.requestCount == nil) + #expect(first.modelBreakdowns.map(\.modelName) == ["gpt-5.4"]) + #expect(first.costUSD != nil) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let unrelatedRoot = env.root.appendingPathComponent("unrelated/sessions", isDirectory: true) + let filtered = CostUsageScanner.buildCodexSessionBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: env.cacheRoot, + sessionRoots: [unrelatedRoot]) + #expect(filtered.isEmpty) + let scopedCache = CostUsageScanner.codexCache(cache, scopedTo: [unrelatedRoot]) + #expect(scopedCache.files.isEmpty) + #expect(scopedCache.days.isEmpty) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index 94bb97fbac..4ea858f6c4 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -166,6 +166,27 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(breakdown.costUSD == nil) #expect(requestCount == 0) } + + @Test + func `local only fetch skips every pricing network refresh`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let counter = UnknownModelPricingRequestCounter() + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + allowPricingRefresh: false, + refreshPricingInBackground: false, + scannerOptions: fixture.options, + modelsDevClient: ModelsDevClient( + transport: CostUsageFetcherCountingModelsDevTransport(counter: counter))) + + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "gpt-new") + #expect(breakdown.costUSD == nil) + #expect(await counter.requestCount == 0) + } } private struct UnknownModelPricingFixture { diff --git a/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift index 24f500435a..0b43092371 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift @@ -5,7 +5,7 @@ import Testing struct MenuCardModelCodexDegradedQuotaTests { @Test - func `codex local token usage keeps remote quota unavailable error visible`() throws { + func `codex local token usage hides remote quota unavailable error`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let metadata = try #require(ProviderDefaults.metadata[.codex]) let tokenSnapshot = CostUsageTokenSnapshot( @@ -41,13 +41,14 @@ struct MenuCardModelCodexDegradedQuotaTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) #expect(model.placeholder == nil) - #expect(model.subtitleStyle == .error) - #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.subtitleStyle == .info) + #expect(model.subtitleText == "Not fetched yet") #expect(model.usesStackedDetailLayout) #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) #expect(model.tokenUsage?.sessionLine.contains("tokens") == true) @@ -55,6 +56,19 @@ struct MenuCardModelCodexDegradedQuotaTests { #expect(model.tokenUsage?.monthLine.contains("tokens") == true) } + @Test + func `codex managed token usage keeps remote quota unavailable error visible`() throws { + let error = "Codex usage is temporarily unavailable. Try refreshing." + let model = try self.makeModel( + tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: false, + lastError: error) + + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == error) + #expect(model.tokenUsage != nil) + } + @Test func `codex remote quota unavailable error stays visible when token usage is hidden`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) @@ -122,6 +136,7 @@ struct MenuCardModelCodexDegradedQuotaTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) @@ -145,7 +160,7 @@ struct MenuCardModelCodexDegradedQuotaTests { } @Test - func `codex local token usage preserves mapped transport error`() throws { + func `codex local token usage hides mapped remote transport error`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let metadata = try #require(ProviderDefaults.metadata[.codex]) let tokenSnapshot = CostUsageTokenSnapshot( @@ -173,13 +188,14 @@ struct MenuCardModelCodexDegradedQuotaTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: true, + codexLocalSessionCostLedgerEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) #expect(model.placeholder == nil) - #expect(model.subtitleStyle == .error) - #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.subtitleStyle == .info) + #expect(model.subtitleText == "Not fetched yet") #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) } @@ -212,6 +228,7 @@ struct MenuCardModelCodexDegradedQuotaTests { private func makeModel( tokenCostUsageEnabled: Bool, + codexLocalSessionCostLedgerEnabled: Bool = true, lastError: String?) throws -> UsageMenuCardView.Model { let now = Date(timeIntervalSince1970: 1_800_000_000) @@ -240,6 +257,7 @@ struct MenuCardModelCodexDegradedQuotaTests { usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: tokenCostUsageEnabled, + codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 034fe1b0cf..5a1cb1fae5 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -50,30 +50,6 @@ struct ProviderSettingsDescriptorTests { #expect(fixture.settings.providerConfig(for: .openai)?.sanitizedWorkspaceID == "proj_abc") } - @Test - func `codex exposes usage and cookie pickers`() throws { - let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex") - let context = fixture.settingsContext(provider: .codex) - - let pickers = CodexProviderImplementation().settingsPickers(context: context) - let toggles = CodexProviderImplementation().settingsToggles(context: context) - #expect(pickers.contains(where: { $0.id == "codex-usage-source" })) - let cookiePicker = try #require(pickers.first(where: { $0.id == "codex-cookie-source" })) - #expect(cookiePicker.placement == .connection) - #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) - let sparkToggle = try #require(toggles.first(where: { $0.id == "codex-spark-usage-visible" })) - #expect(sparkToggle.title == "Show Codex Spark usage") - #expect(sparkToggle.subtitle.contains("menu and provider preview")) - #expect(sparkToggle.binding.wrappedValue) - #expect(sparkToggle.isEnabled?() == true) - - sparkToggle.binding.wrappedValue = false - #expect(fixture.settings.codexSparkUsageVisible == false) - - fixture.settings.showOptionalCreditsAndExtraUsage = false - #expect(sparkToggle.isEnabled?() == false) - } - @Test func `antigravity usage source picker clarifies local ide and agy`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-antigravity-source") diff --git a/docs/codex.md b/docs/codex.md index 2c4ef537e1..e1a4067923 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -143,6 +143,12 @@ Example: - CLI PTY diagnostics can still parse `Credits:` from saved/manual `/status` output. ## Cost usage (local log scan) +- Menu source selection: + - By default, a selected managed account keeps its own `CODEX_HOME` session history. + - **Local session cost estimates** is a Codex-only opt-in that instead scans this Mac's ambient `$CODEX_HOME` + (or `~/.codex`) independently of quota, OAuth, web-dashboard, and administrator access. + - The local-only mode never makes a network request or uploads session content. It uses an existing local models.dev + cache when available, then the bundled `CostUsagePricing` rates. - Source files: - Native Codex logs: - `~/.codex/sessions/YYYY/MM/DD/*.jsonl` @@ -156,9 +162,11 @@ Example: - pi sessions count assistant-message usage rows and attribute `openai-codex` assistant usage to Codex. - pi assistant usage is bucketed by assistant-turn timestamp, so mixed-model pi sessions can contribute to multiple days/models correctly. + - Native conversation rows reuse the corrected cached per-file totals and existing pricing tables. They are hidden + when pi usage joins the aggregate because the native-only rows would not reconcile with the merged total. - Cache: - - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/codex-v2.json` - - pi session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v1.json` + - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/codex-v10.json` + - pi session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v6.json` - Window: configurable 1-365 day rolling history, with a 60s minimum refresh interval. ### Usage & Spend account rows